编辑器文本结构变更的受控处理

先前我们主要处理了浏览器复杂DOM结构的默认行为,以及兼容IME输入法的各种输入场景,以此需要针对性地处理输入法和浏览器兼容的行为。在这里我们关注于处理文本结构性变更行为的处理,主要是针对行级别的操作、文本拖拽操作等,分别处于文本结构结构以及变更操作扩展。

概述

在当前我们主要聊的编辑器输入模式中,主要是关注于文本的半受控输入以及脏DOM的检测,输入状态同步是比较复杂且容易出问题的地方。而在这里我们则关注于输入同步行为扩展,例如回车、删除、拖拽文本等操作,相当于完善了编辑器整体输入模式的处理。

具体来说,执行换行和删除回车时会变更DOM结构,而删除文本以及拖拽文本同样是由BeforeInput等事件组合执行的,在编辑器中这些操作都是输入的一部分。此外,这些操作通常都是可以受控处理的,因此并不太容易出现脏DOM的问题,但是整体上还是有很多需要注意的点:

  • 回车操作通常需要拆分当前行结构,并且还需要关注到行格式的继承问题,特别是类似于列表等结构处理起来会更复杂一些。此外由于数据结构本身的设计不同,回车的操作实现也会有很大的差异,还有诸如软回车、硬回车等不同的回车类型。
  • 删除操作同样会涉及行结构的处理,即删除回车时需要合并行结构,并且也会受到数据结构本身的影响,删除可能并不会符合操作直觉,因此需要手动校正行格式。并且删除的时候还需要关注Unicode字符的处理,特别是类似于Emoji等符号的删除需要特殊处理。
  • 拖拽操作同样会涉及行结构的处理,而在我们的状态管理中本就是以行为单位进行管理的,因此拖拽行级结构相对会简单,当然实现的交互上还是有些工作量。而文本节点本身同样是可以拖拽的,因此我们同样需要根据选区范围进行文本的剪切和插入。

回车操作

在最开始的时候,我们就聊到了ContentEditable的不受控行为,特别是回车操作在不同浏览器中的表现是不一致的。在之前的例子中,我们就提到了回车操作在不同浏览器中的表现差异:

  • 在空contenteditable编辑器的情况下,直接按下回车键,在Chrome中的表现是会插入<div><br></div>,而在FireFox(<60)中的表现是会插入<br>IE中的表现是会插入<p><br></p>
  • 在有文本的编辑器中,如果在文本中间插入回车例如123|123,在Chrome中的表现内容是123<div>123</div>,而在FireFox中的表现则是会将内容格式化为<div>123</div><div>123</div>
  • 同样在有文本的编辑器中,如果在文本中间插入回车后再删除回车,例如123|123->123123,在Chrome中的表现内容会恢复原本的123123,而在FireFox中的表现则是会变为<div>123123</div>

其实这些示例其实也写过很多次了,每次提到浏览器的不受控行为都会提到相关的差异,这些默认行为也变成了我们处理状态同步时需要关注的点。而实际上,关于回车的行为本身我们是可以受控处理的,即阻止其默认行为,然后根据当前的选区状态进行行结构的拆分和格式继承等处理。

通常来说,我们可以通过两种方式阻止默认行为,一种是监听BeforeInput事件并阻止其默认行为,另一种是监听KeyDown事件并阻止其默认行为。前者的好处是可以直接获取到事件的输入类型,例如软硬回车等,而后者的好处则是可以更早地阻止默认行为。

那么自然的我们还是借助BeforeInput事件来处理回车操作,这样会比较方便一些。那么这里的实现就比较简单,理论上来说我们只需要在数据结构中插入一个\nop即可。此外由于我们的编辑器本身不支持软回车,因此这两种类型的回车都需要统一处理为硬回车。

switch (inputType) {
  case "insertLineBreak":
  case "insertParagraph": {
    this.editor.perform.insertBreak(sel);
    break;
  }
}

export class Perform {
  public insertBreak(sel: Range, attributes?: AttributeMap) {
    const raw = RawRange.fromRange(this.editor, sel);
    const start = raw.start;
    const len = raw.len;
    const delta = new Delta().retain(start);
    len && delta.delete(len);
    delta.insertEOL();
    this.editor.state.apply(delta, { range: raw });
  }
}

这件事看起来并没有那么复杂,因为DOM的结构变更处理是由我们的LineState以及Mutate模块实现的,Mutate模块实现了key值的维护以及immutable。接下来,在React适配器中,我们就可以直接以LineState为基准渲染行结构,渲染这件事就自然而然地交给了React

/**
 * 数据同步变更, 异步批量绘制变更
 */
const onContentChange = useMemoFn(() => {
    setLines(state.getLines());
});

/**
 * 监听内容变更事件, 更新当前块视图
 */
useLayoutEffect(() => {
  editor.event.on(EDITOR_EVENT.CONTENT_CHANGE, onContentChange);
  return () => {
    editor.event.off(EDITOR_EVENT.CONTENT_CHANGE, onContentChange);
  };
}, [editor.event, onContentChange]);

Mutate模块实现的算法会比较复杂,这里就暂时先不展开了。简单来说,就是会根据当前的选区位置,找到对应的行结构,然后将其拆分为两行,并且继承当前行的格式属性。当然我们并不是Case By Case地处理,而是根据变更的Delta操作实现一个通用的变更模式。

看起来插入回车这件事就简单的结束了,然而实际上并没有,这件事复杂的点在于行格式的继承问题。在我们的Mutate的设计中,在行样式的处理上我们是完全遵循着delta的数据结构设计,即最后的EOL节点才承载行样式。

那么这样会造成一个比较反直觉的问题,如果我们直接在行中间插入\n的话,原本的行样式是会处于下一行的,因为本质上是因为EOL节点是在末尾的,此时插入\n自然原本的EOL是会直接跟随到下一行的。

这个问题本质上是由于\n太滞后了导致了,而如果我们将承载行内容的节点前提,也就是在行首加入SOL-Start Of Line节点,由该节点来承载样式,\n节点仅用于分割行,那么在执行Mutate Insert的时候自然就能很轻松地得到将行样式保留在上一行,而不是跟随到下一行。

但是这种方式很明显会因为破坏了原本的数据结构,因此导致整个状态管理发生新的问题,需要很多额外的Case来处理这个不需要渲染的节点所带来的问题。还有一种方案是在Mutate Iterator对象中加入used标记,当插入的节点为\n时会检查当前的存量LineState是否被复用过。

如果没有被复用过的话就直接将该Statekeyattrs全部复用过来,当后续的\n节点再读区时则会因为已经复用过导致无法再复用,此时就是完全重新创建的新状态。

但是这里的问题是无法很好地保证第二个\n的实际值,也就是说破坏了我们原本的模型结构,其并不是交换式的,也无法将确定的新值传递到第二个\n上,而且在Mutate Compose的过程中做这件事是会导致真的需要实现这种效果时无法规避这个行为。

实际上Quill则是会存在同样的问题,我发现其如果直接执行插入\n的话也是会将样式跟随到下一行,那么其实这样就意味着其行样式继承是在回车的事件处理的,设想了一下这种方式的处理是合理的,这种情况下我们就可以是完全受控的情况处理。

// https://quilljs.com/playground/snow
quill.updateContents([{ retain: 3 }, { insert: "\n" }]);

那么回到编辑器回车这件事上,在行格式的继承上,如果接着上述的操作实现,则很容易地可以看出来行格式的继承问题。在下面的例子中,quota表示引用格式,如果在Md中引用是以>在行首表示的,插入回车时原始行应该保持引用格式,而下面的例子中引用格式却仅表现在了新行。

[ { insert: "abc{caret}def" }, { insert: "\n", attributes: { quote: "true" } } ]
// 插入回车后 =>
[ { insert: "abc" }, { insert: "\n" }, { insert: "{caret}def" }, { insert: "\n", attributes: { quote: "true" } } ]

那么在这里就需要区分多种情况,那么如果是在行首,就将当前属性全部带入下一行,即默认的行为。如果在末尾插入回车,则需要将下一行的属性全部清空,此时也需要合并传入的属性。如果在行中间插入属性,则需要拷贝当前行属性放置于当前插入的新行属性,如果此时存在传入的属性则同样需要合并。

// |xx(\n {y:1}) => (\n)xx(\n {y:1})
// xx|(\n {y:1}) => xx(\n {y:1})(\n)
// xx|(\n {y:1}) => xx(\n {y:1})(\n & attributes)
// x|x(\n {y:1}) => x(\n {y:1})x(\n {y:1})
// x|x(\n {y:1}) => x(\n {y:1})x(\n {y:1 & attributes})
// 当光标在行首时, 直接移动行属性
// |xx(\n {y:1}) => (\n)|xx(\n {y:1} & attributes)
if (start === startLine.start) {
  delta.insertEOL();
  const lineOffset = endLine.length - 1;
  delta.retain(lineOffset - sel.end.offset).retain(1, attributes);
  point = new Point(sel.start.line + 1, 0);
  // 当光标在行尾时, 将行属性保留在当前行
  // xx|(\n {y:1}) => xx(\n {y:1})(\n attributes)
} else if (start === startLine.start + startLine.length - 1) {
  delta.retain(1).insertEOL(attributes);
  point = new Point(sel.start.line + 1, 0);
  // 当光标在行中时, 将行属性保留在当前行, 下一行合并新属性
  // x|x(\n {y:1}) => xx(\n {y:1})(\n {y:1} & attributes)
} else {
  delta.insertEOL(startLine.attributes);
  const lineOffset = endLine.length - 1;
  const attrs = { ...startLine.attributes, ...attributes };
  delta.retain(lineOffset - sel.end.offset).retain(1, attrs);
}

删除操作

删除操作同样是文本结构变更中比较重要的一个操作,而同样的删除也需要关注行结构的合并以及行格式的问题。首先聊的是相对简单的部分,删除文本片段内容,由于本身我们的选区是携带Range信息的,因此删除文本片段内容其实并没有什么复杂的地方,直接根据选区删除对应的内容即可。

export class Perform {
  public deleteFragment(sel: Range) {
    if (sel.isCollapsed) return void 0;
    const raw = RawRange.fromRange(this.editor, sel);
    if (!raw) return void 0;
    const len = Math.max(raw.len, 0);
    const start = Math.max(raw.start, 0);
    if (start < 0 || len <= 0) return void 0;
    const delta = new Delta().retain(start).delete(len);
    this.editor.state.apply(delta, { range: raw });
    return void 0;
  }
}

而删除本身还存在向前删除和向后删除的情况,因此我们需要分别处理deleteContentBackwarddeleteContentForward两种输入类型。实际上这两种删除的实现是类似的,主要是计算删除的位置不同而已,但是也需要分别处理行首行末等情况。

处理backward删除时主要是处理行首删除的情况,即处于当前行的行首, 且存在行状态节点。此时分别处理上个节点为块节点、当前行存在行属性、当前行不存在行属性三种情况,这里的主要目标是删除时要删除当前行结构,以此更加符合操作直觉,并且将光标移动到合适的位置。

// 处于当前行的行首, 且存在行状态节点
if (line && sel.start.offset === 0) {
  const prevLine = line && line.prev();
  // 上一行为块节点且处于当前行首时, 删除则移动光标到该节点上
  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 (attrsLength > 0) {
    const delta = new Delta().retain(line.start + line.length - 1).retain(1, invertAttributes(line.attributes));
    this.editor.state.apply(delta, { autoCaret: false });
    return void 0;
  }
  // 如果在当前行的行首, 且不存在其他行属性, 则将当前行属性移到下一行
  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;
  }
}

而处理forward删除时主要是处理行末删除的情况,这个情况相对起来会更简单一些,此时并没处理复杂情况,因为其操作更不高频。如果此时光标位于块节点上,那么删除时直接执行当前块节点的删除操作即可。如果光标位于当前行的行末,且下一行为块节点,那么删除时则将光标移动到该块节点上。

// 当前行为块结构时, 执行 backward 删除操作
if (line && sel.start.offset === 1 && isBlockLine(line)) { 
  this.deleteBackward(sel);
  return void 0;
}
const nextLine = line && line.next();
// 下一行为块节点且处于当前行末时, 删除则移动光标到该节点上
if (line && sel.start.offset === line.length - 1 && nextLine && isBlockLine(nextLine)) {
  const firstLeaf = nextLine.getFirstLeaf();
  const range = firstLeaf && firstLeaf.toRange();
  range && this.editor.selection.set(range, true);
  return void 0;
}

在删除内容这里最需要关注的其实是视图层问题,当与React结合的视图层面更新时,同样也会出现非受控行为的问题,这里的不受控是React数据层及其渲染层的问题。其实,这里本质上还是跟IME输入的DOM变更有关。

具体来说,当选区存在跨节点行为时,无论是行内还是跨行的选区,唤醒输入法Composing输入内容后,这部分节点内容会被删除,并且替换为输入的内容。但是当确定内容之后,编辑器便会崩溃,这也是删除与插入的合并操作造成的问题,报错内容如下:

Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

从报错上来看,React会将子节点从父节点移除,这本身是非常合理的行为。举个例子,当实现一个列表时,如果数据源删除了某些节点,那么React就会将对应的DOM节点自动移除掉,也就是不需要操作DOM,而是可以直接通过声明式的方式来实现变更。

那么这里的问题就出现在这些DOM已经实际上被移除了,因此当React尝试移除这些节点时就会报错,而这个异常会导致整个编辑器崩溃,因此我们就需要避免这个情况的发生。那么首先就需要避免removeChild的异常,我们很难直接避免React的行为,因此只能在DOM节点上进行拦截。

然而,即使是在DOM上处理拦截行为也并不容易,removeChild方法是在Node对象上的,如果我们直接重写Node.prototype.removeChild方法,那么就会影响到整个页面的DOM节点,因此我们只能尝试在编辑器的ref上处理。

/**
 * 重写 removeChild 方法
 * - 避免 IME 破坏跨节点渲染造成问题
 * - https://github.com/facebookarchive/draft-js/issues/1320
 */
export const rewriteRemoveChild = (node: Node) => {
  const removeChild = Node.prototype.removeChild;
  node.removeChild = function <T extends Node>(child: T) {
    if (child.parentNode !== this) return child;
    return removeChild.call(this, child) as T;
  };
};

然而编辑器本身会存在大量的DOM节点,我们很难在所有的节点上进行重写,因此我们还需要限制DOM变动的范围。在React中控制重渲染的方式可以通过key来实现,因此就需要在IME输入起始时刷新相关节点的key,以此来避免React复用这些节点,然后刷新范围就限制在了行节点上。

/**
 * 组合输入开始
 * @param event
 */
@Bind
protected onCompositionStart() {
  // 需要强制刷新 state.key, 且需要配合 removeChild 避免抛出异常
  const sel = this.editor.selection.get();
  if (!sel || sel.isCollapsed) return void 0;
  for (let i = sel.start.line; i <= sel.end.line; ++i) {
    const line = this.editor.state.block.getLine(i);
    line && line.forceRefresh();
  }
}

然后在React控制节点的部分,就需要将重写的逻辑加入到块节点以及行节点的DOM上,以此来避免异常的发生。这里还需要避免ref函数的重复执行,React的特性是如果ref引用不同就会原始的引用再调用新的方法,因此这里需要借助useMemoFn实现。

const setModel = useMemoFn((ref: HTMLDivElement | null) => {
  if (ref) {
    rewriteRemoveChild(ref);
  }
});

从本质上来看,是执行输入法时没有办法控制DOM的变更行为,或者阻止浏览器的默认行为。但是我们却可以在start的时候就执行相关的处理,类似于将end时的删除且插入的行为分离出来,也就是说先执行deleteFragment方法,将所有的DOM直接通过先移除掉来同步行为。

但是这里又出现了新的问题,因为本身的delete方法会将选区内的内容全部删除,这样的话会导致唤醒IME时,选区所在的DOM节点会被删除。因此浏览器会将光标兜底到当前行的起始位置,虽然不影响最终输入的内容,但是在输入的时候就可以明显地看出来问题,有些影响用户体验。

在这里其实还可以考虑一种实现,在组合输入时同样会删除选区的内容,但是保留光标所在的DOM节点,这个实现就会很复杂。其实如果能在唤醒输入法前就将选区删除并且再设置好光标位置,再出现输入法的话,倒是就不会出现这个问题,然而目前并没有相关的API可以实现这样的行为。

但是在后期研究slate的实现发现,其仅仅是在IME组合输入开始的时候删除了相关的节点,而我们的编辑器却无法做到。经过排查之后发现是更新内容后的浏览器选区事件被我们阻止了,但是这里的表现也比较奇怪,阻止了选区更新竟然会导致行的该节点后的所有节点都无法渲染出来。

export class Input {
  @Bind
  protected onCompositionStart() {
    // 避免 IME 破坏跨节点渲染造成问题
    const sel = this.editor.selection.get();
    if (!sel || sel.isCollapsed) return void 0;
    this.editor.perform.deleteFragment(sel);
  }
}

因此在这里放行选区更新的事件,即在Update Effect时不再通过Composing状态阻止选区的更新行为,这样就可以避免上述的问题了。然而这里的表现确实是非常奇怪的,React确实是持有了DOM状态,而改动就是这里的更新选区行为,选区本身导致节点无法正常渲染实在是有点费解。

useLayoutEffect(() => {
  const selection = editor.selection.get();
  // 渲染完成后更新浏览器选区
  if (editor.state.isFocused() && selection) {
    editor.logger.debug("UpdateDOMSelection");
    editor.selection.updateDOMSelection(true);
  }
});

Emoji 处理

Unicode可以视为Map,可以从数值code point映射到具体的字形,这样就可以直接引用符号而不需要实际使用符号本身。可能的代码点值范围是从U+0000U+10FFFF,有超过110万个可能的符号,为了保持条理性,Unicode将此代码点范围划分为17个平面。

首个平面U+0000 -> U+FFFF称为基本多语言平面或BMP,包含了最常用的字符。这样BMP之外就剩下大约100万个代码点U+010000 -> U+10FFFF,这些代码点所属的平面称为补充平面或星面。

JavaScript的单个字符由无符号的16位整数支持,因此其无法容纳任何高于U+FFFF的代码点,而是需要将其拆分为代理对。这其实就是JSUCS-2编码形式,造成了所有字符在JS中都是2个字节,而如果是4个字节的字符,那么就会当作两个双字节的字符处理即代理对。

其实这么说起来UTF-8的变长1-4字节的编码是无法表示的,代理对自然是可以解决这个问题。而表达UTF-16的编码长度要么是2个字节,要么是4个字节。在ECMAScript 6中引入了新的表达方式,但是为了向后兼容ECMAScript 5依然可以用代理对的形式表示星面。

"\u{1F3A8}"
// 🎨
"🎨"
// 🎨

实际上在ES6中引入的函数也解决了字符串遍历的问题,正则表达式也提供了u修饰符来处理4字节的字符。

Array.from("1🎨1")
// ["1", "🎨", "1"]
/^.$/u.test("🎨")
// true
"1🎨1".split("")
// ["1", "�", "�", "1"]

另外在基本平面即低位代理对内,从U+D800U+DFFF是一个空段,即这些码点不对应任何字符,自然可以避免原本基本平面的冲突,因此可以用来映射辅助平面的字符。高位[�-�]与低位[�-�]恰好是2^10 * 2^10长度,恰好100多万个代码点。

(0xDBFF - 0xD800 + 1) * (0xDFFF - 0xDC00 + 1) = 1024 * 1024 = 1048576

虽然可以已经用Unicode代理对的方式表达4字节符号,但是类似Emoji这些符号是可以组合的。那么这样会导致字形上看起来是单个字符,实际上是通过\u200dZWJ组合起来的字符,因此其长度会更长,且ES6的函数也是会将其拆离表现的。

"🧑" + "\u200d" + "🎨"
// 🧑‍🎨
"🧑‍🎨".length
// 5
Array.from("🧑‍🎨")
// ["🧑", "‍", "🎨"]

因此,在这里我们需要在删除之前判断即将要删除的文本长度,这本身其实是可以有多种方式来实现的。例如我们即将要提到的词级别的内容删除,将其转换为非受控的状态来删除,而在这里我们则是通过计算末尾的Unicode字符长度来实现删除。

/**
 * 获取末尾 Unicode 字符长度
 * @param str
 */
export const getLastUnicodeLen = (str: string | P.Nil) => {
  if (!str || str.length < 2) {
    return str ? str.length : 0;
  }
  const first = str.charCodeAt(str.length - 2);
  const second = str.charCodeAt(str.length - 1);
  if (0xd800 < first && first < 0xdbff && 0xdc00 < second && second < 0xdfff) {
    // 此时基本 Unicode 字符长度为 2
    let len = 2;
    // 通过连接符号来组合单个 Unicode 字符长度
    // [-][-] \u200d [-][-] \u200d [-][-]
    for (let i = str.length - 3; i > 0; i = i - 3) {
      if (str[i].charCodeAt(0) !== 0x200d) break;
      len = len + 3;
    }
    return len;
  }
  return 1;
};

词级文本处理

先前我们针对Emoji的删除做了特殊处理,因为其本身是多个字符组成的内容,所以在删除时如果直接取长度为1的话会导致出现遗留不可见字符的情况。那么除了Emoji可能存在删除多个字符的情况,使用Alt + Del组合键在默认情况下是删除词级别内容,同样是存在多个字符的情况。

如果仅仅是使用ContentEditable的情况下,浏览器会自动处理词级别的删除行为,包括Emoji的删除行为也是可以自动处理的。因此针对非受控输入的编辑器例如Quill、飞书文档的实现,是不太需要主动处理相关行为的,主要关注点在于DOM变更后的被动同步状态。

而在我们实现的编辑器中,因为输入的相关实现是完全基于beforeInput事件来处理的,是完全受控的行为,因此我们必须要主动处理删除的行为。实际上在事件中,inputType值是给出了deleteWordBackwarddeleteWordForward的,却没有给出默认行为要删除的长度。

因此我最开始是想要么改为非受控输入,要么是通过Intl.Segmenter方法来主动分词实现。然而在看到MDNDEMO之后,发现这个构造器需要传递语言参数,这样的话在编辑器中是没有办法实现的,因为编辑器中无法实际确定语言类型。

const segmenterZH = new Intl.Segmenter("ZH-CN", { granularity: "word" });
const string1 = "当前所有功能都是基于插件化定义实现";
const iterator1 = segmenterZH.segment(string1)[Symbol.iterator]();
console.log(iterator1.next().value.segment); // 当前

因此我去找了相关开源编辑器的实现,slate是完全自定义处理的行为,使用getWordDistance来自行计算词的距离。这样对于英文问题不大,但是对于中文词组的处理就比较差了,是以标点符号为准作为切割目标的,因此对于中文实现更像是按句删除了。

而在Lexical中尝试了删除词组的表现则比较符合预期,本来我以为也是非受控的输入,但是查阅源码后发现同样是基于beforeInput事件来处理的。那么这个表现就非常符合浏览器的行为,本来我以为也是基于Segmenter实现,想查看是如何处理语言问题的,发现首参数是可以不传递的。

const segmenterZH = new Intl.Segmenter(undefined, { granularity: "word" });
const string1 = "当前所有功能都是基于插件化定义实现";
const iterator1 = segmenterZH.segment(string1)[Symbol.iterator]();
console.log(iterator1.next().value.segment); // 当前

然而,再细致地查阅源码后发现,Lexical并未直接使用Segmenter来处理分词,而是使用了selection.modify这个API来预处理选区的变更。基于这个API可以同步地变更选区的DOM引用,然后我们就可以立即得到未来的选区状态,因此就可以构造删除的范围。

const root = this.editor.getContainer();
const domSelection = getRootSelection(root);
const selection = this.current;
if (!domSelection || !selection) return null;
domSelection.modify(ALERT.MOVE, direction, granularity);
const staticSel = getStaticSelection(domSelection);
if (!staticSel || this.limit()) return null;
const { startContainer } = staticSel;
if (!root.contains(startContainer)) return null;
const newRange = toModelRange(this.editor, staticSel, false);
newRange && this.set(newRange);

并且在Lexical中还解释了beforeInput事件以及对应的getTargetRanges()方法。由此先前我对于浏览器没有给出默认要删除的长度的判断是错误的,其是通过Range来表达的。但是注释中还提到了这个方案不可靠,其在复杂场景下可能无法正确反应操作后的选区状态。

而对于使用像Intl.Segmenter等按词组分割的工具,比较容易出错,而且需要对于整个Op进行分词,也有很多不必要的计算。不同语言的分词规则差异巨大,例如英文空格分词以及中文无空格分词,自动识别词边界非常困难,尤其是在涉及自动换行和非罗马语言的情况下会非常困难。

总结起来,使用selection.modify方法直接利用了浏览器引擎自身对选区计算的内置、高度优化的逻辑,浏览器如何分词自然是浏览器本身最熟知。此外,实际上BeforeInput事件还有诸多方法,词级别删除这件事本身其实也可以用getTargetRanges来实现。

event.getTargetRanges()[0] // InputEvent
// StaticRange {startContainer: text, startOffset: 13, endContainer: text, endOffset: 14, collapsed: false}

文本拖拽

既然都提到了getTargetRanges方法,我们自然就可以顺理成章地以此为基础聊一下文本拖拽的实现。在beforeInput事件中,inputType值是有deleteByDrag以及insertFromDrop两种类型,拖拽需要有两个操作组合而来,分别是文本的删除与插入操作。

getTargetRanges方法返回的实际上是个static range数组,因此需要调用先前我们实现的toModelRange方法来转换到编辑器的选区模型。那么移动文本的这个操作实际上只需要关注两个Range,分别记录删除和插入的位置就可以实现了。

因此基于beforeInput事件的拖拽实现其实是非常简单的,主要是将删除和插入的逻辑组合在一起即可。这里需要放置一个临时的变量,用来记录起始的位置,因为这里是两个事件分别发生的,因此需要将删除的位置保存下来,然后在插入时使用这个位置来移动文本片段。

switch (inputType) {
  case "deleteByDrag": {
    const domRange = event.getTargetRanges()[0];
    const range = domRange && toModelRange(this.editor, domRange, false);
    this.dragStartRange = range || null;
  }
  case "insertFromDrop": {
    const domRange = event.getTargetRanges()[0];
    const range = domRange && toModelRange(this.editor, domRange, false);
    range && this.editor.perform.moveFragment(this.dragStartRange, range);
  }
}

移动内容片段的部分则比较简单,唯一一个需要关注的点是使用了transformPosition函数来处理位置偏移。因为在删除内容片段后,插入位置会发生变化,两次变更的基准都是同一个草稿,而变更则是顺序的关系,此时就需要假设A发生来处理对B的影响。

export class Perform {
  /**
   * 移动选区内容片段到目标选区处
   * @param from
   * @param to
   */
  public moveFragment(from: Range, to: Range) {
    const rawFrom = RawRange.fromRange(this.editor, from);
    const rawTo = RawRange.fromRange(this.editor, to);
    if (!rawFrom || !rawTo) return void 0;
    const fragment = this.editor.lookup.getFragment(from);
    if (!fragment) return void 0;
    const delDelta = new Delta().retain(rawFrom.start).delete(rawFrom.len);
    const toStart = delDelta.transformPosition(rawTo.start);
    const insertDelta = new Delta().retain(toStart).merge(new Delta(fragment));
    const composed = delDelta.compose(insertDelta);
    this.editor.state.apply(composed, { range: rawTo });
    return void 0;
  }
}

而除了在BeforeInput事件中处理拖拽外,我们还可以在DragEvent事件中处理拖拽的逻辑,在slate中就是基于Drag相关的事件来完成的。实际上这也是在阻止Drag默认行为后,手动处理拖拽的逻辑,如果需要接管诸如图片的拖拽行为等,那么就必须要采用这个方案了。

基于DragEvent的方案中,主要关注点有三部分,首先是需要在DragStart事件中将当前的选区位置在dataTransfer中保存下来,其次是在DragOver事件中阻止默认行为以允许拖拽,最后是在Drop事件中获取拖拽的内容并且执行移动操作。下面是Slate中的实现:

<div
  onDragStart={useCallback(() => {
    ReactEditor.setFragmentData(
      editor,
      event.dataTransfer,
      'drag'
    )
   }, [])}
  onDragOver={useCallback(() => {
    event.preventDefault();
  }, [])}
  onDrop={useCallback(() => {
    const draggedRange = editor.selection
    const range = ReactEditor.findEventRange(editor, event);
    const data = event.dataTransfer
    Transforms.delete(editor, { at: draggedRange })
    Transforms.select(editor, range)
    ReactEditor.insertData(editor, data)
  }, [])}
></div>

在这里的findEventRange方法是比较需要关注的,因为此时的DragEvent并没有提供类似于getTargetRanges的方法,因此我们并没有办法直接获取拖拽的目标位置。在之前我们提到了在DOM基础上的自绘选区实现,在这里仍然可以调用相关API来实现指定位置的选区获取。

let domRange: Range;
if (document.caretRangeFromPoint) {
  domRange = document.caretRangeFromPoint(x, y);
} else {
  const position = document.caretPositionFromPoint(x, y);
  if (position) {
    domRange = document.createRange();
    domRange.setStart(position.offsetNode, position.offset);
    domRange.setEnd(position.offsetNode, position.offset);
  }
}
const range = domRange && toModelRange(this.editor, domRange, false);
// ...

总结

先前我们针对性地处理输入法和浏览器兼容的行为,由于输入法会直接操作DOM,因此实现编辑器模型的输入状态同步需要处理很多问题。而在这里我们关注于处理文本结构性变更行为的处理,主要是实现了回车插入、删除、拖拽等文本变更行为的处理,至此完成了编辑器输入同步部分的实现。

接下来我们需要实现编辑器的视图层同步能力,即React/Vue等视图层的适配器实现,以此来对接核心层的编辑器模型。先前我们已经实现了模型层delta、控制器层core,再加上视图层的适配器react,就可以完整实现编辑器的MVC框架了。

每日一题

参考