1

前端领域富文本编辑器一直被认为是天坑的存在,但是当富文本遇到 Android 设备时事情变得更糟。

本文主要介绍富文本编辑器框架 Slate 下 Android 设备下输入兼容问题的处理,在真正介绍兼容处理方案之前会先跟大家简单介绍下 Android 设备下输入的特殊性,以及整体兼容处理思路,最后介绍输入兼容的一些细节处理。

Slate 框架是开源社区中一款非常优秀的富文本编辑器框架,我个人一直非常喜欢它,它架构设计优雅、API 简洁,各个方面都有很多值得学习的地方。
对于 Slate 编辑器的框架视图层官方仅支持了 slate-react,因为我们团队的前端技术栈是 Angular ,所以我们参照 slate-react 实现了一个 Angular 版本的视图层 slate-angular,并且也已经开源( https://github.com/worktile/slate-angular )。
本文介绍的 Android 输入兼容方案主要是在 slate-angular 进行的实践,并且参考了 slate-react 的实现思路,但是我们的实现比 slate-react 得更简洁,更容易进行迁移到其它编辑器库。

历史

早在 2021 年 Slate 社区就在讨论支持 Android 设备下富文本内容输入的场景(slate-react 库中),最初支持的时候因为 Android 设备下的输入处理和其它桌面浏览器的行为有很大差异,所以抽取了一个独立的 AndroidEditable 组件用于处理 Android 设备下的输入代理,然后社区逐步发现在一个框架中维护两个 Editable 组件问题很大,于是在 2022 年对整体方案进行了一次大的重构,主要由  https://github.com/BitPhinix  完成(他同时也是 slate-yjs 项目的发起人),将 AndroidEditable 和基础的 Editable 组件进行合并,这是一个大工程参见  Android input handing rewrite ,它提出的新处理思路:RestoreDom、AndroidInputManager 都有很借鉴意义 👍🏻。
因为最初我们公司对于 Android 设别下的富文本输入场景没有足够的需求,slate-angular 一直没有跟进 slate-react 对于 Android 设备下兼容的处理(😂幸亏没跟进,不然也要跟着踩不少坑),而今年(2023)我们公司确定了要支持 Android 设备下富文本的编辑,因此我们对这块技术进行了研究并且在 slate-angular 完成兼容。
目前测试在 Android 版本:API 31、API 33 下,Gboard、Sougou、Baidu、Weixin 输入法下表现正常。

Android 下输入有问题?

最初我一直认为 slate-angular 在 android 设备下输入没有问题,因为我用我的手机试过,发现可以正常输入,也没做什么特殊处理,直到看到 github 上的一个 issue: Weird behavior with the android keyboard "Gboard" ,于是我发现了差异,我的 Android 手机用的是 Sougou 输入法,这东西还能跟输入有关系,在心里打了一个疑问❓
然后移动端的同事告诉我他在 Sougou 输入法下也有问题,这时我已经开始在具体排查问题了,最终发现 Sougou 输入下确实也有问题,只不过是只在切换到「英文」模式下才有问题,「中文」模式下大部分情况是没问题的。

问题根源

大家应该了解到了,Android 设备下的问题可能跟输入法有关系,而且大部分情况下只在「英文」输入模式下有问题。不知道大家有没有留意到手机输入内容时中文和英文的差异,就是在手机上进行英文输入时输入法会给出一个联想或者提示的功能,在正在输入的单词上出现一个下划线,如图(焦点在 editable 单词结尾处):
图片
在输入法的顶部会出现可选单词,可以进行整体替换实现自动补全、纠正拼写错误等等,应该所有的输入法都会有这样的提示功能,可富文本编辑的问题也就是出在这个下划线和自动替换/补全上。在前端处理中 Android 设备下(英文模式)这样的行为被转换为了组合输入事件族(compositionstart/compositionupdate/compositionend),而一个冷知识是在浏览器中组合输入事件的默认行为是无法被阻止的,即使调用 event.preventDefault() 也无济于事,这样一来内容输入过程中的数据模型和界面渲染的一致性将很难维护(Slate 的框架核心机制)。Android 设备下英文输入行为本身有它的特殊性,每次输入一个英文字母时 beforeinput 所携带的数据都是包含前面输入的所有字母的,比如用户要输入一个单词: 'editable', 当用户输入完 'e' 后再输入 'd' 时 beforeinput 事件所携带的数据是 'ed',下一次再输入 'i' 时,携带的数据就是 'edi',这样的行为会给数据处理造成额外的麻烦,尤其是像 Slate 这种需要把用户的输入行为完全映射到对数据模型的修改上的处理方案(对应的 Slate 的数据同步就需要先删除、再插入,需要明确知道在插入之前需要删几个字符,不能出现任何的偏差)。Android 设备下的浏览器一旦进入组合输入状态(触发 compositionstart 事件 )后,键盘事件的 keyCode 码都是 229 ,这种情况下无法识别是按了 Backspace 键和 Enter 键,导致在输入行为意图判断上会有很大的障碍。

这样的行为和中文输入类似(中文输入过程中也是会进行组合输入事件族),不同的是中文输入在组合输入的过程中输入结果时无效或者意义的,我们只关注最终组合输入的结果,通常我们的只需要在 compositionend 中插入最终的内容即可,而 Android 设备下的英文输入则不是这样,它的每一次输入都有可能是最终结果,无法统一在 compositionend 中统一处理。
相信做过富文本编辑器的同学或者了解过 Slate 框架的同学应该有所了解,编辑器中最不好处理的问题就是中文输入,没什么好办法,只能规避浏览器的默认行为,而 Android 输入的问题也类似,并且比中文组合输入更繁琐,不同的输入法表现还不太一样。

处理思路

关于 Slate 如何识别用户交互意图转化为对数据模型的修改这里就不再赘述了,这里就谈谈 Android 设别输入的兼容处理思路。

交互意图识别:
整体思路上是依赖 beforeinput 事件中的 inputType 对交互意图进行判断,有些场景通过 inputType 不能完全确定用户的输入行为,则结合 beforeinput 事件的附属数据组合起来进行判断。

RestoreDOM:
前面解释过了,浏览器一旦进入 Composition 状态,用户输入产生的 DOM 更新(浏览器默认行为)不可阻止,只能等浏览器的渲染完成,然后写代码进行恢复,保证界面渲染正确并且和模型数据一致。
我们在处理这个问题时借鉴了 slate-react 中的 RestoreDOM 的概念,当监控到 Android 设备下的内容输入时,会进入 resotreDom 处理周期,resotreDom 内部会监控接下来一定时间内编辑器内 DOM 的更新(基于 MutationObserver 监控),因为我知道下一次的 DOM 更新一定来自于本次输入浏览器的默认渲染,检测到 DOM 更新后,基于更新的类型(add、remove、或者 type 等于 characterData)对特定的 DOM 进行恢复操作。
RestoreDOM 还支持传入一个处理函数,DOM 恢复完成后,会紧接着执行这个处理函数,执行真正的编辑器数据修改,具体执行什么操作是由调用方基于操作意图识别确定。

处理流程:
用户输入 -> DOM 更新(默认行为) -> 恢复 DOM -> 数据变换 -> DOM 渲染(数据驱动)
image.png

一个潜在的问题,就是监控 DOM 更新期间会不会有其它的不是本次输入的 DOM 更新,这个是一个真空地带,目前我无法保障,尤其是在支持协同编辑的场景下, 具体的表现还有待验证。

代码细节

一:beforeinput 拦截处理

 https://github.com/worktile/slate-angular/blob/a0ce7e32ac90078edca210a818ab464d8edd8dc7/packages/src/components/editable/editable.component.ts#L560 

if (IS_ANDROID) {
  let targetRange: Range | null = null;
  let [nativeTargetRange] = event.getTargetRanges();
  if (nativeTargetRange) {
    targetRange = AngularEditor.toSlateRange(editor, nativeTargetRange);
  }
  // COMPAT: SelectionChange event is fired after the action is performed, so we
  // have to manually get the selection here to ensure it's up-to-date.
  const window = AngularEditor.getWindow(editor);
  const domSelection = window.getSelection();
  if (!targetRange && domSelection) {
    targetRange = AngularEditor.toSlateRange(editor, domSelection);
  }
  targetRange = targetRange ?? editor.selection;
  if (type === 'insertCompositionText') {
    if (data && data.toString().includes('\n')) {
      restoreDom(editor, () => {
        Editor.insertBreak(editor);
      });
    } else {
      if (targetRange) {
        if (data) {
          restoreDom(editor, () => {
            Transforms.insertText(editor, data.toString(), { at: targetRange });
          });
        } else {
          restoreDom(editor, () => {
            Transforms.delete(editor, { at: targetRange });
          });
        }
      }
    }
    return;
  }
  if (type === 'deleteContentBackward') {
    // gboard can not prevent default action, so must use restoreDom,
    // sougou Keyboard can prevent default action(only in Chinese input mode).
    // In order to avoid weird action in Sougou Keyboard, use resotreDom only range's isCollapsed is false (recognize gboard)
    if (!Range.isCollapsed(targetRange)) {
      restoreDom(editor, () => {
        Transforms.delete(editor, { at: targetRange });
      });
      return;
    }
  }
  if (type === 'insertText') {
    restoreDom(editor, () => {
      if (typeof data === 'string') {
        Editor.insertText(editor, data);
      }
    });
    return;
  }
}

代码细节不再赘述了,主要是根据一些上下文状态进行用户输入行为断言。
关键点:其中一个相对重要的点就是基于 event.getTargetRanges() 获取当前输入对应的选区范围,就是在输入一个字符时,它会替换哪些字符,对应到编辑器数据就是需要知道插入之前我需要删除哪些字符,这个不同输入法还有一些兼容问题,如果获取不到则尝试通过 window.getSelection() 获取,如果还获取不到,那就听天由命了,因为如果这个数据拿不到前端是无法确定数据修改范围的,只能忽略不处理!

二:resotreDom 函数

 https://github.com/worktile/slate-angular/blob/a0ce7e32ac90078edca210a818ab464d8edd8dc7/packages/src/utils/restore-dom.ts#LL4C2-L4C2 

export function restoreDom(editor: Editor, execute: () => void) {
    const editable = EDITOR_TO_ELEMENT.get(editor);
    let observer = new MutationObserver(mutations => {
        mutations.reverse().forEach(mutation => {
            if (mutation.type === 'characterData') {
                // We don't want to restore the DOM for characterData mutations
                // because this interrupts the composition.
                return;
            }

            mutation.removedNodes.forEach(node => {
                mutation.target.insertBefore(node, mutation.nextSibling);
            });

            mutation.addedNodes.forEach(node => {
                mutation.target.removeChild(node);
            });
        });
        disconnect();
        execute();
    });
    const disconnect = () => {
        observer.disconnect();
        observer = null;
    };
    observer.observe(editable, { subtree: true, childList: true, characterData: true, characterDataOldValue: true });
    setTimeout(() => {
        if (observer) {
            disconnect();
            execute();
        }
    }, 0);
}

这个函数是 Android 兼容处理的关键,基于 MutationObserver 监控 DOM 变化,然后恢复会导致编辑器数据和界面状态不一致的更新,由它来掌控编辑器真正执行数据模型修改的时机,当一个 setTimeout 周期内没有 DOM 更新时则自动取消 MutationObserver 的监控,避免影响正常的 DOM 更新。
目前验证这个时机的控制没有太大问题,Android 输入内容后 DOM 的更时机在 Promise 周期之后和 setTimeout 周期之前,MutationObserver 的触发时间刚好在这两者之间,使用 setTimeout 可以增加一层保险,避免 MutationObserver 监控到标准的 DOM 更新。

问题记录
这部分是在进行 Android 设备下输入兼容处理时记录的一个个具体问题,原谅我没有录异常的视频,只是用文字记录了,暂时用不到的同学尽量略过,如果有兼容 Android 设备输入问题的需求可以拿来参考。
① 移动焦点会意外触发文字插入
这个问题是以前代码处理逻辑中,某些场景下需要在 compositionend 事件中处理文本插入,以兼容某些浏览器不触发 beforeinput 事件的问题,现在 Android 设备下的浏览器组合输入特性,移动焦点也会触发 compositionend,所以需要增加判断条件阻止 Android 下的错误插入。

② 按 Enter 键行为异常
③ 在单词结尾处按 Backspace 行为异常
这两种输入行为最终触发的都是 inputType 为 insertCompositionText 的 beforeinput 事件,前面提到了 slate-angular 以前没有处理这个类型的输入,并且因为『Android 设备输入法特殊的组合输入特性』的原因,浏览器默认行为无法拦截,所以既需要识别输入意图,也需要处理浏览器默认行为带来的影响。
如何识别用户的换行行为是个难题,目前是参照 slate-react 中的逻辑:通过判断 beforeinput 事件参数中携带的数据是否包含 '\n' 来识别:1. 如果包含 '\n' 则认为是换行行为,2. 如果不包含 '\n' 则认为是插入行为(组合输入情况下删除行为也被当做插入处理),如果携带的数据是 undefined 则是一个普通的删除行为。

④ 在单词中间按 Backspace 键,界面中会显示删除两个字母
⑤ 全选文字按 Backspace 键,无法正确删除内容
这两个问题类似,还是无法拦截浏览器默认行为的问题,只不过它们触发的 beforeinput 事件的输入类型是 deleteContentBackward,所以也需要针对 Android 设备下的 deleteContentBackward 类型输入做特殊处理,最初处理问题 ④ 时只需要在调用 restoreDom 函数的回调中执行 Editor.deleteBackward(editor) 即可,但是问题 ⑤ 出现了,所以最终是在调用 restoreDom 之前获取事件对应的 DOM 选区(转化为 Slate 选区 targetRange),然后 restoreDom 回调中执行 Transforms.delete(editor, { at: targetRange }) 即可解决这两个问题。

⑥ Sougou Keyboard 英文模式下按 Backspace 键数据数据处理异常(额外添加了一段组合文本)
这个问题是只在 Sougou Keyboard 浏览器下,主要原因是处理 beforeinput 的 insertCompositionText 类型输入时通过 event.getTargetRanges() 获取不到真正的选区,从浏览器的表现来看在删除了一个单词后,浏览器确实丢失了 composition 的选区(单词没有了下划线),但是这时输入法的联想输入状态还在,就造成状态的不统一,本质上是 slate-angular 操作 DOM 的原因破坏了输入法的联想提示功能。
解决方案是修改 slate-angular 的 DOM 修改方案,将最后一层渲染字符串的方式从模版换成组件(新增 SlateDefaultStringComponent),在组件中通过状态判断是否更新 DOM,如果要渲染的文字和 DOM 中的文字完全一致(浏览器默认修改行为起作用了),则保持浏览器状态,不进行渲染更新,就不会中断输入法的联想输入状态。

⑦ Sougou Keyboard 因为输入法下在一个单词结尾处(输入处于提示状态)按 Space 键,焦点更新异常
这个问题的根源是在 Sougou Keyboard 中在单词结尾处按 Space 键会触发两次 beforeinput 事件,第一次的输入类型是 insertCompositionText:用于更新组合输入文字,第二次的输入类型是 insertText:用于插入空格,因为在 Android 设备下对 insertCompositionText 输入类型做了特殊处理,它的执行周期放在了 restoreDom 中,这会导致 insertText 处理的执行时机先于 insertCompositionText 执行周期,导致焦点异常,解决办法是 Android 设备对 insertText 也进行特殊处理,放到 restoreDom 执行周期中处理。

⑧ Android 31 通过 event.nativeTargetRange() 无法获取到 targetRange
通过兼容手段获取,这时已经触发了 selectionchange ,此时通过 window.getSelection() 获取 targetRange。

⑨ Sougou keyboard 下在首行输入一个英文字母后,再次输入内容第一个字母会重复输入。
比如用户希望输入 love ,当输入完 l 后再输入 o 时会出现 llo 的结果,通过输入的提示输入也会有类似的问题。

这个其实是数据层的错误,编辑器数据层数据就出现了重复。
问题的原因在于编辑器底层在空字符和有文本时渲染使用的 DOM 结构不同,数据驱动的 DOM 变化会打断输入法的联想提示功能,界面中显示:输入完 l 后联想已经中断,但是在输入法内部它还没有中断,导致状态不一致,下次再输入 o 时是按 lo 插入的。
这个问题和 ⑥ 类似,没有什么好的办法,只能更细的操作 DOM ,避免粗暴的删除 DOM、插入 DOM 完成更新。

总结

本次就分享这么多,主要想介绍 Android 下内容输入问题的根源和兼容思路,还有就是记录下兼容过程中遇到的问题,以便大家在进行 Android 兼容处理时可以参考,大家有疑问可以留言讨论。
Android 输入兼容和编辑器中文兼容一样就是堵水管,哪里漏堵哪里,没有什么特别好的办法,只不过再处理的时候尽量采用一致的思路。

参考
 https://w3c.github.io/uievents/tools/key-event-viewer.html 
 https://discuss.prosemirror.net/t/contenteditable-on-android-... 


用户bPcMdO
2.6k 声望2k 粉丝