Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cozy-mangos-clean.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@platejs/table": patch
---

Fixed `ArrowUp` and `ArrowDown` table navigation to avoid the transient caret flash when moving between table cells.
48 changes: 48 additions & 0 deletions .claude/docs/plans/2026-03-29-table-arrow-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Table Arrow Navigation

## Goal

Make plain `ArrowUp` / `ArrowDown` table navigation stable.

- Remove the transient caret flash when moving across cells.
- Keep caret movement inside the current cell until it reaches the visual first or last line.

## Plan

- [completed] Trace plain table arrow handling through the shared `moveLine` path.
- [completed] Add regression coverage for synchronous cross-cell movement.
- [completed] Add coverage for multi-block, soft-break, and soft-wrap cell cases.
- [completed] Route collapsed table `ArrowUp` / `ArrowDown` through table-owned `moveLine`.
- [completed] Add a visual-line guard before cross-cell movement.
- [completed] Run focused tests, package build, typecheck, and lint.
- [completed] Evaluate reusable knowledge and record it in `solutions/`.

## Findings

- Plain `ArrowUp` / `ArrowDown` goes through the shared `moveLine` seam before browser default caret movement.
- Table navigation needs to take ownership at that seam. Repairing selection later is too late and causes visible caret flash.
- Same-cell adjacent block detection is enough for real multi-block cells.
- Wrapped single-block cells need DOM geometry, not just Slate path checks.
- `editor.api.toDOMRange(...)` plus caret/block rect comparison is enough to detect whether the caret is already on the visual first or last line.
- Missing DOM rects should fail conservatively and preserve previous non-throwing behavior.

## Progress

- Investigated `SlateReactExtensionPlugin -> withApplyTable -> overrideSelectionFromCell -> moveSelectionFromCell`.
- Added a `withTable.moveLine` override so table owns collapsed `ArrowUp` / `ArrowDown` movement before browser default caret motion.
- Refined that override so cross-cell movement only happens once the caret reaches the visual edge of the current cell.
- Added regression coverage for:
- synchronous plain-arrow cell movement
- multi-block cells
- soft-break cells
- soft-wrapped single-block cells

## Verification

- `bun test packages/table/src/lib/transforms/tableSelectionAndSizing.spec.tsx packages/table/src/lib/withApplyTable.spec.ts packages/table/src/lib/transforms/overrideSelectionFromCell.spec.tsx packages/table/src/lib/transforms/moveSelectionFromCell.spec.tsx`
- `bun test packages/table/src/lib/withTable.spec.tsx`
- `pnpm install` failed in `prepare` because `bun x skiller@latest apply` blocks on legacy Claude plugin migration.
- `pnpm install --ignore-scripts`
- `pnpm turbo build --filter=./packages/table`
- `pnpm turbo typecheck --filter=./packages/table`
- `pnpm lint:fix`
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
title: Table arrow navigation must own moveLine and visual line boundaries
type: solution
date: 2026-03-29
status: completed
category: logic-errors
module: table
tags:
- table
- selection
- keyboard
- caret
- timing
- soft-break
- soft-wrap
- dom
- tests
---

# Problem

Plain `ArrowUp` and `ArrowDown` table navigation had two related failures.

- Cross-cell movement could briefly render the native caret in the old cell before the final selection landed in the target cell.
- Wrapped cell content could jump across cells too early, before the caret had exhausted vertical movement inside the current visual line stack.

This showed up both in structural multi-block cells and in single-block cells with soft breaks or browser wrapping.

# Root Cause

The table plugin was not fully owning plain vertical arrow movement at the `moveLine` seam.

That left two gaps:

- Browser default caret motion could paint an intermediate frame before table code repaired the final selection.
- Slate block structure alone could not tell whether a single paragraph was already on its visual first or last line.

So the transform knew too little about timing and too little about visual layout.

# Solution

Handle collapsed table-cell `ArrowUp` and `ArrowDown` directly in `withTable.moveLine(...)`, and only move across cells when the caret is truly at the visual edge of the current cell.

- Override `withTable.moveLine(...)` for collapsed selections inside table cells.
- Keep the fast path that stays native while there is another block in the same cell.
- For wrapped single-block content, build a collapsed DOM range for the caret and a DOM range for the current block.
- Compare caret rects against the first or last block rect to detect the visual boundary.
- Call `moveSelectionFromCell(...)` only after the caret reaches the visual first line for `ArrowUp` or the visual last line for `ArrowDown`.
- Return `true` when table code handles the movement so the shared React keydown handler prevents browser default motion.

The implementation lives in [withTable.ts](/Users/hyeongjin/Workspace/plate/packages/table/src/lib/withTable.ts).

# Why This Works

The browser already knows how to move a caret between visual lines inside one DOM block. The table plugin only needs to take over once that native move is exhausted.

Owning the `moveLine` seam removes the transient flash because the browser default move never gets a chance to paint first. Checking DOM rects removes the early cross-cell jump because the transform can finally see visual line boundaries that Slate paths cannot represent.

# Prevention

- If a plugin owns keyboard navigation semantics, intercept the ownership seam first. Do not rely on later selection repair for plain arrow movement.
- If movement depends on visual line layout, Slate path checks are not enough. Use DOM range geometry at the transform seam.
- Keep regression coverage at both levels:
- synchronous plain-arrow cross-cell movement
- multi-block cells that should stay native until the next or previous block boundary
- soft-break cells that should stay native until the last visual line
- soft-wrapped single-block cells that should stay native until the first or last visual line
- Preserve a conservative fallback when DOM range data is unavailable so non-DOM execution does not throw.
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('overrideSelectionFromCell', () => {
mock.restore();
});

it('routes arrow navigation through moveSelectionFromCell when the new focus leaves the current cell', () => {
it('routes shift+down through moveSelectionFromCell when the new focus leaves the current cell', () => {
const input = (
<editor>
<htable>
Expand All @@ -63,7 +63,7 @@ describe('overrideSelectionFromCell', () => {
const editor = createTableEditor(input);
editorForTimer = editor;

editor.dom.currentKeyboardEvent = { which: 40 } as any;
editor.dom.currentKeyboardEvent = { shiftKey: true, which: 40 } as any;
editor.api.isAt = mock().mockReturnValue(true) as any;

overrideSelectionFromCell(editor, {
Expand All @@ -72,7 +72,7 @@ describe('overrideSelectionFromCell', () => {
});

expect(editor.selection).toEqual({
anchor: { offset: 0, path: [0, 1, 0, 0, 0] },
anchor: { offset: 0, path: [0, 0, 0, 0, 0] },
focus: { offset: 0, path: [0, 1, 0, 0, 0] },
});
});
Expand Down
14 changes: 6 additions & 8 deletions packages/table/src/lib/transforms/overrideSelectionFromCell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,13 @@ export const overrideSelectionFromCell = (

if (
!editor.dom.currentKeyboardEvent ||
!['up', 'down', 'shift+up', 'shift+right', 'shift+down', 'shift+left'].some(
(key) => {
const valid = isHotkey(key, editor.dom.currentKeyboardEvent!);
!['shift+up', 'shift+right', 'shift+down', 'shift+left'].some((key) => {
const valid = isHotkey(key, editor.dom.currentKeyboardEvent!);

if (valid) hotkey = key;
if (valid) hotkey = key;

return valid;
}
) ||
return valid;
}) ||
!editor.selection?.focus ||
!newSelection?.focus ||
!editor.api.isAt({
Expand All @@ -50,7 +48,7 @@ export const overrideSelectionFromCell = (
}

const prevSelection = editor.selection;
const reverse = ['shift+up', 'up'].includes(hotkey);
const reverse = hotkey === 'shift+up';

setTimeout(() => {
moveSelectionFromCell(editor, {
Expand Down
47 changes: 47 additions & 0 deletions packages/table/src/lib/transforms/tableSelectionAndSizing.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,5 +343,52 @@ describe('table sizing and selection helpers', () => {

expect(editor.selection).toEqual(output.selection);
});

it('handles ArrowDown through moveLine without relying on browser default movement', () => {
const input = (
<editor>
<htable>
<htr>
<htd>
<hp>
11
<cursor />
</hp>
</htd>
</htr>
<htr>
<htd>
<hp>21</hp>
</htd>
</htr>
</htable>
</editor>
) as any as SlateEditor;

const output = (
<editor>
<htable>
<htr>
<htd>
<hp>11</hp>
</htd>
</htr>
<htr>
<htd>
<hp>
<cursor />
21
</hp>
</htd>
</htr>
</htable>
</editor>
) as any as SlateEditor;

const editor = createTableEditor(input);

expect(editor.tf.moveLine({ reverse: false })).toBe(true);
expect(editor.selection).toEqual(output.selection);
});
});
});
Loading
Loading