plate 表格 Shift+Arrow 急切选择:在 keydown 阶段接管单格跨单元格扩展,消除原生选区闪烁
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
导读
本文基于 plate 表格插件的一次键盘交互专项修复,深入讲解"当用户按住Shift并按方向键,让选区从一个表格单元格跨入相邻单元格时,如何避免浏览器先绘制一段原生文本选区、再被表格逻辑"修补"成单元格选区而产生的肉眼可见闪烁"。读完本文,你将理解 plate 表格插件中Shift+Arrow的完整选择移动链路、keydown阶段与apply阶段"所有权"(ownership)的划分原理,以及如何用共享视觉行边界 helper 让普通方向键与Shift+方向键使用同一套边界判定规则。
问题背景:多格与单格的Shift+Arrow走了两条不同的路径
在修复之前,plate 表格插件的Shift+Arrow跨单元格扩展实际上存在两条执行路径,二者行为不一致:
- 多格
Shift+Arrow(即已经选中多个单元格后再扩展)已经由 onKeyDownTable.ts 在keydown阶段同步接管,浏览器原生选择不会先发生; - 单格跨格扩展(光标只在一个单元格内,
Shift+Arrow首次把选区扩展到相邻单元格)却仍然依赖set_selection变更 +setTimeout异步等待,浏览器会先绘制一次原生文本选区,表格逻辑在下一个 tick 才把选区"修复"成单元格选区。
以Shift+Down和Shift+Right最为明显:用户看到的是"先闪出一段文本高亮,随后才变成单元格选择框"。最终选区是正确的,但中间态肉眼可见,属于典型的选择时序(timing seam)缺陷。这一问题在 solution 文档 中被归类为async_timing根因、logic_error问题类型。
修复目标
本次计划的核心目标非常明确:移除Shift+Arrow从一个表格单元格扩展到另一个单元格时,瞬时原生文本选区(native text-range flash)的出现。要做到这一点,不能继续在apply阶段"事后修补",而必须在keydown阶段、在原生选择生效之前,就把单格跨边界的移动路径也纳入表格插件自己的选择移动逻辑。
实施计划与完成状态
原计划将工作拆分为以下几个可验证的步骤,且均已标记完成:
- 为"急切"(eager)的单格
Shift+Arrow扩展添加keydown级别的回归测试; - 在原生选择生效前,将单格
Shift+Arrow路由到表格自有的选择移动逻辑(即onKeyDownTable); - 移除
withApplyTable中的 apply 时修复行为,并删除overrideSelectionFromCell及其专属测试; - 更新覆盖率文档与相关文档,明确该行为仅由
keydown阶段拥有; - 运行聚焦测试、包构建、类型检查与 lint。
核心实现:在 onKeyDownTable 中急切拦截单格跨边界移动
1. 单格边界判定:shouldMoveSingleCellSelection
修复的核心代码位于 onKeyDownTable.ts。它先通过Hotkeys.isExtendDownward / isExtendBackward / isExtendForward / isExtendUpward识别四个方向的Shift+方向键组合键,然后对每个命中的键执行两段式处理:
const handled = moveSelectionFromCell(editor, { edge: (KEY_SHIFT_EDGES as any)[key], reverse: key === 'shift+up', }) || (shouldMoveSingleCellSelection(editor, key as keyof typeof KEY_SHIFT_EDGES) && moveSelectionFromCell(editor, { at: editor.selection!, edge: (KEY_SHIFT_EDGES as any)[key], fromOneCell: true, reverse: key === 'shift+up', }));第一段moveSelectionFromCell处理多格扩展(既有路径,保持不变);第二段则是新增的单格急切路径:先由shouldMoveSingleCellSelection判断"焦点边缘是否即将离开当前单元格",若判定成立,立即以fromOneCell: true调用moveSelectionFromCell完成跨格扩展。只要handled为真,就同步执行event.preventDefault()与event.stopPropagation(),把原生行为完全挡在门外。
shouldMoveSingleCellSelection的四个方向判定逻辑如下:
Shift+Left:只有当焦点已位于单元格文本起点(editor.api.isStart(point, cellPath))时才拦截;Shift+Right:只有当焦点已位于单元格文本终点(editor.api.isEnd(point, cellPath))时才拦截;Shift+Up/Shift+Down:先检查单元格内是否存在相邻块(hasAdjacentBlockInCell),若光标所在块在垂直方向上还有相邻块则放行原生行为(让选区继续在当前单元格内扩展);否则复用shouldMoveSelectionFromCell的视觉行边界判定。
水平方向(左右)只做"行首/行尾"判定,是因为跨格移动的语义边界非常明确;垂直方向(上下)则必须考虑单元格内可能存在的多块结构(例如单元格内多个段落),因此需要更复杂的视觉行判定。
2. 共享视觉行边界 helper:shouldMoveSelectionFromCell.ts
原计划特别强调"提取视觉行边界检查为共享 helper,使普通方向键与 Shift 方向键使用同一条垂直边缘规则"。这一诉求在 shouldMoveSelectionFromCell.ts 中得到落实,该文件集中了三个导出:
getTableMoveSelectionContext:以当前焦点(默认为editor.selection?.anchor)为入参,校验光标确实位于表格单元格内,并返回{ blockPath, cellPath, point }上下文;hasAdjacentBlockInCell:沿blockPath向前/向后查找相邻块,并用PathApi.isAncestor(cellPath, adjacentBlock[1])判断该相邻块是否仍属于当前单元格;shouldMoveSelectionFromCell:视觉行边界判定。它通过editor.api.toDOMRange同时取得光标矩形(caret rect)与当前块矩形(block rect),然后比较:
const VISUAL_LINE_TOLERANCE = 1; return reverse ? caretRect.top <= boundary + VISUAL_LINE_TOLERANCE : caretRect.bottom >= boundary - VISUAL_LINE_TOLERANCE;其中boundary是块矩形顶部的最小值(反向/向上)或块矩形底部的最大值(正向/向下)。VISUAL_LINE_TOLERANCE = 1是 1 像素的容差,用于容忍浮点布局误差。这段逻辑与普通方向键移动(moveLine)共用同一规则,从根源上避免两套边界判定"各自漂移"(drift)。
3. moveSelectionFromCell 的 fromOneCell 模式
跨格移动的最终执行者是 moveSelectionFromCell.ts。该函数有两种形态:
- edge 展开形态:当传入
edge: 'bottom' | 'left' | 'right' | 'top'时,通过getTableGridAbove获取网格内的单元格条目,并把锚点/焦点路径向对应方向推进一格后调用editor.tf.select。fromOneCell: true时minCell = 0,即允许"仅选中一个单元格"也能向外扩展(默认minCell = 1要求至少两个单元格); - cell 移动形态:未传
edge时,以单元格为单位整体移动选区,并在表格首尾处通过editor.tf.withoutNormalizing包裹的select + move处理边界回绕。
方向与KEY_SHIFT_EDGES的映射关系定义在 constants.ts:
export const KEY_SHIFT_EDGES = { 'shift+down': 'bottom', 'shift+left': 'left', 'shift+right': 'right', 'shift+up': 'top', };4. 移除 overrideSelectionFromCell 与 apply 时回退
修复的另一半是"减法":删除 apply 时修复路径。原先 withApplyTable.ts 会在apply阶段拦截set_selection操作,当新选区横跨表格边界时改写op.newProperties.focus,把浏览器已经移动过的原生选区再"修补"回来——这正是闪烁的来源。现在overrideSelectionFromCell及其专属测试被整体删除,withApplyTable仅保留与单元格索引清理相关的职责(如remove_node/move_node时调用computeCellIndices重建索引),选择移动的所有权完全收归keydown阶段。
测试覆盖:keydown 级别的回归证据
新增与更新的测试集中在 onKeyDownTable.spec.tsx,全部以jsxt声明式编辑器结构构造输入与期望选区:
| 测试用例 | 输入场景 | 期望结果 |
|---|---|---|
eagerly expands Shift+Down from one cell into the next cell | 光标位于第一行第二格末尾 | preventDefault与stopPropagation各调用一次,选区锚点/焦点分别落在两行的单元格起点 |
keeps Shift+Down native while focus can still extend inside the current cell | 光标位于单元格内首段,下方还有段落 | 不调用preventDefault,选区保持不变(原生扩展继续) |
eagerly expands Shift+Right from one cell into the next cell | 光标位于第一格文本起点 | 同步跨格,焦点落在相邻格起点 |
eagerly expands Shift+Up from one cell into the previous cell | 光标位于第二行单元格起点 | 反向跨格,焦点落在上一格起点 |
eagerly expands Shift+Left from one cell into the previous cell | 光标位于第二格文本起点 | 反向跨格,焦点落在前一格起点 |
extends an existing multi-cell selection with Shift+Right | 已选中两格再向右扩展 | 多格路径继续生效,焦点推进到第三格 |
其中第二个用例尤其关键:它证明修复没有"矫枉过正"——当光标在单元格内还有扩展空间(多块单元格的块间移动)时,行为保持原生,只有真正"即将跨出单元格边界"时才急切接管。此外,moveSelectionFromCell.spec.tsx 中补充了fromOneCell: true的单格向上/向右扩展用例,以及"未传fromOneCell时单格不可扩展(返回undefined)"的守卫用例。
验证命令
计划文档列出的完整验证流程如下,聚焦于packages/table包:
bun test packages/table/src/react/onKeyDownTable.spec.tsx bun test packages/table/src/react/onKeyDownTable.spec.tsx packages/table/src/lib/withApplyTable.spec.ts packages/table/src/lib/withTable.spec.tsx packages/table/src/lib/transforms/moveSelectionFromCell.spec.tsx pnpm install pnpm turbo build --filter=./packages/table pnpm turbo typecheck --filter=./packages/table pnpm lint:fix bun test packages/table/src/react/onKeyDownTable.spec.tsx bun test packages/table/src/lib/withTable.spec.tsx packages/table/src/lib/transforms/shouldMoveSelectionFromCell.spec.ts packages/table/src/lib/transforms/moveSelectionFromCell.spec.tsx packages/table/src/lib/withApplyTable.spec.ts可以看出验证分两条线:第一条是修复落地后的聚焦回归(onKeyDownTable+ 相关模块);第二条是在后续共享 helper 重构完成后,把shouldMoveSelectionFromCell.spec.ts纳入测试集,确认"提取共享 helper"这一重构没有破坏任何行为。
经验与预防原则
从这次修复中可以提炼出三条可复用的工程原则(solution 文档 Prevention 部分 亦有记录):
- 凡是键盘交互,只要不该出现中间原生选区状态,就不要在
apply阶段事后修补,而应在keydown这一接缝(seam)处抢先拥有(own)该交互,避免维护一条可能随时间漂移的二次修复路径; - 当普通方向键与 Shift 方向键共享同一视觉边界规则时,把边界检查收敛到一个 helper 中(如
shouldMoveSelectionFromCell),让两条接缝共用同一实现,从结构上杜绝判定逻辑分叉; - 同步
preventDefault是"急切接管"的落点:判定成立后立即阻止原生事件并完成editor.tf.select,原生选区根本没有机会绘制,闪烁自然消失。
小结
本次修复将表格Shift+Arrow的选择移动所有权完整收归keydown阶段:多格扩展保持原有路径,单格跨边界扩展通过shouldMoveSingleCellSelection的边界判定 +moveSelectionFromCell({ fromOneCell: true })在原生选择生效前同步完成,同时删除 apply 时修复路径overrideSelectionFromCell,并让普通方向键与 Shift 方向键共享同一视觉行边界 helper。最终以 keydown 级回归测试、包级构建、类型检查与 lint 全链路验证收尾,为同类"选择时序"类缺陷提供了可复用的解决范式。
相关参考:计划文档 | 问题与修复记录 | 核心实现 onKeyDownTable.ts | 边界判定 shouldMoveSelectionFromCell.ts
【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考