什么是富文本编辑器 ?
富文本编辑器
,Rich Text Editor
, 简称 RTE
, 是一种可内嵌于浏览器,所见即所得的文本编辑器。它是一种解决可一般的用户不同html等网页标记但是需要在网页上设置字体的颜色、大小、样式等信息问题一个文本编辑器
。
前端常用的富文本编辑器
- wangEditor:wangEditor 是一款使用 Typescript 开发的 Web 富文本编辑器, 轻量、简洁、易用、开源免费。
- Quill:Quill是一种现代的 WYSIWYG 编辑器,旨在实现兼容性和可扩展性。
- TinyMCE:TinyMCE是一款易用、且功能强大的所见即所得的富文本编辑器。
基本原理
对于支持富文本编辑的浏览器来说,通过设置 document
的 designMode
属性为 on
后,再通过执行 document.execCommand(aCommandName, aShowDefaultUI, aValueArgument) 即可,比方说,我们要加粗字体,执行 document.execCommand('bold', false) 即可。
document.execCommand
当一个HTML文档切换到设计模式时,document
暴露 execCommand
方法,该方法允许运行命令来操纵可编辑内容区域的元素。语法:bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument),返回值是一个 Boolean ,如果是 false
则表示操作不被支持或未被启用。
document.execCommand 这个API 已经废弃掉了
,随时有可能被删除,所以现在使用这个API有风险。这个 API 本来也不是标准 API,而是一个 IE
的私有 API,在 IE9
时被引入,后续的若干年里陆续被Chrome / Firefix / Opera
等浏览器也做了兼容支持,但始终没有形成标准。
废弃的主要原因
- 安全问题,在
用户未经授权
的情况下就可以执行一些敏感操作; - 浏览器的行为不一致,不同浏览器生成的结果是不一样的;
- document.execCommand 生成的内容会产生很多
不必要的标签
; - 它是因为这是一个
同步方法
,而且操作了DOM
对象,会阻塞页面渲染和脚本执行,因当初还没Promise
,所以没设计成异步。
富文本编辑器技术阶段划分
Level 0
是编辑器的起始阶段,代表旧一代的编辑器的实现;Level 1
由第一阶段发展过来的,具有一定的先进性,也引入了主流的一些编程思想,对于富文本内容有一定的抽象;Level 2
第三阶段,完全不依赖浏览器的编辑能力,独立的实现光标和排版;
不使用 document.execCommand 如何实现富文本编辑器?
Clipboard
Clipboard 接口实现了 Clipboard API,如果用户授予了相应的权限,就能提供系统剪贴板的读写访问。在 Web 应用程序中,Clipboard API 可用于实现剪切、复制和粘贴功能。系统剪贴板暴露于全局属性 Navigator.clipboard 之中。所有剪贴板 API 方法都是异步的;它们返回一个 Promise
对象,在剪贴板访问完成后被执行,不过它的兼容性不是很好
。
建立抽象数据层
富文本编辑器比较复杂,在此基础上抽象出数据结构难度比较高,但没有数据层就无法实现更高层次的功能。有了数据层的基础,可以实现灵活的定制化渲染、跨平台、在线多人协作等高级特性。
以富文本编辑器 quill
为例,quilljs自带一套数据系统来支撑内容生产,Parchment 和 Delta。
Parchment
Parchment 是 Quill 的文档模型。它是一个并行的树结构,并且提供对内容的编辑(如Quill)的功能。一个Parchment 树是由 Blots
组成的,它反映了一个 DOM 对应的节点。Blots 能够提供结构、格式和内容或者只有内容。
官方例子
import Parchment from 'parchment';
class LinkBlot extends Parchment.Inline {
static create(url) {
let node = super.create();
node.setAttribute('href', url);
node.setAttribute('target', '_blank');
node.setAttribute('title', node.textContent);
return node;
}
static formats(domNode) {
return domNode.getAttribute('href') || true;
}
format(name, value) {
if (name === 'link' && value) {
this.domNode.setAttribute('href', value);
} else {
super.format(name, value);
}
}
formats() {
let formats = super.formats();
formats['link'] = LinkBlot.formats(this.domNode);
return formats;
}
}
LinkBlot.blotName = 'link';
LinkBlot.tagName = 'A';
Parchment.register(LinkBlot);
Parchment 架构解析
由上图看出 Blot
大概可以分成两类:
- 第一种是继承自
ParentBlot
ContainerBlot
表示容器节点;ScrollBlot
表示文档的根节点,不可格式化;BlockBlot
表示块级节点,可格式化的节点;InlineBlot
内联节点,可格式化的节点;
- 第二种是继承自
LeafBlot
EmbedBlot
嵌入式节点;TextBlot
文本节点;
这两种类型的主要区别在于继承 LeafBlot
的节点是都属于一个原子节点,不可用做父节点。所以 leaf 的节点,没有对 child 节点操作的方法。
Blots
Blots
是 Parchment 文档的基本组成部分。提供了几个基本的实现,如:Block、Inline 和Embed 等。
export interface Blot extends LinkedNode {
scroll: Parent;
parent: Parent;
prev: Blot;
next: Blot;
domNode: Node;
attach(): void;
clone(): Blot;
detach(): void;
insertInto(parentBlot: Parent, refBlot?: Blot): void;
isolate(index: number, length: number): Blot;
offset(root?: Blot): number;
remove(): void;
replace(target: Blot): void;
replaceWith(name: string, value: any): Blot;
replaceWith(replacement: Blot): Blot;
split(index: number, force?: boolean): Blot;
wrap(name: string, value: any): Parent;
wrap(wrapper: Parent): Parent;
deleteAt(index: number, length: number): void;
formatAt(index: number, length: number, name: string, value: any): void;
insertAt(index: number, value: string, def?: any): void;
optimize(context: { [key: string]: any }): void;
optimize(mutations: MutationRecord[], context: { [key: string]: any }): void;
update(mutations: MutationRecord[], context: { [key: string]: any }): void;
}
interface LinkedNode {
prev: LinkedNode | null;
next: LinkedNode | null;
length(): number;
}
export default LinkedNode;
从上面的代码中可以看出 Blot 采用的是链表
作为存储结构
。相比数组,链表是一种稍微复杂一点的数据结构,让我们从底层的存储结构来看看二者的区别。
从上图中我们看到,数组需要一块连续的内存空间
来存储,对内存的要求比较高。如果我们申请一个 10 MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 10 MB,仍然会申请失败。
而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针
”将一组零散的内存块串联起来使用,所以如果我们申请的是 10 MB 大小的链表,根本不会有问题。
上图是一种单链表,我们习惯性地把第一个结点叫作头结点
,把最后一个结点叫作尾结点
。其中,头结点用来记录链表的基地址
。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL
,表示这是链表上最后一个结点。
在进行数组
的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以时间复杂度是 O(n)
。而在链表中插入
或者删除
一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的。
在进行富文本编辑的时候会进行大量的
插入删除操作
,所以选择链表这种数据结构比较高效。
Delta
Delta
是一种用来描述内容和修改的基于 JSON
的格式。可以描述任意的富文本文档,包括文本和格式化信息。Delta 是用户操作的数据化表示,一个 Delta 对象和一种 DOM
结构有唯一的双向对应关系。Delta 是 JSON 的一个子集,只包含一个 ops
属性,它的值是一个对象数组,每个数组项代表对编辑器的一个操作。
// Document with text "Gandalf the Grey"
// with "Gandalf" bolded, and "Grey" in grey
const delta = new Delta([
{ insert: 'Gandalf', attributes: { bold: true } },
{ insert: ' the ' },
{ insert: 'Grey', attributes: { color: '#ccc' } }
]);
// Change intended to be applied to above:
// Keep the first 12 characters, delete the next 4,
// and insert a white 'White'
const death = new Delta().retain(12)
.delete(4)
.insert('White', { color: '#fff' });
// {
// ops: [
// { retain: 12 },
// { delete: 4 },
// { insert: 'White', attributes: { color: '#fff' } }
// ]
// }
// Applying the above:
const restored = delta.compose(death);
// {
// ops: [
// { insert: 'Gandalf', attributes: { bold: true } },
// { insert: ' the ' },
// { insert: 'White', attributes: { color: '#fff' } }
// ]
// }
Delta 只有 3
种操作和 1
种属性。
3 种操作:
- insert:插入
- retain:保留
- delete:删除
1 种属性:
- attributes:格式属性
fast-diff
通过阅读 delta 源码 知道,比较两条 delta 之间的差异采用的是 fast-diff
库。fast-diff
这个库只能用来处理 string
类型数据的 diff。fast-diff 是 将 google
的
diff-match-patch
库的简化导入到 Node.js环境中。
diff-match-patch: github.com/google/diff…
fast-diff: github.com/jhchen/fast…
diff 方法在源码中的位置: github.com/quilljs/del…
const NULL_CHARACTER = String.fromCharCode(0); // Placeholder char for embed in diff()
diff(other: Delta, cursor?: number | diff.CursorInfo): Delta {
if (this.ops === other.ops) {
return new Delta();
}
const strings = [this, other].map((delta) => {
return delta
.map((op) => {
if (op.insert != null) {
return typeof op.insert === 'string' ? op.insert : NULL_CHARACTER;
}
const prep = delta === other ? 'on' : 'with';
throw new Error('diff() called ' + prep + ' non-document');
})
.join('');
});
......
}
从上面的源码中可以看出,在调用 fast-diff
之前把所有的 insert
操作全部转成了字符串,对于插入的 number 类型的数据和 object 类型的数据,都转成了一个特殊字符,然后和 string 类型的数据拼在一起,就成了一个大字符串,然后给 fast-diff 处理。转换为字符串然后再进行 diff
算法主要原因是字符串
之间的 diff 更加快速
。
DOM 修改后,怎样同步到 delta?
ScrollBlot
是最顶层的 ContainerBlot
, 即 root Blot
, 包裹所有 blots, 并且管理编辑器中的内容变化。ScrollBlot 会创建一个 MutationObserver
, 用来监控 DOM
更新。DOM 更新时会调用 ScrollBlot 的 update 方法。在 Quill 的 scroll blot 中重写了update 方法,其中对外抛出
SCROLL_UPDATE
事件和 mutations
参数。
update(mutations) {
.....
if (mutations.length > 0) {
this.emitter.emit(Emitter.events.SCROLL_BEFORE_UPDATE, source, mutations);
}
.....
}
editor 会监听 SCROLL_UPDATE
事件,然后触发 editor 的 update
方法,传入 mutations 参数,然后在 editor 的 update 方法中,会依据 mutations 构建出对应的delta 数组,与已有的 delta 合并,使当前 delta 保持最新
。
this.emitter.on(Emitter.events.SCROLL_UPDATE, (source, mutations) => {
const oldRange = this.selection.lastRange;
const [newRange] = this.selection.getRange();
const selectionInfo =
oldRange && newRange ? { oldRange, newRange } : undefined;
modify.call(
this,
() => this.editor.update(null, mutations, selectionInfo),
source,
);
});
源码中的位置 github.com/quilljs/qui…
update(change, mutations = [], selectionInfo = undefined) {
const oldDelta = this.delta;
if (
mutations.length === 1 &&
mutations[0].type === 'characterData' &&
mutations[0].target.data.match(ASCII) &&
this.scroll.find(mutations[0].target)
) {
// Optimization for character changes
const textBlot = this.scroll.find(mutations[0].target);
const formats = bubbleFormats(textBlot);
const index = textBlot.offset(this.scroll);
const oldValue = mutations[0].oldValue.replace(CursorBlot.CONTENTS, '');
const oldText = new Delta().insert(oldValue);
const newText = new Delta().insert(textBlot.value());
const relativeSelectionInfo = selectionInfo && {
oldRange: shiftRange(selectionInfo.oldRange, -index),
newRange: shiftRange(selectionInfo.newRange, -index),
};
const diffDelta = new Delta()
.retain(index)
.concat(oldText.diff(newText, relativeSelectionInfo));
change = diffDelta.reduce((delta, op) => {
if (op.insert) {
return delta.insert(op.insert, formats);
}
return delta.push(op);
}, new Delta());
this.delta = oldDelta.compose(change);
} else {
this.delta = this.getDelta();
if (!change || !isEqual(oldDelta.compose(change), this.delta)) {
change = oldDelta.diff(this.delta, selectionInfo);
}
}
return change;
}
}
editor中的update 方法 github.com/quilljs/qui…
delta 修改后,如何同步到 DOM ?
当 delta 修改后,会遍历 delta 数组, 生成相应的 Blot, Attributor,然后生成 DOM 结构,然后进行 format 操作。
主要的 API
- setContents
setContents(delta: Delta, source: String = 'api'): Delta
setContents
用给定的内容覆盖编辑器的内容。内容应该以一个新行或者换行符结束。返回一个改变的Delta。如果被给定的 Delta 没有无效操作,那么就会作为新的 Delta 通过。操作来源可能为:'user
'、'api
' 或者 'silent
'。
// 源码
setContents(delta, source = Emitter.sources.API) {
return modify.call(
this,
() => {
delta = new Delta(delta);
const length = this.getLength();
// Quill will set empty editor to \n
const delete1 = this.editor.deleteText(0, length);
// delta always applied before existing content
const applied = this.editor.applyDelta(delta);
// Remove extra \n from empty editor initialization
const delete2 = this.editor.deleteText(this.getLength() - 1, 1);
return delete1.compose(applied).compose(delete2);
},
source,
);
}
quill.setContents([
{ insert: 'Hello ' },
{ insert: 'World!', attributes: { bold: true } },
{ insert: '\n' }
]);
- applyDelta
applyDelta
把传入的 Delta 数据应用和渲染到编辑器中。
// applyDelta 源码
applyDelta(delta) {
let consumeNextNewline = false;
this.scroll.update();
let scrollLength = this.scroll.length();
this.scroll.batchStart();
const normalizedDelta = normalizeDelta(delta);
normalizedDelta.reduce((index, op) => {
const length = op.retain || op.delete || op.insert.length || 1;
let attributes = op.attributes || {};
// 1.插入文本
if (op.insert != null) {
if (typeof op.insert === 'string') {
let text = op.insert;
if (text.endsWith('\n') && consumeNextNewline) {
consumeNextNewline = false;
text = text.slice(0, -1);
}
if (
(index >= scrollLength ||
this.scroll.descendant(BlockEmbed, index)[0]) &&
!text.endsWith('\n')
) {
consumeNextNewline = true;
}
this.scroll.insertAt(index, text);
const [line, offset] = this.scroll.line(index);
let formats = merge({}, bubbleFormats(line));
if (line instanceof Block) {
const [leaf] = line.descendant(LeafBlot, offset);
formats = merge(formats, bubbleFormats(leaf));
}
attributes = AttributeMap.diff(formats, attributes) || {};
} else if (typeof op.insert === 'object') {
const key = Object.keys(op.insert)[0]; // There should only be one key
if (key == null) return index;
this.scroll.insertAt(index, key, op.insert[key]);
}
scrollLength += length;
}
// 2.对文本进行格式化
Object.keys(attributes).forEach(name => {
this.scroll.formatAt(index, length, name, attributes[name]);
});
return index + length;
}, 0);
normalizedDelta.reduce((index, op) => {
if (typeof op.delete === 'number') {
this.scroll.deleteAt(index, op.delete);
return index;
}
return index + (op.retain || op.insert.length || 1);
}, 0);
this.scroll.batchEnd();
this.scroll.optimize();
return this.update(normalizedDelta);
}
示例
<div id="editor">
// 内容区域
</div>
<script src="https://cdn.quilljs.com/1.0.0/quill.js"></script>
<!-- Initialize Quill editor -->
<script>
var editor = new Quill('#editor', {
modules: { toolbar: [['bold', 'italic'], ['link', 'image']] },
theme: 'snow'
});
</script>
如下图:
当我们在编辑区域田间 DOM ,
- setContents
setContents(delta: Delta, source: String = 'api'): Delta
setContents
用给定的内容覆盖编辑器的内容。内容应该以一个新行或者换行符结束。返回一个改变的 Delta。如果被给定的 Delta 没有无效操作,那么就会作为新的 Delta 通过。操作来源可能为:'user
'、'api
' 或者 'silent
',默认是 api。
setContents:github.com/quilljs/qui…
// 源码
setContents(delta, source = Emitter.sources.API) {
return modify.call(
this,
() => {
delta = new Delta(delta);
const length = this.getLength();
// 当富文本编辑器为空时,quill会插入 \n;
const delete1 = this.editor.deleteText(0, length);
// delta 总是在现有内容之前应用
const applied = this.editor.applyDelta(delta);
// 从空的编辑器初始化中删除多余的 \n
const delete2 = this.editor.deleteText(this.getLength() - 1, 1);
return delete1.compose(applied).compose(delete2);
},
source,
);
}
quill.setContents([
{ insert: '欢迎使用wangEditor富文本' },
{ insert: '编辑器!', attributes: { bold: true } },
{ insert: '\n' }
]);
通过传入 delta 对象和 source 操作来源,我们可以知道编辑器文本的变化
以及是谁触发变化的
,在方法中通过调用 applyDelta
方法把传入的 delta 数据渲染到编辑器中
。
- updateContents (delta: Delta, source: String = 'api'): Delta
updateContents(delta: Delta, source: String = 'api'): Delta
quill/core/quill.js
updateContents(delta, source = Emitter.sources.API) {
return modify.call(
this,
() => {
delta = new Delta(delta);
return this.editor.applyDelta(delta, source);
},
source,
true,
);
}
updateContents
相比于 setContents 相对简单一些,没有对 ‘\n’
进行什么操作,因为 setContents 以及处理了。但是多了一个入参 ‘true’
,通过对 modify
方法源码查看,多了一个入参是为了获取用户的选择范围
。
function modify(modifier, source, index, shift) {
........
let range = index == null ? null : this.getSelection();// 获取用户的选择范围
const oldDelta = this.editor.delta;
const change = modifier();
if (range != null) {
if (index === true) {
index = range.index; // eslint-disable-line prefer-destructuring
}
if (shift == null) {
range = shiftRange(range, change, source);
} else if (shift !== 0) {
range = shiftRange(range, index, shift, source);
}
this.setSelection(range, Emitter.sources.SILENT);
}
........
return change;
}
index 形参
就是 updateContents 方法传入的第三个参数 true 的实参
。
当我们用鼠标选中了其中一段文本时,通过 getSelection
方法获取到鼠标选中的范围。之所以要传入这个参数是因为当用户更新内容的时候 quill 需要知道更新内容的范围
。
- getContents
getContents(index: Number = 0, length: Number = remaining): Delta
getContents
方法用来检索编辑器的内容,返回 Delta 对象表示的格式数据。该方法接受 index
和 length
两个参数,表示要获取的内容开始内容的位置下标
以及长度
,有了这两个参数就可以准确获取到内容。
quill/core/quill.js
getContents(index = 0, length = this.getLength() - index) {
......
return this.editor.getContents(index, length);
}
quill/core/editor.js
applyDelta(delta) {
......
getContents(index, length) {
return this.delta.slice(index, index + length);// 返回截取的内容
}
.....
}
从上面的源码我们可以知道,通过调用 quill 的 getContents 方法会接着调用 applyDelta 方法中的 getContents 方法并传入 index , length 参数,通过对 applyDelta 方法中传入的 delta 进行截取,从而获取到内容。
- getSelection
getSelection(focus = false): { index: Number, length: Number }
getSelection
检索用户的选择范围,如果编辑器没有焦点,则可能返回 null。
// 源码
getSelection(focus = false) {
if (focus) this.focus();
this.update();
return this.selection.getRange()[0];// getRange 返回的是一个数组,数组的第一项是 range 对象
}
getSelection
方法返回的是一个 Range
对象,里面包含了 index 和 length 属性,有了这两个属性我们就可以知道用户选中的区域。
格式化 Quill 的内容
Toolbar module
使用户可以格式化 Quill 的内容,它能够被配置一个自定义的容器和处理程序,下面列举了其中一种配置方式。
var editor = new Quill('#editor', {
modules: { toolbar: [['bold', 'italic'], ['link', 'image']] },
theme: 'snow'
});
效果图
当我们初始化编辑器的时候,首先会给 toolbar 选项 button
按钮添加 以 ql-
开头的 class
类名。
接下来再为每一个 button 注册一个 click
事件,通过截取类名ql- 后面字符串作为 format
。通过获取到的 format,我就能为 delta 添加 attributes
属性。
quill/modules/toolbar.js
// 获取 container 容器内的所有 button, select
Array.from(this.container.querySelectorAll('button, select')).forEach(
input => {
this.attach(input);
},
);
attach(input) {
let format = Array.from(input.classList).find(className => {
return className.indexOf('ql-') === 0;
});
if (!format) return;
// 截取类名ql- 后面字符串作为 format
format = format.slice('ql-'.length);
.......
const eventName = input.tagName === 'SELECT' ? 'change' : 'click';
input.addEventListener(eventName, e => {// 注册事件
.......
});
.........
}
通过 click 点击事件以及 format 值 , 以及 getSelection
方法会返回包含了 index 下标和 length 长度的对象,给选中区域设置 attributes:{bold: true}
属性,实现文字加粗。
点击取消掉加粗时,会调用 Quill
的 removeFormat方法,传入 index, length, source 三个参数。
quill/core/quill.js
removeFormat(index, length, source) {
[index, length, , source] = overload(index, length, source);
return modify.call(
this,
() => {
return this.editor.removeFormat(index, length);
},
source,
index,
);
}
紧接着会调用 editor
的 removeFormat 方法。
quill/core/editor.js
removeFormat(index, length) {
const text = this.getText(index, length);// 获取要去掉格式化的文本
const [line, offset] = this.scroll.line(index + length);
let suffixLength = 0;
let suffix = new Delta();
if (line != null) {
suffixLength = line.length() - offset;
suffix = line
.delta()
.slice(offset, offset + suffixLength - 1)
.insert('\n');// 获取去除选中要去掉格式化的文本的 delta ops 值
}
const contents = this.getContents(index, length + suffixLength);
const diff = contents.diff(new Delta().insert(text).concat(suffix));// 进行 diff 算法
const delta = new Delta().retain(index).concat(diff);
return this.applyDelta(delta);
}
const text = this.getText(index, length
) 获取要去掉格式化的文本
suffix = line.delta().slice(offset, offset + suffixLength - 1).insert('\n')
获取去除选中要去掉格式化后面文本的 delta 值
-
const contents = this.getContents(index, length + suffixLength)
获取选中去掉格式化文本节点以及后面节点的 delta 值 -
const diff = contents.diff(new Delta().insert(text).concat(suffix))
算出要改变的文本节点属性
-
const delta = new Delta().retain(index).concat(diff)
获取格式化节点以及它前面的 delta 值 -
return this.applyDelta(delta)
更新 DOM 节点