[lexical-table][lexical-playground][lexical-rich-text] Bug Fix: table grid indices, cell state and boundary selection - #9054
Conversation
|
@LeSingh1 is attempting to deploy a commit to the Meta Open Source Team on Vercel. A member of the Team first needs to authorize it. |
… grid indices, cell state and boundary selection ## Description Eleven small table bugs, consolidated into one branch per the review feedback on facebook#9027 and facebook#9035. They fall into three groups. ### 1. Grid geometry Code that uses a row's child index where the table *grid* column index is required, or that ignores a span. These agree only while every cell has `colSpan === 1`/`rowSpan === 1`, so they are invisible until a table has merged cells. - `$insertTableColumnAtNode` (`packages/lexical-table/src/LexicalTableUtils.ts`) walked leftwards from the insertion column looking for the cell to insert after. When every grid position at or before the insertion column is covered by a `rowSpan` from an earlier row, the walk fell off the left edge and appended the new cell to the *end* of the row, so the inserted column did not line up. Replaced with the same left-to-right map scan `$unmergeCellNode` uses: track the last row-map entry with `startRow === i` at or before the insertion column, `insertAfter` it, or `$insertFirst` when the row owns nothing to the left. This also removes the labeled `continue`. (facebook#8836) - `toggleTableColumnIsHeader` (`packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx`) fed `$setTableColumnIsHeader`, which indexes `gridMap[row][columnIndex]`, the result of `$getTableColumnIndexFromTableCellNode` — the cell's index among its row's *children*. With an earlier spanning cell in the row, "Toggle column header" applied to a column left of the one clicked. It now reads `cellMap.startColumn` from `$computeTableMap`. (facebook#9006) ### 2. Cell and table state Transforms that rebuild a cell or its paragraph and drop what it carried, plus the `dir` attribute being written onto an element that the export throws away. - `$insertTableIntoGrid` (`LexicalTableUtils.ts`) copies the template cell's `backgroundColor` onto the destination cell but not `verticalAlign`, which was added to `TableCellNode` after that loop (facebook#7077). Pasting a table with vertically aligned cells over an existing table dropped the alignment. (facebook#9015) - `$tableTransform` (`LexicalTablePluginHelpers.ts`) pads short rows out to the table width with `$createTableCellNode()` and no header state, so a short header row was repaired into a mix of `<th>` and `<td>`. The padding cells now inherit the `TableCellHeaderStates.ROW` bit from the row's last cell — the same reference `$insertTableColumnAtNode` uses when it appends a column. (facebook#9023) - `$unmergeCellNode` (`LexicalTableUtils.ts`) recomputes the header state of the cells it splits off, correctly, but gives them nothing else, so a filled merged cell unmerged into one filled cell and a run of blank ones. The split cells now keep the original's `backgroundColor` and `verticalAlign`, which is the cell's own presentation and not a description of its row or column. (facebook#9025) - `TableNode.exportDOM` (`LexicalTableNode.ts`) narrows the export to `element.querySelector('table')`. When scrollable tables are active `createDOM` returns the scroll wrapper `<div>`, which is where `ElementNode.exportDOM` writes `dir` — so the wrapper, and the direction with it, was thrown away. The direction is now re-applied to the `<table>`, which is where `$convertTableElement` reads it back. (facebook#9029) - `TableObserver.$clearText` (`LexicalTableObserver.ts`) emptied each selected cell by constructing a fresh `ParagraphNode`, so Backspace over a multi-cell selection also reset the format, style, direction and indent of those cells. It now `$copyNode`s the cell's existing paragraph, which carries the element state and returns a childless copy. (facebook#9041) ### 3. Selection and caret at the table boundary - The `KEY_ARROW_LEFT_COMMAND`/`KEY_ARROW_RIGHT_COMMAND` handlers in `registerRichText` (`packages/lexical-rich-text/src/index.ts`) had no equivalent of the `$isSelectionAtEndOfRoot`/`$isSelectionAtStartOfRoot` guards the up/down handlers already use. With a table as the last node of the document the key fell through to the native caret, which walks around the block cursor element, so the caret cycled: last cell → block cursor → root offset before the table → back into the last cell. A new `$isBlockCursorAtRootEdge` consumes the key when the collapsed element point sits at the root edge beside a child that needs a block cursor. Non-collapsed selections and block cursors that still have a sibling to move into are untouched. (facebook#8949, fixes facebook#7999) - `$handleArrowKey` (`LexicalTableSelectionHelpers.ts`) armed the Firefox scrollable-table workaround (`setShouldCheckSelectionForTable`) on every ArrowDown. From the block cursor below a trailing table ArrowDown moves nothing, so the flag survived to be consumed by the next selection change — an ArrowUp back into the table — which snapped the caret to the first cell. A new `$isSelectionBeforeTable` arms it only when the focus is before the table in document order, the only side ArrowDown can enter from. (facebook#8963, fixes facebook#6822) - `$deleteTableRowAtSelection` (`LexicalTableUtils.ts`) removes the table outright when the selection covers every row, but never moved the selection first, leaving a `TableSelection` anchored to detached nodes. Added the `grid.selectPrevious()` that `$deleteTableColumnAtSelection` and `TableObserver.$clearText` already do in their identical branches. (facebook#9037) - `$adjustFocusInDirection` (`LexicalTableSelectionHelpers.ts`) called `getCornerOrThrow` on the rect from `$computeTableCellRectBoundary`, which *grows* the rect until it contains every merged cell straddling an edge — so the anchor is not guaranteed to be at a corner, and Shift+Arrow killed the editor update with an invariant. `getCornerOrThrow` is replaced by `getAnchorCorner`, which falls back the same way `$extractRectCorners` 24 lines below already does: to the corner opposite the focus, then to the top-left. (facebook#9045) ## Test plan Unit tests cover ten of the eleven fixes, two browser tests cover the caret/arrow-key behaviour at the table boundary, and facebook#9006 is a Playwright spec (`packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs`). ### Before Source fixes reverted, new tests kept: ``` $ npx vitest run --project unit packages/lexical-table packages/lexical-rich-text × pasting a table carries the cell backgroundColor and verticalAlign 6ms × a short header row is padded with header cells 5ms × extends the selection instead of throwing 73ms × keeps the paragraph format, style and direction of each cell 89ms × inserts the new cell in the correct column for rows spanned by a rowSpan cell 7ms × exports dir with scrollable tables active 31ms × leaves the selection outside the removed table 13ms × ArrowRight at the block cursor after the last block stays put 5ms × ArrowLeft at the block cursor before the first block stays put 1ms × the cells a merged cell splits into keep its backgroundColor and verticalAlign 13ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 10 ⎯⎯⎯⎯⎯⎯⎯ Test Files 8 failed | 23 passed (31) Tests 10 failed | 291 passed (301) $ npx vitest run --project browser packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts packages/lexical-table/src/__tests__/browser/Issue6822BlockCursorArrowKeys.test.ts × the caret stops beneath a trailing table instead of cycling around it 172ms × ArrowUp from the block cursor below a trailing table returns to the last row 380ms × ArrowUp from the block cursor below a table that is the only root child returns to the last row 355ms Test Files 2 failed (2) Tests 3 failed | 2 passed (5) ``` The remaining new cases pass on both sides of the change — the unstyled-unmerge, short-body-row, non-wrapped-export, delete-every-column and still-owns-a-cell-to-the-left cases — which is what pins each asymmetry rather than the fix itself. ### After ``` $ npx vitest run --project unit packages/lexical-table packages/lexical-rich-text Test Files 31 passed (31) Tests 301 passed (301) $ npx vitest run --project browser packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts packages/lexical-table/src/__tests__/browser/Issue6822BlockCursorArrowKeys.test.ts Test Files 2 passed (2) Tests 5 passed (5) $ npx playwright test --project=chromium Tables.spec.mjs TablesHTMLCopyAndPaste.spec.mjs 7266-column-header-merged-cells.spec.mjs 99 passed, 1 skipped $ npx tsc --noEmit -p . (clean) ``` Supersedes facebook#8836, facebook#8949, facebook#8963, facebook#9006, facebook#9015, facebook#9023, facebook#9025, facebook#9029, facebook#9037, facebook#9041, facebook#9045, consolidated per the review feedback on facebook#9027 and facebook#9035. facebook#9000 and facebook#9011 were part of an earlier revision of this PR and have been dropped, because each contradicted an existing e2e expectation rather than extending it: - facebook#9000 imported `dir` in the `DOMImportExtension` table rules. Google Sheets emits `<table dir="ltr">` unconditionally, so `Copy + paste (Table - Google Sheets)` in `TablesHTMLCopyAndPaste.spec.mjs` went from `dir="auto"` to a pinned `dir="ltr"` on the table, and the rows below it stopped emitting `dir="auto"` at all. Honouring a pasted `dir` is a deliberate behaviour change for third-party HTML and needs its own PR. - facebook#9011 made the right-edge resizer widen the *last* grid column a merged cell spans instead of its first. `Resize merged cells width (2)` in `Tables.spec.mjs` pins the existing behaviour, and both variants widen the merged cell identically — they differ only in which unmerged column absorbs the change, so this is a behaviour preference that needs a maintainer call.
939cae7 to
f3976c1
Compare
|
e2e was red on
I dropped both. Each is a deliberate behaviour change that contradicts an already-merged expectation, so it wants its own PR and a call from you rather than a silent ride-along here. I also removed a stray The remaining eleven members are unchanged. Verified locally: |
Description
Eleven small table bugs, consolidated into one branch per the review feedback
on #9027 and #9035. They fall into three groups.
1. Grid geometry
Code that uses a row's child index where the table grid column index is
required, or that ignores a span. These agree only while every cell has
colSpan === 1/rowSpan === 1, so they are invisible until a table has mergedcells.
$insertTableColumnAtNode(packages/lexical-table/src/LexicalTableUtils.ts)walked leftwards from the insertion column looking for the cell to insert
after. When every grid position at or before the insertion column is covered
by a
rowSpanfrom an earlier row, the walk fell off the left edge andappended the new cell to the end of the row, so the inserted column did not
line up. Replaced with the same left-to-right map scan
$unmergeCellNodeuses: track the last row-map entry with
startRow === iat or before theinsertion column,
insertAfterit, or$insertFirstwhen the row ownsnothing to the left. This also removes the labeled
continue. ([lexical-table] Bug Fix: insert column after the correct cell in rows covered by a rowSpan #8836)toggleTableColumnIsHeader(
packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx)fed
$setTableColumnIsHeader, which indexesgridMap[row][columnIndex], theresult of
$getTableColumnIndexFromTableCellNode— the cell's index among itsrow's children. With an earlier spanning cell in the row, "Toggle column
header" applied to a column left of the one clicked. It now reads
cellMap.startColumnfrom$computeTableMap. ([lexical-playground] Bug Fix: toggle the column header of the clicked cell's grid column #9006)2. Cell and table state
Transforms that rebuild a cell or its paragraph and drop what it carried, plus
the
dirattribute being written onto an element that the export throws away.$insertTableIntoGrid(LexicalTableUtils.ts) copies the template cell'sbackgroundColoronto the destination cell but notverticalAlign, whichwas added to
TableCellNodeafter that loop ([lexical-table] Feature: TableCellNode add verticalAlign attribute #7077). Pasting a table withvertically aligned cells over an existing table dropped the alignment. ([lexical-table] Bug Fix: keep the pasted cell's verticalAlign when a table is inserted into a grid #9015)
$tableTransform(LexicalTablePluginHelpers.ts) pads short rows out to thetable width with
$createTableCellNode()and no header state, so a shortheader row was repaired into a mix of
<th>and<td>. The padding cells nowinherit the
TableCellHeaderStates.ROWbit from the row's last cell — thesame reference
$insertTableColumnAtNodeuses when it appends a column. ([lexical-table] Bug Fix: padding cells inherit the header row state #9023)$unmergeCellNode(LexicalTableUtils.ts) recomputes the header state of thecells it splits off, correctly, but gives them nothing else, so a filled
merged cell unmerged into one filled cell and a run of blank ones. The split
cells now keep the original's
backgroundColorandverticalAlign, which isthe cell's own presentation and not a description of its row or column. ([lexical-table] Bug Fix: unmerged cells keep the merged cell's backgroundColor and verticalAlign #9025)
TableNode.exportDOM(LexicalTableNode.ts) narrows the export toelement.querySelector('table'). When scrollable tables are activecreateDOMreturns the scroll wrapper<div>, which is whereElementNode.exportDOMwritesdir— so the wrapper, and the direction withit, was thrown away. The direction is now re-applied to the
<table>, whichis where
$convertTableElementreads it back. ([lexical-table] Bug Fix: export the table's dir when scrollable tables are active #9029)TableObserver.$clearText(LexicalTableObserver.ts) emptied each selectedcell by constructing a fresh
ParagraphNode, so Backspace over a multi-cellselection also reset the format, style, direction and indent of those cells.
It now
$copyNodes the cell's existing paragraph, which carries the elementstate and returns a childless copy. ([lexical-table] Bug Fix: clearing table cells keeps each cell's paragraph state #9041)
3. Selection and caret at the table boundary
KEY_ARROW_LEFT_COMMAND/KEY_ARROW_RIGHT_COMMANDhandlers inregisterRichText(packages/lexical-rich-text/src/index.ts) had noequivalent of the
$isSelectionAtEndOfRoot/$isSelectionAtStartOfRootguardsthe up/down handlers already use. With a table as the last node of the
document the key fell through to the native caret, which walks around the
block cursor element, so the caret cycled: last cell → block cursor → root
offset before the table → back into the last cell. A new
$isBlockCursorAtRootEdgeconsumes the key when the collapsed element pointsits at the root edge beside a child that needs a block cursor.
Non-collapsed selections and block cursors that still have a sibling to move
into are untouched. ([lexical-rich-text][lexical-table] Bug Fix: caret no longer cycles around a trailing table #8949, fixes Bug: Odd selection behavior with table as last element in document #7999)
$handleArrowKey(LexicalTableSelectionHelpers.ts) armed the Firefoxscrollable-table workaround (
setShouldCheckSelectionForTable) on everyArrowDown. From the block cursor below a trailing table ArrowDown moves
nothing, so the flag survived to be consumed by the next selection change —
an ArrowUp back into the table — which snapped the caret to the first cell.
A new
$isSelectionBeforeTablearms it only when the focus is before thetable in document order, the only side ArrowDown can enter from.
([lexical-table] Bug Fix: ArrowUp from the block cursor below a table returns to the last row #8963, fixes Bug: BlockCursorElement inconsistent/unresponsive behavior #6822)
$deleteTableRowAtSelection(LexicalTableUtils.ts) removes the tableoutright when the selection covers every row, but never moved the selection
first, leaving a
TableSelectionanchored to detached nodes. Added thegrid.selectPrevious()that$deleteTableColumnAtSelectionandTableObserver.$clearTextalready do in their identical branches. ([lexical-table] Bug Fix: move the selection out of the table before deleting its last rows #9037)$adjustFocusInDirection(LexicalTableSelectionHelpers.ts) calledgetCornerOrThrowon the rect from$computeTableCellRectBoundary, whichgrows the rect until it contains every merged cell straddling an edge — so
the anchor is not guaranteed to be at a corner, and Shift+Arrow killed the
editor update with an invariant.
getCornerOrThrowis replaced bygetAnchorCorner, which falls back the same way$extractRectCorners24lines below already does: to the corner opposite the focus, then to the
top-left. ([lexical-table] Bug Fix: Shift+Arrow no longer throws when the anchor is off the rect corner #9045)
Test plan
Unit tests cover ten of the eleven fixes, two browser tests cover the
caret/arrow-key behaviour at the table boundary, and #9006 is a Playwright spec
(
packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs).Before
Source fixes reverted, new tests kept:
The remaining new cases pass on both sides of the change — the unstyled-unmerge,
short-body-row, non-wrapped-export, delete-every-column and
still-owns-a-cell-to-the-left cases — which is what pins each asymmetry rather
than the fix itself.
After
Supersedes #8836, #8949, #8963, #9006, #9015, #9023, #9025, #9029, #9037,
#9041, #9045, consolidated per the review feedback on #9027 and #9035.
#9000 and #9011 were part of an earlier revision of this PR and have been
dropped, because each contradicted an existing e2e expectation rather than
extending it:
dirin theDOMImportExtensiontable rules. Google Sheetsemits
<table dir="ltr">unconditionally, soCopy + paste (Table - Google Sheets)inTablesHTMLCopyAndPaste.spec.mjswent fromdir="auto"to apinned
dir="ltr"on the table, and the rows below it stopped emittingdir="auto"at all. Honouring a pasteddiris a deliberate behaviour changefor third-party HTML and needs its own PR.
spans instead of its first.
Resize merged cells width (2)inTables.spec.mjspins the existing behaviour, and both variants widen themerged cell identically — they differ only in which unmerged column absorbs
the change, so this is a behaviour preference that needs a maintainer call.