wangEditor富文本编辑器V5版本实现插入自定义图片元素

7,990 阅读7分钟

前言

个人觉得wangEditor是一款体验不错的富文本编辑器,还有详细的中文文档,算是非常友好了。只可惜编辑器自带的图片操作方式不是很对我们产品经理的胃口,不过好在编辑器可以自定义元素,扩展还是比较方便的,在此记录下。

首先编辑器自带的图片格式长这样,是通过悬浮的工具条来操作的,想要编辑备注必须要点开来才行

image.png

接下来是自定义的效果,参考了知乎的展示方式和交互

2.gif

功能点

  1. 可以通过关闭按钮删除图片
  2. 可以通过按钮切换图片宽度为全宽或固定宽度
  3. 可以直接编辑备注
  4. 可以复制黏贴备注,备注超过字数限制时停止输入
  5. 上传图片时有loading效果
  6. 支持批量上传图片,失败重传

实现步骤

其实编辑器是提供了部分的自定义功能的,上传图片自定义 提供了insertFn函数,这个函数内部是以默认的图片节点格式{type:'image'}插入图片的。我们要做的就是跳过这个步骤,插入自定义的图片元素。

定义新节点的格式

因为要编辑备注,所以没有设计成void的形式,用children来放文本节点,具体格式如下

const thingImage = {
  type: 'thingImage',
  src: '',
  file: null, // 需要上传的文件
  mode: '', // 居中还是全宽
  children: [{ text: '' }] // 用于放置备注
};

创建渲染函数

这里参考官方文档根据需要来写,几个关键节点写清楚就好。需要注意渲染函数每当节点更新时都会触发。

关闭按钮

通过slate的api进行删除操作,其中path表示图片节点的位置

const path = DomEditor.findPath(editor, elem); //查找节点的位置
const closeIconVnode = h(
    'div',
    {
      props: { className: 'ImageDelete-Wrapper-icon' },
      on: {
        click() {
          SlateTransforms.removeNodes(editor, { at: path }); //删除节点
          editor.restoreSelection(); // 恢复选区
        }
      }
    },
    []
  );

切换宽度按钮

  let isFullWidth = mode === 'full';
  const buttonVnode = h(
    'div',
    {
      props: { className: `Image-buttonWrap ${isFullWidth ? 'status-full' : 'status-middle'}` },
      style: {},
      on: {
        click() {
          SlateTransforms.setNodes(
            editor,
            {
              mode: isFullWidth ? 'middle' : 'full'
            },
            {
              at: path,
              mode: 'highest' // 针对最高层级的节点
            }
          );
        }
      }
    }
    // [`切换为${isFullWidth ? '居中' : '全宽'}`] //这里不知道为啥切换中文输入会导致节点丢失,改用了css方案实现
  );

切换是通过slate的api重新设置节点属性实现

另外,原本我想的是通过文本节点来显示按钮文本,但是不知道为什么切换输入法会导致文本消失,所以后面我就改用了css方案,通过切换类名+伪元素来显示文案。

备注节点

const placeholderText = '添加图片注释,不超过 140 字(可选)';
const emptyText = '';

...
...

const isDisabled = editor.isDisabled(); //编辑器是否禁用
//获取文本节点的位置
const textPath = isDisabled ? [] : DomEditor.findPath(editor, elem.children[0]); 

...
...

const remarkVnode = h(
    'div',
    {
      props: { className: 'Image-caption' },
      style: {},
      on: {
        click() {
          if (remark === placeholderText) {
            SlateTransforms.insertText(editor, emptyText, { at: textPath }); //替换节点
          }
        }
      }
    },
    children //将子节点插入,渲染函数中的参数children
  );

备注是直接将子节点插入,设计上虽然子节点是个数组children,但是我们只将第一个节点的内容作为备注内容。如果是黏贴过来的文本会出现多个子节点,这一部分处理将放到插件中。

点击事件:如果还没有输入过备注则显示默认备注,当点击时如果文本内容还是跟默认备注一致,则表示要编辑备注,此时将备注设置成空字符串,这里的insertText字面上是插入节点,在同一个位置插入textPath,就会变成替换节点。

其他状态判断,选中,失去选中,超出字数限制的处理

const remark_limit = 140; //备注字数限制
const textPath = isDisabled ? [] : DomEditor.findPath(editor, elem.children[0]); //文本节点的位置
const selected = DomEditor.isNodeSelected(editor, elem); // 节点是否选中
let remark = elem.children[0].text;//获取备注内容
...
...
// 未选中/选中未添加备注时处理placeholder
  if (!isDisabled) { //未禁用状态下才会对选中和不选中做出反应
    if (!selected && !remark.trim()) { // 未选中并且没有备注,设置为默认备注
      SlateTransforms.insertText(editor, placeholderText, { at: textPath }); //替换为默认文本
    } else if (selected && remark === placeholderText) { //选中并且文本为默认文本
      SlateTransforms.insertText(editor, emptyText, { at: textPath }); //替换为空文本
    } else if (remark && remark.trim().length > remark_limit) {//超出字数限制时
      SlateTransforms.insertText(editor, remark.trim().substring(0, remark_limit), { at: textPath });
    }
  }

定义插件

插件主要是通过重写api来增加一些节点的特殊情况处理,如插入节点标准化,回车的处理,黏贴文本的处理等。

节点标准化

这部分我是从源码中copy的,这个需求还是挺常见的。就是为了方便编辑需要在后面插入一个空的p

// 重写 normalize
  newEditor.normalizeNode = ([node, path]) => {
    const type = DomEditor.getNodeType(node);
    if (type !== 'thingImage') {
      // 未命中 thingImage ,执行默认的 normalizeNode
      return normalizeNode([node, path]);
    }

    // editor 顶级 node
    const topLevelNodes = newEditor.children || [];

    // --------------------- thingImage 后面必须跟一个 p header blockquote(否则后面无法继续输入文字) ---------------------
    const nextNode = topLevelNodes[path[0] + 1] || {};
    const nextNodeType = DomEditor.getNodeType(nextNode);
    if (nextNodeType !== 'paragraph' && nextNodeType !== 'blockquote' && !nextNodeType.startsWith('header')) {
      // thingImage node 后面不是 p 或 header ,则插入一个空 p
      const p = { type: 'paragraph', children: [{ text: '' }] };
      const insertPath = [path[0] + 1];
      SlateTransforms.insertNodes(newEditor, p, {
        at: insertPath // 在 link-card 后面插入
      });
    }
  };

处理回车

因为前面已经处理了节点,也就是说图片节点后面至少有个空的p,所以回车只需要移动到下一个位置就行了。

  newEditor.insertBreak = () => {
    const selectedNode = DomEditor.getSelectedNodeByType(newEditor, 'thingImage');
    if (selectedNode != null) {
      // 选中了 thingImage ,则移动到下一行
      const path = DomEditor.findPath(editor, selectedNode); //查找改节点在content中的位置
      newEditor.select({
        anchor: { path: [path[0] + 1, 0], offset: 0 },
        focus: { path: [path[0] + 1, 0], offset: 0 }
      });
      return;
    }

    insertBreak();
  };

黏贴文本

import { Editor } from 'slate';
// 重写 insertData - 粘贴文本
newEditor.insertData = data => {
    const selectedNode = DomEditor.getSelectedNodeByType(newEditor, 'thingImage');
    if (selectedNode == null) {
      insertData(data); // 执行默认的 insertData
      return;
    }

    // 获取文本,并插入到备注
    const text = data.getData('text/plain');
    Editor.insertText(newEditor, text);
  };

上传图片Loading效果

要做loading,首先要一个占位图,将选择的文件转成base64插入到编辑器,并通过样式添加loading效果,等上传完毕后再更新节点的图片url。

前面提到编辑器提供了自定义上传的入口,而我们只是要跳过插入图片这部分并替换成插入我们的自定义节点,所以上传这部分还是用已经提供的配置来做。

image.png

然后再渲染函数中判断,如果有file,则添加loading类名

const waitUploadFile = !!file; //需要上传文件的情况
...
...
// 自定义图片元素 vnode
  const thingCardVnode = h(
    // HTML tag
    'div',
    // HTML 属性、样式、事件
    {
      props: {
        className: `thing-page-image Image-captionContainer ${selected && !isDisabled ? 'thing-image-container' : ''} ${
          waitUploadFile ? 'Image-loading' : ''
        }`
      }, // Image-loading就是loading类
    },
    // 子节点
    isDisabled ? [imageWrapVnode] : [imageWrapVnode, remarkVnode]
  );

上传完成的处理则是在插件中处理,重写insertNode,当上传完成时通过setNodes更新节点取消loading。除此之外,也可以做一些默认值的处理,如插入自定义图片节点时添加placeholder

//插件
// 重写 insertNode,插入节点前加入placeholder节点,上传图片
  newEditor.insertNode = node => {
    if (node.type === 'thingImage') {
      if (!node.children) {
        node.children = [{ text: placeholderText }];
      }
      if (node.file) {
        upload(node.file).then(res => { //upload是自己写的上传方法
          const path = DomEditor.findPath(editor, node); //查找改节点的位置
          SlateTransforms.setNodes(
            editor,
            {
              src: res.addr,//图片url
              file: null
            },
            {
              at: path,
              mode: 'highest' // 针对最高层级的节点
            }
          );
        });
      }
      let res = insertNode(node);
      setTimeout(() => {
        // 插入图片后,将光标移动到后面的位置,防止直接选中图片,强制编辑图片备注,这里没想到好的方式,暴力使用setTimeOut方案
        let path = DomEditor.findPath(editor, node);
        newEditor.select({
          anchor: { path: [path[0] + 1, 0], offset: 0 },
          focus: { path: [path[0] + 1, 0], offset: 0 }
        });
      }, 200);
      return res;
    }
    return insertNode(node);
  };

导出模块

参考官方文档导出为模块即可

const module = {
  editorPlugin: withThingImage, // 插件,前文提到的插件方法都在这里
  renderElems: [renderElemConf], // renderElem,前文提到的渲染函数处理都在这里
  elemsToHtml: [elemToHtmlConf], // elemToHtml 参考官方文档
  parseElemsHtml: [parseHtmlConf] // parseElemHtml 参考官方文档
};

export default module;

使用

import { Boot } from '@wangeditor/editor';
import thingImage from './module/thingImage';
Boot.registerModule(thingImage);

结尾语

因为也是第一次使用wangEditor,大部分都是参考官方文档和源码来解决问题的,整体认识还比较粗浅,以实现需求为第一优先级。有些地方实现的不一定对或者有更好的方式,欢迎大家讨论。