
Slate v2 图片 Void 节点键盘导航修复实录模型/DOM 选区一致性与 Void Spacer 布局治理【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate图片类可选中 Void 节点image void的键盘导航是富文本编辑器里最容易出现“选区漂移”的高危区域浏览器认为光标进了图片Slate 模型却停在原地按一次ShiftArrowRight把 DOM 选区拉到了 wrapper 元素上模型选区却还是折叠的。本文以仓库计划文档 2026-04-27-slate-v2-image-keyboard-navigation.md 为主线完整还原 Slate v2 中/examples/images路由上围绕图片 void 节点的键盘导航修复过程涵盖真实浏览器复现方法、水平/垂直/Shift 扩展三类移动路径的“所有者owner”划分、post-native DOM 选区导入时机以及选中图片顶部出现22px幽灵空白的 Void Spacer 布局治理。读完你将掌握一套可直接复用的 void 节点键盘导航排障方法论与回归验证命令。背景与目标围绕图片 void 节点的导航必须“进得去、出得来、选得中”Slate v2 中图片是一种典型的可选中块级 void 节点selectable block void。它在模型里保留一个真实但零宽度的文本子节点用于承载选区与 DOM 映射而真正可见的内容图片 UI由应用自绘。这带来一个天然矛盾浏览器原生选区只会落在 DOM 节点上可能落在 wrapper 元素上而 Slate 模型只接受规范化的文本点text point。计划文档明确了两层目标主行为围绕图片 void 节点的键盘导航必须让模型选区model selection与 DOM 选区保持一致。ArrowLeft/ArrowRight进出图片、ArrowUp/ArrowDown垂直落入图片、ShiftArrow扩展选区三条路径都不能产生漂移。跟进行为被选中的图片 void 不得把隐藏的 Slate 文本子节点渲染成图片内容上方的可见空白。修复的落点是/examples/images示例路由但根因位于共享的slate-react键盘处理与 void 渲染原语层因此方案对/examples/paste-html粘贴图片 void、/examples/embeds等同类示例同样生效。第一步真实浏览器复现不靠静态渲染推断计划文档强调了一条关键纪律浏览器可见的选区 bug 必须用dev-browser --connect http://127.0.0.1:9222在真实浏览器里复现不能从静态渲染推断。因为选区问题本质是 DOM 与模型两个世界的交互结果JSX 快照看不出浏览器最终 settle 的选区形态。在/examples/images上dev-browser复现出四类问题路径位置记法[path]offset例如[0,0]113表示第 0 行第 0 个文本节点的第 113 个字符处操作复现现象ArrowRight从[0,0]113第一次进入第一张图片[1,0]0并可见地选中它第二次退出到[2,0]0此路径正常ArrowLeft反向从[2,0]0进入[1,0]0再退出到[0,0]113此路径正常ArrowDown从[0,0]113DOM 选区进入了[1,0]0Slate 模型选区却停留在[0,0]113图片没有可见选中态ShiftArrowRight两次第一次 DOM 扩展到图片 wrapper 而模型仍折叠第二次模型 focus 移到[1,0]0但 DOM focus 已经跑到后续段落 wrapper 上——DOM/模型选区分裂这个复现表本身就是排查方法论“普通水平移动正常”不能代表整族路径正常。垂直原生移动和 Shift 扩展路径各自有独立的坑。根因分析三类移动路径分属不同的“所有者”对照关联的经验文档 2026-04-27-slate-react-void-keyboard-navigation-needs-post-native-sync-and-shift-model-ownership.md问题本质是键盘移动的“所有权”划分错误普通水平移动ArrowLeft/ArrowRight早已是模型所有model-owned通过 Slate 的move变换驱动路径正确——这也是为什么最初测试全绿。垂直原生移动ArrowUp/ArrowDown浏览器负责排版布局最终选区由浏览器决定Slate 必须在原生行为 settle 之后导入 DOM 选区。失败的根因是ArrowDown后第一次selectionchange事件触发时Chrome 在 void spacer 附近的最终原生选区尚未稳定此时导入会拿到中间态导致模型停留在原地。Shift 扩展移动ShiftArrowLeft/ArrowRight不能交给浏览器原生扩展。在 void/只读边界上浏览器扩展会产生 wrapper 形态的 DOM 端点而不是 Slate 规范文本点因此必须由模型拥有并执行。此外图片没有渲染选中态的另一个原因很直接useSelected()读取的是陈旧的模型选区——模型没动图片自然不亮。修复实现Shift 走模型所有权垂直走 post-native 同步1. Shift 扩展移动归类为 model-owned 水平移动在 keydown 决策层把ShiftArrowLeft/ShiftArrowRight显式分类为带extend: true的水平move-selection意图if (Hotkeys.isExtendBackward(nativeEvent)) { return { axis: horizontal, extend: true, kind: move-selection, reverse: true, } } if (Hotkeys.isExtendForward(nativeEvent)) { return { axis: horizontal, extend: true, kind: move-selection } }随后由 caret 引擎通过editor.move({ edge: focus })执行而不是让浏览器原生扩展 DOM 选区if (Hotkeys.isExtendForward(nativeEvent)) { event.preventDefault() editor.update(() { editor.move({ edge: focus, reverse: isRTL }) }) return caretMovementHandled() }从源码看Slate v2 包内move变换有对应的内部封装 moveSelection.ts它把SelectionMoveOptions透传给 core 的moveedge: focus正是“只移动 focus 端、保留 anchor 端”的选区扩展语义——这解释了为什么ShiftArrowRight能以模型文本点为端点扩展而不是落到 wrapper 上。2. 垂直移动让浏览器先 settle再导入 DOM 选区垂直移动保留原生能力但在 keydown 默认行为之后调度一次 post-keydown 的 DOM 选区导入if ( !readOnly decision.intent native-selection-move (event.key ArrowUp || event.key ArrowDown) ) { setTimeout(() { syncEditorSelectionFromDOM({ editor, inputController }) }) }setTimeout的意义在于把导入推迟到 Chrome 完成最终原生选区之后再执行绕开“第一次selectionchange太早”的陷阱。这与经验文档的结论一致在零宽度 void spacer 附近selectionchange 追踪可能过早post-native 导入时机必须在真实浏览器里证明。3. 为什么这种分工成立块级 void 图片拥有真实零宽文本 spacer但浏览器在原生移动时可能落在 wrapper 形态的 DOM 端点上。因此水平 Shift 移动不需要浏览器排版信息 → 模型所有editor.move是正确归属垂直移动需要原生排版结果 → 等浏览器 settle 后导入最终 DOM 点。跟进修复选中图片上方的 22px 幽灵空白与 VoidElement/SlateSpacer键盘导航修复通过浏览器验证后跟进复现暴露了第二个问题选中第一张图片时图片内容上方会出现约22px的空白行。现象与根因选中图片会暴露一个原始的[1,0]文本子节点渲染成一个零宽br却占用了大约一行的布局高度导致图片可见内容从 void 节点顶部往下偏移约22px。这与之前 embeds 示例的 spacer 回归属于同一类问题Slate 隐藏子节点被放进了应用自有的布局里而不是放在VoidElement/SlateSpacer中。经验文档 2026-04-26-slate-react-custom-voids-must-render-children-through-spacer.md 记录了两个典型症状embeds 示例在 URL 输入框与段落之间多出38.390625px图片示例在图片 void 顶部与图片内容之间多出22.390625px。注意此时键盘导航测试仍然全绿——这是布局回归不是遍历回归说明“导航正确”与“渲染正确”必须分开验证。修复自定义 void 通过 VoidElement 渲染修复方式是把应用 UI 放进content、Slate 子节点放进spacerVoidElement content{ VideoFrame / UrlInput / / } contentAsdiv spacer{children} /VoidElement的SlateSpacer默认样式是position: absolute且height: 0因此 Slate 子节点仍然参与选区与 DOM 映射却不再参与布局。落地范围/examples/images图片 UI 改经VoidElement渲染Slate 子节点放入spacer/examples/paste-html粘贴产生的图片 void 采用同样处理/examples/editable-voids有意保留自定义 wrapper——浏览器测试证明VoidElement会破坏该示例的焦点恢复它不属于图片式视觉空白这一类不应盲目套用。回归断言测量用户可见的间距而非仅断言 DOM 存在回归测试应断言用户可见的布局差距。对图片类 void断言图片内容起始位置贴近 void 节点顶部expect(contentOffset).toBeGreaterThanOrEqual(0) expect(contentOffset).toBeLessThanOrEqual(1)对通用内容型 void如 embeds断言间距落在合理区间expect(gap).toBeGreaterThanOrEqual(12) expect(gap).toBeLessThanOrEqual(24)计划文档记录的 RED/GREEN 验证印证了这一点加入 “image void spacer” 回归行后修复前失败contentOffset为22.390625修复后整套images.test.ts通过且dev-browser实测选中图片时模型选区[1,0]0、内容偏移0、spacer 为absolute、高度0px。验证体系浏览器行 Playwright typecheck/lint 四层把关修复以浏览器验证为最高优先级计划文档记录了完整命令链以下命令均以localhost:3100的 playground 为被测地址# 真实浏览器手动验证connect 到已打开的调试实例 dev-browser --connect http://127.0.0.1:9222 # 图片示例全套浏览器回归含 image void spacer 行 PLAYWRIGHT_BASE_URLhttp://localhost:3100 PLAYWRIGHT_RETRIES0 bun run playwright playwright/integration/examples/images.test.ts --projectchromium # 相邻回归面富文本、行内节点、editable-voids PLAYWRIGHT_BASE_URLhttp://localhost:3100 PLAYWRIGHT_RETRIES0 bun run playwright playwright/integration/examples/richtext.test.ts --projectchromium --grep ArrowDown then ArrowRight|browser line extension|movement commands|core command metadata|kernel policies PLAYWRIGHT_BASE_URLhttp://localhost:3100 PLAYWRIGHT_RETRIES0 bun run playwright playwright/integration/examples/inlines.test.ts --projectchromium --grep arrow keys skip PLAYWRIGHT_BASE_URLhttp://localhost:3100 PLAYWRIGHT_RETRIES0 bun run playwright playwright/integration/examples/inlines.test.ts playwright/integration/examples/editable-voids.test.ts --projectchromium --grep move-selection|selectionchange noise|nested editor PLAYWRIGHT_BASE_URLhttp://localhost:3100 PLAYWRIGHT_RETRIES0 bun run playwright playwright/integration/examples/paste-html.test.ts playwright/integration/examples/editable-voids.test.ts --projectchromium # 静态质量门禁 bun --filter slate-react typecheck bun typecheck:root bun lint:fix浏览器手动验证的三个关键断言点均以图片前段落为起点ArrowDown模型[1,0]0、DOM[1,0]0、图片呈选中态三者对齐ShiftArrowRight模型 anchor[0,0]113、focus[1,0]0DOM anchor/focus 与模型一致ArrowRight两次第一次进入图片第二次退出到后续段落。经验沉淀与预防清单这次修复沉淀了两份解决方案文档分别对应“导航一致”与“渲染布局”两个独立维度2026-04-27-slate-react-void-keyboard-navigation-needs-post-native-sync-and-shift-model-ownership.mdvoid 键盘导航需要 post-native 同步 Shift 模型所有权2026-04-26-slate-react-custom-voids-must-render-children-through-spacer.md自定义 void 必须把子节点渲染进 spacer。由此提炼的预防规则一条绿色路径不能证明整族路径键盘 bug 只要涉及 void就必须同时测试普通移动、垂直移动、Shift 扩展移动三条路径。可选中 void 的浏览器行应同时断言模型选区、DOM 选区、可见选中态如useSelected()的盒阴影三者一致。允许原生移动时必须证明 post-native 导入时机零宽度 void spacer 附近的selectionchange可能过早需要真实浏览器佐证。自定义renderElement中不要把 void 的{children}直接渲染在应用 UI 之后除非应用有经过验证的自定义 spacer wrapper否则应走VoidElement。不要盲目包裹所有 void 渲染器内联 mention 与 editable void 可能有浏览器特定的子节点摆放或contentEditablefalse焦点契约改动它们需要各自的浏览器证明。结语图片 void 键盘导航的修复本质是回答一个问题一段键盘移动到底归模型所有还是归浏览器所有。Slate v2 的答案是“混合所有”——水平 Shift 扩展由editor.move({ edge: focus })模型驱动垂直移动等浏览器 settle 后导入 DOM 选区同时用VoidElement/SlateSpacer把隐藏子节点从布局中剥离。这套“真实浏览器复现 → 所有者划分 → post-native 同步 → 视觉 spacer 回归”的流程同样适用于表格、嵌入媒体、mention 等一切可选中 void 场景。【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考