从零实现富文本编辑器#-浏览器选区与编辑器选区模型同步

从零实现富文本编辑器#-浏览器选区与编辑器选区模型同步

大家好,我是你们的技术博主。今天我们来聊聊富文本编辑器实现中一个非常核心、也最容易让人"掉头发"的问题------浏览器选区与编辑器内部选区模型的同步 。很多小伙伴一开始接触富文本编辑器,觉得不就是给 div 加上 contenteditable="true" 嘛,完事儿了。但当你真正动手做加粗、斜体、超链接、甚至自定义格式时,就会发现,浏览器自己那套选区(Selection/Range)规则,跟咱们想要的"文档模型"完全是两码事 。打个比方,浏览器选区是"物理坐标"------它告诉你"我选中了页面上第 3 个字到第 8 个字之间的这些 DOM 节点";但编辑器模型是"逻辑坐标"------它关心的是"我选中的是这段文本的第 2 个字符到第 5 个字符,中间跨了一个加粗节点"。要把这两者对齐,就必须建立一套映射机制 。### 一、为什么不能直接用浏览器选区?我们先看一个简单的例子。假设我们的编辑器内容是这样的:html<div id="editor" contenteditable="true"> <p>Hello <strong>World</strong>!</p></div>现在用户用鼠标选中了"World"这个词。浏览器给你的 Range 对象大概是:- startContainer:指向 <strong> 元素- startOffset:0- endContainer:指向 <strong> 元素- endOffset:5看起来挺简单对吧?但如果用户选中了"Hello Wo"(跨了 <strong> 的边界),那 Range 就变成了:- startContainer:指向 <p> 元素(或文本节点)- startOffset:0- endContainer:指向 <strong> 里的文本节点- endOffset:2这时你会发现,"偏移量"的含义在不同节点类型下完全不同 。文本节点的偏移是字符数,元素节点的偏移是子节点数。如果编辑器里嵌套了多层标签(比如 <span style="color:red"><b>xxx</b></span>),那这个偏移量的计算会让人崩溃。更麻烦的是,用户可能把光标放在空行上、图片旁边、甚至表格单元格里 ,这些情况浏览器给出的 Range 都会千奇百怪。所以,我们必须把浏览器的"物理选区"翻译成我们自己的"文档模型选区"。### 二、核心思路:双模型映射我们的编辑器内部,通常维护一个扁平化的文档模型 (类似 JSON 数组),比如:json[ { "type": "paragraph", "children": [ { "text": "Hello " }, { "text": "World", "bold": true }, { "text": "!" } ]}]而浏览器的 DOM 是我们根据这个模型渲染出来的。所以,同步的核心就是:1. 浏览器选区 → 模型选区 :当用户点击、拖拽、或通过键盘改变光标位置时,我们监听 selectionchange 事件,拿到 Range,然后通过 DOM 结构反推出它对应模型里的哪个位置(offset)。2. 模型选区 → 浏览器选区 :当我们在代码里设置选区(比如程序化地加粗后恢复光标)时,要能根据模型的位置,找到对应的 DOM 节点和偏移量,然后创建新的 Range 并设置到浏览器上。这两条路,就像是"翻译官"来回翻译。### 三、关键实现:从 DOM 到模型位置我们先来实现第一个方向:给定一个 DOM 节点和偏移量,如何找到它在模型里的位置。这里我以一个简化版为例(假设我们的编辑器只包含段落和行内文本,没有嵌套块级元素)。我们给每个文本节点加一个自定义属性 data-model-path,记录它在模型里的路径(比如 0-1 表示第 0 段第 1 个子节点)。这样查找就变得非常简单。javascript// 简化的模型const model = [ { type: 'paragraph', children: [ { text: 'Hello ' }, { text: 'World', bold: true }, { text: '!' } ]}];// 渲染 DOM(假设已有函数 renderToDOM 完成了构建,并给文本节点加了 data-model-path)function domToModelPosition(node, offset) { // 如果节点是文本节点,直接通过属性找到路径 if (node.nodeType === Node.TEXT_NODE) { const pathStr = node.parentElement.getAttribute('data-model-path'); if (pathStr) { const [blockIdx, childIdx] = pathStr.split('-').map(Number); // 计算该文本节点在模型中的起始字符偏移 let charOffset = 0; const children = model[blockIdx].children; for (let i = 0; i < childIdx; i++) { charOffset += children[i].text.length; } return { blockIdx, offset: charOffset + offset }; } } // 如果是元素节点,我们取它的第一个子节点(通常是最深的文本) if (node.nodeType === Node.ELEMENT_NODE) { if (node.childNodes.length > 0) { return domToModelPosition(node.childNodes[0], 0); } } // 兜底:返回起始位置 return { blockIdx: 0, offset: 0 };}// 使用时:const range = window.getSelection().getRangeAt(0);const start = domToModelPosition(range.startContainer, range.startOffset);const end = domToModelPosition(range.endContainer, range.endOffset);console.log('模型选区:', start, end);注意 :这个实现非常简化,真实编辑器里需要处理各种边界情况(比如空段落、光标在块级元素开头/结尾等)。但核心思想就是这样:给 DOM 节点打上模型标签,然后通过标签反向计算 。### 四、关键实现:从模型位置到 DOM 选区现在反过来,假设我们在代码里有了一个模型位置(比如 {blockIdx: 0, offset: 7}),要把它转成浏览器能识别的 Range。这个方向相对简单,因为我们控制渲染过程,可以保证 DOM 结构完全按照模型来 。所以我们只需要遍历模型,找到对应的文本节点和字符偏移即可。javascriptfunction modelToDomPoint(blockIdx, offset) { const block = model[blockIdx]; if (!block) return null; // 找到该块对应的 DOM 元素(假设我们给每个块元素加了 data-block-idx) const blockDom = document.querySelector(`[data-block-idx="${blockIdx}"]`); if (!blockDom) return null; // 遍历该块内的文本节点,累加长度 let remaining = offset; for (let i = 0; i < block.children.length; i++) { const child = block.children[i]; const childDom = blockDom.childNodes[i]; // 假设子节点一一对应 if (remaining <= child.text.length) { // 找到目标文本节点 return { node: childDom.firstChild, offset: remaining }; } remaining -= child.text.length; } // 如果 offset 超过了最后,就放在块尾 const lastChild = blockDom.lastChild; return { node: lastChild, offset: lastChild.textContent.length };}// 使用:设置选区function setModelSelection(startPos, endPos) { const start = modelToDomPoint(startPos.blockIdx, startPos.offset); const end = modelToDomPoint(endPos.blockIdx, endPos.offset); const range = document.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);}### 五、同步的时机与注意事项同步不是无时无刻都在做,我们需要在合适的时机触发:- 用户交互时 :监听 selectionchange(注意这个事件会频繁触发),用 requestAnimationFramesetTimeout 做节流,然后更新我们的模型选区状态。- 程序化修改时 :当我们执行加粗、插入链接等操作后,模型变了,DOM 会重新渲染,此时需要重新计算选区位置(通常根据之前保存的模型位置来恢复)。还有一个大坑:当 DOM 被修改时,浏览器会自动调整选区 。比如你加粗一段文本,DOM 结构变了,原来的 Range 可能会变得无效或指向错误的位置。所以在修改 DOM 之前,一定要先把浏览器选区转换成模型选区;修改完成后再把模型选区设回去 。### 六、总结浏览器选区与编辑器模型同步是富文本编辑器最核心的"地基"之一。我们通过双模型映射 ,把浏览器的"物理 DOM 坐标"和编辑器的"逻辑文档坐标"互相翻译。具体实现上:- 给 DOM 节点打上 data-* 属性来标识模型路径- 写两个函数:domToModelPositionmodelToDomPoint- 在用户交互和程序化修改时做好同步当然,真实世界的编辑器要复杂得多(比如处理嵌套列表、表格、图片、跨块选区等),但基本原理是不变的。一旦你掌握了这个映射思想,再去看 Quill、Slate 这些库的源码,就会觉得豁然开朗。希望这篇文章能让你对富文本编辑器的实现有一个更清晰的认识。如果你也在写自己的编辑器,不妨从这两个函数开始,慢慢构建你的"选区帝国"。我们下期见!

相关推荐
唐青枫27 分钟前
http-server:别再双击 index.html,一条命令把目录变成网站
前端·javascript
吴声子夜歌38 分钟前
Java面试题——基础(一)
java·开发语言
网安蟹佬霸1 小时前
OSINT开源情报收集实战:从信息搜集到资产测绘(2026最新万字保姆级指南)
前端·网络·安全·web安全·网络安全·开源
南城以南溫暖如初1471 小时前
外卖CPS实战指南:从技术选型到运营落地全流程解析
java·spring boot·mysql·架构·vue
单线程_011 小时前
【源码阅读】Vue3 Tokenizer 词法分析器完整状态机流转深度梳理(3.4+ 新版架构)
前端
PBitW2 小时前
PM 丢来 3 个 Excel、12 个功能、4 种情形?用 TRAE Work 30 分钟整理成前端开发文档
前端·trae
傻啦嘿哟2 小时前
英雄联盟皮肤爬虫:爬取全皮肤价格与特效,做比价工具
java·c++·爬虫
码匠许师傅3 小时前
【C++ 面试真题】24. 聊聊 C++ 的时间日期处理
java·c++·面试
mqiqe3 小时前
AgentScope Java 2.0 Agent 状态存储(AgentStateStore)深度解析:构建可恢复、可扩展的智能体运行时
java·运维·网络
zhifou1234563 小时前
java 17升级安装
java·开发语言