从零实现富文本编辑器#7-基于组合事件的半受控输入模式

在先前我们实现了编辑器选区和模型选区的双向同步,来实现受控的选区操作,这是编辑器中非常重要的基础能力。接下来我们需要在编辑器选区模块的基础上,通过浏览器的组合事件来实现半受控的输入模式,在这里我们需要处理浏览器复杂DOM结构默认行为,还需要兼容IME输入法的各种输入场景。

从零实现富文本编辑器项目的相关文章:

编辑器输入模式

Input模块是处理输入的模块,输入是编辑器的核心操作之一,我们需要处理输入法、键盘、鼠标等输入操作。输入法的交互处理是需要非常多的兼容处理,例如输入法还存在候选词、联想词、快捷输入、重音等等。甚至是移动端的输入法兼容更麻烦,在draft中还单独列出了移动端输入法的兼容问题。

编辑器输入模块与选区模块类似,都需要在浏览器DOM的基础上处理其默认行为,特别是需要唤醒输入法的输入则需要更多模块的联动,因此还需要复杂的兼容性适配。而输入模式本身则分为三种类型,即非受控输入、半受控输入和受控输入,每种输入模式都有其特定的使用场景和实现方式。

非受控输入

非受控的方法,指的是完全依赖浏览器的默认行为来处理输入操作,而不需要对输入进行干预或修改,当DOM结构发生变化后需要收集变更,再应用到编辑器中。这种方式可以最大限度利用浏览器原生能力,包括选区、光标等,然而其最大的问题就是输入不受控制,无法阻止默认行为,不够稳定。

举个目前比较常见的例子,ContentEditable无法真正阻止IME的输入,这就导致了我们无法真正接管中文的输入行为。在下面的这个例子中,输入英文和数字是不会有响应的,但是中文却是可以正常输入的,这也是很多编辑器选择自绘选区和受控输入的原因之一,例如VSCode、钉钉文档等。

html 复制代码
<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 Modeldiff,由此来得到变更的ops,这样就可以应用到当前的Model中进行后续的工作了。

即使是非受控的输入也存在多种实现的方案,例如可以在触发Input事件后以行为基础做文本diff,得到ops后就可以根据schema组合属性。或者也可以完全依赖MutationObserver来得到节点级别的片段变更,在此基础上再做diff,著名的quill编辑器就是如此实现的。

quill针对输入的处理本身并不复杂,虽然涉及到非常多处的事件通信以及特殊case处理,但核心逻辑还是比较清晰的。但是我觉得有一点比较麻烦的是,quill封装的视图层parchment并不在核心包中,虽然继承重写了部分方法,但是诸如Text是直接导出的,很多地方还是很难调试。

整体来说,quill的非受控输入分为两种处理,如果普通的ASCII输入则直接根据MutationRecordoldValue与最新的newText文本进行对比,得到变更的ops。若是IME的输入,例如中文输入的内容,则会导致多次Mutation,此时就会进行全量deltadiff得到变更。

js 复制代码
// 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,这部分按行存在缓存,否则性能容易出问题。

js 复制代码
// 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

js 复制代码
// 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的内容,以此来得到最新的文本内容,最后再调度scrollupdate来更新编辑器模型。我们主要关注输入的变更,这里其实还有诸如format引起的DOM结构变更,属于optimize方法处理MutationRecord部分。

js 复制代码
// 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流程当中,以更高的性能来处理文本变更。

js 复制代码
// 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输入,以及额外的KeyDownInput事件来辅助完成这部分工作。通过这种方式就可以劫持用户的输入,由此构造变更来应用到当前的内容模型。

当然对于类似CompositionEvent需要一些额外的处理,因为先前我们也提到了IME的输入是无法完全受控的,因此半受控也是当前主流的实现方法。当然由于浏览器的兼容性,通常会需要对BeforeInputEvent做兼容,例如借助React的合成事件或者onKeyDown来完成相关的兼容。

slate编辑器的输入模式就是半受控的实现方式,主要是基于beforeinput事件以及composition相关事件来处理输入和删除操作。在slate刚开始实现的时候,beforeinput事件还没有被广泛支持,但是现在已经可以在大多数现代浏览器中使用了,composition事件则早已广泛支持。

首先来看受控的部分,我们的受控特指可以阻止默认的输入行为,而我们可以根据相关事件主动更新编辑器模型。在输入这个场景我们主要关注insert相关的inputType即可,只不过输入上还有大量的模式需要处理,此外slate还存在大量兼容性逻辑来处理各种浏览器的实现问题。

js 复制代码
// 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: 调换两个字符的位置,常见于MacOSCtrl+T操作。
  • insertCompositionText: 插入输入法IME中的组合文本。
  • deleteWordBackward: 向后删除一个单词,例如Option+Backspace
  • deleteWordForward: 向前删除一个单词,例如Option+Delete
  • deleteSoftLineBackward: 向后删除一行,当换行是自动换行时。
  • deleteSoftLineForward: 向前删除一行,当换行是自动换行时。
  • deleteEntireSoftLine: 删除当前所在的整个软换行。
  • deleteHardLineBackward: 向后删除一行,当换行是硬回车时。
  • deleteHardLineForward: 向前删除一行,当换行是硬回车时。
  • deleteByDrag: 通过拖拽的方式删除内容。
  • deleteByCut: 通过剪切操作删除内容。
  • deleteContent: 向前删除内容,即Delete键。
  • deleteContentBackward: 向后删除内容,即Backspace键。

实际上这些事件我们很难全部关注到,特别是软回车相关的内容在浏览器实现的编辑器中应用不多,因此这部分我们可以直接将其认为是硬回车来操作。在quillslate中都是作为硬回车处理的,而TinyMCETipTap都有软回车的实现,即Shift+Enter会插入<br>而非创建新段落。

事件中相关的信息传递需要关注,例如deleteWord是需要删除词级别的内容的,这部分数据范围是通过getTargetRanges得到StaticRange数组传递。此外,诸如insertCompositionTextinsertFromPaste也都是可以在Composition事件、Paste事件中来实际处理。

js 复制代码
// [StaticRange]
[{
  collapsed: false,
  endContainer: text,
  endOffset: 4,
  startContainer: text,
  startOffset: 2
}]

接下来我们可以关注slate中非受控部分,这也就是由于无法真正接管IME输入而导致的必须要兼容的问题。slate中这部分兼容起来也有点复杂,在不同浏览器中的表现还不一致,例如在safari中存在insertFromComposition的类型,都是需要在类似的时机修正编辑器模型。

除了无法阻止默认行为外,非受控的表现还体现在对于DOM结构的修改,这部分甚至于可以说是最难以处理的,因为只要唤醒了IME就意味着必然会修改DOM。那么就相当于这部分DOM是处于未知状态的,若是出现了不可预知的DOM内容,则意味着编辑器模型同步状态被破坏,这就需要额外兼容。

js 复制代码
// 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来查看当前焦点。

html 复制代码
<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一样自绘选区。从PlaygroundDOM节点上来看,其是维护了一个隐藏的iframe来实现的,这个iframe内存在一个textarea,以此来处理IME的输入。

那么先来看一个简单的例子,以iframe和文本选区的焦点抢占为例,可以发现在iframe不断抢占的情况下,我们是无法拖拽文本选区的。这里值得一提的是,我们不能直接在onblur事件中进行focus,这个操作会被浏览器禁止,必须要以宏任务的异步时机触发。

html 复制代码
<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的话,就可以发现文本选区是可以拖拽的。通过这个表现其实可以看出来,框架内外的文档的选区是完全独立的,如果焦点在同个框架内则会相互抢占,如果不在同个框架内则是可以正常表达,也就是$1win的区别。

另外可以注意到此时文本选区是灰色的,这个可以用::selection伪元素来处理样式,而且各种事件都是可以正常触发的,例如SelectionChange事件以及手动设置选区等。如果直接在iframe中放置textarea的话,同样也可以正常的输入内容,并且不会打断IME的输入法。

html 复制代码
<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.jsREADME中提到了移动端是Not Fully Supported

而对于完全实现自绘选区的编辑器,目前我还没有关注到有开源的实现,因为其本身做起来就比较复杂,特别是需要模拟整个浏览器的交互行为。浏览器确实是处理了相当多的选区交互细节,例如拖拽的时候即使不在文本上,选区也是可以向下延伸的,以及拖拽字符的中间是选中与否的分界线等。

不过富文本编辑器是没有太关注到,但是代码编辑器例如CodeMirrorVSCode(Monaco)都是自绘选区的实现,商业化的在线文档产品例如钉钉文档、Zoom文档、有道云笔记也是自绘选区。由于选区的DOM通常都是不会响应任何鼠标事件的,所以可以直接使用DOM操作来查找调试。

js 复制代码
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实现的编辑器,还专门写了文章来介绍其旧版与新版的不同,主要是提了编辑界面以及布局引擎的更新,链接放在了最后的参考文章部分。

有趣的是,对比相对受控DOM输入的编辑器,基于Canvas实现的富文本编辑器反而是有开源实现,canvas-editor就是目前做的比较好的使用Canvas绘制的开源富文本编辑器。不过除了非常需要类似word的分页、排版、打印等明确需求场景,否则真靠Canvas实现成本还是相当高的。

这里的实现成本高主要体现在两点,首先是编辑器图文的排版引擎是需要自己实现的。例如在word中我们编写的文字正好排满了一行,假如在这里再加一个句号,那么前边的字就会挤一挤,从而可以使这个句号不需要换行。而如果我们再敲一个字的话,这个字是会换行的。

html 复制代码
<!-- word -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本。
<!-- 浏览器 -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本
。

而如上所示在浏览器中这个行为是不同的,所以假如需要突破浏览器的排版限制,就需要自己实现排版能力。每个字渲染的位置、渲染的折行换行策略等等都需要自行实现,需要考虑的边界条件都会相当多。在先前我基于Canvas实现的简历编辑器中,就在富文本绘制的排版部分写了很久。

再就是选区绘制的实现,在前边我们提到了caretRangeFromPoint以及caretPositionFromPoint两个API来计算选区位置,这是浏览器提供的选区计算能力。而使用Canvas绘制的文本是不会涉及到DOM的,因此这些内容都需要自行计算,不过诸如字宽等信息都会保存,算起来不是很复杂。

虽然Canvas可以实现脱离浏览器的排版限制,并且可以抛弃DOM复杂兼容性带来的性能问题。但是使用Canvas本身的复杂性本身就是最大的问题,而且抛弃DOM就相当于抛弃了现在的相关生态,诸如SEOA11Y等都不能够直接使用,因此没有绝对的需求场景还是需要慎重。

回到内容的输入部分,由于在浏览器中我们不能直接与IME交互,因此还是只能与浏览器的输入事件处理,也就是仍然只能使用input来做输入,与DOM实现自绘选区的模式一致。基于Canvas实现富文本,相当于实现了一个浏览器的排版引擎,这个工作量还是非常大的。

这里再提一个关于输入的协同问题,前面提到输入法按下左右按键是可以进行候选词切换的,那么在协同的情况下这段文本内容的变更自然不应该协同到其他客户端。然而这段文本是会变更内容长度的,协同不能直接过滤local属性,而且完全模拟浏览器行为也是带格式的,不能直接脱离现有的渲染框架。

因此这部分协同的输入需要额外处理,最简单的方法是临时关闭协同处理,将最终状态是希望将状态合并起来再协同出去。再有就是在AXY的调度模型的基础上,实现扩展Z即本地队列,但由于队列内容已经本地应用,需要实现op在队列前后移动的方法,临时状态协同这部分我们后续再细聊。

半受控输入实现

基于上述的输入模式概述,我们将重点放在半受控输入模式的实现上,因为半受控输入模式是目前大多数富文本编辑器的主流实现方式。通常来说,半受控模式下能够在保证用户输入体验的同时,提供相对较好的控制和灵活性。基于之前聊到过输入的设计与抽象,我们可以比较简单地设计整个流程:

  • 通过选区映射到我们自行维护的Range Model,包括选区变换时根据DOMRange映射到Model,这一步需要比较多的查找和遍历,还需要借助我们之前聊的WeakMap对象来查找Model来计算位置。
  • 通过键盘进行输入,借助于浏览器的BeforeInputEvent以及CompositionEvent分别处理输入/删除与IME输入,基于输入构造Delta Change应用到状态结构上并且触发ContentChange,视图层由此进行更新。
  • 当视图层更新之后,需要根据浏览器的DOM以及我们维护的Model刷新模型选区,这里的选区变更交互需要模拟浏览器行为,然后需要根据Model映射到DOMRange选区,再应用到浏览器的Selection对象中,这其中也涉及了很多边界条件。

其实曾经我也想通过自绘选区和光标的形式来完成,因为我发现通过Editable来控制输入太难控制了,特别是在IME中很容易影响到当前的DOM结构。由此还需要进行脏数据检查,强行更新DOM结构,但是简单了解了下听说自绘的坑也不少,于是依然选用了大多数编辑器都在用的Editable

实际上Editable的坑也很多,这其中有非常多的细节,我们很难把所有的边界条件都处理完成,例如如何检测DOM被破坏由此需要强制刷新。当我们将所有的边界case都处理到位了,那么代码复杂度就上来了,可能接下来就需要处理性能问题了,此时就可能有大量的计算,特别是对于大文档来说。

在性能方面,除了上边提到的WeakMap可以算作是一种优化方案之外,我们还有很多值得优化的地方。例如因为Delta数据结构的关系,我们在这里需要维护一个Range-RawRange选区的相互变换,而在这其中由于我们对LineStatestartsize有所记录,也就是空间换时间。

那么我们在变换查找的时候就可以考虑到用二分的方法,因为start必然是单向递增的。此外,由于我们是完全可以推算出本次更新究竟是更新了什么内容,所以对于原本的状态对象是可以通过计算来进行复用的,而不是每次更新都需要刷新所有的对象,实现immutable降低了维护的细节和难度。

还有一点,对于大文档来说扁平化的数据结构应该是比较好的,扁平化意味着没有那么复杂,例如现在的Delta就是扁平化的数据结构,但是随机访问的效率就稍微慢一点了。或许到了出现性能问题时,需要结合一些数据存储的方案例如PieceTable,当然对于现在来说还是有点远。

受控输入模式

这里的受控输入模式指的是不需要唤醒IME输入法的部分,通常是指英文输入、数字输入等。在上述内容的基础上,我们这里的实现就可以变得简单了,只需要阻止所有默认行为,再受控处理原始的行为。我们以内容的输入insertText和删除deleteContentBackward为例,实现内容的输入和删除。

js 复制代码
// packages/core/src/input/index.ts
onBeforeInput(event: InputEvent) {
  event.preventDefault();
  const { inputType, data = "" } = event;
  const sel = this.editor.selection.get();
  switch (inputType) {
    case "deleteContent":
    case "deleteContentBackward": {
      this.editor.perform.deleteBackward(sel);
      break;
    }
    case "insertText": {
      data && this.editor.perform.insertText(sel, data);
      break;
    }
  }
}

具体的变更实现则是封装在perform类中,当插入文本内容时,需要先拿到当前选区所在的状态节点,若是当前的节点是void节点,则避免内容的输入。然后获取当前准备好的折叠选区属性值,或者在非折叠选区时的尾部属性值,最后再构造变更的delta应用到编辑器即可。

js 复制代码
// packages/core/src/perform/index.ts
const raw = RawRange.fromRange(this.editor, sel);
const point = sel.start;
const leaf = this.editor.lookup.getLeafAtPoint(point);
// 当前节点为 void 时, 不能插入文本
if (leaf && leaf.void) return void 0;
let attributes: AttributeMap | undefined = this.editor.lookup.marks;
if (!sel.isCollapsed) {
  // 非折叠选区时, 需要以 start 起始判断该节点的尾部 marks
  const isLeafTail = isLeafOffsetTail(leaf, point);
  attributes = this.editor.lookup.getLeafMarks(leaf, isLeafTail);
}
const delta = new Delta().retain(raw.start).delete(raw.len).insert(text, attributes);
this.editor.state.apply(delta, { range: raw });

在删除内容时这里就变得复杂了起来,因为我们删除的时候需要考虑到回车的状态。这里的主要问题是,我们的行属性是放置在回车节点上的,操作起来并不符合直觉。而EtherPad控制行格式的实现是放在行首,这里很多表现是与渲染相关的,因为无论是列表、引用等行格式的DOM渲染看起来都在行首。

这部分的交互策略会复杂不少,假如上一行是标题格式,当前行是引用格式,当前光标在行首,若是直接执行删除,那么标题格式就会被删除,引用格式也会合并到上一行。这个表现跟quill编辑器一致,因为本身还是由于数据结构引起的,若是需要需要更符合直觉,要么处理数据结构,要么处理内容变更。

而如果直接修改数据结构的话,也会导致很复杂的兼容实现,例如最基本的normalize就需要保证lmkr前不能直接连着文本,而是需要保证\n,包括块结构等,此外还有文本的长度也需要判断当前是否存在行首属性等。因此,在这里我们尽可能保证数据结构文档,而是通过处理变更的方式。

js 复制代码
// packages/core/src/perform/index.ts
// 上一行为块节点且处于当前行首时, 删除则移动光标到该节点上
if (prevLine && isBlockLine(prevLine)) {
  // 当前行为空时特殊处理, 先删除掉该行
  if (isEmptyLine(line)) {
    const delta = new Delta().retain(line.start).delete(1);
    this.editor.state.apply(delta, { autoCaret: false });
  }
  const firstLeaf = prevLine.getFirstLeaf();
  const range = firstLeaf && firstLeaf.toRange();
  range && this.editor.selection.set(range, true);
  return void 0;
}
const attrsLength = Object.keys(line.attributes).length;
// 如果在当前行的行首, 且不存在其他行属性, 则将当前行属性移到下一行
if (prevLine && !attrsLength) {
  const prevAttrs = { ...prevLine.attributes };
  const delta = new Delta()
    .retain(line.start - 1)
    .delete(1)
    .retain(line.length - 1)
    .retain(1, prevAttrs);
  this.editor.state.apply(delta);
  return void 0;
}

而最基本的内容删除处理则比较简单,毕竟在这里只需要删除长度为1的内容即可。当然,由于Emoji等内容的存在,其长度常常会大于1,还有按住alt+delete的话会以词的角度删除内容,这些长度不为1的内容长度我们后续会再处理。

js 复制代码
// packages/core/src/perform/index.ts
const raw = RawRange.fromRange(this.editor, sel);
const start = raw.start - len;
const delta = new Delta().retain(start).delete(len);
this.editor.state.apply(delta, { range: raw });

看起来输入的部分并没有什么很复杂的点,但是这件事也并非这么容易。举个例子,此时已经完成了通过选区映射到我们自行维护的Range Model,那么当进行输入操作的时候,假设我们此时有两个span,最开始当前的DOM结构是<span>DOM1</span><span>DO|M2</span>|表示光标位置。

我们在第2spanDOM2字符之间插入内容x,此时无论是用代码apply还是用户输入的方式,都会使得DOM2这个span由于apply造成ContentChange继而DOM节点会刷新,也就是说就是第2span已经不是原来的span而是创建了一个新对象。

那由于这个DOM变了导致浏览器光标找不到原本的DOM2这个span结构了,那么此时光标就变成了<span>DOM1|</span><span>DOxM2</span>。本身我们可能认为起码在输入的时候选区应该是会跟着变动的,然而实践证明这个方法是不行的,这里的DOM节点并非一致的。

所以实际上在这里就是缺了一步根据我们的Range Model来更新DOM Range的操作,而且由于我们应该在DOM结构完成后尽早更新DOM Range。这个操作需要在useLayoutEffect中完成而不是useEffect中,也就对标了类组件的DidUpdate,更新DOM Range的操作应该是主动完成的。

js 复制代码
// packages/react/src/model/block.tsx
/**
 * 视图更新需要重新设置选区 无依赖数组
 */
useLayoutEffect(() => {
  const selection = editor.selection.get();
  if (
    !editor.state.get(EDITOR_STATE.COMPOSING) &&
    editor.state.get(EDITOR_STATE.FOCUS) &&
    selection
  ) {
    // 更新浏览器选区
    editor.logger.debug("UpdateDOMSelection");
    editor.selection.updateDOMSelection(true);
  }
});

非受控输入模式

这里的非受控输入模式指的是需要唤醒IME输入法的部分,通常是指中文输入、日文输入等。既然是非受控的模式,就很容易导致一些问题,因为浏览器中输入法的输入会直接修改DOM,我们无法阻止这种行为,因此只能在DOM变更后再进行修正,这就是我们常说的脏DOM检测。

举个例子,最开始当前的DOM结构是<s>DOM1</s><b>DOM2</b>,此时我们在两个DOM的最后输入中文,也就是唤起了IME输入。当我们输入了"试试"这两个字,需要注意的是这里不追加样式,类似于inline-code,此时的DOM结构变成了<s>DOM1</s><b>DOM2试试</b><s>试试</s>

很明显在<b>标签里边的文字是异常的,我们此时的数据结构Delta内容上是没问题的,因为我们定义的schema是不追加样式的。然而也就是因为这样造成了问题,我们关注到<b>这个节点,对我们当前节点而言其状态以及delta没有改变,但是DOM由于输入法发生了变化。

那么由我们维护的Model映射到React维护的Fiber时,由于Model没有变化那么React根据VDOM diff的结果发现没有改变于是原地复用了这个DOM结构,而实际上这个DOM结构由于我们的IME输入是已经被破坏了的,而我们无法受控地处理IME输入就造成了问题。

由于英文输入时我们阻止了默认行为,因此是不会去改变原本的DOM结构的,所以在这里我们需要进行脏数据检查,并且将脏数据进行修正,确保最后的数据是正常的。目前采取的一个方案是对于最基本的Text组件进行处理,在ref回调中检查当前的内容是否与op.insert一致,若不一致要清理掉除第一个节点外的所有节点,并且将第一个节点的内容回归到原本的text内容上。

js 复制代码
// packages/react/src/preset/text.tsx
/**
 * 纯文本节点的检查更新
 * @param dom DOM 节点
 * @param text 目标文本
 */
export const updateDirtyText = (dom: HTMLElement, text: string) => {
  if (text === dom.textContent) return false;
  const nodes = dom.childNodes;
  // 文本节点内部仅应该存在一个文本节点, 需要移除额外节点
  for (let i = 1; i < nodes.length; ++i) {
    const node = nodes[i];
    node && node.remove();
  }
  // 如果文本内容不一致, 则是由于输入的脏 DOM, 需要纠正内容
  // Case1: [inline-code][caret][text] IME 会导致模型/文本差异
  if (isDOMText(dom.firstChild)) {
    dom.firstChild.nodeValue = text;
  }
  return true;
};

回到中文的输入,我们这里需要关注到两点,首先是唤醒IME输入法时,我们需要避免编辑器的相关事件触发,例如选区变换、输入事件等。其次则是关注到输入法结束的事件,这个事件是compositionend事件,在输入法输入结束后我们就可以在这里进行内容插入,以及上述的脏DOM检查。

js 复制代码
// packages/core/src/input/index.ts
onBeforeInput(event: InputEvent) {
  if (this.editor.state.get(EDITOR_STATE.COMPOSING)) {
    return void 0;
  }
  // ...
}

onCompositionEnd(event: CompositionEvent) {
  event.preventDefault();
  const data = event.data;
  const sel = this.editor.selection.get();
  data && sel && this.editor.perform.insertText(sel, data);
}

说到Composition事件的组合,其事件的执行顺序为compositionstartcompositionupdatecompositionend三个事件,分别为输入法的唤醒、更新、结束的事件。即使不实现编辑器,在input输入内容也会用到,例如按下回车时如果不判断是否唤醒了输入法,会导致意外的执行。

html 复制代码
<input id="$1" />
<script>
  $1.addEventListener("compositionstart", (e) => console.log("Composition started", e));
  $1.addEventListener("compositionupdate", (e) => console.log("Composition updated", e));
  $1.addEventListener("compositionend", (e) => console.log("Composition ended", e));
  $1.onkeydown = (e) => {
    if (e.key === "Enter") {
      if (e.isComposing) {
        console.log("Enter key pressed during composition");
        return;
      }
      console.log("Enter key pressed");
    }
  };
</script>

总结

在先前我们实现了编辑器的选区模块,实现了受控的选区同步模式,这是我们在MVC分层架构提到的核心状态同步模式之一。在这里我们在选区模块的基础上,通过浏览器的组合事件来实现半受控的输入模式,这同样是状态同步的重要实现,这个输入模式是目前大多数富文本编辑器的主流实现方式。

接下来我们要关注于处理浏览器复杂DOM结构默认行为,以及兼容IME输入法的各种输入场景,相当于我们来Case By Case地处理输入法和浏览器兼容的行为。例如我们需要处理Emoji表情符号的长度问题、DOM结构输入法操作问题、更加复杂的脏DOM检查等等。

每日一题

参考

相关推荐
就是帅我不改15 小时前
震惊!高并发下,我竟用Spring普通Bean实现了线程级隔离!
后端·面试·github
Cache技术分享15 小时前
177. Java 注释 - 重复注释
前端·后端
rocksun15 小时前
如何使用Enhance构建App:后端优先框架指南
前端·前端框架·前端工程化
Mike的AI工坊15 小时前
[知识点记录]createWebHistory的用法
前端
红色石头本尊15 小时前
8-Gsap动画库基本使用与原理
前端
小高00716 小时前
🎯v-for 先还是 v-if 先?Vue2/3 编译真相
前端·javascript·vue.js
原生高钙16 小时前
大模型的流式响应实现
前端·ai编程
zzywxc78716 小时前
如何利用AI IDE快速构建一个简易留言板系统
开发语言·前端·javascript·ide·vue.js·人工智能·前端框架
CaracalTiger16 小时前
网站漏洞早发现:cpolar+Web-Check安全扫描组合解决方案
java·开发语言·前端·python·安全·golang·wpf