php学校网站模板,家用电脑如何做网站,浏览器怎么打开网站服务器连接,十大后悔专业排行榜先前我们主要处理了浏览器复杂DOM结构的默认行为#xff0c;以及兼容IME输入法的各种输入场景#xff0c;以此需要针对性地处理输入法和浏览器兼容的行为。在这里我们关注于处理文本结构性变更行为的处理#xff0c;主要是针对行级别的操作、文本拖拽操作等#xff0c;分别…先前我们主要处理了浏览器复杂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中的表现是会插入divbr/div而在FireFox(60)中的表现是会插入brIE中的表现是会插入pbr/p。在有文本的编辑器中如果在文本中间插入回车例如123|123在Chrome中的表现内容是123div123/div而在FireFox中的表现则是会将内容格式化为div123/divdiv123/div。同样在有文本的编辑器中如果在文本中间插入回车后再删除回车例如123|123-123123在Chrome中的表现内容会恢复原本的123123而在FireFox中的表现则是会变为div123123/div。其实这些示例其实也写过很多次了每次提到浏览器的不受控行为都会提到相关的差异这些默认行为也变成了我们处理状态同步时需要关注的点。而实际上关于回车的行为本身我们是可以受控处理的即阻止其默认行为然后根据当前的选区状态进行行结构的拆分和格式继承等处理。通常来说我们可以通过两种方式阻止默认行为一种是监听BeforeInput事件并阻止其默认行为另一种是监听KeyDown事件并阻止其默认行为。前者的好处是可以直接获取到事件的输入类型例如软硬回车等而后者的好处则是可以更早地阻止默认行为。那么自然的我们还是借助BeforeInput事件来处理回车操作这样会比较方便一些。那么这里的实现就比较简单理论上来说我们只需要在数据结构中插入一个\n的op即可。此外由于我们的编辑器本身不支持软回车因此这两种类型的回车都需要统一处理为硬回车。/* by yours.tools - online tools website : yours.tools/zh/whois.html */ 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。/* by yours.tools - online tools website : yours.tools/zh/whois.html */ /** * 数据同步变更, 异步批量绘制变更 */ 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; } }而删除本身还存在向前删除和向后删除的情况因此我们需要分别处理deleteContentBackward和deleteContentForward两种输入类型。实际上这两种删除的实现是类似的主要是计算删除的位置不同而已但是也需要分别处理行首行末等情况。处理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映射到具体的字形这样就可以直接引用符号而不需要实际使用符号本身。可能的代码点值范围是从U0000到U10FFFF有超过110万个可能的符号为了保持条理性Unicode将此代码点范围划分为17个平面。首个平面U0000 - UFFFF称为基本多语言平面或BMP包含了最常用的字符。这样BMP之外就剩下大约100万个代码点U010000 - U10FFFF这些代码点所属的平面称为补充平面或星面。JavaScript的单个字符由无符号的16位整数支持因此其无法容纳任何高于UFFFF的代码点而是需要将其拆分为代理对。这其实就是JS的UCS-2编码形式造成了所有字符在JS中都是2个字节而如果是4个字节的字符那么就会当作两个双字节的字符处理即代理对。其实这么说起来UTF-8的变长1-4字节的编码是无法表示的代理对自然是可以解决这个问题。而表达UTF-16的编码长度要么是2个字节要么是4个字节。在ECMAScript 6中引入了新的表达方式但是为了向后兼容ECMAScript 5依然可以用代理对的形式表示星面。\u{1F3A8} // \uD83C\uDFA8 // 实际上在ES6中引入的函数也解决了字符串遍历的问题正则表达式也提供了u修饰符来处理4字节的字符。Array.from(11) // [1, , 1] /^.$/u.test() // true 11.split() // [1, \uD83C, \uDFA8, 1]另外在基本平面即低位代理对内从UD800到UDFFF是一个空段即这些码点不对应任何字符自然可以避免原本基本平面的冲突因此可以用来映射辅助平面的字符。高位[\uD800-\uDBFF]与低位[\uDC00-\uDFFF]恰好是2^10 * 2^10长度恰好100多万个代码点。(0xDBFF - 0xD800 1) * (0xDFFF - 0xDC00 1) 1024 * 1024 1048576虽然可以已经用Unicode代理对的方式表达4字节符号但是类似Emoji这些符号是可以组合的。那么这样会导致字形上看起来是单个字符实际上是通过\u200d即ZWJ组合起来的字符因此其长度会更长且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值是给出了deleteWordBackward和deleteWordForward的却没有给出默认行为要删除的长度。因此我最开始是想要么改为非受控输入要么是通过Intl.Segmenter方法来主动分词实现。然而在看到MDN的DEMO之后发现这个构造器需要传递语言参数这样的话在编辑器中是没有办法实现的因为编辑器中无法实际确定语言类型。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进行分词也有很多不必要的计算。不同语言的分词规则差异巨大例如英文空格分词以及中文无空格分词自动识别词边界非常困难尤其是在涉及自动换行和非罗马语言的情况下会非常困难。https://github.com/facebook/lexical/blob/af687fa/packages/lexical/src/LexicalSelection.ts#L1605总结起来使用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框架了。每日一题https://github.com/WindRunnerMax/EveryDay参考https://mathiasbynens.be/notes/javascript-unicodehttp://www.ruanyifeng.com/blog/2014/12/unicode.htmlhttps://eev.ee/blog/2015/09/12/dark-corners-of-unicodehttps://developer.mozilla.org/zh-CN/docs/Web/API/InputEventhttps://developer.mozilla.org/zh-CN/docs/Web/API/Selection/modifyhttps://w3c.github.io/input-events/#interface-InputEvent-Attributeshttps://developer.mozilla.org/zh-CN/docs/Web/API/InputEvent/getTargetRanges