首页 / 资讯中心 / 文章详情

Quill富文本光标innerHTML索引换算与批注高亮

Quill富文本光标innerHTML索引换算与批注高亮 ★ FEATURED ARTICLE
quill 富文本编辑器获取光标所在 innerHTML 索引这件事我第一次碰到是在给一个内容审核后台做定位高亮的时候。产品要求很直白用户在编辑器里点到哪右侧的 HTML 源码面板就要同步高亮到对应的那段标签区间点一下源码也要反过来把光标跳到编辑器里对应位置。听上去是个双向绑定的活动手才发现里面全是坑——Quill 给你的getSelection()返回的是{index, length}那是 Delta 模型下的线性字符偏移而你想拿来定位的innerHTML是一串带标签、带属性、带实体转义的字符串两套坐标根本没在一个维度上。这篇文章就把我从零踩出来的这条路完整摊开为什么这两个坐标对不上、有几种换算思路、每种思路的适用边界在哪、代码怎么写、哪些细节会让偏移量莫名其妙差几个字符、以及线上跑起来之后才知道的那些经验。前端做富文本的同学、需要对接外部 HTML 渲染的同学、做批注和内容审核的同学都能从里面拿到能直接抄的东西。1. 先把坐标系统对齐不然越算越乱1.1 Quill 的 index 到底是什么单位很多人第一反应是quill.getSelection().index就是光标在这个 HTML 里的字符位置因为它看起来就是个数字嘛。但 Quill 的 index 计量的是 Delta 里的操作位单位是内容字符不含任何标记。你写一段pabc/p光标停在 c 后面index是 3而这段字符串的长度是 12你想要的那个位置是 6。差出来的 6 个字符全是p和/p。更麻烦的是有些东西在 Delta 里算 1在 HTML 里算一大坨。插入一张图片Delta 里就是{insert: {image: ...}}这一个嵌入对象index 只前进 1但序列化出来的 innerHTML 是img srcdata:image/png;base64,iVBORw0KGgo...一行 base64 几千个字符索引直接爆掉。所以你不能指望用一个比例或者一个固定补偿值去换两套坐标之间的映射是非线性的必须逐节点走一遍。我在项目初期图省事写过一段用quill.getText(0, index).length反推的代码在纯文本段落里跑得挺欢一上列表和图片就全乱。这个教训很值钱Delta index 和 HTML 索引之间不存在解析式只存在遍历式。1.2 真正需要 HTML 偏移量的四类场景绝大多数时候你不需要这个索引因为 Quill 自己的setSelection(index, length)已经能搞定所有编辑器内的定位。但下面这几类场景绕不开做批注锚点时你得把选区的位置序列化成一个能存库的区间而评审系统往往只认字符串偏移做敏感词或术语检测时你要在渲染出来的 HTML 上插入高亮标签插入位置必须精确到字符做 AI 改写时要把某一段原样文本抠出来交给模型再原位置替换回去抠取的边界就是字符索引还有一类是外部渲染器对接比如你有一份历史遗留的富文本高亮库它只接受在字符串第 N 位插入一个 mark你没法改它只能适配它。这四类场景有个共同点——它们都发生在 Quill 之外都在跟一份 HTML 字符串打交道。所以问题的本质不是怎么在 Quill 里定位而是怎么把 Quill 里的位置翻译成外部世界的坐标。想明白这一层方案选型就有方向了。1.3 三套坐标的对照关系坐标类型获取方式计量单位是否包含标签/属性稳定程度典型用途Delta indexquill.getSelection().index内容字符否高与 HTML 结构无关编辑器内定位、内容操作DOM Rangewindow.getSelection().getRangeAt(0)节点 节点内偏移否中受 DOM 结构影响浏览器原生选区操作HTML 字符串索引需自行计算UTF-16 码元按.length计是低受序列化规则影响字符串拼接、外部渲染换算路径只有一条是可靠的Delta index ↔ DOM Range 交给 Quill 自己做DOM Range ↔ HTML 索引由我们算。千万别尝试 Delta index 和 HTML 索引直连中间必须落到 DOM 上因为只有 DOM 才知道每个节点被序列化成多长。2. 方案选型五条路各自的适用边界2.1 克隆 Range 法最省事但有个致命尾巴思路很轻巧把光标之前的内容整体克隆出来塞进一个临时 div读它的innerHTML.length。代码短得像玩具function htmlIndexByClone(root) { const sel window.getSelection(); if (!sel.rangeCount) return -1; const range sel.getRangeAt(0); if (!root.contains(range.startContainer)) return -1; const pre document.createRange(); pre.selectNodeContents(root); pre.setEnd(range.startContainer, range.startOffset); const frag pre.cloneContents(); const box document.createElement(div); box.appendChild(frag); return box.innerHTML.length; }问题是cloneContents会把 Range 跨越到的祖先元素一起复制并自动补齐闭合标签。举例原文是pabcbdef/b/p光标停在 c 后面克隆结果是pabc/p长度 9但你真正想要的HTML 前缀是pabc长度 6。多出来的/p就是误差。这个误差在光标停在段落中间时几乎必然出现而且大小不固定取决于祖先链有多深。可以事后剥离尾部闭合标签但剥离逻辑很难写对因为你要判断哪些闭合标签是真的在原文里就该有的。我试过用正则一层层剥遇到嵌套列表和各种 style 属性就崩了。这个方法适合快速验证和一次性脚本不适合上生产。2.2 遍历累加法慢一点但结果稳换成模拟序列化的方式从头遍历 DOM 树遇到文本节点就累加它转义后的长度遇到元素节点就累加它的开标签长度递归处理子节点后再累加闭合标签长度当遍历到光标所在的文本节点和偏移时停下来返回累计值。这个思路没有任何猜测成分算出来的就是浏览器序列化时会产生的那串字符的前缀长度。它的开销是 O(节点数)比克隆法重但节点数在正常文档里也就几百上千个一次遍历毫秒级完全可接受。而且它有个克隆法没有的好处可以中途停止不需要真的把前面所有内容都复制一遍。2.3 哨兵法用浏览器自己的序列化器当裁判还有一条更赖的路在光标处插一个带特殊属性的空元素读整个容器的innerHTMLindexOf找到哨兵位置然后把哨兵删掉。function htmlIndexByMarker(root, range) { const marker document.createElement(span); marker.setAttribute(data-caret-probe, 1); range.insertNode(marker); const html root.innerHTML; const key span>function getNativeRange(quill) { // Quill 2.x 内部维护了原生 Range if (quill.selection typeof quill.selection.getRange function) { const r quill.selection.getRange(); if (r) return r; } const sel window.getSelection(); if (!sel || !sel.rangeCount) return null; return sel.getRangeAt(0); }拿到 Range 之后必须校验range.startContainer在不在quill.root里面。编辑器失焦、选区跑到工具栏、或者用户点在右侧面板上都可能让 Range 指向容器外面这时候返回 -1别硬算。3.2 把两个序列化辅助函数写对整个算法依赖两个模拟浏览器序列化的函数这两个函数写错后面全错。第一个负责文本节点转义const TEXT_ESCAPE { : amp;, : lt;, : gt;, \u00A0: nbsp; }; function serializeText(text) { return text.replace(/[\u00A0]/g, ch TEXT_ESCAPE[ch]); }注意三点。第一用单次正则加映射表天然避免amp;被二次转义成amp;amp;的顺序问题。第二文本节点里的引号和不转义只有属性值里才转义别多此一举。第三\u00A0不换行空格在主流浏览器序列化时都会输出成nbsp;必须映射这一个字符的差异就能让索引整体偏移而且在长文本里越靠后偏得越多。第二个函数生成开标签字符串。这里有个非常巧妙的写法不用手写属性拼接function serializeOpenTag(el) { const clone el.cloneNode(false); // 只克隆自身不带子节点 const html clone.outerHTML; const tag el.namespaceURI http://www.w3.org/1999/xhtml ? el.tagName.toLowerCase() : el.tagName; const closing /${tag}; const tail html.lastIndexOf(closing); return tail -1 ? html : html.slice(0, tail); }cloneNode(false)再读outerHTML浏览器会按它自己的规则把属性顺序、引号风格、style 归一化全部处理好我们只是把尾部的闭合标签切掉。属性值里可能出现、这类字符但lastIndexOf(closing)是从后面找闭合标签不受属性值内容干扰比indexOf()稳得多。至于 void 元素判断表要完整const VOID_TAGS new Set([ area,base,br,col,embed,hr,img,input, link,meta,param,source,track,wbr ]);Quill 里最常撞见的是img和br但embed视频和hr分割线也可能出现别只判断这两个。3.3 递归遍历的核心循环真正的主函数长这样注释比代码多因为这里的每个分支都对应一类真实场景function htmlIndexOfCursor(root, targetNode, targetOffset) { let pos 0; let result -1; function walk(node) { // 文本节点命中就按偏移切片累加否则整段累加 if (node.nodeType Node.TEXT_NODE) { if (node targetNode) { const off Math.min(targetOffset, node.data.length); pos serializeText(node.data.slice(0, off)).length; result pos; return true; } pos serializeText(node.data).length; return false; } // 注释等其它节点innerHTML 同样会序列化 if (node.nodeType ! Node.ELEMENT_NODE) { if (node targetNode) { result pos; return true; } pos node.nodeType Node.COMMENT_NODE ? !--${node.data}--.length : 0; return false; } const tag node.namespaceURI http://www.w3.org/1999/xhtml ? node.tagName.toLowerCase() : node.tagName; const open serializeOpenTag(node); // void 元素没有子节点也没有闭合标签 if (VOID_TAGS.has(tag)) { if (node targetNode) { result pos; return true; } pos open.length; return false; } pos open.length; // 光标恰好落在元素节点上空行、块级元素边界 if (node targetNode) { const children node.childNodes; const lim Math.min(targetOffset, children.length); for (let i 0; i lim; i) walk(children[i]); result pos; return true; } for (let i 0; i node.childNodes.length; i) { if (walk(node.childNodes[i])) return true; } pos /${tag}.length; return false; } walk(root); return result; }调用入口简单封装一层function getCursorHtmlIndex(quill) { const range getNativeRange(quill); if (!range) return -1; const root quill.root; if (!root.contains(range.startContainer)) return -1; return htmlIndexOfCursor(root, range.startContainer, range.startOffset); }3.4 反向映射从 HTML 索引还原光标业务上往往要成对出现比如源码面板点一下要跳回编辑器。反向算法的骨架一样只是判断条件换成当前累计长度是否已经覆盖目标值function textOffsetFromHtml(text, htmlOffset) { let acc 0; for (let i 0; i text.length; i) { const len serializeText(text[i]).length; if (acc len htmlOffset) return i; acc len; } return text.length; } function rangeFromHtmlIndex(root, target) { if (target 0) { const r document.createRange(); r.setStart(root, 0); r.collapse(true); return r; } let pos 0; let out null; function walk(node) { if (out) return; if (node.nodeType Node.TEXT_NODE) { const len serializeText(node.data).length; if (pos len target) { const r document.createRange(); r.setStart(node, textOffsetFromHtml(node.data, target - pos)); r.collapse(true); out r; return; } pos len; return; } if (node.nodeType ! Node.ELEMENT_NODE) return; const tag node.tagName.toLowerCase(); pos serializeOpenTag(node).length; if (VOID_TAGS.has(tag)) return; for (let i 0; i node.childNodes.length; i) { walk(node.childNodes[i]); if (out) return; } pos /${tag}.length; } walk(root); if (!out) { const r document.createRange(); r.selectNodeContents(root); r.collapse(false); return r; } return out; }反向映射有个天然缺陷当目标索引正好落在一个标签内部比如指到了p cla|ssx的中间没法还原成一个合法光标位置只能落到最近的文本边界。所以对外暴露接口时最好约定只接受文本位置或者返回时带上一个snapped标记让调用方知道发生了吸附。4. 那些让偏移量对不上的序列化细节4.1 实体转义是最常见的偏差来源前面提过nbsp;但实际项目里踩得最惨的是用户粘贴进来的内容。从 Word 或者网页里复制一段文字里面带、、全角不换行空格全部会在 innerHTML 里膨胀。一个从 1 个字符变 5 个一百个就是 400 个字符的偏差而且是累积性的越往后越离谱。我的处理方式是在计算之前先把内容规范一遍用 Quill 的clipboard.dangerouslyPasteHTML或者自己的清洗逻辑把全角空格、零宽字符、多余 span 全部清掉。清洗发生在内容进入编辑器时而不是在计算索引时这样偏移量在整个生命周期内都是稳定的。4.2 空行的br和 Quill 的特殊节点Quill 的空段落长这样pbr/p。光标落在这个空行里时startContainer通常是那个p元素startOffset是 0 或 1而不是文本节点。如果你的遍历函数只处理文本节点命中这里就会返回一个错误的值。所以第 3.3 节里那个光标恰好落在元素节点上的分支不是可选项是必须项。另外 Quill 2.x 的列表项里有span classql-ui contenteditablefalse/span这是列表标记的占位节点用户完全看不见但它在 innerHTML 里实实在在占着几十个字符。这意味着如果你把 innerHTML 直接丢给外部渲染器列表的偏移量会整体错位因为外部渲染器不会渲染这个 span。解决办法要么用getSemanticHTML()作为基准要么在计算时把ql-ui这类节点按零长度处理——但后者要求你前端的渲染逻辑也做同样的忽略两边必须一致。我个人的选择是在内容模型层面统一用 getSemanticHTML只在需要跟 Quill 内部状态对齐时才用 innerHTML并且在接口注释里写死这个约定避免后来人搞混。4.3 属性带来的长度膨胀span stylecolor: rgb(230, 0, 0); font-size: 18px;这样一个标签就有 60 多个字符。一段带格式的短文本标签长度可能是正文的好几倍。这不影响正确性但影响两件事一是索引值会变得很大存数据库时要注意字段类型二是这类属性很容易被浏览器归一化比如stylecolor:#e60000可能被序列化成rgb(230, 0, 0)如果内容经过一次 round-trip之前算好的索引就全废了。所以我的原则很明确HTML 索引只能作为瞬时坐标绝不能持久化。要存就存 Delta 快照加 Delta index需要时现场换算。4.4 图片和嵌入内容的长度黑洞一张 base64 内联图片长度轻松上万。光标在图片后面索引就是一万多。这个数字本身没错但它极不稳定图片被重新压缩、被替换成 CDN 链接、被裁切长度全变。如果你的批注系统把索引存了下来第二天图片一换批注位置就飞到天边去了。解决方案是把图片这类 embed 内容在坐标层面视为一个不可分割的原子用一个占位符替代它参与长度计算同时记录第几个 embed。这样索引就与图片的实际字节数解耦了。实现上可以在遍历时给每个 embed 记一个编号返回{ htmlIndex, embedCount }这样的复合坐标。5. 常见问题与排查实录5.1 光标在段落中间时索引差了几个字符这几乎百分百是克隆 Range 法留下的尾巴也就是尾部闭合标签被算进去了。验证方法很简单在控制台里把克隆出来的box.innerHTML打印出来肉眼看看结尾是不是多了/p、/li这类标签。换成遍历累加法立刻就好。如果是遍历法也偏那大概率是serializeText漏了某种转义。把光标前后的那段 HTML 打印出来跟原文本逐字符对比一定会发现某个实体没被映射。5.2 光标在列表里索引整体偏移先检查ql-ui那个 span。你可以这样验证把 innerHTML 里的classql-ui换成别的字样再算一次如果结果变了就是这个原因。处理办法见 4.2 节要么统一基准要么两边都忽略。还有一种情况是嵌套列表ulliaullib/li/ul/li/ul光标在内层 li 里。递归遍历本身能处理好但如果你的实现里对li做了特殊处理比如跳过第一个子节点因为里面是 ql-ui内层和外层的规则不一致就会错。5.3 输入法组字期间索引乱跳中文输入时compositionstart到compositionend之间DOM 里会有临时文本光标位置也会频繁变动。这段时间算出来的索引没有意义因为内容还没定型。我的做法是在组字期间挂起所有基于索引的计算等compositionend之后再触发一次。let composing false; quill.root.addEventListener(compositionstart, () { composing true; }); quill.root.addEventListener(compositionend, () { composing false; scheduleRecompute(); });Quill 自身也有quill.on(composition-before)之类的钩子版本不同名字略变用原生事件更省心。5.4 问题速查表现象最可能原因快速验证修复方式索引固定偏小/偏大一个小值尾部闭合标签被算入/漏算打印克隆片段看结尾改用遍历法越往后偏移越大实体转义漏映射nbsp;最常见对比原文与 innerHTML 中特殊字符补全 ESCAPE 映射表列表位置全错ql-ui占位 span替换该 class 名重算统一基准或按零长度处理图片后位置剧烈跳动base64 长度变化看索引是否上万embed 原子化处理空行返回 -1 或 0光标在元素节点上未处理打印 startContainer.nodeType补元素节点分支失焦后拿到旧位置Range 未校验检查root.contains()加容器校验、失焦置空计算偶发卡顿大文档全量遍历打时间戳统计耗时缓存前缀长度、节流5.5 一个容易忽略的坑normalize 之后节点引用失效如果你在别处调用了root.normalize()相邻的文本节点会被合并之前缓存的节点引用全部作废再拿去比较node targetNode永远不成立。表现就是函数返回 -1 或者返回一个莫名其妙的小值。这类问题极难从现象倒推我建议在函数入口处加一句断言式的检查if (!root.contains(targetNode)) { console.warn([htmlIndex] target node detached); return -1; }contains对已经脱离文档的节点会返回 false能提前暴露问题比返回一个错误数字强得多——错误数字会静默污染下游数据。6. 把索引真正用起来三个落地场景6.1 场景一批注锚点的插入与还原用户在编辑器里选一段话点添加批注系统需要把这段位置的起止索引存下来并在 HTML 上包一层span classanno>const start htmlIndexOfCursor(root, range.startContainer, range.startOffset); const end htmlIndexOfCursor(root, range.endContainer, range.endOffset); // 先插后面那个 html html.slice(0, end) /span html.slice(end); html html.slice(0, start) span classanno>let cache { node: null, offset: -1, value: -1 }; function getCursorHtmlIndexCached(quill) { const range getNativeRange(quill); if (!range) return -1; const { startContainer: n, startOffset: o } range; if (cache.node n cache.offset o) return cache.value; const v htmlIndexOfCursor(quill.root, n, o); cache { node: n, offset: o, value: v }; return v; }缓存要记得在内容变更时失效text-change里把cache.node置空即可。这个优化在典型场景下能省掉八成以上的重复计算。7.2 用对拍测试保证长期正确遍历法的正确性完全依赖我模拟的序列化规则和浏览器一致这个假设而浏览器的规则是会变的。所以一定要有一组对拍用例。我的做法是准备十几段刁钻内容——带、带全角空格、带嵌套列表、带图片、带空行、带自定义 class——对每一段在每个文本节点的每个偏移上都跑一遍遍历法和哨兵法断言两者相等。这个测试第一次跑就抓出了两个 bug一个是注释节点没处理Quill 里少见但粘贴内容可能带一个是\u00A0之外的另一种不换行字符\u2007没映射。这类问题靠肉眼审查代码基本发现不了。7.3 版本升级的回归清单需要重点回归的升级有三类Quill 大版本升级1.x 到 2.x列表结构、getSemanticHTML都有变化、浏览器大版本升级序列化规则可能微调、内容清洗规则变更。每次升级后把 7.2 的对拍用例跑一遍全绿再发布。这个习惯帮我挡住了一次因为升级导致的全站批注位置偏移事故。最后一个体会这套东西真正难的不是算法是约定。你必须跟所有下游系统明确一件事——我们约定的基准到底是innerHTML还是getSemanticHTML这个基准一旦定下来就是契约改一次全量数据作废。我现在的习惯是在项目文档第一页就写死这一条比写多少代码都管用。
阅读完成 · 觉得有帮助?
咨询建站