消失的光标——深度解析:Slate.js 全量更新结构后的光标恢复方案

深度解析:Slate.js 全量更新结构后的光标恢复方案

引言:消失的光标

在开发富文本编辑器时,我们经常需要对内容进行"二次加工"。比如在翻译场景中,我们需要根据标点符号将用户输入的段落动态拆分成多个"句子级"的标签(如 span),并为每一句分配唯一的 compareid 以实现原文和译文的精准高亮。

然而,Slate.js 的数据模型是基于 路径(Path) 的。当你执行"全量节点替换"来更新文档结构时,原本的路径信息会瞬间失效,导致浏览器失去焦点,光标直接"跑路"。

痛点:为什么传统的恢复方法行不通?

在普通的 <textarea> 中,我们可以简单地通过 selectionStart 获取偏移量并在更新后还原。但在 Slate.js 中,你会遇到以下难题:

  1. 路径破碎 :Slate 的选区(Selection)依赖于 Path(如:第 0 个段落的第 1 个文本节点)。当你删除并重新插入节点后,旧的引用和路径彻底作废。
  2. 结构突变:为了分句,我们可能把一个原本 100 字符的 Text 节点拆成了 3 个小节点。这时,原本处于偏移量 50 的位置,在新的结构中可能变成了"第 2 个节点的偏移量 10"。
  3. 焦点失踪 :DOM 的大规模变动会导致浏览器强制执行 blur(失焦),用户打字会突然中断,体验极差。

核心算法:从"物理地址"到"逻辑位移"

既然路径会变,结构会变,那么在整个文档生命周期中,唯一不变的真理是什么? ------是"字符偏移量"。

无论文档被拆成多少个标签,光标相对于全文起点的总字符数(第 N 个字符)是相对稳定的。

方案步骤:
  1. 更新前"快照" :计算当前光标相对于全文开头的全局字符偏移量(Global Offset)。同时记录当前是否拥有焦点。
  2. 暴力重构:执行全量 DOM 替换,生成带新 ID 的节点树。
  3. 重新导航(核心) :在新生成的节点树中,通过深度优先遍历累加每个文本节点的长度,找回那个"第 N 个字符"对应的物理坐标。
  4. 强制锚定:将计算出的新坐标(Path + Offset)传给 Slate,并强制找回焦点。

代码实现

以下是基于 TypeScript 和 Slate 原生 API 的通用恢复逻辑:

ts 复制代码
/**
 * 更新编辑器内容并无缝恢复光标
 */
export const updateEditorContent = (editor: Editor, newNodes: Descendant[]) => {
  // 1. 记录更新前的状态
  let selectionOffset = 0;
  const { selection } = editor;
  // 记录当前编辑器是否有焦点
  const isFocused = typeof window !== 'undefined' && ReactEditor.isFocused(editor as ReactEditor);

  // 仅在光标存在且未选中一段文字(即折叠状态)时记录位置
  if (selection && Range.isCollapsed(selection)) {
    try {
      // 计算全局偏移量:从文章起点到光标处的总字符数
      const beforeRange = {
        anchor: Editor.start(editor, []),
        focus: selection.focus,
      };
      selectionOffset = Editor.string(editor, beforeRange).length;
    } catch (e) {
      console.warn('Failed to save selection offset:', e);
    }
  }

  // 使用 requestAnimationFrame 避开同步更新冲突
  requestAnimationFrame(() => {
    // withoutNormalizing 包装可以防止在删除/插入中间过程触发 Slate 的规范化检查
    Editor.withoutNormalizing(editor, () => {
      // 2. 执行全量更新(销毁再重建)
      const totalNodes = editor.children.length;
      for (let i = 0; i < totalNodes; i++) {
        Transforms.removeNodes(editor, { at: [0] });
      }
      Transforms.insertNodes(editor, newNodes, { at: [0] });

      // 3. 恢复光标位置
      if (selection && selectionOffset > 0) {
        try {
          let currentTotal = 0;
          let targetPath: number[] | null = null;
          let targetOffset = 0;

          // 【关键】遍历新生成的所有文本节点,重新匹配全局偏移量
          const nodes = Array.from(Editor.nodes(editor, { at: [], match: Text.isText }));
          for (const [node, path] of nodes) {
            const nodeLength = (node as Text).text.length;
            if (currentTotal + nodeLength >= selectionOffset) {
              // 找到了!光标就在这个节点里
              targetPath = path;
              // 在该节点内部的偏移量 = 目标总偏移量 - 之前节点的总长度
              targetOffset = selectionOffset - currentTotal;
              break;
            }
            currentTotal += nodeLength;
          }

          if (targetPath) {
            const newPoint = { path: targetPath, offset: targetOffset };
            // 设置光标坐标
            Transforms.select(editor, { anchor: newPoint, focus: newPoint });
            // 如果之前有焦点,必须显式调用 focus(),否则光标虽然定位了但不会显示
            if (isFocused) {
              ReactEditor.focus(editor as ReactEditor);
            }
          }
        } catch (e) {
          console.warn('Failed to restore selection:', e);
        }
      }
    });
  });
}

深度问答:为什么非要"遍历文本"不可?

很多开发者会问:既然我已经知道偏移量了,为什么不能直接传给 Slate 恢复?

这源于 Slate 的底层设计:

  1. 没有全局索引:Slate 的坐标系是"局部变量"。它不知道什么是"全文第 500 个字符",它只认"某某节点的第几个字符"。
  2. 结构与内容分离:分句逻辑会改变文本节点的数量。原本的一个长 Text 节点被拆分后,原本的"偏移量 50"可能跨越到了新的第二个、甚至第三个 Text 节点中。
  3. 重建即新生 :在 insertNodes 执行后,所有的 Path 都是全新的。遍历文本的过程,本质上是 将"逻辑坐标"重新翻译为"物理坐标" 的过程。

结语

在富文本编辑器的世界里,光标就是灵魂。通过"全局偏移量记录 + 文本节点遍历重构"的方案,我们可以在不改变 Slate 核心数据模型的前提下,完美解决复杂结构调整带来的光标丢失问题,为用户提供极其顺滑的输入体验。

相关推荐
孟无岐14 小时前
【Laya】Byte 二进制数据处理
网络·typescript·游戏引擎·游戏程序·laya
孟无岐16 小时前
【Laya】ClassUtils 类反射工具
typescript·游戏引擎·游戏程序·laya
We་ct21 小时前
LeetCode 380. O(1) 时间插入、删除和获取随机元素 题解
前端·算法·leetcode·typescript
孟无岐1 天前
【Laya】Ease 缓动函数
typescript·游戏引擎·游戏程序·laya
We་ct1 天前
LeetCode 238. 除了自身以外数组的乘积|最优解详解(O(n)时间+O(1)空间)
前端·算法·leetcode·typescript
踢球的打工仔1 天前
typescript-类的静态属性和静态方法
前端·javascript·typescript
奔跑的web.1 天前
TypeScript Enum 类型入门:从基础到实战
前端·javascript·typescript
wuhen_n2 天前
初识TypeScript
javascript·typescript
踢球的打工仔2 天前
typescript-类
前端·javascript·typescript
奔跑的web.2 天前
TypeScript 泛型完全指南:写法、四大应用场景与高级用法
前端·javascript·vue.js·typescript