实现一个富文本编辑器是一个复杂的项目,涉及到前端和后端的多个方面。以下是一个简化的实现方案,我们将使用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实现的编辑器,还专门写了文章来介绍其旧版与新版的不同,主要是提了编辑界面以及布局引擎的更新,链接放在了最后的