乐趣区

关于编辑器:Web-富文本编辑器之-Android-输入兼容

作者:PingCode 知识库 研发负责人 杨振兴 @pubuzhixing

前端畛域富文本编辑器始终被认为是天坑的存在,然而当富文本遇到 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 渲染(数据驱动)

一个潜在的问题,就是监控 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 输出兼容和编辑器中文兼容一样就是堵水管,哪里漏堵哪里,没有什么特地好的方法,只不过再解决的时候尽量采纳统一的思路。

对于 PingCode:PingCode 是一款笼罩研发全生命周期的研发治理平台,被宽泛用于需要收集、需要治理、需要优先级、产品路线图、迭代治理、项目管理(麻利 /kanban/ 瀑布)、测试治理、缺点追踪、团队知识库、效力度量等畛域。集成了 github、gitlab、jinkens、企微、飞书等支流工具,并且可能与现有的自研工具买通提供接口。

参考

https://w3c.github.io/uievents/tools/key-event-viewer.htmlhtt…

退出移动版