消失的光标——深度解析: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 核心数据模型的前提下,完美解决复杂结构调整带来的光标丢失问题,为用户提供极其顺滑的输入体验。

相关推荐
cypking7 小时前
解决 TypeScript 找不到静态资源模块及类型声明问题
前端·javascript·typescript
开发者小天10 小时前
React中useCallback的使用
前端·javascript·react.js·typescript·前端框架·css3·html5
snow@li10 小时前
小程序-uniapp:vue3-typescript项目使用mp-html实现展示富文本
javascript·typescript·uni-app
雨季~~10 小时前
前端使用ffmpeg进行视频格式转换 (WebM → MP4)
前端·typescript·ffmpeg·vue
Nan_Shu_61410 小时前
学习:TypeScript (1)
前端·javascript·学习·typescript
张雨zy19 小时前
Pinia 与 TypeScript 完美搭配:Vue 应用状态管理新选择
vue.js·ubuntu·typescript
Kagol1 天前
🎉TinyVue v3.27.0 正式发布:增加 Space 新组件,ColorPicker 组件支持线性渐变
前端·vue.js·typescript
若梦plus1 天前
Node.js之TypeScript支持
前端·typescript
LoveDreaMing1 天前
MCP入门梳理
前端·typescript·mcp