先前我们主要处理了浏览器复杂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-基于组合事件的半受控输入模式
- 从零实现富文本编辑器#8-浏览器输入模式的非受控DOM行为
- 从零实现富文本编辑器#9-编辑器文本结构变更的受控处理
概述
在当前我们主要聊的编辑器输入模式中,主要是关注于文本的半受控输入以及脏DOM的检测,输入状态同步是比较复杂且容易出问题的地方。而在这里我们则关注于输入同步行为扩展,例如回车、删除、拖拽文本等操作,相当于完善了编辑器整体输入模式的处理。
具体来说,执行换行和删除回车时会变更DOM结构,而删除文本以及拖拽文本同样是由BeforeInput等事件组合执行的,在编辑器中这些操作都是输入的一部分。此外,这些操作通常都是可以受控处理的,因此并不太容易出现脏DOM的问题,但是整体上还是有很多需要注意的点:
- 回车操作通常需要拆分当前行结构,并且还需要关注到行格式的继承问题,特别是类似于列表等结构处理起来会更复杂一些。此外由于数据结构本身的设计不同,回车的操作实现也会有很大的差异,还有诸如软回车、硬回车等不同的回车类型。
- 删除操作同样会涉及行结构的处理,即删除回车时需要合并行结构,并且也会受到数据结构本身的影响,删除可能并不会符合操作直觉,因此需要手动校正行格式。并且删除的时候还需要关注Unicode字符的处理,特别是类似于Emoji等符号的删除需要特殊处理。
- 拖拽操作同样会涉及行结构的处理,而在我们的状态管理中本就是以行为单位进行管理的,因此拖拽行级结构相对会简单,当然实现的交互上还是有些工作量。而文本节点本身同样是可以拖拽的,因此我们同样需要根据选区范围进行文本的剪切和插入。
回车操作
在最开始的时候,我们就聊到了ContentEditable的不受控行为,特别是回车操作在不同浏览器中的表现是不一致的。在之前的例子中,我们就提到了回车操作在不同浏览器中的表现差异:
- 在空contenteditable编辑器的情况下,直接按下回车键,在Chrome中的表现是会插入
,而在FireFox(123123,在Chrome中的表现内容会恢复原本的123123,而在FireFox中的表现则是会变为123123。
其实这些示例其实也写过很多次了,每次提到浏览器的不受控行为都会提到相关的差异,这些默认行为也变成了我们处理状态同步时需要关注的点。而实际上,关于回车的行为本身我们是可以受控处理的,即阻止其默认行为,然后根据当前的选区状态进行行结构的拆分和格式继承等处理。
通常来说,我们可以通过两种方式阻止默认行为,一种是监听BeforeInput事件并阻止其默认行为,另一种是监听KeyDown事件并阻止其默认行为。前者的好处是可以直接获取到事件的输入类型,例如软硬回车等,而后者的好处则是可以更早地阻止默认行为。
那么自然的我们还是借助BeforeInput事件来处理回车操作,这样会比较方便一些。那么这里的实现就比较简单,理论上来说我们只需要在数据结构中插入一个\n的op即可。此外由于我们的编辑器本身不支持软回车,因此这两种类型的回车都需要统一处理为硬回车。- 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是否被复用过。
如果没有被复用过的话就直接将该State的key、attrs全部复用过来,当后续的\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;
- }
- }
复制代码 而处理forward删除时主要是处理行末删除的情况,这个情况相对起来会更简单一些,此时并没处理复杂情况,因为其操作更不高频。如果此时光标位于块节点上,那么删除时直接执行当前块节点的删除操作即可。如果光标位于当前行的行末,且下一行为块节点,那么删除时则将光标移动到该块节点上。- // 处于当前行的行首, 且存在行状态节点
- 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;
- }
- }
复制代码 在删除内容这里最需要关注的其实是视图层问题,当与React结合的视图层面更新时,同样也会出现非受控行为的问题,这里的不受控是React数据层及其渲染层的问题。其实,这里本质上还是跟IME输入的DOM变更有关。
具体来说,当选区存在跨节点行为时,无论是行内还是跨行的选区,唤醒输入法Composing输入内容后,这部分节点内容会被删除,并且替换为输入的内容。但是当确定内容之后,编辑器便会崩溃,这也是删除与插入的合并操作造成的问题,报错内容如下:- // 当前行为块结构时, 执行 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就会将对应的DOM节点自动移除掉,也就是不需要操作DOM,而是可以直接通过声明式的方式来实现变更。
那么这里的问题就出现在这些DOM已经实际上被移除了,因此当React尝试移除这些节点时就会报错,而这个异常会导致整个编辑器崩溃,因此我们就需要避免这个情况的发生。那么首先就需要避免removeChild的异常,我们很难直接避免React的行为,因此只能在DOM节点上进行拦截。
然而,即使是在DOM上处理拦截行为也并不容易,removeChild方法是在Node对象上的,如果我们直接重写Node.prototype.removeChild方法,那么就会影响到整个页面的DOM节点,因此我们只能尝试在编辑器的ref上处理。- Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
复制代码 然而编辑器本身会存在大量的DOM节点,我们很难在所有的节点上进行重写,因此我们还需要限制DOM变动的范围。在React中控制重渲染的方式可以通过key来实现,因此就需要在IME输入起始时刷新相关节点的key,以此来避免React复用这些节点,然后刷新范围就限制在了行节点上。- /**
- * 重写 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的变更行为,或者阻止浏览器的默认行为。但是我们却可以在start的时候就执行相关的处理,类似于将end时的删除且插入的行为分离出来,也就是说先执行deleteFragment方法,将所有的DOM直接通过先移除掉来同步行为。
但是这里又出现了新的问题,因为本身的delete方法会将选区内的内容全部删除,这样的话会导致唤醒IME时,选区所在的DOM节点会被删除。因此浏览器会将光标兜底到当前行的起始位置,虽然不影响最终输入的内容,但是在输入的时候就可以明显地看出来问题,有些影响用户体验。
在这里其实还可以考虑一种实现,在组合输入时同样会删除选区的内容,但是保留光标所在的DOM节点,这个实现就会很复杂。其实如果能在唤醒输入法前就将选区删除并且再设置好光标位置,再出现输入法的话,倒是就不会出现这个问题,然而目前并没有相关的API可以实现这样的行为。
但是在后期研究slate的实现发现,其仅仅是在IME组合输入开始的时候删除了相关的节点,而我们的编辑器却无法做到。经过排查之后发现是更新内容后的浏览器选区事件被我们阻止了,但是这里的表现也比较奇怪,阻止了选区更新竟然会导致行的该节点后的所有节点都无法渲染出来。- /**
- * 组合输入开始
- * @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();
- }
- }
复制代码 因此在这里放行选区更新的事件,即在Update Effect时不再通过Composing状态阻止选区的更新行为,这样就可以避免上述的问题了。然而这里的表现确实是非常奇怪的,React确实是持有了DOM状态,而改动就是这里的更新选区行为,选区本身导致节点无法正常渲染实在是有点费解。- const setModel = useMemoFn((ref: HTMLDivElement | null) => {
- if (ref) {
- rewriteRemoveChild(ref);
- }
- });
复制代码 Emoji 处理
Unicode可以视为Map,可以从数值code point映射到具体的字形,这样就可以直接引用符号而不需要实际使用符号本身。可能的代码点值范围是从U+0000到U+10FFFF,有超过110万个可能的符号,为了保持条理性,Unicode将此代码点范围划分为17个平面。
首个平面U+0000 -> U+FFFF称为基本多语言平面或BMP,包含了最常用的字符。这样BMP之外就剩下大约100万个代码点U+010000 -> U+10FFFF,这些代码点所属的平面称为补充平面或星面。
JavaScript的单个字符由无符号的16位整数支持,因此其无法容纳任何高于U+FFFF的代码点,而是需要将其拆分为代理对。这其实就是JS的UCS-2编码形式,造成了所有字符在JS中都是2个字节,而如果是4个字节的字符,那么就会当作两个双字节的字符处理即代理对。
其实这么说起来UTF-8的变长1-4字节的编码是无法表示的,代理对自然是可以解决这个问题。而表达UTF-16的编码长度要么是2个字节,要么是4个字节。在ECMAScript 6中引入了新的表达方式,但是为了向后兼容ECMAScript 5依然可以用代理对的形式表示星面。
[code]"\u{1F3A8}"//
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |