实现一个富文本编辑器是一个复杂的项目,涉及到前端和后端的多个方面。以下是一个简化的实现方案,我们将使用HTML、CSS和JavaScript来构建一个基本的富文本编辑器。### 1. HTML结构首先,我们需要一个容器来放置编辑器:```htmlRich
摘要:在先前我们实现了编辑器选区和模型选区的双向同步,来实现受控的选区操作,这是编辑器中非常重要的基础能力。接下来我们需要在编辑器选区模块的基础上,通过浏览器的组合事件来实现半受控的输入模式,在这里我们需要处理浏览器复杂DOM结构默认行为,还需要
在先前我们实现了编辑器选区和模型选区的双向同步,来实现受控的选区操作,这是编辑器中非常重要的基础能力。接下来我们需要在编辑器选区模块的基础上,通过浏览器的组合事件来实现半受控的输入模式,在这里我们需要处理浏览器复杂DOM结构默认行为,还需要兼容IME输入法的各种输入场景。
开源地址: https://github.com/WindRunnerMax/BlockKit
在线编辑: https://windrunnermax.github.io/BlockKit/
项目笔记: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
从零实现富文本编辑器项目的相关文章:
深感一无所长,准备试着从零开始写个富文本编辑器
从零实现富文本编辑器#2-基于MVC模式的编辑器架构设计
从零实现富文本编辑器#3-基于Delta的线性数据结构模型
从零实现富文本编辑器#4-浏览器选区模型的核心交互策略
从零实现富文本编辑器#5-编辑器选区模型的状态结构表达
从零实现富文本编辑器#6-浏览器选区与编辑器选区模型同步
从零实现富文本编辑器#7-基于组合事件的半受控输入模式
编辑器输入模式
Input模块是处理输入的模块,输入是编辑器的核心操作之一,我们需要处理输入法、键盘、鼠标等输入操作。输入法的交互处理是需要非常多的兼容处理,例如输入法还存在候选词、联想词、快捷输入、重音等等。甚至是移动端的输入法兼容更麻烦,在draft中还单独列出了移动端输入法的兼容问题。
编辑器输入模块与选区模块类似,都需要在浏览器DOM的基础上处理其默认行为,特别是需要唤醒输入法的输入则需要更多模块的联动,因此还需要复杂的兼容性适配。而输入模式本身则分为三种类型,即非受控输入、半受控输入和受控输入,每种输入模式都有其特定的使用场景和实现方式。
非受控输入
非受控的方法,指的是完全依赖浏览器的默认行为来处理输入操作,而不需要对输入进行干预或修改,当DOM结构发生变化后需要收集变更,再应用到编辑器中。这种方式可以最大限度利用浏览器原生能力,包括选区、光标等,然而其最大的问题就是输入不受控制,无法阻止默认行为,不够稳定。
举个目前比较常见的例子,ContentEditable无法真正阻止IME的输入,这就导致了我们无法真正接管中文的输入行为。在下面的这个例子中,输入英文和数字是不会有响应的,但是中文却是可以正常输入的,这也是很多编辑器选择自绘选区和受控输入的原因之一,例如VSCode、钉钉文档等。
<div contenteditable id="$1"></div>
<script>
const stop = (e) => {
e.preventDefault();
e.stopPropagation();
};
$1.addEventListener("beforeinput", stop);
$1.addEventListener("input", stop);
$1.addEventListener("keydown", stop);
$1.addEventListener("keypress", stop);
$1.addEventListener("keyup", stop);
$1.addEventListener("compositionstart", stop);
$1.addEventListener("compositionupdate", stop);
$1.addEventListener("compositionend", stop);
</script>
采用非受控方法输入的时候,我们需要MutationObserver来确定当前正在输入字符,之后通过解析DOM结构得到最新的Text Model。紧接着需要与原来的Text Model做diff,由此来得到变更的ops,这样就可以应用到当前的Model中进行后续的工作了。
即使是非受控的输入也存在多种实现的方案,例如可以在触发Input事件后以行为基础做文本diff,得到ops后就可以根据schema组合属性。或者也可以完全依赖MutationObserver来得到节点级别的片段变更,在此基础上再做diff,著名的quill编辑器就是如此实现的。
quill针对输入的处理本身并不复杂,虽然涉及到非常多处的事件通信以及特殊case处理,但核心逻辑还是比较清晰的。但是我觉得有一点比较麻烦的是,quill封装的视图层parchment并不在核心包中,虽然继承重写了部分方法,但是诸如Text是直接导出的,很多地方还是很难调试。
整体来说,quill的非受控输入分为两种处理,如果普通的ASCII输入则直接根据MutationRecord的oldValue与最新的newText文本进行对比,得到变更的ops。若是IME的输入,例如中文输入的内容,则会导致多次Mutation,此时就会进行全量delta的diff得到变更。
// https://github.com/slab/quill/blob/07b68c9/packages/quill/src/core/editor.ts#L273
const oldDelta = this.delta;
if (
mutations.length === 1 &&
mutations[0].type === 'characterData' &&
mutations[0].target.data.match(ASCII)
) {
const textBlot = this.scroll.find(mutations[0].target) as Blot;
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 diffDelta = new Delta()
.retain(index)
.concat(oldText.diff(newText, relativeSelectionInfo));
} else {
this.delta = this.getDelta();
if (!change || !isEqual(oldDelta.compose(change), this.delta)) {
change = oldDelta.diff(this.delta, selectionInfo);
}
}
这里需要关注的问题是,为什么textBlot能够得到最新的值,无论是在MutationRecord中还是在getDelta中,都是通过textBlot.value()来获取最新的文本内容。getDelta部分是迭代了一遍所有的Bolt来重新得到最新的value,这部分按行存在缓存,否则性能容易出问题。
// https://github.com/slab/quill/blob/07b68c9/packages/quill/src/core/editor.ts#L162
this.scroll.lines().reduce((delta, line) => {
return delta.concat(line.delta());
}, new Delta());
// https://github.com/slab/quill/blob/07b68c9/packages/quill/src/blots/block.ts#L183
function blockDelta(blot: BlockBlot, filter = true) {
return blot
.descendants(LeafBlot)
.reduce((delta, leaf) => {
if (leaf.length() === 0) {
return delta;
}
return delta.insert(leaf.value(), bubbleFormats(leaf, {}, filter));
}, new Delta())
.insert('\n', bubbleFormats(blot));
}
TextBlot就是定义在parchment中的实现,因此这里调试起来就比较麻烦。首先需要关注的是更新到最新的文本,我们只关注于纯文本内容更新即可,Blot中存在更新的方法,当DOM发生变化之后就会触发该方法,这里需要注意更新是从静态方法上得到的,而不是实例的.value。
// https://github.com/slab/parchment/blob/3d0b71c/src/blot/text.ts#L80
public update(mutations: MutationRecord[], _context: { [key: string]: any }): void {
if (
mutations.some((mutation) => {
return (mutation.type === 'characterData' && mutation.target === this.domNode);
})
) {
this.text = this.statics.value(this.domNode);
}
}
public static value(domNode: Text): string {
return domNode.data;
}
此外需要关注的是更新时机,也就是说调用时机必须要先更新Blot的内容,以此来得到最新的文本内容,最后再调度scroll的update来更新编辑器模型。我们主要关注输入的变更,这里其实还有诸如format引起的DOM结构变更,属于optimize方法处理MutationRecord部分。
// https://github.com/slab/parchment/blob/3d0b71c/src/blot/scroll.ts#L205
// handleCompositionEnd - batchEnd - scrollUpdate - blotUpdate - editorUpdate
mutations
.map((mutation: MutationRecord) => {
const blot = this.find(mutation.target, true);
// ...
})
.forEach((blot: Blot | null) => {
if (blot != null && blot !== this && mutationsMap.has(blot.domNode)) {
blot.update(mutationsMap.get(blot.domNode) || [], context);
}
});
这里还有个有趣的实现是在执行diff方法时的cursor参数,考虑到一个问题,文本若是从xxx变更到xx,那么就存在很多种可能。在这里可以是在任意一个位置删除一个字符,也可以是在光标处向前forward删除一个字符,甚至是删除两个x再插入一个x。
因此如果想比较精确地得到变更的ops,那就需要将光标位置传入diff方法中,以此可以将字符串切为三段,前缀和后缀是相同的,中间就可以作为差异部分。输入这部分是非常高频的操作,这种方式就不需要实际参与到复杂的diff流程当中,以更高的性能来处理文本变更。
// https://github.com/jhchen/fast-diff/blob/da83236/diff.js#L1039
var newBefore = newText.slice(0, newCursor);
var newAfter = newText.slice(newCursor);
var prefixLength = Math.min(oldCursor, newCursor);
var oldPrefix = oldBefore.slice(0, prefixLength);
var newPrefix = newBefore.slice(0, prefixLength);
var oldMiddle = oldBefore.slice(prefixLength);
var newMiddle = newBefore.slice(prefixLength);
return remove_empty_tuples([
[DIFF_EQUAL, before],
[DIFF_DELETE, oldMiddle],
[DIFF_INSERT, newMiddle],
[DIFF_EQUAL, after],
]);
半受控输入
半受控的方法,指的是通过BeforeInputEvent以及CompositionEvent分别处理英文输入、内容删除以及IME输入,以及额外的KeyDown、Input事件来辅助完成这部分工作。通过这种方式就可以劫持用户的输入,由此构造变更来应用到当前的内容模型。
当然对于类似CompositionEvent需要一些额外的处理,因为先前我们也提到了IME的输入是无法完全受控的,因此半受控也是当前主流的实现方法。当然由于浏览器的兼容性,通常会需要对BeforeInputEvent做兼容,例如借助React的合成事件或者onKeyDown来完成相关的兼容。
slate编辑器的输入模式就是半受控的实现方式,主要是基于beforeinput事件以及composition相关事件来处理输入和删除操作。在slate刚开始实现的时候,beforeinput事件还没有被广泛支持,但是现在已经可以在大多数现代浏览器中使用了,composition事件则早已广泛支持。
首先来看受控的部分,我们的受控特指可以阻止默认的输入行为,而我们可以根据相关事件主动更新编辑器模型。在输入这个场景我们主要关注insert相关的inputType即可,只不过输入上还有大量的模式需要处理,此外slate还存在大量兼容性逻辑来处理各种浏览器的实现问题。
// https://github.com/ianstormtaylor/slate/blob/ef76eb4/packages/slate-react/src/components/editable.tsx#L550
switch (event.inputType) {
case 'insertFromComposition':
case 'insertFromDrop':
case 'insertFromPaste':
case 'insertFromYank':
case 'insertReplacementText':
case 'insertText': {
if (typeof data === 'string') {
Editor.insertText(editor, data)
}
}
}
从上面的示例中可以看出,inputType本身存在大量的操作类型分支需要处理,而本身除了输入、删除之外,还存在诸如格式化、历史记录等操作类型。不过在这里我们还是主要关注输入、删除相关的操作,下面是比较常见可能需要处理的inputType类型:
insertText: 插入文本,通常是通过键盘输入。
insertReplacementText: 替换当前选区或单词的文本,例如通过拼写校正或自动完成。
insertLineBreak: 插入换行符,通常是按下回车键。
insertParagraph: 插入一个段落分隔符,通常存在于ContentEditable元素中按回车键。
insertFromDrop: 通过拖拽操作插入内容。
insertFromPaste: 通过粘贴操作插入内容。
insertTranspose: 调换两个字符的位置,常见于MacOS的Ctrl+T操作。
insertCompositionText: 插入输入法IME中的组合文本。
deleteWordBackward: 向后删除一个单词,例如Option+Backspace。
deleteWordForward: 向前删除一个单词,例如Option+Delete。
deleteSoftLineBackward: 向后删除一行,当换行是自动换行时。
deleteSoftLineForward: 向前删除一行,当换行是自动换行时。
deleteEntireSoftLine: 删除当前所在的整个软换行。
deleteHardLineBackward: 向后删除一行,当换行是硬回车时。
deleteHardLineForward: 向前删除一行,当换行是硬回车时。
deleteByDrag: 通过拖拽的方式删除内容。
deleteByCut: 通过剪切操作删除内容。
deleteContent: 向前删除内容,即Delete键。
deleteContentBackward: 向后删除内容,即Backspace键。
实际上这些事件我们很难全部关注到,特别是软回车相关的内容在浏览器实现的编辑器中应用不多,因此这部分我们可以直接将其认为是硬回车来操作。在quill和slate中都是作为硬回车处理的,而TinyMCE、TipTap都有软回车的实现,即Shift+Enter会插入<br>而非创建新段落。
事件中相关的信息传递需要关注,例如deleteWord是需要删除词级别的内容的,这部分数据范围是通过getTargetRanges得到StaticRange数组传递。此外,诸如insertCompositionText、insertFromPaste也都是可以在Composition事件、Paste事件中来实际处理。
// [StaticRange]
[{
collapsed: false,
endContainer: text,
endOffset: 4,
startContainer: text,
startOffset: 2
}]
接下来我们可以关注slate中非受控部分,这也就是由于无法真正接管IME输入而导致的必须要兼容的问题。slate中这部分兼容起来也有点复杂,在不同浏览器中的表现还不一致,例如在safari中存在insertFromComposition的类型,都是需要在类似的时机修正编辑器模型。
除了无法阻止默认行为外,非受控的表现还体现在对于DOM结构的修改,这部分甚至于可以说是最难以处理的,因为只要唤醒了IME就意味着必然会修改DOM。那么就相当于这部分DOM是处于未知状态的,若是出现了不可预知的DOM内容,则意味着编辑器模型同步状态被破坏,这就需要额外兼容。
// https://github.com/ianstormtaylor/slate/blob/ef76eb4/packages/slate-react/src/components/editable.tsx#L1299
// COMPAT: In Chrome, `beforeinput` events for compositions
// aren't correct and never fire the "insertFromComposition"
// type that we need. So instead, insert whenever a composition
// ends since it will already have been committed to the DOM.
if (
!IS_WEBKIT &&
!IS_FIREFOX_LEGACY &&
!IS_IOS &&
!IS_WECHATBROWSER &&
!IS_UC_MOBILE &&
event.data
) {
Editor.insertText(editor, event.data)
}
受控输入
全受控的方法,指的是当执行任意内容输入的时候,输入的字符需要记录,当输入结束的时候将原来的内容删除,并且构造为新的Model。全受控通常需要一个隐藏的输入框甚至是iframe来完成,由于浏览器页面上必须保持单一焦点,因此这种方式还需要伴随着自绘选区的实现。
这其中也有很多细节需要处理,例如在CompositionEvent时需要绘制内容但不能触发协同。此外如果需要实现与浏览器一致的输入体验,例如浏览器中唤醒输入法时会有拼音状态提示,这个提示不仅仅是用来展示的,若是按下左右按键是可以进行候选词切换的,全受控模式下自然也需要模拟。
以受控模式实现的编辑器中,我们可以针对浏览器API的依赖程度来分为三类,浏览器依赖程度由高到低,也就意味着实现的难度由低到高。三种类型分别是依赖iframe焦点魔法以及Editable的类型、不依赖Editable而依赖DOM实现自绘选区的类型、完全基于Canvas绘制的类型。
这三种类型我们分别可以找到典型的编辑器实现,依赖iframe魔法的TextBus等,自绘选区的钉钉文档、Zoom文档等,以及完全基于Canvas绘制的腾讯文档、Google Doc等。实际上开源的编辑器中比较少实现受控的输入模式,因为本身实现起来比较复杂,且需要大量的兼容性处理。
接下来我们分别来看一下这三种类型,首先需要聊到的就是iframe魔法的实现方式,这里就不得不提到浏览器焦点问题。在浏览器中,文本内容的选中效果是会将焦点放在选中的文本上的,而此时若是鼠标点击到其他输入框就会导致焦点转移,可以通过document.activeElement来查看当前焦点。
<div tabindex="-1">选中文本后,点击 input 可以观察焦点转移</div>
<input />
<script>
document.onselectionchange = () => {
console.log("Focused Element", document.activeElement);
};
</script>
至于什么样子的元素可以获得焦点,这个同样是存在一定的规范的,诸如可编辑元素、tabindex属性、a标签等等,我们就不过多叙述了。那么这里的问题就在于,若是我们放置独立的input来接收输入,而不是直接依赖Editable输入的话,就会出现浏览器选区的转移问题,导致无法选中文本。
因此通常来说,在选择使用额外的input来处理输入后,就必须要自行绘制那个选区的效果,也就是我们俗称的拖蓝。然而在iframe存在的情况下,浏览器并不是非常严格的保持单一的选区效果,这也就是我们所谓的魔法,即前面提到的TextBus非常特殊实现。
TextBus没有使用ContentEditable这种常见的实现方案,也没有像CodeMirror或者Monaco一样自绘选区。从Playground的DOM节点上来看,其是维护了一个隐藏的iframe来实现的,这个iframe内存在一个textarea,以此来处理IME的输入。
那么先来看一个简单的例子,以iframe和文本选区的焦点抢占为例,可以发现在iframe不断抢占的情况下,我们是无法拖拽文本选区的。这里值得一提的是,我们不能直接在onblur事件中进行focus,这个操作会被浏览器禁止,必须要以宏任务的异步时机触发。
<span>123123</span>
<iframe id="$1"></iframe>
<script>
const win = $1.contentWindow;
win.addEventListener("blur", () => {
console.log("blur");
setTimeout(() => $1.focus(), 0);
});
win.addEventListener("focus", () => console.log("focus"));
win.focus();
</script>
注意我们的焦点聚焦调用是直接调用的$1.focus,假如此时是调用win.focus的话,就可以发现文本选区是可以拖拽的。通过这个表现其实可以看出来,框架内外的文档的选区是完全独立的,如果焦点在同个框架内则会相互抢占,如果不在同个框架内则是可以正常表达,也就是$1和win的区别。
另外可以注意到此时文本选区是灰色的,这个可以用::selection伪元素来处理样式,而且各种事件都是可以正常触发的,例如SelectionChange事件以及手动设置选区等。如果直接在iframe中放置textarea的话,同样也可以正常的输入内容,并且不会打断IME的输入法。
<span>123123</span>
<iframe id="$1"></iframe>
<script>
const win = $1.contentWindow;
const textarea = document.createElement("textarea");
$1.contentDocument.body.appendChild(textarea);
textarea.focus();
textarea.addEventListener("blur", () => {
setTimeout(() => textarea.focus(), 0);
});
win.addEventListener("blur", () => console.log("blur"));
win.addEventListener("focus", () => console.log("focus"));
win.focus();
</script>
最主要的是,这个Magic的表现在诸多浏览器都可以正常触发。当然这里主要指的是PC端的浏览器,若是在移动端的浏览器中表现还不太一样,其实在移动端的浏览器按键输入的事件规范不统一就容易存在问题,例如draft.js在README中提到了移动端是Not Fully Supported。
而对于完全实现自绘选区的编辑器,目前我还没有关注到有开源的实现,因为其本身做起来就比较复杂,特别是需要模拟整个浏览器的交互行为。浏览器确实是处理了相当多的选区交互细节,例如拖拽的时候即使不在文本上,选区也是可以向下延伸的,以及拖拽字符的中间是选中与否的分界线等。
不过富文本编辑器是没有太关注到,但是代码编辑器例如CodeMirror、VSCode(Monaco)都是自绘选区的实现,商业化的在线文档产品例如钉钉文档、Zoom文档、有道云笔记也是自绘选区。由于选区的DOM通常都是不会响应任何鼠标事件的,所以可以直接使用DOM操作来查找调试。
document.querySelectorAll(`[style*="pointer-events: none;"]`);
[...document.querySelectorAll("*")].filter(node => node.style.pointerEvents === "none");
当然像是钉钉文档这种将其作为web-component的实现方式,就需要我们稍微费点劲找一下了。此外,先前我们也提到过一种自绘选区的实现方式,即通过caretRangeFromPoint以及caretPositionFromPoint两个API来计算选区位置,可以参考先前的浏览器选区模型的核心交互策略文章。
最后是完全采用Canvas进行绘制的编辑器实现,这种方式那就是相当麻烦了,无论是文本还是选区全部都要自己绘制。由于浏览器对于Canvas仅仅是提供了最基础的API,这就是非常单纯的空画板,所有的想做的东西都需要自己去绘制,事件流也都需要自行模拟,非常麻烦。
目前比较典型的实现是Google Doc和腾讯文档,这两款商业的文档编辑器都是完全基于Canvas进行绘制的。Google Doc作为最先用Canvas实现的编辑器,还专门写了文章来介绍其旧版与新版的不同,主要是提了编辑界面以及布局引擎的更新,链接放在了最后的
