Skip to content

[lexical-table] Bug Fix: use visual line detection instead of caret rect for cell exit in arrow key handling - #9393

Open
lou-magnenat wants to merge 8 commits into
meta:mainfrom
Infomaniak:fix/table-caret-line-detection-soft-wrap
Open

lou-magnenat wants to merge 8 commits into
meta:mainfrom
Infomaniak:fix/table-caret-line-detection-soft-wrap

Conversation

@lou-magnenat

Copy link
Copy Markdown

Description

$handleArrowKey decides whether a vertical arrow key leaves the table cell by comparing the caret's rect (the DOM selection range's getBoundingClientRect()) with the cell's edge child box. At a soft wrap the caret sits between two visual lines, and a collapsed range there reports its bounding rect at the end of the previous line in every engine (it is the first of the client rects). With the caret logically at the start of a cell's last visual line:

  • ArrowDown skipped the hijack on Chromium and the native movement landed in the cell to the right (document order) instead of the one below; Gecko moves below natively, masking the bug.
  • ArrowUp hijacked the caret out of the table in every engine, instead of moving up one visual line within the cell.

This change measures the caret's visual line through its adjacent character instead: the rect of the character to the right (last client rect, so a range spanning the wrap boundary reports the line it ends on), falling back to the character to the left when the caret ends its text node, and to the collapsed range's own rect when nothing can be measured (jsdom, hidden editor). The exit comparison itself, the shift/extend handling and the element-anchor path are unchanged.

Test plan

Before

New browser test packages/lexical-table/src/__tests__/browser/WrappedTextCellArrowKeys.test.ts fails without the fix:

❯ packages/lexical-table/src/tests/browser/WrappedTextCellArrowKeys.test.ts (5 tests | 2 failed)
  × ArrowDown from the start of the last visual line moves to the cell below
    AssertionError: expected 'c5' to be 'c7'
  × ArrowUp from the start of the last visual line stays in the cell
    AssertionError: expected 'c1' to be 'c4 aaabbb cccddd'

(Chromium; on Firefox, the ArrowUp test also fails without the fix.)

Repro: any table cell whose text soft-wraps, caret at the start of the last visual line, ArrowDown (Chromium) or ArrowUp (any engine).

After

Test Files  8 passed (8)
     Tests  63 passed (63)   // pnpm run test-browser packages/lexical-table (chromium)
Test Files  21 passed (21)
     Tests  213 passed (213) // pnpm run test-unit packages/lexical-table

@meta-cla

meta-cla Bot commented Oct 20, 2026

Copy link
Copy Markdown

Hi @lou-magnenat!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Nov 13, 2026 11:51am UTC
lexical-playground Ready Ready Preview Nov 13, 2026 11:51am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 21, 2026
@meta-cla

meta-cla Bot commented Oct 21, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@rootnode-bot rootnode-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed write-up and the browser test. The start-of-line case is real, and the new test reproduces it: before this change, ArrowUp from the start of a wrapped cell's second line leaves the table, and in Chromium ArrowDown from the start of the last line lands in the cell to the right.

One problem needs fixing before this can land, plus one smaller one:

1. A caret at the end of a wrapped line now goes the wrong way (packages/lexical-table/src/LexicalTableSelectionHelpers.ts:2424-2437, :2726-2727)

At a soft wrap, "end of line N" and "start of line N+1" are the same DOM position: same text node, same offset. Only the caret's affinity differs, and the DOM doesn't expose it. The old code always assumed the end of line N; getVisualLineRectAtCaret always assumes the start of line N+1. So a caret placed at the end of a wrapped line, by End or by clicking past the line's last word, is now treated as being on the next line. With the test's 3×3 table and a two-line c4:

Caret placed by Key Before After
Home on line 2 ArrowDown c5 ✗ c7 ✓
Home on line 2 ArrowUp c1 ✗ stays in c4 ✓
End on line 1 (or a click past its end) ArrowDown line 2 of c4 ✓ c7 ✗ (Chromium and Firefox)
End on line 1 (or a click past its end) ArrowUp c1 ✓ c3 ✗ (Chromium)

The ArrowUp case also happens in cells of three or more lines. Could you detect the ambiguous case (the collapsed-range rect and the adjacent-character rect sit on different lines) and resolve it without a fixed guess? One option is to let the native movement run and correct a landing outside the cell's column. Please also add End or click-past-end cases for both keys to WrappedTextCellArrowKeys.test.ts.

2. The jsdom fallback described in the comment doesn't happen (:2433)

jsdom's Range has no getClientRects, and the repository's vitest.setup.mts stubs only getBoundingClientRect. So with a collapsed caret inside a cell's text, the ArrowUp and ArrowDown commands now hit TypeError: characterRange.getClientRects is not a function instead of falling back as the comment at :2721-2725 says. Test setups that stub only getBoundingClientRect (what the old code needed) hit the same error. A typeof characterRange.getClientRects === 'function' guard, plus a unit case under __tests__/unit (the tier AGENTS.md assigns to jsdom behaviour), would keep that promise.

Optional, non-blocking:

  • The two-line precondition (WrappedTextCellArrowKeys.test.ts:283, :298) depends on "c4 aaabbb" fitting the 75px cell in the browser's default serif font. With a wider fallback such as DejaVu, the cell renders three lines and both main tests fail at that assertion. Choosing the offsets per line (ArrowUp from the start of line 2, ArrowDown from the start of the last line) makes the test independent of the line count, and the fix already handles three-line cells correctly.
  • A wrap where the second line starts a new text node (a bold word, for example) is still mis-detected. Lexical normalizes the caret to the end of the previous text node, and the helper then measures the trailing space that hangs on line 1. You could handle that case by measuring the next sibling's first character, or note it in the comment.

@rootnode-bot rootnode-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for reworking this. The change looks good: deferring to the browser's own move at an ambiguous wrap, then correcting a landing outside the cell's column, fixes the start-of-line cases without breaking the end-of-line ones. Home, End and clicks on either side of a wrap all reached the right line or cell in Chromium, and the new browser tests fail without this change and pass with it in Chromium and Firefox. The getClientRects guard and the per-line test offsets also address the earlier comments.

All of the following are optional and non-blocking:

  1. A deferred exit that nothing consumes can redirect a later selection change (packages/lexical-table/src/LexicalTableSelectionHelpers.ts:2794-2810). The core editor dispatches KEY_ARROW_UP_COMMAND/KEY_ARROW_DOWN_COMMAND with Alt held, and on Linux and Windows Alt+ArrowUp/Down doesn't move the caret. So pendingVerticalExit stays armed until the next selection change, from the keyboard or from code. With the caret at the start of c4's last line in your test table, Alt+ArrowDown then Tab lands in c7 instead of c5, and Alt+ArrowDown then Ctrl+End lands in c7 instead of c9 (Chromium and Firefox). Clearing the flag in the existing capture-phase keydown listener in registerTableWindowHandlers, next to clearTouchTap, fixes both and keeps your tests green. A browser test for Alt+ArrowDown then Tab would lock it in. The same file already guards this kind of stale flag for shouldCheckSelectionForTable.
  2. Changeset after rebasing. CONTRIBUTING.md on current main now asks for a changeset for every user-facing change, for example '@lexical/table': patch with [fix] Arrow keys at a soft wrap in a table cell move to the right line or cell and a Verify: line.
  3. PR description and Test plan. They still describe the first revision: 5 tests, a test name that no longer exists, and an "unchanged" exit comparison with no mention of the native-move deferral. AGENTS.md ("Commits and Pull Requests") asks for the real Before/After output of the command you ran.
  4. Minor edge case. In a first-row cell of a table that is the first block, End on line 1 then ArrowUp now moves the caret to the start of line 1 instead of leaving the table. A second ArrowUp exits.

Automated review (maintainer link)

Comment on lines +2794 to +2810
if (
!event.shiftKey &&
adjacentCharacterRect !== null &&
Math.abs(adjacentCharacterRect.top - caretRect.top) > 4
) {
const cords = tableNode.getCordsFromCellNode(
anchorCellNode,
tableObserver.table,
);
tableObservers.setPendingVerticalExit({
cellKey: anchorCellNode.getKey(),
direction,
tableKey: tableNode.getKey(),
x: cords.x,
y: cords.y,
});
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, non-blocking: this arms pendingVerticalExit for any non-shift vertical arrow, Alt included (core dispatches KEY_ARROW_UP_COMMAND/KEY_ARROW_DOWN_COMMAND with Alt held). On Linux and Windows, Alt+ArrowUp/Down moves no caret, so nothing consumes the flag and the next selection change gets "corrected". With the caret at the start of c4's last line, Alt+ArrowDown then Tab lands in c7 instead of c5, and Alt+ArrowDown then Ctrl+End lands in c7 instead of c9 (Chromium and Firefox). Clearing it in the existing capture-phase keydown listener in registerTableWindowHandlers, next to clearTouchTap, fixes both and keeps the new tests green. A browser test pressing Alt+ArrowDown then Tab would cover it.

This branch was successfully deployed

2 active deployments
Preview – lexical — 5ebccd95 Deployed Nov 13, 2026 by vercel[bot]
Preview – lexical-playground — 5ebccd95 Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant