前言
入职后的第一个项目就涉及富文本编辑器,接手时编辑器功能已开发的比较完善,我则是站在“前人的肩膀上前行”。此篇内容意在分享如何搭建一个富文本编辑器,及自己在开发过程中的踩坑经历。因项目使用 Quill
,故本文也以此为基础描述。
前戏:快速搭个富文本编辑器
虽项目为Quill
搭配Vue
,但Quill
实则为一个彻彻底底的“绿茶”,只要“爽”无论你是当红的Vue
、React
,还是“老当益壮”的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
/Modules
及Delta
。
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
,这可是个大工程。先说一下思路:
- 在粘贴事件中获取到
Delta
; - 对
Delta
进行格式化处理; - 将处理后的
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
如下图所示:
可以看到主要由insert
和attributes
组成,上面说到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-family
:Windings
(在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
中找到,在源码的最后部分:
注释中写道“没有宽度”,其实这个字符是用来处理下一个要输入的字符的。
事后
上面讲了这么多,也没说自定义的format
和module
到底怎么用(狗头保命)~其实用起来很简单,只需使用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,后续会出更多好玩并且有用的文章分享给大家,感谢阅读~