纠缠不清的富文本编辑器

6,340 阅读9分钟

前言

​ 入职后的第一个项目就涉及富文本编辑器,接手时编辑器功能已开发的比较完善,我则是站在“前人的肩膀上前行”。此篇内容意在分享如何搭建一个富文本编辑器,及自己在开发过程中的踩坑经历。因项目使用 Quill,故本文也以此为基础描述。

前戏:快速搭个富文本编辑器

​ 虽项目为Quill搭配Vue,但Quill实则为一个彻彻底底的“绿茶”,只要“爽”无论你是当红的VueReact,还是“老当益壮”的jQuery,它都愿意“跟”~

本文则用React演示,为方便搭建则使用Parcel进行构建

依赖

yarn add parcel-bundler quill react react-dom sass

npm jiǒ 本

{
  // ...
  "scripts": {
    "clean": "rm -rf dist/ ./cache",	// 不清理 ./cache 可能会造成页面刷新问题
    "start": "yarn clean && parcel src/index.html"
  },
	// ...
}

欢迎Quill登场

index.js

import React from 'react';
import {render} from 'react-dom';
import App from './app';

render(<App />, document.getElementById('app'));

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Quill</title>
</head>
<body>
    <div id="app"></div>
    <script src="./index.js"></script>
</body>
</html>

app.js

import React, {Component} from 'react';
import Quill from 'quill';
import './app.scss';

export default class QuillEditor extends Component {
  constructor(props) {
    super(props);
    this.state = {
      quill: null,
    };
  }

  componentDidMount() {
    const quill = new Quill('#editor', {	// param1: 编辑器挂载的节点
      theme: 'snow',	// 选择心仪的编辑器“皮肤”
    });
    this.setState({quill});
  }

  render() {
    return <div className="editor-wrapper">
      <div id="editor"></div>
    </div>;
  }
};

app.scss

@import "../node_modules/quill/dist/quill.snow.css";

* {
  margin: 0;
  padding: 0;
}

.editor-wrapper {
  width: 800px;
}

.ql-container.ql-snow {
  height: 600px;
}

​ 此时最基础的编辑器已搭建好,运行yarn start会在浏览器看到如下界面:

​ 通过上面的代码,大家可以看出这个“绿茶”的确与框架无关,它只要知道了它的”猎物“即可!

身子热乎了,来撸起袖子干

知己知彼

​ 以上搭的编辑器仅有简单的有序/无序列表/加粗/斜体/下划线/超链接等功能,根本无法满足需求,所以接下来要解锁Quill的更多”姿势“。扩充功能之前需了解Quill中的几个基本概念:Parchment/Blot/Attributor/Formats/ModulesDelta

Parchment:Quill的文档模型,是一个和DOM树平行的树结构,为Quill提供一些可用的功能。

Blot: 一个Parchment由多个Blot组成,Blot对应的是一个DOM节点,Blot可以提供结格式化或内容等。让我们来康康Blot源码中都有些什么,从而来帮助我们理解:

parchment/src/blot/inline.d.ts

// 暂且与DOM相对照介绍属性或方法含义
declare class TextBlot extends LeafBlot implements Leaf {
  	// ...
    static blotName: string;	// Blot的名字
	  // Blot是行内/块级还是其他scope(并不是指HTML中的块级元素等,是对于Blot而言)
    static scope: Registry.Scope;	
    static value(domNode: Text): string;	// DOM节点中的值
  	// ...
}
export default TextBlot;

Attributor: 可以提供格式化信息(下面将结合format进行介绍);

Format: 在工具栏上的每一个功能都对应一个format,工具栏的功能当然不止上图中的,用户也可以自定义工具栏(本文对自定义工具栏不做赘述,官方文档请点此处),让我们再来康康format这葫芦里卖的什么药:

quill/formats/align.js

import Parchment from 'parchment';

let config = {
  scope: Parchment.Scope.BLOCK,	// Align的scope是块级的
  whitelist: ['right', 'center', 'justify']	// 以白名单的方式进行操作,可以对文字内容进行右对/居中/justify的对齐操作
};

// 以下三行代码就很直白的体现出Attributor“提供格式化信息”的功能了
let AlignAttribute = new Parchment.Attributor.Attribute('align', 'align', config);
let AlignClass = new Parchment.Attributor.Class('align', 'ql-align', config);
let AlignStyle = new Parchment.Attributor.Style('align', 'text-align', config);

export { AlignAttribute, AlignClass, AlignStyle };

/* 以上的代码就是align format的内容,对应的就是工具栏中的居中/右对齐的操作 */

Module: 可以通过module自定义Quill的行为和功能;

目前官方提供了如下5种可自定义的module

  • ToolBar:工具栏
  • Keyboard:键盘事件比如⌘+B将文字设为粗体等
  • History:可以设置返回上一步的最大次数等
  • Clipboard:可以对剪贴板事件进行处理
  • Syntax:可以通过自动监测和语法高亮增强代码格式等

Delta: 描述修改的内容和数据格式。

开始心心念的事情

首先将编辑器的字体改一下

我们需要自定义format来修改我们的字体:

import Quill from 'quill';

const Parchment = Quill.import('parchment');

const config = {
  scope: Parchment.Scope.INLINE,
  whitelist: ['Times New Roman'],
};

class FontStyleAttributor extends Parchment.Attributor.Style{
  value(node) {
    return super.value(node).replace(/["']/g, '');
  }
}

const FontStyle = new FontStyleAttributor('font', 'font-family', config);

export default FontStyle;

再给编辑器添加个分割线功能

分割线就要format搭配着module一起定义了:

Format

import Quill from 'quill';

const BlockEmbed = Quill.import('blots/block/embed');

class Divider extends BlockEmbed {
  static create() {
    const node = super.create();
    node.setAttribute('style', 'border: 1px solid #eee; margin: 12px 0;');
    return node;
  }
}

Divider.blotName = 'divider';
Divider.tagName = 'HR';

export default Divider;

Module

import Quill from 'quill';

export default class Divider {
  constructor(quill) {
    this.quill = quill;
    this.toolbar = quill.getModule('toolbar');
    if (typeof this.toolbar !== 'undefined') {
      this.toolbar.addHandler('divider', this.insertDivider);
    }
  }

  insertDivider() {
    const range = this.quill.getSelection(true);
    // 第三个参数source设为user意为:当编辑器为disabled时忽略调用
    this.quill.insertText(range.index, '\n', Quill.sources.USER);	// 插入空行
    this.quill.insertEmbed(range.index + 1, 'divider', true, Quill.sources.USER);	// 插入分割线
    this.quill.setSelection(range.index + 2, Quill.sources.USER);	// 设置光标位置
  }
}

处理图片

目前编辑器虽然是可以粘贴图片,但是图片对于我们是外网地址呀!如果想上传到服务器再使用我们内网地址展示应该咋整呢?这时候我们需要自定义一下module

import Quill from 'quill';

export default class ImageDrop {
  constructor(quill, options = {}) {
    this.quill = quill;
    // 绑定粘贴处理事件
    this.handlePaste = this.handlePaste.bind(this);
    // 监听粘贴事件
    this.quill.root.addEventListener('paste', this.handlePaste, false);
  }
 
  handlePaste(e) {
    // 判断剪切板中是否存在内容
    if (e.clipboardData && e.clipboardData.items && e.clipboardData.items.length) {
      this.readFiles(e.clipboardData.items, () => {
        this.loading = false;
      });
    }
  }

  readFiles(files, callback) {
    // 获取剪切板中的图片
    [].forEach.call(files, file => {
      if (file.type.match(/^image\/(gif|jpe?g|a?png|svg|webp|bmp|vnd\.microsoft\.icon)/i)) {
        const { index } = this.quill.getSelection();
        const asFile = file.getAsFile();
        if (asFile && asFile.size > MaxSize) {
          // 如果检测到文件大小超出限制范围
         	// 可以在此处进行相应处理或提示
        } else if (asFile) {
          // 插入图片
          this.quill.insertEmbed(index, 'pasteImage', {file: asFile, callback}, Quill.sources.USER);
          // 设置光标位置
          this.quill.setSelection(index + 2);
        }
      }
    })
  }
}

​ 可以格式化文字,还能插入图片,这就够了吗?

​ 不!我们是有更高追求的人!我们在做业务的时候要好好思考一下业务场景,对于富文本编辑器的使用,用户会老老实实的在编辑器里一个字一个字的敲进去吗?“并不会!”所以,这就要求我们对粘贴进来的内容进行处理,虽然编辑器是可以保留粘贴的内容格式的,但是这并不复合业务,对于使用编辑器产出的文章,内容格式以及风格应当保持一致才对。所以我们应该对内容进行统一处理,比如:将h1标签转换为我们的h1标签,将h2/3/4等装换为正文文字(当然,着要看需求而定,只是在此举例)。

处理粘贴内容

还记得官方提供的5个module吗?其中就有clipboard!下面我们自定义一下clipboard,这可是个大工程。先说一下思路:

  1. 在粘贴事件中获取到Delta
  2. Delta进行格式化处理;
  3. 将处理后的Delta更新到编辑器中。
获取Delta
export default class PlainClipboard extends Clipboard {
  constructor(quill, options) {
    super(quill, options);
    this.quill = quill;
  }

  onPaste(e) {
    e.preventDefault();
    
    const html = e.clipboardData.getData('text/html');
    const text = e.clipboardData.getData('text/plain');
    // 可以通过clipboard的convert方法将clipboard中的内容转换成delta
    let delta = this.quill.clipboard.convert(html || text);

    console.info(delta);
  }
  // do something else ...
}

​ 我全选并复制一下Quill官网首页的内容,输出的Delta如下图所示:

​ 可以看到主要由insertattributes组成,上面说到Delta是“描述修改的内容和数据格式”,insert就是内容,attributes则为数据格式。

处理Delta
export default class PlainClipboard extends Clipboard {

  // ...
  
  onPaste(e) {
    this.updateContents(this.quill, delta);
    this.quill.selection.scrollIntoView(this.quill.scrollingContainer);
  }

  updateContents(quill, delta) {
    let ops = [];
    let opsLength = 0;
    const {index, length} = quill.getSelection();
    delta.ops.forEach((op, idx, arr) => {
      const {attributes, insert} = op;
      const newOp = {};
      if (attributes) {
        newOp.attributes = {};
        // 保留bold | underline | italic格式
        newOp.attributes.bold = attributes.bold || false;
        newOp.attributes.underline = attributes.underline || false;
        newOp.attributes.italic = attributes.italic || false;
        // 保留align: center | right对齐
        // 排除从word单独粘贴分割线的情况
        if (typeof insert === 'string' && attributes.align) {
          newOp.attributes.align = attributes.align;
          newOp.insert = '\n';
        }
        // 保留分割线
        if (typeof insert === 'object' && insert.divider) {
          newOp.insert = insert;
        }
      }

        newOp.insert = insert;

        // 处理insert末尾有多个空行的情况
        if (typeof newOp.insert === 'string') {
          newOp.insert = newOp.insert.replace(/\n+$/g, '\n');
        }
        if (newOp.insert && !insert.image) {
          ops.push(newOp);
        }
        opsLength += insert.length || 0;
      }

      // 处理粘贴的图片
      [op, ops, opsLength] = this.handleImagePaste({op, ops, opsLength});
    });

		// 更新编辑器的内容
    this.handleUpdateContents({quill, ops, opsLength, index, length});
  }

  handleImagePaste(config) {
    const {op} = config;
    let {ops, opsLength} = config;
    if (
        op.insert &&
        typeof op.insert === 'object' &&
        op.insert.image
    ) {
      ops = [
          ...ops,
        {
          insert: '\n',
        },
        {
          insert: {
            image: op.insert.image,
          },
        },
        {
          insert: '\n',
          attributes: {	// 图片居中显示
            align: 'center',
          },
        },
      ];
      opsLength += 3;
    }

    return [op, ops, opsLength];
  }

  handleUpdateContents(config) {
    const {quill, ops, opsLength, index, length} = config;
    if (length) {
      ops.unshift({
        delete: length,
      });
    }
    if (index) {
      ops.unshift({
        retain: index,
      });
    }

    // 将处理好的delta更新到编辑器中,并设置光标位置
    quill.updateContents(ops);
    quill.setSelection(index + opsLength, 0, Quill.sources.SILENT);
  }

  isTextAlign(attributes) {
    return attributes.align === 'center' || attributes.align === 'right';
  }
};

踩过的坑

1. 保留Word格式

​ 可以看到在上面的代码中有部分为word写的,其实word复制粘贴的内容情况很多,下面总结一下word中遇到的比较坑的点:

分割线

​ 用户可以通过输入多个短横线然后按下回车生成分割线,也可以通过插入分割线的方式,如图:

​ 只有通过后者插入的分割线,才能在Delta中获取到,才能对Delta进行相应处理;

有序/无序列表

​ 有些开发人员在自定义font之后发现粘贴有序无序列表就无法显示前面的列表序号或符号了,这是因为word中粘贴的列表的序号和符号是一种特殊的font-familyWindings(在Windows操作系统下),所以如果想正常显示序号,需要将Windings加入white-list

​ 当我们将一个列表复制到编辑器中后,会发现Delta中有很多的“空格”,在Windows系统下,Delta中的这五个空白符并不是普通的空格,这个空白符的char code为160,而空格的char code为32。

2. 转义

​ 虽然编辑器已经帮我们做了转义操作,但是clipboard.convert方法却有漏洞,当我们将纯文本格式的<img src='1' onerror='alert(1)' />粘贴到编辑器后,convert方法会将文本字符串转换为img实例,从而就触发了onerror事件。

3. 判断内容是否相等

​ 在用户编辑一篇已保存的文章,然后点击退出或返回时,为了更好的用户体验则需对编辑后的文章内容与之前保存的内容进行比对,从而判断是否要弹出二次弹窗让用户确认是否确认退出编辑页面。但是有时虽然在视觉上看着内容完全一致,并且比较方法写的也没任何问题,但是比较的方法一致返回内容不一致。这时就有可能是因为\uFEFF!该Unicode字符可以在quill/blots/cursor中找到,在源码的最后部分:

​ 注释中写道“没有宽度”,其实这个字符是用来处理下一个要输入的字符的。

事后

​ 上面讲了这么多,也没说自定义的formatmodule到底怎么用(狗头保命)~其实用起来很简单,只需使用Quill.register方法注册即可,例如:

import Quill from 'quill';

const AlignStyle = Quill.import('attributors/style/align');

Quill.register(AlignStyle, true);
Quill.register({'formats/font': FontStyle}, true);

Quill.register('modules/imageDrop', ImageDrop);
Quill.register('modules/clipboard', PlainClipboard);

本文篇幅略长,如有误之处请指出,定及时改正!

欢迎大家关注公众号:Refactor,后续会出更多好玩并且有用的文章分享给大家,感谢阅读~