Repository navigation
[lexical-table] Bug Fix: use visual line detection instead of caret rect for cell exit in arrow key handling - #9393
Conversation
…ect for cell exit in arrow key handling
|
Hi @lou-magnenat! Thank you for your pull request and welcome to our community. Action RequiredIn 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. ProcessIn 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 If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks! |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks! |
There was a problem hiding this comment.
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.
…ith native movement and column correction
…Range.getClientRects
…of the rendered line count
There was a problem hiding this comment.
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:
- A deferred exit that nothing consumes can redirect a later selection change (
packages/lexical-table/src/LexicalTableSelectionHelpers.ts:2794-2810). The core editor dispatchesKEY_ARROW_UP_COMMAND/KEY_ARROW_DOWN_COMMANDwith Alt held, and on Linux and Windows Alt+ArrowUp/Down doesn't move the caret. SopendingVerticalExitstays 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 inregisterTableWindowHandlers, next toclearTouchTap, 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 forshouldCheckSelectionForTable. - Changeset after rebasing.
CONTRIBUTING.mdon currentmainnow asks for a changeset for every user-facing change, for example'@lexical/table': patchwith[fix] Arrow keys at a soft wrap in a table cell move to the right line or celland aVerify:line. - 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.
- 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.
| 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; |
There was a problem hiding this comment.
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.
Description
$handleArrowKeydecides whether a vertical arrow key leaves the table cell by comparing the caret's rect (the DOM selection range'sgetBoundingClientRect()) 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: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.tsfails without the fix:(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