wangeditor图片功能扩展,支持在线选择图片上传

505 阅读5分钟

wangeditor 是开源 Web 富文本编辑器,开箱即用,配置简单,有以下优势:

  • 简洁易用,功能强大
  • 支持 JS Vue React
  • 踩过 5000+ 坑

以上是官网上的简单介绍,有点王婆卖瓜的感觉,大家看看就好。我们项目上之所以选择wangeditor,主要有以下几点:

  1. 文档详情,社区活跃,尤其是V5版本(推荐大家使用)
  2. 功能支持扩展,包括自定义数据节点、自定义菜单(工具栏,操作)
  3. 业务驱动需要自定义数据节点,即自定义dom

wangeditor原生支持的扩展功能包括,“网络图片” 和 “上传图片”,如下图

image.png

## 一、插入网络图片

网络图片即往编辑器中插入一个网络图片。

image.png

二、上传图片

上传图片,选择本地图片进行上传,下面是一个自定义图片上传的代码实现功能,不是本文的重点,一笔带过,仅供参考:

async customUpload(file, insertFn) {
  // 自定义图片上传
  const image: any = await uploadServer({
    type: UPLOAD_TYPE.IMAGE,
    file,
    progressHandler: handleProgress
  })
  const { url, title } = image
  // 插入图片
  insertFn(url, title);
  
  // 对选中节点居中显示
  SlateTransforms.setNodes(editorRef.value, {
    textAlign: 'center'
  }, {
    mode: 'highest' // 针对最高层级的节点
  })
},

对上传的图片进行默认居中显示,是业务上的需求。

三、从素材库选择上传

image.png

我们的业务为知识库系统,图片视频等资源会存储在服务器,并通过可视化系统进行管理。如何将素材库运用到编辑器,并将其进行关联。 思路如下:
  • 创建一个按钮菜单“素材图片”,将其配置到菜单栏中,将按钮“素材图片”与业务系统关联起来。比如点击按钮,弹出选择素材的对话框。
  • 如何将与“素材图片”按钮放到图片菜单组中,与“网络图片”、“上传图片”并列现实。
  • 自定义图片插入功能,将选择的素材插入到编辑器中。

思路和方向没有问题,接下来就是实现了。

image.png

1. 自定义菜单

创建ButtonMenu按钮,创建class

import { IButtonMenu, IDomEditor } from '@wangeditor/editor';

// const svg = require('@/assets/svg/ai-keywords-extract.svg')
const svgIcon = '<svg t="1691112864399" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1825" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1008.298667 504.149333c0 114.346667-38.912 225.28-110.250667 314.606934l110.250667 106.5984a56.0128 56.0128 0 0 1-79.223467 79.189333l-110.2848-106.496C601.224533 1071.786667 283.989333 1036.288 110.250667 818.722133-63.488 601.224533-27.989333 283.989333 189.508267 110.250667A504.149333 504.149333 0 0 1 504.149333 0c278.4256 0 504.149333 225.723733 504.149334 504.149333zM504.149333 111.991467c-216.541867 0-392.0896 175.547733-392.0896 392.0896 0 216.576 175.547733 392.123733 392.0896 392.123733s392.0896-175.547733 392.0896-392.123733c0-216.541867-175.547733-392.0896-392.0896-392.0896z" fill="#4F4F4F" p-id="1826"></path><path d="M412.5696 682.666667l21.2992-72.6016h154.794667L610.304 682.666667h142.199467L587.1616 242.858667H438.954667L273.681067 682.666667h138.888533z m147.592533-167.697067h-96.904533l48.298667-158.1056 48.605866 158.1056z" fill="#4F4F4F" p-id="1827"></path></svg>'

class SelectImageMaterialMenu implements IButtonMenu {   // TS 语法
  title: string;
  tag: string;
  iconSvg: string;
  
  constructor() {
    this.title = '选择素材' // 自定义菜单标题
    this.tag = 'button'
    this.iconSvg = svgIcon // 可选
  }
  
  // 获取菜单执行时的 value ,用不到则返回空 字符串或 false
  getValue(editor: IDomEditor): string | boolean {
    return ''
  }
  
  // 菜单是否需要激活(如选中加粗文本,“加粗”菜单会激活),用不到则返回 false
  isActive(editor: IDomEditor): boolean {
    return false
  }
  
  // 菜单是否需要禁用(如选中 H1 ,“引用”菜单被禁用),用不到则返回 false
  isDisabled(editor: IDomEditor): boolean {
    return false
  }
  
  // 点击菜单时触发的函数
  exec(editor: IDomEditor, value: string | boolean) {
    // 自定义触发事件。
    editor.emit('on-select-image-material')
  }
  
}

export default SelectImageMaterialMenu

自定义事件on-select-image-material,在按钮点击时触发,并在编辑器创建完成后监听,这样就将编辑器按钮与业务进行关联。

触发:editor.emit('on-select-image-material')

监听:

editor.on('on-select-image-material', () => {
  ElMessage.success('触发了素材图片按钮!!!')
  // 后续的业务操作,如弹出选择素材的对话框。
})

注册菜单到 wangEditor

const selectImageMaterialMenuConf = {
  key: 'selectImageMaterial',
  factory() {
    return new SelectImageMaterial()
  },
}

插入菜单到工具栏

import { Boot, } from '@wangeditor/editor';

Boot.registerModule({
  editorPlugin: dealEditorPlugin,
  menus: [
    selectImageMaterialMenuConf,
  ],
});

2. 配置工具栏菜单。

toolbarConfig提供的工具栏的配置api包括:

  • toolbarKeys: 重新配置工具栏,显示哪些菜单,以及菜单的排序、分组。
  • insertKeys: 可以在当前 toolbarKeys 的基础上继续插入新菜单,如自定义扩展的菜单。
  • excludeKeys: 如果仅仅想排除掉某些菜单,其他都保留
  • modalAppendToBody:将菜单弹出的 modal 添加到 body 下,并自定义 modal 的定位和其他样式

通过提供api,insertKeys向工具栏中指定位置插入菜单,仅支持一级菜单,并不支持配置菜单组中二级菜单,图片为菜单组。因此只能通过toolbarKeys重新配置工具栏。将自定义的菜单插入图片菜单组中。

获取当前编辑器工具栏所有的配置

image.png

重新配置工具栏

toolbarConfig.toolbarKeys = [
  "headerSelect",
  "blockquote",
  "|",
  "bold",
  "underline",
  "italic",
  {
    "key": "group-more-style",
    "title": "更多",
    "iconSvg": "<svg viewBox="0 0 1024 1024"><path d="M204.8 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M505.6 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path><path d="M806.4 505.6m-76.8 0a76.8 76.8 0 1 0 153.6 0 76.8 76.8 0 1 0-153.6 0Z"></path></svg>",
    "menuKeys": [
      "through",
      "code",
      "sup",
      "sub",
      "clearStyle"
    ]
  },
  "color",
  "bgColor",
  "|",
  "fontSize",
  "fontFamily",
  "lineHeight",
  "|",
  "bulletedList",
  "numberedList",
  "todo",
  {
    "key": "group-justify",
    "title": "对齐",
    "iconSvg": "<svg viewBox="0 0 1024 1024"><path d="M768 793.6v102.4H51.2v-102.4h716.8z m204.8-230.4v102.4H51.2v-102.4h921.6z m-204.8-230.4v102.4H51.2v-102.4h716.8zM972.8 102.4v102.4H51.2V102.4h921.6z"></path></svg>",
    "menuKeys": [
      "justifyLeft",
      "justifyRight",
      "justifyCenter",
      "justifyJustify"
    ]
  },
  {
    "key": "group-indent",
    "title": "缩进",
    "iconSvg": "<svg viewBox="0 0 1024 1024"><path d="M0 64h1024v128H0z m384 192h640v128H384z m0 192h640v128H384z m0 192h640v128H384zM0 832h1024v128H0z m0-128V320l256 192z"></path></svg>",
    "menuKeys": [
      "indent",
      "delIndent"
    ]
  },
  "|",
  "emotion",
  "insertLink",
  {
    "key": "group-image",
    "title": "图片",
    "iconSvg": "<svg viewBox="0 0 1024 1024"><path d="M959.877 128l0.123 0.123v767.775l-0.123 0.122H64.102l-0.122-0.122V128.123l0.122-0.123h895.775zM960 64H64C28.795 64 0 92.795 0 128v768c0 35.205 28.795 64 64 64h896c35.205 0 64-28.795 64-64V128c0-35.205-28.795-64-64-64zM832 288.01c0 53.023-42.988 96.01-96.01 96.01s-96.01-42.987-96.01-96.01S682.967 192 735.99 192 832 234.988 832 288.01zM896 832H128V704l224.01-384 256 320h64l224.01-192z"></path></svg>",
    "menuKeys": [
      "insertImage",
      "uploadImage",
      "selectImageMaterial", // 自定义菜单
    ]
  },
  {
    "key": "group-video",
    "title": "视频",
    "iconSvg": "<svg viewBox="0 0 1024 1024"><path d="M981.184 160.096C837.568 139.456 678.848 128 512 128S186.432 139.456 42.816 160.096C15.296 267.808 0 386.848 0 512s15.264 244.16 42.816 351.904C186.464 884.544 345.152 896 512 896s325.568-11.456 469.184-32.096C1008.704 756.192 1024 637.152 1024 512s-15.264-244.16-42.816-351.904zM384 704V320l320 192-320 192z"></path></svg>",
    "menuKeys": [
      "insertVideo",
      "uploadVideo"
    ]
  },
  "insertTable",
  "codeBlock",
  "divider",
  "|",
  "undo",
  "redo"
]

image.png

3、自定义图片插入方法

wangeditor有插入图片的实现,但是没有对外暴露,因此需要自己试下插入方法。当前实现需要阅读源码。

import { Transforms, Range, Editor } from 'slate'
import { IDomEditor, DomEditor } from '@wangeditor/core'
// 定义数据类型
type EmptyText = {
  text: ''
}

export type ImageStyle = {
  width?: string
  height?: string
}

export type ImageElement = {
  type: 'image'
  src: string
  alt?: string
  href?: string
  style?: ImageStyle
  children: EmptyText[]
}

export function replaceSymbols(str: string) {
  return str.replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

async function check(
  menuKey: string,
  editor: IDomEditor,
  src: string,
  alt: string = '',
  href: string = ''
): Promise<boolean> {
  const { checkImage } = editor.getMenuConfig(menuKey)
  if (checkImage) {
    const res = await checkImage(src, alt, href)
    if (typeof res === 'string') {
      // 检验未通过,提示信息
      editor.alert(res, 'error')
      return false
    }
    if (res == null) {
      // 检验未通过,不提示信息
      return false
    }
  }
  
  return true
}

async function parseSrc(menuKey: string, editor: IDomEditor, src: string): Promise<string> {
  const { parseImageSrc } = editor.getMenuConfig(menuKey)
  if (parseImageSrc) {
    const newSrc = await parseImageSrc(src)
    return newSrc
  }
  return src
}

export async function insertImageNode(
  editor: IDomEditor,
  src: string,
  alt: string = '',
  href: string = ''
) {
  const res = await check('insertImage', editor, src, alt, href)
  if (!res) return // 检查失败,终止操作
  
  const parsedSrc = await parseSrc('insertImage', editor, src)
  
  // 新建一个 image node
  const image: ImageElement = {
    type: 'image',
    src: replaceSymbols(parsedSrc),
    href,
    alt,
    style: {},
    children: [{ text: '' }], // 【注意】void node 需要一个空 text 作为 children
  }
  
  // 如果 blur ,则恢复选区
  if (editor.selection === null) editor.restoreSelection()
  
  // 如果当前正好选中了图片,则 move 一下(如:连续上传多张图片时)
  if (DomEditor.getSelectedNodeByType(editor, 'image')) {
    editor.move(1)
  }
  
  if (isInsertImageMenuDisabled(editor)) return
  
  // 插入图片
  Transforms.insertNodes(editor, image)
  
  // 回调
  const { onInsertedImage } = editor.getMenuConfig('insertImage')
  if (onInsertedImage) onInsertedImage(image)
}

export function isInsertImageMenuDisabled(editor: IDomEditor): boolean {
  const { selection } = editor
  if (selection == null) return true
  if (!Range.isCollapsed(selection)) return true // 选区非折叠,禁用
  
  const [match] = Editor.nodes(editor, {
    match: n => {
      const type = DomEditor.getNodeType(n)
      
      if (type === 'code') return true // 代码块
      if (type === 'pre') return true // 代码块
      if (type === 'link') return true // 链接
      if (type === 'list-item') return true // list
      if (type.startsWith('header')) return true // 标题
      if (type === 'blockquote') return true // 引用
      if (Editor.isVoid(editor, n)) return true // void
      
      return false
    },
    universal: true,
  })
  
  if (match) return true
  return false
}

const isDisabled = (editor: IDomEditor) =>  {
  return isInsertImageMenuDisabled(editor)
}

/**
 * 自定义插入图片
 */
export const insertImage = (editor: IDomEditor, src: string, alt: string = '', href: string = '') => {
  if (!src) return
  // 还原选区
  editor.restoreSelection()
  
  if (isDisabled(editor)) return
  
  // 插入图片
  insertImageNode(editor, src, alt, href)
}

至此大功告成

只需要在我们需要的地方,调用insertImage方法即可,如:选择素材之后。

image.png

参考

1. 工具栏配置

2. insertImage github源码

3. slatejs 官方文档