diff --git a/packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs b/packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs
index 97c3db92d62..a70cd881870 100644
--- a/packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs
+++ b/packages/lexical-playground/__tests__/regression/7266-column-header-merged-cells.spec.mjs
@@ -8,11 +8,14 @@
import {
assertTableHTML as assertHTML,
click,
+ expect,
focusEditor,
+ getPageOrFrame,
html,
initialize,
insertTable,
mergeTableCells,
+ selectCellFromTableCoord,
selectCellsFromTableCords,
test,
toggleColumnHeader,
@@ -171,6 +174,53 @@ test.describe('Regression test #7266', () => {
);
});
+ test('toggling column header applies to the grid column of the clicked cell, not its index among its row children', async ({
+ page,
+ isPlainText,
+ isCollab,
+ }) => {
+ test.skip(isPlainText);
+
+ await initialize({isCollab, page});
+
+ await focusEditor(page);
+
+ await insertTable(page, 3, 4);
+
+ // Merge grid columns 1 and 2 of row 1 into one cell, so the cells after it
+ // in that row sit one grid column to the right of their child index.
+ await click(page, '.PlaygroundEditorTheme__tableCell');
+ await selectCellsFromTableCords(
+ page,
+ {x: 1, y: 1},
+ {x: 2, y: 1},
+ false,
+ false,
+ );
+ await mergeTableCells(page);
+
+ // The last cell of row 1 is now the third child of its row but still
+ // occupies grid column 3.
+ await selectCellFromTableCoord(page, {x: 2, y: 1}, false);
+ await toggleColumnHeader(page);
+
+ // Read back which grid columns are headers, per row.
+ const headerGrid = await getPageOrFrame(page).evaluate(() => {
+ const table = document.querySelector('table');
+ return Array.from(table.querySelectorAll(':scope > tr')).map(row =>
+ Array.from(row.children).flatMap(cell =>
+ Array.from({length: cell.colSpan}, () => cell.tagName),
+ ),
+ );
+ });
+
+ expect(headerGrid).toEqual([
+ ['TH', 'TH', 'TH', 'TH'],
+ ['TH', 'TD', 'TD', 'TH'],
+ ['TH', 'TD', 'TD', 'TH'],
+ ]);
+ });
+
test('toggling row header with merged row cells should only apply row header to the selected row', async ({
page,
isPlainText,
diff --git a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx
index 654d6f95201..e579740e88d 100644
--- a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx
+++ b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx
@@ -15,7 +15,6 @@ import {
$deleteTableRowAtSelection,
$getNodeTriplet,
$getTableCellNodeFromLexicalNode,
- $getTableColumnIndexFromTableCellNode,
$getTableNodeFromLexicalNodeOrThrow,
$getTableRowIndexFromTableCellNode,
$insertTableColumnAtSelection,
@@ -364,7 +363,16 @@ function TableActionMenu({
const toggleTableColumnIsHeader = useCallback(() => {
editor.update(() => {
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
- const columnIndex = $getTableColumnIndexFromTableCellNode(tableCellNode);
+ // $setTableColumnIsHeader indexes the table grid, so the cell's position
+ // has to come from the table map rather than from its index among its
+ // row's children — the two diverge once an earlier cell in the row spans
+ // more than one column.
+ const [, cellMap] = $computeTableMap(
+ tableNode,
+ tableCellNode,
+ tableCellNode,
+ );
+ const columnIndex = cellMap.startColumn;
const isHeader = !tableCellNode.hasHeaderState(
TableCellHeaderStates.COLUMN,
);
diff --git a/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts b/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts
index 7c5112009ee..da48aec79bb 100644
--- a/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts
+++ b/packages/lexical-rich-text/src/__tests__/unit/RichTextBlockCursorRootBoundary.test.ts
@@ -14,9 +14,12 @@ import {
$createTextNode,
$getRoot,
$getSelection,
+ $isNodeSelection,
$isRangeSelection,
$setSelection,
KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_LEFT_COMMAND,
+ KEY_ARROW_RIGHT_COMMAND,
KEY_ARROW_UP_COMMAND,
type LexicalEditor,
} from 'lexical';
@@ -119,3 +122,60 @@ describe('block cursor root boundary navigation (#8886)', () => {
expectBlockCursorAt(editor, 3);
});
});
+
+describe('block cursor root boundary navigation (#7999)', () => {
+ test('ArrowRight at the block cursor after the last block stays put', () => {
+ using editor = createBoundaryEditor();
+
+ editor.update(() => $getRoot().select(3, 3), {discrete: true});
+ expectBlockCursorAt(editor, 3);
+
+ const event = makeArrowEvent('ArrowRight');
+ const handled = editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event);
+ expect(handled).toBe(true);
+ expect(event.defaultPrevented).toBe(true);
+ expectBlockCursorAt(editor, 3);
+ });
+
+ test('ArrowLeft at the block cursor before the first block stays put', () => {
+ using editor = createBoundaryEditor();
+
+ editor.update(() => $getRoot().select(0, 0), {discrete: true});
+ expectBlockCursorAt(editor, 0);
+
+ const event = makeArrowEvent('ArrowLeft');
+ const handled = editor.dispatchCommand(KEY_ARROW_LEFT_COMMAND, event);
+ expect(handled).toBe(true);
+ expect(event.defaultPrevented).toBe(true);
+ expectBlockCursorAt(editor, 0);
+ });
+
+ test('ArrowRight at the block cursor before the last block is not consumed', () => {
+ using editor = createBoundaryEditor();
+
+ // root:2 sits between the paragraph and the trailing decorator, so there
+ // is still a block to move into.
+ editor.update(() => $getRoot().select(2, 2), {discrete: true});
+
+ const event = makeArrowEvent('ArrowRight');
+ editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event);
+ // The pre-existing decorator navigation still runs and selects the
+ // trailing decorator rather than leaving the caret at the root.
+ editor.read(() => {
+ const s = $getSelection();
+ assert($isNodeSelection(s));
+ expect(s.getNodes()).toEqual([$getRoot().getChildAtIndex(2)]);
+ });
+ });
+
+ test('ArrowRight with a non-collapsed selection at the root edge is not consumed', () => {
+ using editor = createBoundaryEditor();
+
+ editor.update(() => $getRoot().select(0, 3), {discrete: true});
+
+ const event = makeArrowEvent('ArrowRight');
+ const handled = editor.dispatchCommand(KEY_ARROW_RIGHT_COMMAND, event);
+ expect(handled).toBe(false);
+ expect(event.defaultPrevented).toBe(false);
+ });
+});
diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts
index 2f18b7cfb12..f1b62652b6a 100644
--- a/packages/lexical-rich-text/src/index.ts
+++ b/packages/lexical-rich-text/src/index.ts
@@ -584,6 +584,35 @@ function $isSelectionAtStartOfRoot(selection: RangeSelection) {
return focus.key === 'root' && focus.offset === 0;
}
+/**
+ * True when the selection is a collapsed element point at the very start or
+ * end of the root, beside a child that renders a block cursor (a decorator, a
+ * table or another shadow root). There is nothing past the block cursor to
+ * move to, so horizontal arrow navigation in that direction has to be a no-op.
+ * Left to the browser, the native caret escapes to the other side of the block
+ * instead and the caret appears to cycle around it. See #7999.
+ */
+function $isBlockCursorAtRootEdge(
+ selection: RangeSelection,
+ direction: CaretDirection,
+): boolean {
+ if (!selection.isCollapsed()) {
+ return false;
+ }
+ const isNext = direction === 'next';
+ if (
+ !(isNext
+ ? $isSelectionAtEndOfRoot(selection)
+ : $isSelectionAtStartOfRoot(selection))
+ ) {
+ return false;
+ }
+ const offset = selection.focus.offset;
+ return $needsBlockCursorBeside(
+ $getRoot().getChildAtIndex(isNext ? offset - 1 : offset),
+ );
+}
+
function $isSelectionCollapsedAtFrontOfIndentedBlock(
selection: RangeSelection,
): boolean {
@@ -1491,12 +1520,16 @@ export function registerRichText(
if (!$isRangeSelection(selection)) {
return false;
}
+ const leftDirection = $isParentRTL(selection.anchor.getNode())
+ ? 'next'
+ : 'previous';
+ if ($isBlockCursorAtRootEdge(selection, leftDirection)) {
+ event.preventDefault();
+ return true;
+ }
if (
!event.shiftKey &&
- $tryBlockCursorShadowRootNavigation(
- selection,
- $isParentRTL(selection.anchor.getNode()) ? 'next' : 'previous',
- )
+ $tryBlockCursorShadowRootNavigation(selection, leftDirection)
) {
event.preventDefault();
return true;
@@ -1539,12 +1572,16 @@ export function registerRichText(
if (!$isRangeSelection(selection)) {
return false;
}
+ const rightDirection = $isParentRTL(selection.anchor.getNode())
+ ? 'previous'
+ : 'next';
+ if ($isBlockCursorAtRootEdge(selection, rightDirection)) {
+ event.preventDefault();
+ return true;
+ }
if (
!event.shiftKey &&
- $tryBlockCursorShadowRootNavigation(
- selection,
- $isParentRTL(selection.anchor.getNode()) ? 'previous' : 'next',
- )
+ $tryBlockCursorShadowRootNavigation(selection, rightDirection)
) {
event.preventDefault();
return true;
diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts
index 8528a0fe54a..4772a431dc2 100644
--- a/packages/lexical-table/src/LexicalTableNode.ts
+++ b/packages/lexical-table/src/LexicalTableNode.ts
@@ -575,6 +575,20 @@ export class TableNode extends ElementNode {
exportDOM(editor: LexicalEditor): DOMExportOutput {
const superExport = super.exportDOM(editor);
const {element} = superExport;
+ const exportedElement =
+ !isHTMLTableElement(element) && isHTMLElement(element)
+ ? element.querySelector('table')
+ : element;
+ // ElementNode.exportDOM writes `dir` onto whatever createDOM returned,
+ // which is the scroll wrapper when scrollable tables are active. That
+ // wrapper is not part of the export, so the direction has to be re-applied
+ // to the
itself — that is where $convertTableElement reads it back.
+ if (isHTMLTableElement(exportedElement) && exportedElement !== element) {
+ const direction = this.getDirection();
+ if (direction) {
+ exportedElement.dir = direction;
+ }
+ }
return {
after: tableElement => {
if (superExport.after) {
@@ -647,10 +661,7 @@ export class TableNode extends ElementNode {
}
return tableElement;
},
- element:
- !isHTMLTableElement(element) && isHTMLElement(element)
- ? element.querySelector('table')
- : element,
+ element: exportedElement,
};
}
diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts
index 92fe5f08c68..fa46da899c7 100644
--- a/packages/lexical-table/src/LexicalTableObserver.ts
+++ b/packages/lexical-table/src/LexicalTableObserver.ts
@@ -8,6 +8,7 @@
import invariant from '@lexical/internal/invariant';
import {
+ $copyNode,
$createParagraphNode,
$createRangeSelection,
$createTextNode,
@@ -606,7 +607,15 @@ export class TableObserver {
selectedNodes.forEach(cellNode => {
if ($isElementNode(cellNode)) {
- const paragraphNode = $createParagraphNode();
+ // Clearing a cell empties its content; it does not reset how that
+ // content is laid out. A fresh ParagraphNode would start with no
+ // format, style, direction or indent, so the cell's paragraph is
+ // copied instead when there is one — $copyNode carries that state and
+ // returns it childless.
+ const firstChild = cellNode.getFirstChild();
+ const paragraphNode = $isParagraphNode(firstChild)
+ ? $copyNode(firstChild)
+ : $createParagraphNode();
const textNode = $createTextNode();
paragraphNode.append(textNode);
cellNode.append(paragraphNode);
diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts
index 4a0b7bcd476..0d879cb2e58 100644
--- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts
+++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts
@@ -46,6 +46,7 @@ import {
import {
$createTableCellNode,
$isTableCellNode,
+ TableCellHeaderStates,
TableCellNode,
} from './LexicalTableCellNode';
import {
@@ -152,9 +153,16 @@ function $tableTransform(node: TableNode) {
if (rowLength === maxRowLength) {
continue;
}
+ // Padding cells are appended to the end of the row, so they are never in
+ // a header column — but they are in a header row whenever the row they
+ // extend is one. Inherit only that bit from the row's last cell, the same
+ // reference $insertTableColumnAtNode uses when it appends a column.
+ const lastCell = rowNode.getLastChild();
+ const headerState = $isTableCellNode(lastCell)
+ ? lastCell.getHeaderStyles() & TableCellHeaderStates.ROW
+ : TableCellHeaderStates.NO_STATUS;
for (let j = rowLength; j < maxRowLength; ++j) {
- // TODO: inherit header state from another header or body
- const newCell = $createTableCellNode();
+ const newCell = $createTableCellNode(headerState);
newCell.append($createParagraphNode());
rowNode.append(newCell);
}
diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts
index 51214cc3fe4..1a59a49e483 100644
--- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts
+++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts
@@ -14,6 +14,7 @@ import invariant from '@lexical/internal/invariant';
import {objectKlassEquals} from '@lexical/utils';
import {
$caretFromPoint,
+ $comparePointCaretNext,
$createParagraphNode,
$createRangeSelectionFromDom,
$createTextNode,
@@ -21,6 +22,7 @@ import {
$findMatchingParent,
$getAdjacentChildCaret,
$getChildCaret,
+ $getCommonAncestor,
$getNearestNodeFromDOMNode,
$getNodeByKey,
$getNodeByKeyOrThrow,
@@ -1770,17 +1772,29 @@ function getCorner(
return [colName, rowName];
}
-function getCornerOrThrow(
+/**
+ * Resolve the corner of `rect` that the anchor sits on.
+ *
+ * `$computeTableCellRectBoundary` grows the rect until it contains every
+ * merged cell that straddles an edge, so the anchor is not guaranteed to be at
+ * a corner of the result — a cell merged across the rect's edge pushes that
+ * edge past the anchor. Fall back the same way {@link $extractRectCorners}
+ * does: to the corner opposite the focus, and finally to the top-left.
+ */
+function getAnchorCorner(
rect: TableCellRectBoundary,
- cellValue: TableMapValueType,
+ anchorCellValue: TableMapValueType,
+ focusCellValue: TableMapValueType,
): Corner {
- const corner = getCorner(rect, cellValue);
- invariant(
- corner !== null,
- 'getCornerOrThrow: cell %s is not at a corner of rect',
- cellValue.cell.getKey(),
- );
- return corner;
+ const anchorCorner = getCorner(rect, anchorCellValue);
+ if (anchorCorner) {
+ return anchorCorner;
+ }
+ const focusCorner = getCorner(rect, focusCellValue);
+ if (focusCorner) {
+ return oppositeCorner(focusCorner);
+ }
+ return ['minColumn', 'minRow'];
}
function oppositeCorner([colName, rowName]: Corner): Corner {
@@ -1863,7 +1877,7 @@ function $adjustFocusInDirection(
);
const spans = $computeTableCellRectSpans(tableMap, rect);
const {topSpan, leftSpan, bottomSpan, rightSpan} = spans;
- const anchorCorner = getCornerOrThrow(rect, anchorCellValue);
+ const anchorCorner = getAnchorCorner(rect, anchorCellValue, focusCellValue);
const [focusColumn, focusRow] = oppositeCorner(anchorCorner);
let fCol = rect[focusColumn];
let fRow = rect[focusRow];
@@ -2095,6 +2109,27 @@ function $findNextTableCell(
return null;
}
+/**
+ * True when the selection focus sits before `tableNode` in document order —
+ * the only side an ArrowDown can move the caret into the table from.
+ */
+function $isSelectionBeforeTable(
+ selection: null | BaseSelection,
+ tableNode: TableNode,
+): boolean {
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+ const focusCaret = $caretFromPoint(selection.focus, 'next');
+ // A ChildCaret is ordered at the table's 'enter' (pre-order) position, so
+ // any caret strictly before it is outside of and above the table.
+ const tableCaret = $getChildCaret(tableNode, 'next');
+ return (
+ $getCommonAncestor(focusCaret.origin, tableCaret.origin) !== null &&
+ $comparePointCaretNext(focusCaret, tableCaret) < 0
+ );
+}
+
function $handleArrowKey(
editor: LexicalEditor,
event: KeyboardEvent,
@@ -2282,7 +2317,17 @@ function $handleArrowKey(
}
}
}
- if (direction === 'down' && $isScrollableTablesActive(editor)) {
+ if (
+ direction === 'down' &&
+ $isScrollableTablesActive(editor) &&
+ // Only arm the workaround when ArrowDown could actually move the caret
+ // into the table. From a caret after the table (e.g. the block cursor
+ // below a trailing table) ArrowDown moves nothing, so the flag would
+ // survive to be consumed by an unrelated later selection change — an
+ // ArrowUp back into the last row would then be snapped to the first
+ // cell.
+ $isSelectionBeforeTable(selection, tableNode)
+ ) {
// Enable Firefox workaround
tableObservers.setShouldCheckSelectionForTable(tableNode.getKey());
}
diff --git a/packages/lexical-table/src/LexicalTableUtils.ts b/packages/lexical-table/src/LexicalTableUtils.ts
index 3f90dedd559..aa97e83276a 100644
--- a/packages/lexical-table/src/LexicalTableUtils.ts
+++ b/packages/lexical-table/src/LexicalTableUtils.ts
@@ -524,7 +524,7 @@ export function $insertTableColumnAtNode(
return cell;
}
let loopRow: TableRowNode = gridFirstChild;
- rowLoop: for (let i = 0; i < rowCount; i++) {
+ for (let i = 0; i < rowCount; i++) {
if (i !== 0) {
const currentRow = loopRow.getNextSibling();
invariant(
@@ -550,32 +550,34 @@ export function $insertTableColumnAtNode(
);
continue;
}
- const {
- cell: currentCell,
- startColumn: currentStartColumn,
- startRow: currentStartRow,
- } = rowMap[insertAfterColumn];
+ const {cell: currentCell, startColumn: currentStartColumn} =
+ rowMap[insertAfterColumn];
if (currentStartColumn + currentCell.__colSpan - 1 <= insertAfterColumn) {
- let insertAfterCell: TableCellNode = currentCell;
- let insertAfterCellRowStart = currentStartRow;
- let prevCellIndex = insertAfterColumn;
- while (insertAfterCellRowStart !== i && insertAfterCell.__rowSpan > 1) {
- // prevCellIndex always sits on the last grid column of insertAfterCell,
- // so stepping to the column before it means subtracting *that* cell's
- // colSpan, not the colSpan of the cell we started from.
- prevCellIndex -= insertAfterCell.__colSpan;
- if (prevCellIndex >= 0) {
- const {cell: cell_, startRow: startRow_} = rowMap[prevCellIndex];
- insertAfterCell = cell_;
- insertAfterCellRowStart = startRow_;
- } else {
- loopRow.append($createTableCellNodeForInsertTableColumn(headerState));
- continue rowLoop;
+ // Find the last cell this row actually owns at or before the insertion
+ // column. Grid positions covered by a rowSpan from an earlier row are not
+ // children of this row, so they can not be inserted after.
+ let insertAfterCell: null | TableCellNode = null;
+ for (let column = 0; column <= insertAfterColumn; column++) {
+ const currentCellMap = rowMap[column];
+ if (currentCellMap.startRow === i) {
+ insertAfterCell = currentCellMap.cell;
+ }
+ if (currentCellMap.cell.__colSpan > 1) {
+ column += currentCellMap.cell.__colSpan - 1;
}
}
- insertAfterCell.insertAfter(
- $createTableCellNodeForInsertTableColumn(headerState),
- );
+ if (insertAfterCell === null) {
+ // Every grid column to the left is covered by a rowSpan from an earlier
+ // row, so the new cell is this row's first child.
+ $insertFirst(
+ loopRow,
+ $createTableCellNodeForInsertTableColumn(headerState),
+ );
+ } else {
+ insertAfterCell.insertAfter(
+ $createTableCellNodeForInsertTableColumn(headerState),
+ );
+ }
} else {
currentCell.setColSpan(currentCell.__colSpan + 1);
}
@@ -640,7 +642,11 @@ export function $deleteTableRowAtSelection(): void {
const {startRow: focusStartRow} = focusCellMap;
const focusEndRow = focusStartRow + focusCell.__rowSpan - 1;
if (gridMap.length === focusEndRow - anchorStartRow + 1) {
- // Empty grid
+ // Empty grid. Move the selection out of the table before removing it,
+ // otherwise a TableSelection is left pointing at cells that no longer
+ // exist — $deleteTableColumnAtSelection and TableObserver.$clearText both
+ // call selectPrevious() here for the same reason.
+ grid.selectPrevious();
grid.remove();
return;
}
@@ -987,13 +993,19 @@ export function $unmergeCellNode(cellNode: TableCellNode): void {
return rowStyle;
});
+ // The cells the merged cell splits into are the same region of the table it
+ // was, so they keep its per-cell presentation. Only the header state is
+ // recomputed (above), because that describes the row/column rather than the
+ // cell.
+ const $createSplitCell = (headerState: TableCellHeaderState) =>
+ $createTableCellNode(headerState)
+ .setBackgroundColor(cell.getBackgroundColor())
+ .setVerticalAlign(cell.getVerticalAlign())
+ .append($createParagraphNode());
+
if (colSpan > 1) {
for (let i = 1; i < colSpan; i++) {
- cell.insertAfter(
- $createTableCellNode(colStyles[i] | rowStyles[0]).append(
- $createParagraphNode(),
- ),
- );
+ cell.insertAfter($createSplitCell(colStyles[i] | rowStyles[0]));
}
cell.setColSpan(1);
}
@@ -1023,17 +1035,13 @@ export function $unmergeCellNode(cellNode: TableCellNode): void {
for (let j = colSpan - 1; j >= 0; j--) {
$insertFirst(
currentRowNode,
- $createTableCellNode(colStyles[j] | rowStyles[i]).append(
- $createParagraphNode(),
- ),
+ $createSplitCell(colStyles[j] | rowStyles[i]),
);
}
} else {
for (let j = colSpan - 1; j >= 0; j--) {
insertAfterCell.insertAfter(
- $createTableCellNode(colStyles[j] | rowStyles[i]).append(
- $createParagraphNode(),
- ),
+ $createSplitCell(colStyles[j] | rowStyles[i]),
);
}
}
@@ -1591,6 +1599,10 @@ export function $insertTableIntoGrid(
if (backgroundColor !== null && backgroundColor !== undefined) {
cell.setBackgroundColor(backgroundColor);
}
+ const verticalAlign = templateCell.getVerticalAlign();
+ if (verticalAlign !== undefined) {
+ cell.setVerticalAlign(verticalAlign);
+ }
const originalChildren = cell.getChildren();
templateCell.getChildren().forEach(child => {
if ($isTextNode(child)) {
diff --git a/packages/lexical-table/src/__tests__/browser/Issue6822BlockCursorArrowKeys.test.ts b/packages/lexical-table/src/__tests__/browser/Issue6822BlockCursorArrowKeys.test.ts
new file mode 100644
index 00000000000..834d4f80527
--- /dev/null
+++ b/packages/lexical-table/src/__tests__/browser/Issue6822BlockCursorArrowKeys.test.ts
@@ -0,0 +1,234 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {buildEditorFromExtensions} from '@lexical/extension';
+import {RichTextExtension} from '@lexical/rich-text';
+import {
+ $createTableNodeWithDimensions,
+ $isTableCellNode,
+ $isTableRowNode,
+ TableExtension,
+} from '@lexical/table';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+ defineExtension,
+ type LexicalEditor,
+} from 'lexical';
+import {describe, expect, onTestFinished, test} from 'vitest';
+import {userEvent} from 'vitest/browser';
+
+// Regression tests for #6822.
+//
+// A table that is the first or last child of the root gets a block cursor
+// beside it when the caret steps off its edge. Getting back into the table
+// from that block cursor is native caret movement, so these tests run in a
+// real browser (see the `browser` project in vitest.config.mts) against real
+// layout — jsdom has neither a caret nor line boxes to move it between.
+//
+// The theme mirrors the playground's block cursor and table CSS because the
+// engine's vertical caret movement is layout-driven; an approximation of the
+// real stylesheet lands the caret in different cells.
+const PLAYGROUND_CSS = `
+.test-blockCursor { display: block; pointer-events: none; position: absolute; }
+.test-blockCursor:after { content: ''; display: block; position: absolute; top: -2px; width: 20px; border-top: 1px solid black; }
+.test-scrollWrapper { overflow-x: auto; margin: 0px 0px 5px 0px; scrollbar-width: none; }
+.test-scrollWrapper > .test-table { margin-top: 0; margin-bottom: 0; }
+.test-table { border-collapse: collapse; border-spacing: 0; table-layout: fixed; width: fit-content; margin-top: 25px; margin-bottom: 30px; }
+.test-cell { border: 1px solid #bbb; width: 75px; vertical-align: top; text-align: start; padding: 6px 8px; position: relative; outline: none; overflow: auto; }
+.test-cell > * { overflow: inherit; }
+.test-cellHeader { background-color: #f2f3f5; text-align: start; }
+`;
+
+const THEME = {
+ blockCursor: 'test-blockCursor',
+ table: 'test-table',
+ tableCell: 'test-cell',
+ tableCellHeader: 'test-cellHeader',
+ tableScrollableWrapper: 'test-scrollWrapper',
+};
+
+/**
+ * A 3x3 table whose cells read "c1".."c9" in document order, so an assertion
+ * on the anchor's text content names the cell the caret landed in.
+ */
+function $createNumberedTable() {
+ const table = $createTableNodeWithDimensions(3, 3, true);
+ let n = 0;
+ for (const row of table.getChildren()) {
+ if (!$isTableRowNode(row)) {
+ continue;
+ }
+ for (const cell of row.getChildren()) {
+ if (!$isTableCellNode(cell)) {
+ continue;
+ }
+ const paragraph = $createParagraphNode();
+ paragraph.append($createTextNode(`c${++n}`));
+ cell.clear().append(paragraph);
+ }
+ }
+ return table;
+}
+
+function setUpEditor($initialEditorState: () => void): LexicalEditor {
+ const style = document.createElement('style');
+ style.textContent = PLAYGROUND_CSS;
+ document.head.appendChild(style);
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const editor = buildEditorFromExtensions(
+ defineExtension({
+ $initialEditorState,
+ afterRegistration(builtEditor) {
+ const rootElement = document.createElement('div');
+ rootElement.contentEditable = 'true';
+ container.appendChild(rootElement);
+ builtEditor.setRootElement(rootElement);
+ return () => {
+ document.body.removeChild(container);
+ document.head.removeChild(style);
+ };
+ },
+ // hasHorizontalScroll defaults on, which is what arms the Firefox
+ // scroll workaround under test.
+ dependencies: [RichTextExtension, TableExtension],
+ name: '[6822-browser]',
+ theme: THEME,
+ }),
+ );
+ onTestFinished(() => editor.dispose());
+ return editor;
+}
+
+async function press(key: string): Promise {
+ await userEvent.keyboard(key);
+ // Let the engine's selectionchange (and Lexical's reconcile of it) settle.
+ await new Promise(resolve => setTimeout(resolve, 60));
+}
+
+/** The text of the node the caret is anchored in, or a description of a
+ * non-text anchor (`block cursor` for a collapsed element point on the root). */
+function anchorText(editor: LexicalEditor): string {
+ return editor.getEditorState().read(() => {
+ const selection = $getSelection();
+ if (!$isRangeSelection(selection)) {
+ return `not a RangeSelection: ${String(selection)}`;
+ }
+ const anchor = selection.anchor;
+ return anchor.type === 'text'
+ ? anchor.getNode().getTextContent()
+ : `element point on ${anchor.getNode().getType()}@${anchor.offset}`;
+ });
+}
+
+function hasBlockCursor(editor: LexicalEditor): boolean {
+ return editor._blockCursorElement !== null;
+}
+
+describe('block cursor beside a table (#6822)', () => {
+ test('ArrowUp from the block cursor below a trailing table returns to the last row', async () => {
+ const editor = setUpEditor(() => {
+ const paragraph = $createParagraphNode();
+ paragraph.append($createTextNode('before'));
+ $getRoot().clear().append(paragraph, $createNumberedTable());
+ });
+ editor.update(
+ () => {
+ // The last descendant of the root is the text in the table's last
+ // cell in both layouts under test.
+ $getRoot().getLastDescendant()!.selectEnd();
+ },
+ {discrete: true},
+ );
+ editor.focus();
+ await new Promise(resolve => setTimeout(resolve, 60));
+ expect(anchorText(editor)).toBe('c9');
+
+ // Step off the bottom edge: the caret becomes a block cursor after the
+ // table (an element point on the root).
+ await press('{ArrowDown}');
+ expect(hasBlockCursor(editor)).toBe(true);
+ expect(anchorText(editor)).toBe('element point on root@2');
+
+ // A second ArrowDown moves nothing — there is nothing below the table.
+ await press('{ArrowDown}');
+ expect(hasBlockCursor(editor)).toBe(true);
+ expect(anchorText(editor)).toBe('element point on root@2');
+
+ // ArrowUp must step back into the row the caret left, not jump to the
+ // top-left cell.
+ await press('{ArrowUp}');
+ expect(hasBlockCursor(editor)).toBe(false);
+ expect(anchorText(editor)).toBe('c9');
+
+ await press('z');
+ expect(
+ editor.getEditorState().read(() => $getRoot().getTextContent()),
+ ).toBe('before\n\nc1\n\nc2\n\nc3\n\nc4\n\nc5\n\nc6\n\nc7\n\nc8\n\nc9z');
+ });
+
+ test('ArrowUp from the block cursor below a table that is the only root child returns to the last row', async () => {
+ const editor = setUpEditor(() => {
+ $getRoot().clear().append($createNumberedTable());
+ });
+ editor.update(
+ () => {
+ // The last descendant of the root is the text in the table's last
+ // cell in both layouts under test.
+ $getRoot().getLastDescendant()!.selectEnd();
+ },
+ {discrete: true},
+ );
+ editor.focus();
+ await new Promise(resolve => setTimeout(resolve, 60));
+
+ await press('{ArrowDown}');
+ expect(hasBlockCursor(editor)).toBe(true);
+ await press('{ArrowDown}');
+ expect(hasBlockCursor(editor)).toBe(true);
+ await press('{ArrowUp}');
+ expect(anchorText(editor)).toBe('c9');
+ });
+
+ test('ArrowDown from the block cursor above a leading table still enters the first cell', async () => {
+ // A control: this passes with and without the fix, because in this engine
+ // the native ArrowDown lands in the first cell on its own. It is here to
+ // pin the direction the Firefox scroll workaround is gated to — entering
+ // the table from above must keep working.
+ const editor = setUpEditor(() => {
+ const paragraph = $createParagraphNode();
+ paragraph.append($createTextNode('after'));
+ $getRoot().clear().append($createNumberedTable(), paragraph);
+ });
+ editor.update(
+ () => {
+ $getRoot().getFirstDescendant()!.selectStart();
+ },
+ {discrete: true},
+ );
+ editor.focus();
+ await new Promise(resolve => setTimeout(resolve, 60));
+
+ await press('{ArrowUp}');
+ expect(hasBlockCursor(editor)).toBe(true);
+ expect(anchorText(editor)).toBe('element point on root@0');
+
+ await press('{ArrowDown}');
+ expect(hasBlockCursor(editor)).toBe(false);
+ expect(anchorText(editor)).toBe('c1');
+
+ await press('z');
+ expect(
+ editor.getEditorState().read(() => $getRoot().getTextContent()),
+ ).toBe('zc1\n\nc2\n\nc3\n\nc4\n\nc5\n\nc6\n\nc7\n\nc8\n\nc9\n\nafter');
+ });
+});
diff --git a/packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts b/packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts
new file mode 100644
index 00000000000..34eb0c93b67
--- /dev/null
+++ b/packages/lexical-table/src/__tests__/browser/TableTrailingCaret.test.ts
@@ -0,0 +1,127 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {buildEditorFromExtensions, defineExtension} from '@lexical/extension';
+import {RichTextExtension} from '@lexical/rich-text';
+import {$createTableNodeWithDimensions, TableExtension} from '@lexical/table';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+ type LexicalEditor,
+} from 'lexical';
+import {assert, expect, onTestFinished, test} from 'vitest';
+import {userEvent} from 'vitest/browser';
+
+// Regression tests for #7999.
+//
+// With a table as the last node of the document, the caret used to cycle
+// around it: right arrow out of the last cell reached the block cursor
+// beneath the table, the next right arrow jumped back to the root offset
+// *before* the table (where Enter inserts a paragraph above it), and the one
+// after that dropped back into the last cell.
+//
+// Nothing in the Lexical model moves on that second key press - the native
+// caret walks around the block cursor element and the selectionchange
+// listener imports the result - so this only reproduces in a real browser.
+// See the `browser` project in vitest.config.mts.
+
+function mount($initialEditorState: () => void): {
+ editor: LexicalEditor;
+ contentEditable: HTMLElement;
+} {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const contentEditable = document.createElement('div');
+ contentEditable.contentEditable = 'true';
+ container.appendChild(contentEditable);
+ const editor = buildEditorFromExtensions(
+ defineExtension({
+ $initialEditorState,
+ dependencies: [RichTextExtension, TableExtension],
+ name: 'issue-7999',
+ onError: (error: Error) => {
+ throw error;
+ },
+ }),
+ );
+ editor.setRootElement(contentEditable);
+ onTestFinished(() => {
+ editor.setRootElement(null);
+ document.body.removeChild(container);
+ });
+ contentEditable.focus();
+ return {contentEditable, editor};
+}
+
+function expectCaretAfterTable(editor: LexicalEditor): void {
+ editor.read(() => {
+ const selection = $getSelection();
+ assert($isRangeSelection(selection), 'Expected RangeSelection');
+ expect(selection.isCollapsed()).toBe(true);
+ expect(selection.anchor.type).toBe('element');
+ expect(selection.anchor.key).toBe($getRoot().getKey());
+ expect(selection.anchor.offset).toBe($getRoot().getChildrenSize());
+ });
+}
+
+test('the caret stops beneath a trailing table instead of cycling around it', async () => {
+ const {editor} = mount(() => {
+ $getRoot()
+ .clear()
+ .append($createTableNodeWithDimensions(2, 2, false));
+ });
+ editor.update(() => $getRoot().getLastChildOrThrow().selectEnd(), {
+ discrete: true,
+ });
+
+ // Leaves the last cell and lands on the block cursor beneath the table.
+ await userEvent.keyboard('{ArrowRight}');
+ expectCaretAfterTable(editor);
+
+ // Further presses have nowhere to go and must leave the caret alone.
+ await userEvent.keyboard('{ArrowRight}');
+ expectCaretAfterTable(editor);
+ await userEvent.keyboard('{ArrowRight}');
+ expectCaretAfterTable(editor);
+
+ // Enter at that caret adds the paragraph after the table, not before it.
+ await userEvent.keyboard('{Enter}');
+ expect(
+ editor.read(() =>
+ $getRoot()
+ .getChildren()
+ .map(node => node.getType()),
+ ),
+ ).toEqual(['table', 'paragraph']);
+});
+
+test('the caret still moves past a table that is followed by a paragraph', async () => {
+ const {editor} = mount(() => {
+ $getRoot()
+ .clear()
+ .append(
+ $createTableNodeWithDimensions(2, 2, false),
+ $createParagraphNode().append($createTextNode('after')),
+ );
+ });
+ editor.update(() => $getRoot().getFirstChildOrThrow().selectEnd(), {
+ discrete: true,
+ });
+
+ await userEvent.keyboard('{ArrowRight}');
+ await userEvent.keyboard('{ArrowRight}');
+
+ editor.read(() => {
+ const selection = $getSelection();
+ assert($isRangeSelection(selection), 'Expected RangeSelection');
+ expect(selection.anchor.getNode().getTextContent()).toBe('after');
+ });
+});
diff --git a/packages/lexical-table/src/__tests__/unit/ClearTextKeepsParagraphState.test.ts b/packages/lexical-table/src/__tests__/unit/ClearTextKeepsParagraphState.test.ts
new file mode 100644
index 00000000000..7186b6d50d3
--- /dev/null
+++ b/packages/lexical-table/src/__tests__/unit/ClearTextKeepsParagraphState.test.ts
@@ -0,0 +1,122 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {buildEditorFromExtensions} from '@lexical/extension';
+import {
+ $computeTableMapSkipCellCheck,
+ $createTableNodeWithDimensions,
+ $createTableSelectionFrom,
+ $isTableCellNode,
+ $isTableNode,
+ $isTableRowNode,
+ TableExtension,
+} from '@lexical/table';
+import {
+ $createTextNode,
+ $getRoot,
+ $isParagraphNode,
+ $setSelection,
+ defineExtension,
+ KEY_BACKSPACE_COMMAND,
+} from 'lexical';
+import {afterEach, assert, beforeEach, describe, expect, test} from 'vitest';
+
+let container: HTMLDivElement;
+let editor: ReturnType;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ editor = buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [TableExtension],
+ name: 'clear-text-host',
+ }),
+ );
+ // The table selection observer only registers its key handlers once the
+ // editor has a root element in the document.
+ editor.setRootElement(container);
+});
+
+afterEach(() => {
+ editor.dispose();
+ document.body.removeChild(container);
+});
+
+function $firstRowParagraphState() {
+ const table = $getRoot().getFirstChild();
+ assert($isTableNode(table), 'expected a TableNode at the root');
+ const firstRow = table.getChildren().filter($isTableRowNode)[0];
+ return firstRow
+ .getChildren()
+ .filter($isTableCellNode)
+ .map(cell => {
+ const paragraph = cell.getFirstChild();
+ assert($isParagraphNode(paragraph), 'expected a ParagraphNode in a cell');
+ return {
+ direction: paragraph.getDirection(),
+ format: paragraph.getFormatType(),
+ style: paragraph.getStyle(),
+ text: cell.getTextContent(),
+ };
+ });
+}
+
+describe('clearing a table selection', () => {
+ test('keeps the paragraph format, style and direction of each cell', () => {
+ editor.update(
+ () => {
+ const table = $createTableNodeWithDimensions(2, 2, false);
+ $getRoot().clear().append(table);
+ const [map] = $computeTableMapSkipCellCheck(table, null, null);
+ for (const {cell} of map[0]) {
+ const paragraph = cell.getFirstChild();
+ assert($isParagraphNode(paragraph), 'expected a ParagraphNode');
+ paragraph
+ .setFormat('center')
+ .setDirection('rtl')
+ .setStyle('line-height: 2;');
+ paragraph.append($createTextNode('text'));
+ }
+ $setSelection(
+ $createTableSelectionFrom(table, map[0][0].cell, map[0][1].cell),
+ );
+ },
+ {discrete: true},
+ );
+
+ // Backspace over a multi-cell selection routes to TableObserver.$clearText.
+ // The selection covers only the first row, so the table itself survives.
+ editor.update(
+ () => {
+ editor.dispatchCommand(
+ KEY_BACKSPACE_COMMAND,
+ new KeyboardEvent('keydown', {key: 'Backspace'}),
+ );
+ },
+ {discrete: true},
+ );
+
+ editor.read('latest', () => {
+ expect($firstRowParagraphState()).toEqual([
+ {
+ direction: 'rtl',
+ format: 'center',
+ style: 'line-height: 2;',
+ text: '',
+ },
+ {
+ direction: 'rtl',
+ format: 'center',
+ style: 'line-height: 2;',
+ text: '',
+ },
+ ]);
+ });
+ });
+});
diff --git a/packages/lexical-table/src/__tests__/unit/DeleteAllRowsSelection.test.ts b/packages/lexical-table/src/__tests__/unit/DeleteAllRowsSelection.test.ts
new file mode 100644
index 00000000000..dda2f1a7374
--- /dev/null
+++ b/packages/lexical-table/src/__tests__/unit/DeleteAllRowsSelection.test.ts
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {buildEditorFromExtensions} from '@lexical/extension';
+import {
+ $computeTableMapSkipCellCheck,
+ $createTableNodeWithDimensions,
+ $createTableSelectionFrom,
+ $deleteTableColumnAtSelection,
+ $deleteTableRowAtSelection,
+ $isTableNode,
+ $isTableSelection,
+ TableExtension,
+} from '@lexical/table';
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+ $setSelection,
+ defineExtension,
+} from 'lexical';
+import {assert, describe, expect, test} from 'vitest';
+
+function deleteWholeTable(kind: 'row' | 'column'): {
+ selectionType: string;
+ staleTableSelection: boolean;
+ rootChildren: number;
+} {
+ using editor = buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [TableExtension],
+ name: `delete-all-${kind}-host`,
+ }),
+ );
+
+ editor.update(
+ () => {
+ const root = $getRoot().clear();
+ root.append($createParagraphNode().append($createTextNode('before')));
+ root.append($createTableNodeWithDimensions(2, 2, false));
+ const table = root.getChildAtIndex(1);
+ assert($isTableNode(table), 'expected a TableNode');
+ const [map] = $computeTableMapSkipCellCheck(table, null, null);
+ // Every cell selected, so deleting rows/columns empties the table.
+ $setSelection(
+ $createTableSelectionFrom(table, map[0][0].cell, map[1][1].cell),
+ );
+ },
+ {discrete: true},
+ );
+
+ editor.update(
+ () => {
+ if (kind === 'row') {
+ $deleteTableRowAtSelection();
+ } else {
+ $deleteTableColumnAtSelection();
+ }
+ },
+ {discrete: true},
+ );
+
+ return editor.read(() => {
+ const selection = $getSelection();
+ return {
+ rootChildren: $getRoot().getChildrenSize(),
+
+ selectionType: $isRangeSelection(selection)
+ ? 'range'
+ : $isTableSelection(selection)
+ ? 'table'
+ : String(selection),
+ // A TableSelection left behind by the removed table reports isValid()
+ // false, because the cells it points at are gone.
+ staleTableSelection: $isTableSelection(selection) && !selection.isValid(),
+ };
+ });
+}
+
+describe('deleting every row of a table', () => {
+ test('leaves the selection outside the removed table', () => {
+ expect(deleteWholeTable('row')).toEqual({
+ rootChildren: 1,
+ selectionType: 'range',
+ staleTableSelection: false,
+ });
+ });
+
+ test('matches what deleting every column already does', () => {
+ expect(deleteWholeTable('column')).toEqual({
+ rootChildren: 1,
+ selectionType: 'range',
+ staleTableSelection: false,
+ });
+ });
+});
diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts
index 8e255739f2b..4d59adb41ea 100644
--- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts
+++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts
@@ -23,6 +23,7 @@ import {
$isTableSelection,
$mergeCells,
INSERT_TABLE_COMMAND,
+ TableCellHeaderStates,
type TableCellNode,
TableExtension,
type TableNode,
@@ -516,6 +517,139 @@ describe('TableExtension', () => {
]);
});
});
+
+ test('pasting a table carries the cell backgroundColor and verticalAlign', () => {
+ editor.update(
+ () => {
+ const root = $getRoot().clear();
+ const table = $createTableNode();
+ const row = $createTableRowNode();
+ const cell = $createTableCellNode();
+ cell.append($createParagraphNode().append($createTextNode('old')));
+ row.append(cell);
+ table.append(row);
+ root.append(table);
+ cell.selectStart();
+ },
+ {discrete: true},
+ );
+
+ editor.update(
+ () => {
+ const template = $createTableNode();
+ const row = $createTableRowNode();
+ const cell = $createTableCellNode()
+ .setBackgroundColor('rgb(255, 0, 0)')
+ .setVerticalAlign('middle');
+ cell.append($createParagraphNode().append($createTextNode('new')));
+ row.append(cell);
+ template.append(row);
+ const selection = $getSelection();
+ assert(selection !== null, 'Expected a selection');
+ $insertGeneratedNodes(editor, [template], selection);
+ },
+ {discrete: true},
+ );
+
+ editor.read('latest', () => {
+ const table = $getRoot().getFirstChild();
+ assert($isTableNode(table), 'Expected table node');
+ const row = table.getFirstChild();
+ assert($isTableRowNode(row), 'Expected row node');
+ const cell = row.getFirstChild();
+ assert($isTableCellNode(cell), 'Expected cell node');
+ expect(cell.getTextContent()).toBe('new');
+ expect(cell.getBackgroundColor()).toBe('rgb(255, 0, 0)');
+ expect(cell.getVerticalAlign()).toBe('middle');
+ });
+ });
+ });
+
+ describe('$tableTransform padding', () => {
+ test('a short header row is padded with header cells', () => {
+ editor.update(
+ () => {
+ const root = $getRoot().clear();
+ const table = $createTableNode();
+ // First row is a header row but is one cell short.
+ const headerRow = $createTableRowNode();
+ headerRow.append(
+ $createTableCellNode(TableCellHeaderStates.ROW).append(
+ $createParagraphNode().append($createTextNode('h')),
+ ),
+ );
+ table.append(headerRow);
+ const bodyRow = $createTableRowNode();
+ for (const text of ['a', 'b']) {
+ bodyRow.append(
+ $createTableCellNode().append(
+ $createParagraphNode().append($createTextNode(text)),
+ ),
+ );
+ }
+ table.append(bodyRow);
+ root.append(table);
+ },
+ {discrete: true},
+ );
+
+ editor.read('latest', () => {
+ const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode);
+ const rows = table.getChildren().filter($isTableRowNode);
+ const tags = rows.map(row =>
+ row
+ .getChildren()
+ .filter($isTableCellNode)
+ .map(cell => cell.getTag()),
+ );
+ expect(tags).toEqual([
+ ['th', 'th'],
+ ['td', 'td'],
+ ]);
+ });
+ });
+
+ test('a short body row is padded with body cells', () => {
+ editor.update(
+ () => {
+ const root = $getRoot().clear();
+ const table = $createTableNode();
+ const headerRow = $createTableRowNode();
+ for (const text of ['h1', 'h2']) {
+ headerRow.append(
+ $createTableCellNode(TableCellHeaderStates.ROW).append(
+ $createParagraphNode().append($createTextNode(text)),
+ ),
+ );
+ }
+ table.append(headerRow);
+ const bodyRow = $createTableRowNode();
+ bodyRow.append(
+ $createTableCellNode().append(
+ $createParagraphNode().append($createTextNode('a')),
+ ),
+ );
+ table.append(bodyRow);
+ root.append(table);
+ },
+ {discrete: true},
+ );
+
+ editor.read('latest', () => {
+ const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode);
+ const rows = table.getChildren().filter($isTableRowNode);
+ const tags = rows.map(row =>
+ row
+ .getChildren()
+ .filter($isTableCellNode)
+ .map(cell => cell.getTag()),
+ );
+ expect(tags).toEqual([
+ ['th', 'th'],
+ ['td', 'td'],
+ ]);
+ });
+ });
});
describe('colWidths', () => {
diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts
index e43cb35250d..e3392865e3f 100644
--- a/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts
+++ b/packages/lexical-table/src/__tests__/unit/LexicalTableUtils.test.ts
@@ -21,6 +21,7 @@ import {
$setTableColumnIsHeader,
$setTableRowIsHeader,
TableCellHeaderStates,
+ type TableCellNode,
TableExtension,
type TableNode,
} from '@lexical/table';
@@ -1276,6 +1277,28 @@ describe('$insertTableColumnAtNode', () => {
return tableMap.map(row => row.map(({cell}) => cell.getTextContent()));
}
+ function $cell(text: string, rowSpan = 1, colSpan = 1): TableCellNode {
+ const cell = $createTableCellNode();
+ cell.setRowSpan(rowSpan);
+ cell.setColSpan(colSpan);
+ return cell.append($createParagraphNode().append($createTextNode(text)));
+ }
+
+ function $appendTable(rows: TableCellNode[][]): void {
+ const table = $createTableNode();
+ for (const cells of rows) {
+ table.append($createTableRowNode().append(...cells));
+ }
+ $getRoot().append(table);
+ }
+
+ // Inserts a column after the cell that occupies the given grid coordinate.
+ function $insertColumnAfterGridCell(row: number, column: number): void {
+ const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode);
+ const [tableMap] = $computeTableMapSkipCellCheck(table, null, null);
+ $insertTableColumnAtNode(tableMap[row][column].cell, true, false);
+ }
+
test('walks left by each visited cell colSpan when a row is spanned', () => {
// Grid:
// row0: [A0][X(rowSpan=2)][C(colSpan=2,rowSpan=2)][D0]
@@ -1321,4 +1344,90 @@ describe('$insertTableColumnAtNode', () => {
]);
});
});
+
+ test('inserts the new cell in the correct column for rows spanned by a rowSpan cell', () => {
+ // Grid:
+ // row0: [A(rowSpan=2), B]
+ // row1: [C] (grid col 0 is covered by A's rowSpan)
+ // row2: [D, E]
+ editor.update(
+ () => {
+ $appendTable([
+ [$cell('A', 2), $cell('B')],
+ [$cell('C')],
+ [$cell('D'), $cell('E')],
+ ]);
+ },
+ {discrete: true},
+ );
+
+ editor.update(() => $insertColumnAfterGridCell(0, 0), {discrete: true});
+
+ editor.read('latest', () => {
+ const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode);
+ // The inserted (empty) column must line up at grid column 1 in every row.
+ // Row 1 is entirely covered at column 0 by A's rowSpan, so the new cell
+ // has to be prepended before C rather than appended after it.
+ expect($getGridTexts(table)).toEqual([
+ ['A', '', 'B'],
+ ['A', '', 'C'],
+ ['D', '', 'E'],
+ ]);
+ });
+ });
+
+ test('does not prepend when a spanned row still owns a cell left of a colSpan > 1 anchor', () => {
+ // Grid:
+ // row0: [P, A(rowSpan=2), C(rowSpan=2, colSpan=2)] cols P=0 A=1 C=2-3
+ // row1: [B] cols 1-3 are covered
+ editor.update(
+ () => {
+ $appendTable([
+ [$cell('P'), $cell('A', 2), $cell('C', 2, 2)],
+ [$cell('B')],
+ ]);
+ },
+ {discrete: true},
+ );
+
+ // Insert after C, i.e. after grid column 3.
+ editor.update(() => $insertColumnAfterGridCell(0, 3), {discrete: true});
+
+ editor.read('latest', () => {
+ const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode);
+ // Row 1 owns B at column 0, so the new cell belongs after B, not before.
+ expect($getGridTexts(table)).toEqual([
+ ['P', 'A', 'C', 'C', ''],
+ ['B', 'A', 'C', 'C', ''],
+ ]);
+ });
+ });
+
+ test('inserts after the last owned cell of a spanned row, not an earlier one', () => {
+ // Grid:
+ // row0: [A, B, V(rowSpan=2), X(rowSpan=2, colSpan=2)] cols V=2 X=3-4
+ // row1: [C0, C1] cols 2-4 covered
+ editor.update(
+ () => {
+ $appendTable([
+ [$cell('A'), $cell('B'), $cell('V', 2), $cell('X', 2, 2)],
+ [$cell('C0'), $cell('C1')],
+ ]);
+ },
+ {discrete: true},
+ );
+
+ // Insert after X, i.e. after grid column 4.
+ editor.update(() => $insertColumnAfterGridCell(0, 4), {discrete: true});
+
+ editor.read('latest', () => {
+ const table = $assertNodeType($getRoot().getFirstChild(), $isTableNode);
+ // C1 is the last cell row 1 owns before the insertion column, so the new
+ // cell goes after C1 rather than after C0.
+ expect($getGridTexts(table)).toEqual([
+ ['A', 'B', 'V', 'X', 'X', ''],
+ ['C0', 'C1', 'V', 'X', 'X', ''],
+ ]);
+ });
+ });
});
diff --git a/packages/lexical-table/src/__tests__/unit/ShiftArrowOffCornerAnchor.test.ts b/packages/lexical-table/src/__tests__/unit/ShiftArrowOffCornerAnchor.test.ts
new file mode 100644
index 00000000000..167807e02e2
--- /dev/null
+++ b/packages/lexical-table/src/__tests__/unit/ShiftArrowOffCornerAnchor.test.ts
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {buildEditorFromExtensions} from '@lexical/extension';
+import {
+ $computeTableMapSkipCellCheck,
+ $createTableNodeWithDimensions,
+ $createTableSelectionFrom,
+ $isTableNode,
+ $isTableSelection,
+ $mergeCells,
+ TableExtension,
+} from '@lexical/table';
+import {
+ $getRoot,
+ $getSelection,
+ $setSelection,
+ defineExtension,
+ KEY_ARROW_DOWN_COMMAND,
+} from 'lexical';
+import {afterEach, assert, beforeEach, describe, expect, test} from 'vitest';
+
+let container: HTMLDivElement;
+let editor: ReturnType;
+
+beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ editor = buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [TableExtension],
+ name: 'shift-arrow-corner-host',
+ }),
+ );
+ // The arrow key handlers come from the table selection observer, which needs
+ // the editor to have a root element.
+ editor.setRootElement(container);
+});
+
+afterEach(() => {
+ editor.dispose();
+ document.body.removeChild(container);
+});
+
+function $table() {
+ const table = $getRoot().getFirstChild();
+ assert($isTableNode(table), 'expected a TableNode at the root');
+ return table;
+}
+
+describe('Shift+Arrow with an anchor that is not on a rect corner', () => {
+ test('extends the selection instead of throwing', () => {
+ editor.update(
+ () => {
+ const table = $createTableNodeWithDimensions(3, 3, false);
+ $getRoot().clear().append(table);
+ const [map] = $computeTableMapSkipCellCheck(table, null, null);
+ // Merge grid columns 0 and 1 of row 1. This cell straddles the left
+ // edge of the rect selected below, so $computeTableCellRectBoundary
+ // grows the rect out to column 0 — past the anchor, which then sits on
+ // no corner of it.
+ const merged = $mergeCells([map[1][0].cell, map[1][1].cell]);
+ assert(merged !== null, 'expected the cells to merge');
+ },
+ {discrete: true},
+ );
+
+ editor.update(
+ () => {
+ const table = $table();
+ const [map] = $computeTableMapSkipCellCheck(table, null, null);
+ // Anchor at (row 0, column 1), focus at (row 1, column 2).
+ $setSelection(
+ $createTableSelectionFrom(table, map[0][1].cell, map[1][2].cell),
+ );
+ },
+ {discrete: true},
+ );
+
+ expect(() =>
+ editor.update(
+ () => {
+ editor.dispatchCommand(
+ KEY_ARROW_DOWN_COMMAND,
+ new KeyboardEvent('keydown', {key: 'ArrowDown', shiftKey: true}),
+ );
+ },
+ {discrete: true},
+ ),
+ ).not.toThrow();
+
+ editor.read('latest', () => {
+ const selection = $getSelection();
+ assert($isTableSelection(selection), 'expected a TableSelection');
+ expect(selection.isValid()).toBe(true);
+ });
+ });
+});
diff --git a/packages/lexical-table/src/__tests__/unit/TableExportDirection.test.ts b/packages/lexical-table/src/__tests__/unit/TableExportDirection.test.ts
new file mode 100644
index 00000000000..737fb20755d
--- /dev/null
+++ b/packages/lexical-table/src/__tests__/unit/TableExportDirection.test.ts
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {buildEditorFromExtensions, configExtension} from '@lexical/extension';
+import {$generateHtmlFromNodes} from '@lexical/html';
+import {
+ $createTableNodeWithDimensions,
+ $isTableNode,
+ TableExtension,
+} from '@lexical/table';
+import {$getRoot, defineExtension} from 'lexical';
+import {assert, describe, expect, test} from 'vitest';
+
+function buildEditor(hasHorizontalScroll: boolean) {
+ return buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [configExtension(TableExtension, {hasHorizontalScroll})],
+ name: 'table-export-dir-host',
+ theme: {tableScrollableWrapper: 'scroll-wrapper'},
+ }),
+ );
+}
+
+function exportRtlTable(hasHorizontalScroll: boolean): string {
+ using editor = buildEditor(hasHorizontalScroll);
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append(
+ $createTableNodeWithDimensions(1, 1, false).setDirection('rtl'),
+ );
+ },
+ {discrete: true},
+ );
+ return editor.read(() => {
+ const table = $getRoot().getFirstChild();
+ assert($isTableNode(table), 'expected a TableNode at the root');
+ expect(table.getDirection()).toBe('rtl');
+ return $generateHtmlFromNodes(editor);
+ });
+}
+
+describe('TableNode.exportDOM direction', () => {
+ test('exports dir with scrollable tables active', () => {
+ expect(exportRtlTable(true)).toContain('');
+ });
+
+ test('exports dir without scrollable tables', () => {
+ expect(exportRtlTable(false)).toContain('');
+ });
+});
diff --git a/packages/lexical-table/src/__tests__/unit/UnmergeCellStyles.test.ts b/packages/lexical-table/src/__tests__/unit/UnmergeCellStyles.test.ts
new file mode 100644
index 00000000000..568efbe2944
--- /dev/null
+++ b/packages/lexical-table/src/__tests__/unit/UnmergeCellStyles.test.ts
@@ -0,0 +1,116 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {buildEditorFromExtensions} from '@lexical/extension';
+import {
+ $createTableNodeWithDimensions,
+ $isTableCellNode,
+ $isTableNode,
+ $isTableRowNode,
+ $mergeCells,
+ $unmergeCellNode,
+ type TableCellNode,
+ TableExtension,
+} from '@lexical/table';
+import {$getRoot, defineExtension} from 'lexical';
+import {assert, describe, expect, test} from 'vitest';
+
+function buildEditor() {
+ return buildEditorFromExtensions(
+ defineExtension({
+ dependencies: [TableExtension],
+ name: 'unmerge-styles-host',
+ }),
+ );
+}
+
+function $cells(): TableCellNode[][] {
+ const table = $getRoot().getFirstChild();
+ assert($isTableNode(table), 'expected a TableNode at the root');
+ return table
+ .getChildren()
+ .filter($isTableRowNode)
+ .map(row => row.getChildren().filter($isTableCellNode));
+}
+
+describe('$unmergeCellNode keeps the cell presentation', () => {
+ test('the cells a merged cell splits into keep its backgroundColor and verticalAlign', () => {
+ using editor = buildEditor();
+
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append($createTableNodeWithDimensions(2, 2, false));
+ const rows = $cells();
+ const merged = $mergeCells([
+ rows[0][0],
+ rows[0][1],
+ rows[1][0],
+ rows[1][1],
+ ]);
+ assert(merged !== null, 'expected the cells to merge');
+ merged.setBackgroundColor('rgb(255, 0, 0)').setVerticalAlign('middle');
+ },
+ {discrete: true},
+ );
+
+ editor.update(
+ () => {
+ const merged = $cells()[0][0];
+ expect(merged.getColSpan()).toBe(2);
+ expect(merged.getRowSpan()).toBe(2);
+ $unmergeCellNode(merged);
+ },
+ {discrete: true},
+ );
+
+ editor.read(() => {
+ const rows = $cells();
+ expect(rows.map(row => row.length)).toEqual([2, 2]);
+ for (const row of rows) {
+ for (const cell of row) {
+ expect(cell.getColSpan()).toBe(1);
+ expect(cell.getRowSpan()).toBe(1);
+ expect(cell.getBackgroundColor()).toBe('rgb(255, 0, 0)');
+ expect(cell.getVerticalAlign()).toBe('middle');
+ }
+ }
+ });
+ });
+
+ test('an unstyled merged cell still splits into unstyled cells', () => {
+ using editor = buildEditor();
+
+ editor.update(
+ () => {
+ $getRoot()
+ .clear()
+ .append($createTableNodeWithDimensions(1, 2, false));
+ const rows = $cells();
+ const merged = $mergeCells([rows[0][0], rows[0][1]]);
+ assert(merged !== null, 'expected the cells to merge');
+ },
+ {discrete: true},
+ );
+
+ editor.update(
+ () => {
+ $unmergeCellNode($cells()[0][0]);
+ },
+ {discrete: true},
+ );
+
+ editor.read(() => {
+ for (const cell of $cells()[0]) {
+ expect(cell.getBackgroundColor()).toBe(null);
+ expect(cell.getVerticalAlign()).toBe(undefined);
+ }
+ });
+ });
+});