From 53d1df522ece288d865e26c89c0949e884b3902a Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Wed, 21 Jan 2026 16:52:18 +1100 Subject: [PATCH 01/14] refactor: slightly more sensible separation of SELECTION_INSERT_CLIPBOARD_NODES command handler --- .../src/LexicalTablePluginHelpers.ts | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index a5b80a3b522..31f295400ce 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -32,6 +32,7 @@ import { isDOMNode, LexicalEditor, NodeKey, + RangeSelection, SELECT_ALL_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, } from 'lexical'; @@ -52,6 +53,7 @@ import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode'; import { $createTableSelectionFrom, $isTableSelection, + TableSelection, } from './LexicalTableSelection'; import { $findTableNode, @@ -406,24 +408,14 @@ export function registerTablePlugin( ), editor.registerCommand( SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, - (selectionPayload, dispatchEditor) => { + (payload, dispatchEditor) => { if (editor !== dispatchEditor) { return false; } - if ($tableSelectionInsertClipboardNodesCommand(selectionPayload)) { - return true; - } - const {selection, nodes} = selectionPayload; - if ( - hasNestedTables.peek() || - editor !== dispatchEditor || - !$isRangeSelection(selection) - ) { - return false; - } - const isInsideTableCell = - $findTableNode(selection.anchor.getNode()) !== null; - return isInsideTableCell && nodes.some($isTableNode); + return $tableSelectionInsertClipboardNodesCommand( + payload, + hasNestedTables, + ); }, COMMAND_PRIORITY_EDITOR, ), @@ -447,9 +439,9 @@ function $tableSelectionInsertClipboardNodesCommand( selectionPayload: CommandPayloadType< typeof SELECTION_INSERT_CLIPBOARD_NODES_COMMAND >, + hasNestedTables: Signal, ) { const {nodes, selection} = selectionPayload; - const anchorAndFocus = selection.getStartEndPoints(); const isTableSelection = $isTableSelection(selection); const isRangeSelection = $isRangeSelection(selection); const isSelectionInsideOfGrid = @@ -462,12 +454,30 @@ function $tableSelectionInsertClipboardNodesCommand( ) !== null) || isTableSelection; - if ( - nodes.length !== 1 || - !$isTableNode(nodes[0]) || - !isSelectionInsideOfGrid || - anchorAndFocus === null - ) { + if (!isSelectionInsideOfGrid) { + // Not pasting in a grid - no special handling required. + return false; + } + + if (nodes.length === 1 && $isTableNode(nodes[0])) { + return $insertTableSelectionIntoGrid(nodes[0], selection); + } + + return ( + isSelectionInsideOfGrid && + nodes.some($isTableNode) && + !hasNestedTables.peek() + ); +} + +function $insertTableSelectionIntoGrid( + tableNode: TableNode, + selection: RangeSelection | TableSelection, +) { + const anchorAndFocus = selection.getStartEndPoints(); + const isTableSelection = $isTableSelection(selection); // TODO: always true? + + if (anchorAndFocus === null) { return false; } @@ -486,14 +496,13 @@ function $tableSelectionInsertClipboardNodesCommand( return false; } - const templateGrid = nodes[0]; const [initialGridMap, anchorCellMap, focusCellMap] = $computeTableMap( gridNode, anchorCellNode, focusCellNode, ); const [templateGridMap] = $computeTableMapSkipCellCheck( - templateGrid, + tableNode, null, null, ); From db386d1781083f77e2cd3b61dbe8b20a6ea2a206 Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Thu, 22 Jan 2026 14:25:52 +1100 Subject: [PATCH 02/14] Add stub for $insertRangeSelectionIntoCells --- .../src/LexicalTablePluginHelpers.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index 31f295400ce..a7cc767b553 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -442,6 +442,12 @@ function $tableSelectionInsertClipboardNodesCommand( hasNestedTables: Signal, ) { const {nodes, selection} = selectionPayload; + + if (!nodes.some($isTableNode)) { + // Not pasting a table - no special handling required. + return false; + } + const isTableSelection = $isTableSelection(selection); const isRangeSelection = $isRangeSelection(selection); const isSelectionInsideOfGrid = @@ -459,15 +465,17 @@ function $tableSelectionInsertClipboardNodesCommand( return false; } + // When pasting from a table, flatten the table on the destination table, even when nested tables are allowed. if (nodes.length === 1 && $isTableNode(nodes[0])) { return $insertTableSelectionIntoGrid(nodes[0], selection); } - return ( - isSelectionInsideOfGrid && - nodes.some($isTableNode) && - !hasNestedTables.peek() - ); + // Allow pasting inside a grid if nested tables are allowed. + if (hasNestedTables.peek()) { + return $insertRangeSelectionIntoCells(selectionPayload); + } + + return false; } function $insertTableSelectionIntoGrid( @@ -642,3 +650,13 @@ function $insertTableSelectionIntoGrid( return true; } + +function $insertRangeSelectionIntoCells( + selectionPayload: CommandPayloadType< + typeof SELECTION_INSERT_CLIPBOARD_NODES_COMMAND + >, +) { + const {selection} = selectionPayload; + // Any tables in the selection will need to be resized to fit the shadow root. + return false; +} From 5b6fb1472afc9b143e53cafcec5763f268fddde8 Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Fri, 23 Jan 2026 11:10:17 +1100 Subject: [PATCH 03/14] detect cellWidth (preferring table-level colWidths) of cell being pasted in also, tightening naming and adding test for proportional resizing (currently failing) --- .../src/LexicalTablePluginHelpers.ts | 97 ++++++++++-- .../unit/LexicalTableExtension.test.ts | 141 +++++++++++++++++- 2 files changed, 217 insertions(+), 21 deletions(-) diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index a7cc767b553..8a32294fce6 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -8,6 +8,7 @@ import {Signal, signal} from '@lexical/extension'; import { + $dfs, $findMatchingParent, $insertFirst, $insertNodeToNearestRoot, @@ -31,6 +32,7 @@ import { ElementNode, isDOMNode, LexicalEditor, + LexicalNode, NodeKey, RangeSelection, SELECT_ALL_COMMAND, @@ -67,6 +69,8 @@ import { $computeTableMapSkipCellCheck, $createTableNodeWithDimensions, $getNodeTriplet, + $getTableColumnIndexFromTableCellNode, + $getTableNodeFromLexicalNodeOrThrow, $insertTableColumnAtNode, $insertTableRowAtNode, $mergeCells, @@ -443,7 +447,10 @@ function $tableSelectionInsertClipboardNodesCommand( ) { const {nodes, selection} = selectionPayload; - if (!nodes.some($isTableNode)) { + const hasTables = nodes.some( + (n) => $isTableNode(n) || $dfs(n).some((d) => $isTableNode(d.node)), + ); + if (!hasTables) { // Not pasting a table - no special handling required. return false; } @@ -465,25 +472,26 @@ function $tableSelectionInsertClipboardNodesCommand( return false; } - // When pasting from a table, flatten the table on the destination table, even when nested tables are allowed. + // When pasting just a table, flatten the table on the destination table, even when nested tables are allowed. if (nodes.length === 1 && $isTableNode(nodes[0])) { - return $insertTableSelectionIntoGrid(nodes[0], selection); + return $insertTableIntoGrid(nodes[0], selection); } - // Allow pasting inside a grid if nested tables are allowed. - if (hasNestedTables.peek()) { - return $insertRangeSelectionIntoCells(selectionPayload); + // When pasting multiple nodes (including tables) into a cell, update the table to fit. + if (isRangeSelection && hasNestedTables.peek()) { + return $insertTableNodesIntoCells(nodes, selection); } - return false; + // If we reached this point, there's a table in the clipboard and nested tables are not allowed - reject the paste. + return true; } -function $insertTableSelectionIntoGrid( +function $insertTableIntoGrid( tableNode: TableNode, selection: RangeSelection | TableSelection, ) { const anchorAndFocus = selection.getStartEndPoints(); - const isTableSelection = $isTableSelection(selection); // TODO: always true? + const isTableSelection = $isTableSelection(selection); if (anchorAndFocus === null) { return false; @@ -651,12 +659,71 @@ function $insertTableSelectionIntoGrid( return true; } -function $insertRangeSelectionIntoCells( - selectionPayload: CommandPayloadType< - typeof SELECTION_INSERT_CLIPBOARD_NODES_COMMAND - >, +// Inserts the given nodes (which will include TableNodes) into the table at the given selection. +function $insertTableNodesIntoCells( + nodes: LexicalNode[], + selection: TableSelection | RangeSelection, ) { - const {selection} = selectionPayload; - // Any tables in the selection will need to be resized to fit the shadow root. + // Currently only support pasting into a single cell. In other cases we reject the insertion. + const isMultiCellTableSelection = + $isTableSelection(selection) && + !selection.focus.getNode().is(selection.anchor.getNode()); + const isMultiCellRangeSelection = + $isRangeSelection(selection) && + $isTableCellNode(selection.anchor.getNode()) && + !selection.anchor.getNode().is(selection.focus.getNode()); + if (isMultiCellTableSelection || isMultiCellRangeSelection) { + return true; + } + + // Determine the width of the cell being pasted into. + const destinationCellNode = $findMatchingParent( + selection.focus.getNode(), + $isTableCellNode, + ); + if (!destinationCellNode) { + return false; + } + const destinationTableNode = + $getTableNodeFromLexicalNodeOrThrow(destinationCellNode); + + const columnIndex = + $getTableColumnIndexFromTableCellNode(destinationCellNode); + let cellWidth = destinationCellNode.getWidth(); + const colWidths = destinationTableNode.getColWidths(); + if (colWidths) { + cellWidth = colWidths[columnIndex]; + } + if (cellWidth === undefined) { + return false; + } + + // Recursively find all table nodes in the nodes array (including nested tables) + const tablesToResize: TableNode[] = []; + + function collectTables(node: LexicalNode): void { + if ($isTableNode(node)) { + tablesToResize.push(node); + } + // Recursively check children for nested tables + if ($isElementNode(node)) { + for (const child of node.getChildren()) { + collectTables(child); + } + } + } + + // Collect all tables from the nodes being pasted + for (const node of nodes) { + collectTables(node); + } + + // Clear column widths on all tables so they fit their container + // When column widths are undefined, tables will auto-size to fit their container + for (const table of tablesToResize) { + table.setColWidths(undefined); + } + + // Return false to let normal insertion proceed with the modified nodes return false; } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 6d8d53c192f..872d7e1de7e 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -16,6 +16,8 @@ import { $createTableNode, $createTableNodeWithDimensions, $createTableRowNode, + $createTableSelection, + $createTableSelectionFrom, $isTableCellNode, $isTableNode, $isTableRowNode, @@ -23,11 +25,14 @@ import { $mergeCells, INSERT_TABLE_COMMAND, TableExtension, + TableRowNode, type TableNode, } from '@lexical/table'; import { $createParagraphNode, + $createRangeSelection, $createTextNode, + $getNodeByKey, $getRoot, $getSelection, $isElementNode, @@ -171,7 +176,7 @@ describe('TableExtension', () => { }); describe('$insertGeneratedNodes', () => { - test('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler prevents pasting tables in cells by default', () => { + test('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler prevents pasting whole table into cells by default', () => { editor.update( () => { const root = $getRoot().clear(); @@ -188,15 +193,18 @@ describe('TableExtension', () => { {discrete: true}, ); - // Try to paste a table inside the cell + // Try to paste a table inside the cell. Whole table paste (as opposed to a table merge) is done by having multiple nodes + // on the clipboard. editor.update( () => { const tableNode = $createTableNode(); const selection = $getSelection(); - if (selection === null) { - throw new Error('Expected valid selection'); - } - $insertGeneratedNodes(editor, [tableNode], selection); + assert($isRangeSelection(selection), 'Expected range selection'); + $insertGeneratedNodes( + editor, + [tableNode, $createParagraphNode()], + selection, + ); }, {discrete: true}, ); @@ -215,12 +223,69 @@ describe('TableExtension', () => { }); }); + test('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler allows pasting whole table into a single cell when hasNestedTables is true', () => { + const extension = getExtensionDependencyFromEditor( + editor, + TableExtension, + ); + extension.output.hasNestedTables.value = true; + + editor.update( + () => { + const root = $getRoot().clear(); + const table = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + const paragraph = $createParagraphNode(); + cell.append(paragraph); + row.append(cell); + table.append(row); + root.append(table); + paragraph.select(); + }, + {discrete: true}, + ); + + // Try to paste a table inside the cell + editor.update( + () => { + const tableNode = $createTableNode(); + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected range selection'); + $insertGeneratedNodes( + editor, + [tableNode, $createParagraphNode()], + selection, + ); + }, + {discrete: true}, + ); + + // Verify a nested table was created + editor.getEditorState().read(() => { + const root = $getRoot(); + const table = root.getFirstChild(); + assert($isTableNode(table), 'Expected table node'); + const row = table.getFirstChild(); + assert($isElementNode(row), 'Expected row node'); + const cell = row.getFirstChild(); + assert($isElementNode(cell), 'Expected cell node'); + const cellChildren = cell.getChildren(); + expect(cellChildren.some($isTableNode)).toBe(true); + }); + }); + + test.todo( + 'SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler allows pasting whole table into multiple cells when hasNestedTables is true', + ); + test('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler allows extending table when hasNestedTables is true', () => { const extension = getExtensionDependencyFromEditor( editor, TableExtension, ); extension.output.hasNestedTables.value = true; + let destCellKey: string | null = null; editor.update( () => { @@ -228,6 +293,7 @@ describe('TableExtension', () => { const table = $createTableNode(); const row = $createTableRowNode(); const cell = $createTableCellNode(); + destCellKey = cell.getKey(); const paragraph = $createParagraphNode(); cell.append(paragraph); row.append(cell); @@ -354,6 +420,69 @@ describe('TableExtension', () => { expect(table.getColWidths()).toEqual([10, 20]); }); }); + + test('proportionally adjusts colWidths of inner table when pasting a table into another table (with hasNestedTables)', () => { + const extension = getExtensionDependencyFromEditor( + editor, + TableExtension, + ); + extension.output.hasNestedTables.value = true; + + editor.update( + () => { + const root = $getRoot().clear(); + const table = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + const paragraph = $createParagraphNode(); + cell.append(paragraph); + row.append(cell); + table.append(row); + root.append(table); + paragraph.select(); + + table.setColWidths([500]); + }, + {discrete: true}, + ); + + editor.update( + () => { + const tableNode = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + cell.append($createParagraphNode()); + row.append(cell); + tableNode.append(row); + + // Larger than the destination cell. + tableNode.setColWidths([500, 500]); + + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected range selection'); + $insertGeneratedNodes( + editor, + [tableNode, $createParagraphNode()], + selection, + ); + }, + {discrete: true}, + ); + + // Verify a nested table was created + editor.getEditorState().read(() => { + const root = $getRoot(); + const table = root.getFirstChild(); + assert($isTableNode(table), 'Expected outer table node'); + const row = table.getFirstChild(); + assert($isElementNode(row), 'Expected outer row node'); + const cell = row.getFirstChild(); + assert($isElementNode(cell), 'Expected outer cell node'); + const [tableNode] = cell.getChildren(); + assert($isTableNode(tableNode), 'Expected inner table node'); + expect(tableNode.getColWidths()).toEqual([250, 250]); + }); + }); }); describe('SELECT_ALL_COMMAND', () => { From eb7997aee27e3956c2216edccb6ab3bf4ea83600 Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Tue, 27 Jan 2026 09:46:38 +1100 Subject: [PATCH 04/14] Add proportional resizing of top-level table when pasting table into another table --- packages/lexical-playground/src/Editor.tsx | 4 +- packages/lexical-playground/src/Settings.tsx | 16 +- .../lexical-playground/src/appSettings.ts | 2 +- .../lexical-react/src/LexicalTablePlugin.ts | 31 ++- .../src/LexicalTableExtension.ts | 10 +- .../src/LexicalTablePluginHelpers.ts | 176 ++++++++++++++---- .../unit/LexicalTableExtension.test.ts | 88 +++++++-- 7 files changed, 252 insertions(+), 75 deletions(-) diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 1284efb2da1..de3399e360d 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -104,6 +104,7 @@ export default function Editor(): JSX.Element { isCharLimit, hasLinkAttributes, hasNestedTables, + hasFitNestedTables, isCharLimitUtf8, isRichText, showTreeView, @@ -112,7 +113,6 @@ export default function Editor(): JSX.Element { shouldPreserveNewLinesInMarkdown, tableCellMerge, tableCellBackgroundColor, - tableHorizontalScroll, shouldAllowHighlightingWithBrackets, selectionAlwaysOnDisplay, listStrictIndent, @@ -238,7 +238,7 @@ export default function Editor(): JSX.Element { diff --git a/packages/lexical-playground/src/Settings.tsx b/packages/lexical-playground/src/Settings.tsx index 361445cd54c..7688d71d3bf 100644 --- a/packages/lexical-playground/src/Settings.tsx +++ b/packages/lexical-playground/src/Settings.tsx @@ -24,6 +24,7 @@ export default function Settings(): JSX.Element { isCollab, isRichText, hasNestedTables, + hasFitNestedTables, isMaxLength, hasLinkAttributes, isCharLimit, @@ -36,7 +37,6 @@ export default function Settings(): JSX.Element { shouldUseLexicalContextMenu, shouldPreserveNewLinesInMarkdown, shouldAllowHighlightingWithBrackets, - // tableHorizontalScroll, selectionAlwaysOnDisplay, isCodeHighlighted, isCodeShiki, @@ -123,6 +123,13 @@ export default function Settings(): JSX.Element { checked={hasNestedTables} text="Nested Tables" /> + { + setOption('hasFitNestedTables', !hasFitNestedTables); + }} + checked={hasFitNestedTables} + text="Fit nested tables" + /> setOption('isCharLimit', !isCharLimit)} checked={isCharLimit} @@ -183,13 +190,6 @@ export default function Settings(): JSX.Element { checked={shouldPreserveNewLinesInMarkdown} text="Preserve newlines in Markdown" /> - {/* { - setOption('tableHorizontalScroll', !tableHorizontalScroll); - }} - checked={tableHorizontalScroll} - text="Tables have horizontal scroll" - /> */} { setOption( diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts index 1ea2c59eb52..285aaec3831 100644 --- a/packages/lexical-playground/src/appSettings.ts +++ b/packages/lexical-playground/src/appSettings.ts @@ -14,6 +14,7 @@ export const isDevPlayground: boolean = export const DEFAULT_SETTINGS = { disableBeforeInput: false, emptyEditor: isDevPlayground, + hasFitNestedTables: false, hasLinkAttributes: false, hasNestedTables: false, isAutocomplete: false, @@ -35,7 +36,6 @@ export const DEFAULT_SETTINGS = { showTreeView: true, tableCellBackgroundColor: true, tableCellMerge: true, - tableHorizontalScroll: true, useCollabV2: false, } as const; diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index 46fa97f7d37..b7f21a4b1b4 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -8,7 +8,7 @@ import type {JSX} from 'react'; -import {signal} from '@lexical/extension'; +import {Signal, signal} from '@lexical/extension'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { $isScrollableTablesActive, @@ -45,6 +45,12 @@ export interface TablePluginProps { * @experimental Nested tables are not officially supported. */ hasNestedTables?: boolean; + /** + * When `true` (default `false`), nested tables will be resized to fit the width of the parent table cell. + * + * @experimental Nested tables are not officially supported. + */ + hasFitNestedTables?: boolean; } /** @@ -59,6 +65,7 @@ export function TablePlugin({ hasTabHandler = true, hasHorizontalScroll = false, hasNestedTables = false, + hasFitNestedTables = false, }: TablePluginProps): JSX.Element | null { const [editor] = useLexicalComposerContext(); @@ -72,14 +79,16 @@ export function TablePlugin({ } }, [editor, hasHorizontalScroll]); - const hasNestedTablesSignal = useMemo(() => signal(false), []); - if (hasNestedTablesSignal.peek() !== hasNestedTables) { - hasNestedTablesSignal.value = hasNestedTables; - } + const hasNestedTablesSignal = usePropSignal(hasNestedTables); + const hasFitNestedTablesSignal = usePropSignal(hasFitNestedTables); useEffect( - () => registerTablePlugin(editor, {hasNestedTables: hasNestedTablesSignal}), - [editor, hasNestedTablesSignal], + () => + registerTablePlugin(editor, { + hasFitNestedTables: hasFitNestedTablesSignal, + hasNestedTables: hasNestedTablesSignal, + }), + [editor, hasNestedTablesSignal, hasFitNestedTablesSignal], ); useEffect( @@ -108,3 +117,11 @@ export function TablePlugin({ return null; } + +function usePropSignal(value: T): Signal { + const configSignal = useMemo(() => signal(value), [value]); + if (configSignal.peek() !== value) { + configSignal.value = value; + } + return configSignal; +} diff --git a/packages/lexical-table/src/LexicalTableExtension.ts b/packages/lexical-table/src/LexicalTableExtension.ts index a55651b122a..338563711d6 100644 --- a/packages/lexical-table/src/LexicalTableExtension.ts +++ b/packages/lexical-table/src/LexicalTableExtension.ts @@ -47,6 +47,12 @@ export interface TableConfig { * @experimental Nested tables are not officially supported. */ hasNestedTables: boolean; + /** + * When `true` (default `false`), nested tables will be resized to fit the width of the parent table cell. + * + * @experimental Nested tables are not officially supported. + */ + hasFitNestedTables: boolean; } /** @@ -60,6 +66,7 @@ export const TableExtension = defineExtension({ config: safeCast({ hasCellBackgroundColor: true, hasCellMerge: true, + hasFitNestedTables: false, hasHorizontalScroll: true, hasNestedTables: false, hasTabHandler: true, @@ -68,7 +75,6 @@ export const TableExtension = defineExtension({ nodes: () => [TableNode, TableRowNode, TableCellNode], register(editor, config, state) { const stores = state.getOutput(); - const {hasNestedTables} = stores; return mergeRegister( effect(() => { const hasHorizontalScroll = stores.hasHorizontalScroll.value; @@ -80,7 +86,7 @@ export const TableExtension = defineExtension({ editor.registerNodeTransform(TableNode, () => {})(); } }), - registerTablePlugin(editor, {hasNestedTables}), + registerTablePlugin(editor, stores), effect(() => registerTableSelectionObserver(editor, stores.hasTabHandler.value), ), diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index 8a32294fce6..dd831a785e1 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -6,7 +6,7 @@ * */ -import {Signal, signal} from '@lexical/extension'; +import {NamedSignalsOutput, Signal, signal} from '@lexical/extension'; import { $dfs, $findMatchingParent, @@ -17,6 +17,7 @@ import { } from '@lexical/utils'; import { $createParagraphNode, + $getEditor, $getNearestNodeFromDOMNode, $getPreviousSelection, $getRoot, @@ -40,6 +41,7 @@ import { } from 'lexical'; import invariant from 'shared/invariant'; +import {PIXEL_VALUE_REG_EXP} from './constants'; import { $createTableCellNode, $isTableCellNode, @@ -49,6 +51,7 @@ import { INSERT_TABLE_COMMAND, InsertTableCommandPayload, } from './LexicalTableCommands'; +import {TableConfig} from './LexicalTableExtension'; import {$isTableNode, TableNode} from './LexicalTableNode'; import {$getTableAndElementByKey, TableObserver} from './LexicalTableObserver'; import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode'; @@ -394,13 +397,17 @@ export function registerTableSelectionObserver( */ export function registerTablePlugin( editor: LexicalEditor, - options?: {hasNestedTables?: Signal}, + options?: Pick< + NamedSignalsOutput, + 'hasNestedTables' | 'hasFitNestedTables' + >, ): () => void { if (!editor.hasNodes([TableNode])) { invariant(false, 'TablePlugin: TableNode is not registered on editor'); } - const {hasNestedTables = signal(false)} = options ?? {}; + const {hasNestedTables = signal(false), hasFitNestedTables = signal(false)} = + options ?? {}; return mergeRegister( editor.registerCommand( @@ -419,6 +426,7 @@ export function registerTablePlugin( return $tableSelectionInsertClipboardNodesCommand( payload, hasNestedTables, + hasFitNestedTables, ); }, COMMAND_PRIORITY_EDITOR, @@ -444,6 +452,7 @@ function $tableSelectionInsertClipboardNodesCommand( typeof SELECTION_INSERT_CLIPBOARD_NODES_COMMAND >, hasNestedTables: Signal, + hasFitNestedTables: Signal, ) { const {nodes, selection} = selectionPayload; @@ -479,10 +488,14 @@ function $tableSelectionInsertClipboardNodesCommand( // When pasting multiple nodes (including tables) into a cell, update the table to fit. if (isRangeSelection && hasNestedTables.peek()) { - return $insertTableNodesIntoCells(nodes, selection); + return $insertTableNodesIntoCells( + nodes, + selection, + hasFitNestedTables.peek(), + ); } - // If we reached this point, there's a table in the clipboard and nested tables are not allowed - reject the paste. + // If we reached this point, there's a table in the selection and nested tables are not allowed - reject the paste. return true; } @@ -663,6 +676,7 @@ function $insertTableIntoGrid( function $insertTableNodesIntoCells( nodes: LexicalNode[], selection: TableSelection | RangeSelection, + hasFitNestedTables: boolean, ) { // Currently only support pasting into a single cell. In other cases we reject the insertion. const isMultiCellTableSelection = @@ -676,54 +690,134 @@ function $insertTableNodesIntoCells( return true; } - // Determine the width of the cell being pasted into. - const destinationCellNode = $findMatchingParent( - selection.focus.getNode(), - $isTableCellNode, - ); - if (!destinationCellNode) { + if (!hasFitNestedTables) { + return false; + } + + const focusNode = selection.focus.getNode(); + const parentCell = $findMatchingParent(focusNode, $isTableCellNode); + if (!parentCell) { return false; } - const destinationTableNode = - $getTableNodeFromLexicalNodeOrThrow(destinationCellNode); - const columnIndex = - $getTableColumnIndexFromTableCellNode(destinationCellNode); - let cellWidth = destinationCellNode.getWidth(); + const contentBoxWidth = $getCellContentBoxWidth(parentCell); + if (contentBoxWidth === undefined) { + return false; + } + $resizeTablesToFitWidth(nodes, contentBoxWidth); + + return false; +} + +/** + * Return the width of a specific cell, preferring to use the table-level column widths if possible. + */ +function $getCellWidth(cell: TableCellNode) { + const destinationTableNode = $getTableNodeFromLexicalNodeOrThrow(cell); + + const columnIndex = $getTableColumnIndexFromTableCellNode(cell); + // prefer to use table-level colWidths const colWidths = destinationTableNode.getColWidths(); if (colWidths) { - cellWidth = colWidths[columnIndex]; + return colWidths[columnIndex]; } - if (cellWidth === undefined) { - return false; + return cell.getWidth(); +} + +/** + * Returns the content box width (that is, not including padding or border width) of a given cell. + * + * TODO: merged cells? + */ +function $getCellContentBoxWidth(cell: TableCellNode) { + const destinationCellWidth = $getCellWidth(cell); + if (destinationCellWidth === undefined) { + return undefined; + } + + const cellDOM = $getEditor().getElementByKey(cell.getKey()); + if (cellDOM === null) { + // no DOM, return full width of cell. + return destinationCellWidth; + } + const paddingLeft = + window.getComputedStyle(cellDOM).getPropertyValue('padding-left') || '0px'; + const paddingRight = + window.getComputedStyle(cellDOM).getPropertyValue('padding-right') || '0px'; + const borderLeftWidth = + window.getComputedStyle(cellDOM).getPropertyValue('border-left-width') || + '0px'; + const borderRightWidth = + window.getComputedStyle(cellDOM).getPropertyValue('padding-right-width') || + '0px'; + + if ( + !PIXEL_VALUE_REG_EXP.test(paddingLeft) || + !PIXEL_VALUE_REG_EXP.test(paddingRight) || + !PIXEL_VALUE_REG_EXP.test(borderLeftWidth) || + !PIXEL_VALUE_REG_EXP.test(borderRightWidth) + ) { + return undefined; + } + const paddingLeftPx = parseFloat(paddingLeft); + const paddingRightPx = parseFloat(paddingRight); + const borderLeftWidthPx = parseFloat(borderLeftWidth); + const borderRightWidthPx = parseFloat(borderRightWidth); + + return ( + cellDOM.getBoundingClientRect().width - + paddingLeftPx - + paddingRightPx - + borderLeftWidthPx - + borderRightWidthPx + ); +} + +function $getTableWidth(table: TableNode) { + const colWidths = table.getColWidths(); + if (colWidths) { + return colWidths.reduce((curWidth, width) => curWidth + width, 0); } + const tableRow = table.getFirstChild(); - // Recursively find all table nodes in the nodes array (including nested tables) - const tablesToResize: TableNode[] = []; + invariant( + $isTableRowNode(tableRow), + 'Expected first child of a Table to be a TableRowNode', + ); + + // TODO merged cells? + return tableRow + .getChildren() + .filter($isTableCellNode) + .reduce((curWidth, cell) => curWidth + (cell.getWidth() ?? 92), 0); // TODO no width +} - function collectTables(node: LexicalNode): void { - if ($isTableNode(node)) { - tablesToResize.push(node); +/** + * Recursively resizes table cells to fit a given width. + * @param nodes the + * @param width + * @returns + */ +function $resizeTablesToFitWidth(nodes: LexicalNode[], maximumWidth: number) { + return nodes.map((node) => { + if (!$isTableNode(node)) { + return node; } - // Recursively check children for nested tables - if ($isElementNode(node)) { - for (const child of node.getChildren()) { - collectTables(child); - } + const tableWidth = $getTableWidth(node); + if (tableWidth <= maximumWidth) { + return node; } - } - // Collect all tables from the nodes being pasted - for (const node of nodes) { - collectTables(node); - } + const proportionalWidth = maximumWidth / tableWidth; + const oldColWidths = node.getColWidths(); + if (oldColWidths) { + node.setColWidths(oldColWidths.map((width) => width * proportionalWidth)); + } - // Clear column widths on all tables so they fit their container - // When column widths are undefined, tables will auto-size to fit their container - for (const table of tablesToResize) { - table.setColWidths(undefined); - } + if (node.getChildren().some($isTableCellNode)) { + $resizeTablesToFitWidth(node.getChildren(), maximumWidth); + } - // Return false to let normal insertion proceed with the modified nodes - return false; + return node; + }); } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 872d7e1de7e..050ecbcca3d 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -16,8 +16,6 @@ import { $createTableNode, $createTableNodeWithDimensions, $createTableRowNode, - $createTableSelection, - $createTableSelectionFrom, $isTableCellNode, $isTableNode, $isTableRowNode, @@ -25,14 +23,11 @@ import { $mergeCells, INSERT_TABLE_COMMAND, TableExtension, - TableRowNode, type TableNode, } from '@lexical/table'; import { $createParagraphNode, - $createRangeSelection, $createTextNode, - $getNodeByKey, $getRoot, $getSelection, $isElementNode, @@ -285,7 +280,6 @@ describe('TableExtension', () => { TableExtension, ); extension.output.hasNestedTables.value = true; - let destCellKey: string | null = null; editor.update( () => { @@ -293,7 +287,6 @@ describe('TableExtension', () => { const table = $createTableNode(); const row = $createTableRowNode(); const cell = $createTableCellNode(); - destCellKey = cell.getKey(); const paragraph = $createParagraphNode(); cell.append(paragraph); row.append(cell); @@ -421,12 +414,13 @@ describe('TableExtension', () => { }); }); - test('proportionally adjusts colWidths of inner table when pasting a table into another table (with hasNestedTables)', () => { + test('preserves colWidths of inner table when pasting a table into another table (with hasNestedTables=true and hasFitNestedTables=false)', () => { const extension = getExtensionDependencyFromEditor( editor, TableExtension, ); extension.output.hasNestedTables.value = true; + extension.output.hasFitNestedTables.value = false; editor.update( () => { @@ -451,12 +445,78 @@ describe('TableExtension', () => { const tableNode = $createTableNode(); const row = $createTableRowNode(); const cell = $createTableCellNode(); + const cell2 = $createTableCellNode(); cell.append($createParagraphNode()); + row.append(cell, cell2); + tableNode.append(row); + + // The sum is wider than the destination cell. + tableNode.setColWidths([750, 250]); + + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected range selection'); + $insertGeneratedNodes( + editor, + [tableNode, $createParagraphNode()], + selection, + ); + }, + {discrete: true}, + ); + + // Verify a nested table was created, with colWidths preserved. + editor.getEditorState().read(() => { + const root = $getRoot(); + const table = root.getFirstChild(); + assert($isTableNode(table), 'Expected outer table node'); + const row = table.getFirstChild(); + assert($isElementNode(row), 'Expected outer row node'); + const cell = row.getFirstChild(); + assert($isElementNode(cell), 'Expected outer cell node'); + const [innerTableNode] = cell.getChildren(); + assert($isTableNode(innerTableNode), 'Expected inner table node'); + expect(innerTableNode.getColWidths()).toEqual([750, 250]); + }); + }); + + test('proportionally adjusts colWidths of inner table when pasting a table into another table (with hasNestedTables=true and hasFitNestedTables=true)', () => { + const extension = getExtensionDependencyFromEditor( + editor, + TableExtension, + ); + extension.output.hasNestedTables.value = true; + extension.output.hasFitNestedTables.value = true; + + editor.update( + () => { + const root = $getRoot().clear(); + const table = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + const paragraph = $createParagraphNode(); + cell.append(paragraph); row.append(cell); + table.append(row); + root.append(table); + paragraph.select(); + + table.setColWidths([500]); + }, + {discrete: true}, + ); + + editor.update( + () => { + const tableNode = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + const cell2 = $createTableCellNode(); + cell.append($createParagraphNode()); + row.append(cell, cell2); tableNode.append(row); - // Larger than the destination cell. - tableNode.setColWidths([500, 500]); + // The sum is wider than the destination cell. + tableNode.setColWidths([750, 250]); const selection = $getSelection(); assert($isRangeSelection(selection), 'Expected range selection'); @@ -469,7 +529,7 @@ describe('TableExtension', () => { {discrete: true}, ); - // Verify a nested table was created + // Verify a nested table was created, with colWidths updated. editor.getEditorState().read(() => { const root = $getRoot(); const table = root.getFirstChild(); @@ -478,9 +538,9 @@ describe('TableExtension', () => { assert($isElementNode(row), 'Expected outer row node'); const cell = row.getFirstChild(); assert($isElementNode(cell), 'Expected outer cell node'); - const [tableNode] = cell.getChildren(); - assert($isTableNode(tableNode), 'Expected inner table node'); - expect(tableNode.getColWidths()).toEqual([250, 250]); + const [innerTableNode] = cell.getChildren(); + assert($isTableNode(innerTableNode), 'Expected inner table node'); + expect(innerTableNode.getColWidths()).toEqual([375, 125]); }); }); }); From 976e8ed25012628a8d38dc14b239ef330d71b606 Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Tue, 27 Jan 2026 18:05:29 +1100 Subject: [PATCH 05/14] handle nested table cells --- .../src/LexicalTablePluginHelpers.ts | 86 ++++++++++--------- .../unit/LexicalTableExtension.test.ts | 4 - 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index dd831a785e1..a65f685b91c 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -700,11 +700,16 @@ function $insertTableNodesIntoCells( return false; } - const contentBoxWidth = $getCellContentBoxWidth(parentCell); - if (contentBoxWidth === undefined) { + const cellWidth = $getCellWidth(parentCell); + const borderBoxInsets = $calculateCellInsets(parentCell); + if (cellWidth === undefined) { return false; } - $resizeTablesToFitWidth(nodes, contentBoxWidth); + const tables = nodes.filter($isTableNode); + for (const table of tables) { + // Note: here we assume the inset is consistent for cells at all nesting levels. + $resizeTableToFitCell(table, cellWidth, borderBoxInsets); + } return false; } @@ -725,20 +730,14 @@ function $getCellWidth(cell: TableCellNode) { } /** - * Returns the content box width (that is, not including padding or border width) of a given cell. + * Returns horizontal insets of the given cell (padding + border). * * TODO: merged cells? */ -function $getCellContentBoxWidth(cell: TableCellNode) { - const destinationCellWidth = $getCellWidth(cell); - if (destinationCellWidth === undefined) { - return undefined; - } - +function $calculateCellInsets(cell: TableCellNode) { const cellDOM = $getEditor().getElementByKey(cell.getKey()); if (cellDOM === null) { - // no DOM, return full width of cell. - return destinationCellWidth; + return 0; } const paddingLeft = window.getComputedStyle(cellDOM).getPropertyValue('padding-left') || '0px'; @@ -757,7 +756,7 @@ function $getCellContentBoxWidth(cell: TableCellNode) { !PIXEL_VALUE_REG_EXP.test(borderLeftWidth) || !PIXEL_VALUE_REG_EXP.test(borderRightWidth) ) { - return undefined; + return 0; } const paddingLeftPx = parseFloat(paddingLeft); const paddingRightPx = parseFloat(paddingRight); @@ -765,15 +764,11 @@ function $getCellContentBoxWidth(cell: TableCellNode) { const borderRightWidthPx = parseFloat(borderRightWidth); return ( - cellDOM.getBoundingClientRect().width - - paddingLeftPx - - paddingRightPx - - borderLeftWidthPx - - borderRightWidthPx + paddingLeftPx + paddingRightPx + borderLeftWidthPx + borderRightWidthPx ); } -function $getTableWidth(table: TableNode) { +function $getTotalTableWidth(table: TableNode) { const colWidths = table.getColWidths(); if (colWidths) { return colWidths.reduce((curWidth, width) => curWidth + width, 0); @@ -794,30 +789,39 @@ function $getTableWidth(table: TableNode) { /** * Recursively resizes table cells to fit a given width. - * @param nodes the - * @param width - * @returns + * + * @param node the table node to resize + * @param parentCellWidth the width of the parent cell + * @param borderBoxInsets the insets of the parent cell (padding + border) */ -function $resizeTablesToFitWidth(nodes: LexicalNode[], maximumWidth: number) { - return nodes.map((node) => { - if (!$isTableNode(node)) { - return node; - } - const tableWidth = $getTableWidth(node); - if (tableWidth <= maximumWidth) { - return node; - } +function $resizeTableToFitCell( + node: TableNode, + parentCellWidth: number, + borderBoxInsets: number, +) { + const usableWidth = parentCellWidth - borderBoxInsets; + const tableWidth = $getTotalTableWidth(node); + if (tableWidth <= usableWidth) { + return node; + } - const proportionalWidth = maximumWidth / tableWidth; - const oldColWidths = node.getColWidths(); - if (oldColWidths) { - node.setColWidths(oldColWidths.map((width) => width * proportionalWidth)); - } + const proportionalWidth = usableWidth / tableWidth; + const oldColWidths = node.getColWidths(); + if (oldColWidths) { + node.setColWidths(oldColWidths.map((width) => width * proportionalWidth)); + } - if (node.getChildren().some($isTableCellNode)) { - $resizeTablesToFitWidth(node.getChildren(), maximumWidth); + const rowChildren = node.getChildren().filter($isTableRowNode); + for (const rowChild of rowChildren) { + const cellChildren = rowChild.getChildren().filter($isTableCellNode); + for (const cellChild of cellChildren) { + const cellWidth = $getCellWidth(cellChild); + if (cellWidth === undefined) { + continue; + } + for (const table of cellChild.getChildren().filter($isTableNode)) { + $resizeTableToFitCell(table, cellWidth, borderBoxInsets); + } } - - return node; - }); + } } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 050ecbcca3d..88bb1650ae1 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -270,10 +270,6 @@ describe('TableExtension', () => { }); }); - test.todo( - 'SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler allows pasting whole table into multiple cells when hasNestedTables is true', - ); - test('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler allows extending table when hasNestedTables is true', () => { const extension = getExtensionDependencyFromEditor( editor, From 0faa6ebb95a6c08049ad5df482d1725d3ad1c0e6 Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Tue, 27 Jan 2026 18:24:30 +1100 Subject: [PATCH 06/14] add unit test for nested inner table --- .../unit/LexicalTableExtension.test.ts | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 88bb1650ae1..d2b390c2071 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -525,7 +525,7 @@ describe('TableExtension', () => { {discrete: true}, ); - // Verify a nested table was created, with colWidths updated. + // Verify a nested table was created, with colWidths updated. Note: due to no DOM, insets are not calculated. editor.getEditorState().read(() => { const root = $getRoot(); const table = root.getFirstChild(); @@ -536,9 +536,93 @@ describe('TableExtension', () => { assert($isElementNode(cell), 'Expected outer cell node'); const [innerTableNode] = cell.getChildren(); assert($isTableNode(innerTableNode), 'Expected inner table node'); + // Fitting 750, 250 into a 500-wide cell. expect(innerTableNode.getColWidths()).toEqual([375, 125]); }); }); + + test('proportionally adjusts colWidths of all nested inner tables when pasting a table into another table (with hasNestedTables=true and hasFitNestedTables=true)', () => { + const extension = getExtensionDependencyFromEditor( + editor, + TableExtension, + ); + extension.output.hasNestedTables.value = true; + extension.output.hasFitNestedTables.value = true; + + editor.update( + () => { + const root = $getRoot().clear(); + const table = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + const paragraph = $createParagraphNode(); + cell.append(paragraph); + row.append(cell); + table.append(row); + root.append(table); + paragraph.select(); + + table.setColWidths([500]); + }, + {discrete: true}, + ); + + editor.update( + () => { + const tableNode = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + const cell2 = $createTableCellNode(); + row.append(cell, cell2); + tableNode.append(row); + + // The sum is wider than the destination cell. + tableNode.setColWidths([750, 250]); + + const deepTableNode = $createTableNode(); + const deepRow = $createTableRowNode(); + const deepCell = $createTableCellNode(); + const deepCell2 = $createTableCellNode(); + deepRow.append(deepCell, deepCell2); + deepTableNode.append(deepRow); + + deepTableNode.setColWidths([500, 250]); + cell.append(deepTableNode); + + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected range selection'); + $insertGeneratedNodes( + editor, + [tableNode, $createParagraphNode()], + selection, + ); + }, + {discrete: true}, + ); + + // Verify a nested table was created, with colWidths updated. Note: due to no DOM, insets are not calculated. + editor.getEditorState().read(() => { + const root = $getRoot(); + const table = root.getFirstChild(); + assert($isTableNode(table), 'Expected outer table node'); + const row = table.getFirstChild(); + assert($isElementNode(row), 'Expected outer row node'); + const cell = row.getFirstChild(); + assert($isElementNode(cell), 'Expected outer cell node'); + const [middleTableNode] = cell.getChildren(); + assert($isTableNode(middleTableNode), 'Expected middle table node'); + // Fitting 750, 250 into a 500-wide cell. + expect(middleTableNode.getColWidths()).toEqual([375, 125]); + const middleTableRow = middleTableNode.getFirstChild(); + assert($isTableRowNode(middleTableRow), 'Expected middle row node'); + const middleCell = middleTableRow.getFirstChild(); + assert($isTableCellNode(middleCell), 'Expected middle cell node'); + const [deepTableNode] = middleCell.getChildren(); + assert($isTableNode(deepTableNode), 'Expected deep table node'); + // Fitting 500, 250 into what is now a 375-wide cell. + expect(deepTableNode.getColWidths()).toEqual([250, 125]); + }); + }); }); describe('SELECT_ALL_COMMAND', () => { From b6a1d371d759a79b29413af9b0b787481eb4094a Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Tue, 27 Jan 2026 18:32:48 +1100 Subject: [PATCH 07/14] restore tableHorizontalScroll, was used for tests --- packages/lexical-playground/src/Editor.tsx | 2 ++ packages/lexical-playground/src/appSettings.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index de3399e360d..958549b007e 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -113,6 +113,7 @@ export default function Editor(): JSX.Element { shouldPreserveNewLinesInMarkdown, tableCellMerge, tableCellBackgroundColor, + tableHorizontalScroll, shouldAllowHighlightingWithBrackets, selectionAlwaysOnDisplay, listStrictIndent, @@ -238,6 +239,7 @@ export default function Editor(): JSX.Element { diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts index 285aaec3831..a04023ac9e9 100644 --- a/packages/lexical-playground/src/appSettings.ts +++ b/packages/lexical-playground/src/appSettings.ts @@ -36,6 +36,7 @@ export const DEFAULT_SETTINGS = { showTreeView: true, tableCellBackgroundColor: true, tableCellMerge: true, + tableHorizontalScroll: true, useCollabV2: false, } as const; From 15c6d627948562d38b4fd912694d00a98504ec4c Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Wed, 28 Jan 2026 00:28:09 +1100 Subject: [PATCH 08/14] add playwright tests --- .../__tests__/e2e/Tables.spec.mjs | 180 ++++++++++++++++++ .../__tests__/utils/index.mjs | 62 ++++-- 2 files changed, 231 insertions(+), 11 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 69dcad56dbe..e5a89fe1178 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -49,6 +49,7 @@ import { LEGACY_EVENTS, mergeTableCells, pasteFromClipboard, + resizeTableCell, selectCellFromTableCoord, selectCellsFromTableCords, selectFromAdditionalStylesDropdown, @@ -5948,6 +5949,185 @@ test.describe.parallel('Tables', () => { ); }); + test(`Can paste tables inside table cells (with hasNestedTables)`, async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + await initialize({hasNestedTables: true, isCollab, page}); + await focusEditor(page); + + // Create and copy a table + await insertTable(page, 2, 2); + await page.keyboard.type('test inner table'); + await selectAll(page); + await withExclusiveClipboardAccess(async () => { + const clipboard = await copyToClipboard(page); + await page.keyboard.press('Backspace'); + await moveToEditorBeginning(page); + + // Create another table and try to paste the first table into a cell + await insertTable(page, 2, 2); + await click(page, '.PlaygroundEditorTheme__tableCell:first-child'); + await pasteFromClipboard(page, clipboard); + }); + + // Verify that a nested table was pasted into the cell + await assertHTML( + page, + html` +


+ + + + + + + + + + + + + +
+


+ + + + + + + + + + + + + +
+

+ test inner table +

+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + undefined, + {ignoreClasses: true, ignoreDir: true}, + ); + }); + + test(`Can paste and autofit tables inside table cells (with hasNestedTables, hasFitNestedTables)`, async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + await initialize({ + hasFitNestedTables: true, + hasNestedTables: true, + isCollab, + page, + }); + await focusEditor(page); + + // Create and copy a table + await insertTable(page, 2, 2); + + await page.keyboard.type('test inner table'); + + await selectAll(page); + await withExclusiveClipboardAccess(async () => { + const clipboard = await copyToClipboard(page); + await page.keyboard.press('Backspace'); + await moveToEditorBeginning(page); + + // Create another table and try to paste the first table into a cell + await insertTable(page, 2, 2); + // Resize outer table cell (92px default + 50px = 142px) + await resizeTableCell(page, 'tr:nth-child(2) > th:nth-child(1)', 50); + await click( + page, + 'tr:nth-child(2) > th:nth-child(1) > .PlaygroundEditorTheme__paragraph', + ); + + await pasteFromClipboard(page, clipboard); + }); + + // Verify that a nested table was pasted into the cell + await assertHTML( + page, + html` +


+ + + + + + + + + + + + + +
+


+ + + + + + + + + + + + + +
+

+ test inner table +

+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + undefined, + {ignoreClasses: true, ignoreDir: true}, + ); + }); + test(`Click and drag to create selection in Firefox #7245`, async ({ page, isPlainText, diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 91aaa341cb7..ef08aa00960 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -58,17 +58,31 @@ function wrapAndSlowDown(method, delay) { }; } -export function wrapTableHtml(expected, {ignoreClasses = false} = {}) { +export function wrapTableHtml( + expected, + {ignoreClasses = false, ignoreDir = false} = {}, +) { return html` ${expected - .replace( - /]*)(dir="\w+")([^>]*)>/g, - `
`, - ) + .replace(/]*)?>/g, (match, rawAttrs = '') => { + const attrs = [...rawAttrs.matchAll(/(\w+)=["']([^"']*)["']/g)].map( + (m) => [m[1], m[2]], + ); + const dirAttr = attrs.find(([k]) => k === 'dir'); + const divAttrs = [ + dirAttr, + !ignoreClasses && [ + 'class', + 'PlaygroundEditorTheme__tableScrollableWrapper', + ], + ] + .filter(Boolean) + .map(([k, v]) => `${k}="${v}"`); + const tableAttrs = attrs + .filter(([k]) => k !== 'dir') + .map(([k, v]) => `${k}="${v}"`); + return `
`; + }) .replace(/<\/table>/g, '
')} `; } @@ -82,6 +96,7 @@ export async function initialize({ isMaxLength, hasLinkAttributes, hasNestedTables, + hasFitNestedTables, showNestedEditorTreeView, tableCellMerge, tableCellBackgroundColor, @@ -117,6 +132,7 @@ export async function initialize({ appSettings.isMaxLength = !!isMaxLength; appSettings.hasLinkAttributes = !!hasLinkAttributes; appSettings.hasNestedTables = !!hasNestedTables; + appSettings.hasFitNestedTables = !!hasFitNestedTables; if (tableCellMerge !== undefined) { appSettings.tableCellMerge = tableCellMerge; } @@ -230,11 +246,13 @@ async function assertHTMLOnPageOrFrame( expectedHtml, ignoreClasses, ignoreInlineStyles, + ignoreDir, frameName, actualHtmlModificationsCallback = (actualHtml) => actualHtml, ) { const expected = await prettifyHTML(expectedHtml.replace(/\n/gm, ''), { ignoreClasses, + ignoreDir, ignoreInlineStyles, }); return await expect(async () => { @@ -246,6 +264,7 @@ async function assertHTMLOnPageOrFrame( ); let actual = await prettifyHTML(actualHtml.replace(/\n/gm, ''), { ignoreClasses, + ignoreDir, ignoreInlineStyles, }); @@ -283,7 +302,7 @@ export async function assertHTML( page, expectedHtml, expectedHtmlFrameRight = expectedHtml, - {ignoreClasses = false, ignoreInlineStyles = false} = {}, + {ignoreClasses = false, ignoreInlineStyles = false, ignoreDir = false} = {}, actualHtmlModificationsCallback, ) { if (IS_COLLAB) { @@ -293,6 +312,7 @@ export async function assertHTML( expectedHtml, ignoreClasses, ignoreInlineStyles, + ignoreDir, 'left frame', actualHtmlModificationsCallback, ), @@ -301,6 +321,7 @@ export async function assertHTML( expectedHtmlFrameRight, ignoreClasses, ignoreInlineStyles, + ignoreDir, 'right frame', actualHtmlModificationsCallback, ), @@ -311,6 +332,7 @@ export async function assertHTML( expectedHtml, ignoreClasses, ignoreInlineStyles, + ignoreDir, 'page', actualHtmlModificationsCallback, ); @@ -851,7 +873,7 @@ export async function dragImage( export async function prettifyHTML( string, - {ignoreClasses, ignoreInlineStyles} = {}, + {ignoreClasses, ignoreInlineStyles, ignoreDir} = {}, ) { let output = string; @@ -863,6 +885,10 @@ export async function prettifyHTML( output = output.replace(/\sstyle="([^"]*)"/g, ''); } + if (ignoreDir) { + output = output.replace(/\sdir="([^"]*)"/g, ''); + } + output = output.replace(/\s__playwright_target__="[^"]+"/, ''); return await prettier.format(output, { @@ -1031,6 +1057,20 @@ export async function insertTableColumnAfter(page) { await click(page, '.item[data-test-id="table-insert-column-after"]'); } +export async function resizeTableCell(page, selector, width = 0, height = 0) { + await click(page, selector); + const resizerBoundingBox = await selectorBoundingBox( + page, + '.TableCellResizer__resizer:first-child', + ); + const x = resizerBoundingBox.x + resizerBoundingBox.width / 2; + const y = resizerBoundingBox.y + resizerBoundingBox.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + width, y + height); + await page.mouse.up(); +} + export async function mergeTableCells(page) { await clickTableCellActiveButton(page); await click(page, '.item[data-test-id="table-merge-cells"]'); From 0d0fe98fdb9ef6fd30201a5e75360ca09e05b00e Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Wed, 28 Jan 2026 13:10:09 +1100 Subject: [PATCH 09/14] handle merged cells --- .../src/LexicalTablePluginHelpers.ts | 45 ++++++------ .../unit/LexicalTableExtension.test.ts | 68 +++++++++++++++++++ 2 files changed, 87 insertions(+), 26 deletions(-) diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index a65f685b91c..68e70b2fd4d 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -72,7 +72,7 @@ import { $computeTableMapSkipCellCheck, $createTableNodeWithDimensions, $getNodeTriplet, - $getTableColumnIndexFromTableCellNode, + $getTableCellNodeRect, $getTableNodeFromLexicalNodeOrThrow, $insertTableColumnAtNode, $insertTableRowAtNode, @@ -701,10 +701,10 @@ function $insertTableNodesIntoCells( } const cellWidth = $getCellWidth(parentCell); - const borderBoxInsets = $calculateCellInsets(parentCell); if (cellWidth === undefined) { return false; } + const borderBoxInsets = $calculateCellInsets(parentCell); const tables = nodes.filter($isTableNode); for (const table of tables) { // Note: here we assume the inset is consistent for cells at all nesting levels. @@ -715,24 +715,26 @@ function $insertTableNodesIntoCells( } /** - * Return the width of a specific cell, preferring to use the table-level column widths if possible. + * Return the width of a specific cell, using the table-level colWidths. */ function $getCellWidth(cell: TableCellNode) { const destinationTableNode = $getTableNodeFromLexicalNodeOrThrow(cell); - const columnIndex = $getTableColumnIndexFromTableCellNode(cell); - // prefer to use table-level colWidths + const cellRect = $getTableCellNodeRect(cell); const colWidths = destinationTableNode.getColWidths(); - if (colWidths) { - return colWidths[columnIndex]; + if (!cellRect || !colWidths) { + return undefined; + } + const {columnIndex, colSpan} = cellRect; + let totalWidth = 0; + for (let i = columnIndex; i < columnIndex + colSpan; i++) { + totalWidth += colWidths[i]; } - return cell.getWidth(); + return totalWidth; } /** * Returns horizontal insets of the given cell (padding + border). - * - * TODO: merged cells? */ function $calculateCellInsets(cell: TableCellNode) { const cellDOM = $getEditor().getElementByKey(cell.getKey()); @@ -770,27 +772,14 @@ function $calculateCellInsets(cell: TableCellNode) { function $getTotalTableWidth(table: TableNode) { const colWidths = table.getColWidths(); - if (colWidths) { - return colWidths.reduce((curWidth, width) => curWidth + width, 0); - } - const tableRow = table.getFirstChild(); - - invariant( - $isTableRowNode(tableRow), - 'Expected first child of a Table to be a TableRowNode', - ); - - // TODO merged cells? - return tableRow - .getChildren() - .filter($isTableCellNode) - .reduce((curWidth, cell) => curWidth + (cell.getWidth() ?? 92), 0); // TODO no width + invariant(!!colWidths, 'Tables without colWidths are not supported'); + return colWidths.reduce((curWidth, width) => curWidth + width, 0); } /** * Recursively resizes table cells to fit a given width. * - * @param node the table node to resize + * @param node the table node to resize. The table must have colWidths to be resized. * @param parentCellWidth the width of the parent cell * @param borderBoxInsets the insets of the parent cell (padding + border) */ @@ -799,6 +788,10 @@ function $resizeTableToFitCell( parentCellWidth: number, borderBoxInsets: number, ) { + if (node.getColWidths() === undefined) { + return node; + } + const usableWidth = parentCellWidth - borderBoxInsets; const tableWidth = $getTotalTableWidth(node); if (tableWidth <= usableWidth) { diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index d2b390c2071..19a6decb966 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -623,6 +623,74 @@ describe('TableExtension', () => { expect(deepTableNode.getColWidths()).toEqual([250, 125]); }); }); + + test('proportionally adjusts colWidths of inner table when pasting a table into a merged cell (with hasNestedTables=true and hasFitNestedTables=true)', () => { + const extension = getExtensionDependencyFromEditor( + editor, + TableExtension, + ); + extension.output.hasNestedTables.value = true; + extension.output.hasFitNestedTables.value = true; + + editor.update( + () => { + const root = $getRoot().clear(); + const table = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + const cell2 = $createTableCellNode(); + const paragraph = $createParagraphNode(); + cell.append(paragraph); + row.append(cell, cell2); + table.append(row); + root.append(table); + paragraph.select(); + + table.setColWidths([250, 250]); + $mergeCells([cell, cell2]); + }, + {discrete: true}, + ); + + editor.update( + () => { + const tableNode = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + const cell2 = $createTableCellNode(); + cell.append($createParagraphNode()); + row.append(cell, cell2); + tableNode.append(row); + + // The sum is wider than the destination cell. + tableNode.setColWidths([750, 250]); + + const selection = $getSelection(); + assert($isRangeSelection(selection), 'Expected range selection'); + $insertGeneratedNodes( + editor, + [tableNode, $createParagraphNode()], + selection, + ); + }, + {discrete: true}, + ); + + // Verify a nested table was created, with colWidths updated. Note: due to no DOM, insets are not calculated. + editor.getEditorState().read(() => { + const root = $getRoot(); + const table = root.getFirstChild(); + assert($isTableNode(table), 'Expected outer table node'); + const row = table.getFirstChild(); + assert($isElementNode(row), 'Expected outer row node'); + const cell = row.getFirstChild(); + assert($isElementNode(cell), 'Expected outer cell node'); + const [innerTableNode] = cell.getChildren(); + assert($isTableNode(innerTableNode), 'Expected inner table node'); + // Fitting 750, 250 into a 500-wide cell. + expect(innerTableNode.getColWidths()).toEqual([375, 125]); + }); + }); }); describe('SELECT_ALL_COMMAND', () => { From 9d80ab8d7ea0ebd60da42dd61d49a1461a1d8089 Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Mon, 2 Feb 2026 14:41:56 +1100 Subject: [PATCH 10/14] review: use isTableRowNode/isTableCellNode where appropriate --- .../unit/LexicalTableExtension.test.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 19a6decb966..63fbfe4fc0f 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -119,9 +119,9 @@ describe('TableExtension', () => { const table = root.getFirstChild(); assert($isTableNode(table), 'Expected table node'); const row = table.getFirstChild(); - assert($isElementNode(row), 'Expected row node'); + assert($isTableRowNode(row), 'Expected row node'); const cell = row.getFirstChild(); - assert($isElementNode(cell), 'Expected cell node'); + assert($isTableCellNode(cell), 'Expected cell node'); const cellChildren = cell.getChildren(); expect(cellChildren.some($isTableNode)).toBe(false); }); @@ -162,9 +162,9 @@ describe('TableExtension', () => { const table = root.getFirstChild(); assert($isTableNode(table), 'Expected table node'); const row = table.getFirstChild(); - assert($isElementNode(row), 'Expected row node'); + assert($isTableRowNode(row), 'Expected row node'); const cell = row.getFirstChild(); - assert($isElementNode(cell), 'Expected cell node'); + assert($isTableCellNode(cell), 'Expected cell node'); const cellChildren = cell.getChildren(); expect(cellChildren.some($isTableNode)).toBe(true); }); @@ -210,9 +210,9 @@ describe('TableExtension', () => { const table = root.getFirstChild(); assert($isTableNode(table), 'Expected table node'); const row = table.getFirstChild(); - assert($isElementNode(row), 'Expected row node'); + assert($isTableRowNode(row), 'Expected row node'); const cell = row.getFirstChild(); - assert($isElementNode(cell), 'Expected cell node'); + assert($isTableCellNode(cell), 'Expected cell node'); const cellChildren = cell.getChildren(); expect(cellChildren.some($isTableNode)).toBe(false); }); @@ -262,9 +262,9 @@ describe('TableExtension', () => { const table = root.getFirstChild(); assert($isTableNode(table), 'Expected table node'); const row = table.getFirstChild(); - assert($isElementNode(row), 'Expected row node'); + assert($isTableRowNode(row), 'Expected row node'); const cell = row.getFirstChild(); - assert($isElementNode(cell), 'Expected cell node'); + assert($isTableCellNode(cell), 'Expected cell node'); const cellChildren = cell.getChildren(); expect(cellChildren.some($isTableNode)).toBe(true); }); @@ -466,9 +466,9 @@ describe('TableExtension', () => { const table = root.getFirstChild(); assert($isTableNode(table), 'Expected outer table node'); const row = table.getFirstChild(); - assert($isElementNode(row), 'Expected outer row node'); + assert($isTableRowNode(row), 'Expected outer row node'); const cell = row.getFirstChild(); - assert($isElementNode(cell), 'Expected outer cell node'); + assert($isTableCellNode(cell), 'Expected outer cell node'); const [innerTableNode] = cell.getChildren(); assert($isTableNode(innerTableNode), 'Expected inner table node'); expect(innerTableNode.getColWidths()).toEqual([750, 250]); @@ -531,9 +531,9 @@ describe('TableExtension', () => { const table = root.getFirstChild(); assert($isTableNode(table), 'Expected outer table node'); const row = table.getFirstChild(); - assert($isElementNode(row), 'Expected outer row node'); + assert($isTableRowNode(row), 'Expected outer row node'); const cell = row.getFirstChild(); - assert($isElementNode(cell), 'Expected outer cell node'); + assert($isTableCellNode(cell), 'Expected outer cell node'); const [innerTableNode] = cell.getChildren(); assert($isTableNode(innerTableNode), 'Expected inner table node'); // Fitting 750, 250 into a 500-wide cell. @@ -606,9 +606,9 @@ describe('TableExtension', () => { const table = root.getFirstChild(); assert($isTableNode(table), 'Expected outer table node'); const row = table.getFirstChild(); - assert($isElementNode(row), 'Expected outer row node'); + assert($isTableRowNode(row), 'Expected outer row node'); const cell = row.getFirstChild(); - assert($isElementNode(cell), 'Expected outer cell node'); + assert($isTableCellNode(cell), 'Expected outer cell node'); const [middleTableNode] = cell.getChildren(); assert($isTableNode(middleTableNode), 'Expected middle table node'); // Fitting 750, 250 into a 500-wide cell. @@ -682,9 +682,9 @@ describe('TableExtension', () => { const table = root.getFirstChild(); assert($isTableNode(table), 'Expected outer table node'); const row = table.getFirstChild(); - assert($isElementNode(row), 'Expected outer row node'); + assert($isTableRowNode(row), 'Expected outer row node'); const cell = row.getFirstChild(); - assert($isElementNode(cell), 'Expected outer cell node'); + assert($isTableCellNode(cell), 'Expected outer cell node'); const [innerTableNode] = cell.getChildren(); assert($isTableNode(innerTableNode), 'Expected inner table node'); // Fitting 750, 250 into a 500-wide cell. @@ -707,7 +707,7 @@ describe('TableExtension', () => { const firstCell = firstRow.getFirstChild(); assert($isTableCellNode(firstCell), 'Expected first cell'); const paragraph = firstCell.getFirstChild(); - assert($isElementNode(paragraph), 'Expected paragraph in cell'); + assert($isParagraphNode(paragraph), 'Expected paragraph in cell'); paragraph.selectStart(); }, {discrete: true}, From e7603b5cc76aca9582abf858639c4a58b3c5fd7b Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Mon, 2 Feb 2026 17:31:57 +1100 Subject: [PATCH 11/14] review: use state instead of memo for signal --- packages/lexical-react/src/LexicalTablePlugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index b7f21a4b1b4..4b72ae4eef6 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -19,7 +19,7 @@ import { TableCellNode, TableNode, } from '@lexical/table'; -import {useEffect, useMemo} from 'react'; +import {useEffect, useState} from 'react'; export interface TablePluginProps { /** @@ -119,7 +119,7 @@ export function TablePlugin({ } function usePropSignal(value: T): Signal { - const configSignal = useMemo(() => signal(value), [value]); + const [configSignal] = useState(() => signal(value)); if (configSignal.peek() !== value) { configSignal.value = value; } From 5b5066b4206fe280f12eca93cb4d0336bca9f11b Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Mon, 2 Feb 2026 17:33:03 +1100 Subject: [PATCH 12/14] review: use non-degenerate tables for test --- .../src/__tests__/unit/LexicalTableExtension.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 63fbfe4fc0f..3525da4119c 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -192,7 +192,11 @@ describe('TableExtension', () => { // on the clipboard. editor.update( () => { - const tableNode = $createTableNode(); + const tableNode = $createTableNode().append( + $createTableRowNode().append( + $createTableCellNode().append($createParagraphNode()), + ), + ); const selection = $getSelection(); assert($isRangeSelection(selection), 'Expected range selection'); $insertGeneratedNodes( @@ -244,7 +248,11 @@ describe('TableExtension', () => { // Try to paste a table inside the cell editor.update( () => { - const tableNode = $createTableNode(); + const tableNode = $createTableNode().append( + $createTableRowNode().append( + $createTableCellNode().append($createParagraphNode()), + ), + ); const selection = $getSelection(); assert($isRangeSelection(selection), 'Expected range selection'); $insertGeneratedNodes( From 955f11aeadd064b0868abe140997887d147d0cb4 Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Mon, 2 Feb 2026 17:33:15 +1100 Subject: [PATCH 13/14] review: reuse computedStyle --- .../lexical-table/src/LexicalTablePluginHelpers.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index 68e70b2fd4d..e4c6adaba81 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -741,16 +741,13 @@ function $calculateCellInsets(cell: TableCellNode) { if (cellDOM === null) { return 0; } - const paddingLeft = - window.getComputedStyle(cellDOM).getPropertyValue('padding-left') || '0px'; - const paddingRight = - window.getComputedStyle(cellDOM).getPropertyValue('padding-right') || '0px'; + const computedStyle = window.getComputedStyle(cellDOM); + const paddingLeft = computedStyle.getPropertyValue('padding-left') || '0px'; + const paddingRight = computedStyle.getPropertyValue('padding-right') || '0px'; const borderLeftWidth = - window.getComputedStyle(cellDOM).getPropertyValue('border-left-width') || - '0px'; + computedStyle.getPropertyValue('border-left-width') || '0px'; const borderRightWidth = - window.getComputedStyle(cellDOM).getPropertyValue('padding-right-width') || - '0px'; + computedStyle.getPropertyValue('padding-right-width') || '0px'; if ( !PIXEL_VALUE_REG_EXP.test(paddingLeft) || From 28b5e676195816280403ddbc5a829b383d741ea5 Mon Sep 17 00:00:00 2001 From: Randal Grant Date: Mon, 2 Feb 2026 17:35:04 +1100 Subject: [PATCH 14/14] review: get old colWidths once --- .../lexical-table/src/LexicalTablePluginHelpers.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index e4c6adaba81..f3bf4e0eadf 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -767,9 +767,7 @@ function $calculateCellInsets(cell: TableCellNode) { ); } -function $getTotalTableWidth(table: TableNode) { - const colWidths = table.getColWidths(); - invariant(!!colWidths, 'Tables without colWidths are not supported'); +function $getTotalTableWidth(colWidths: readonly number[]) { return colWidths.reduce((curWidth, width) => curWidth + width, 0); } @@ -785,21 +783,19 @@ function $resizeTableToFitCell( parentCellWidth: number, borderBoxInsets: number, ) { - if (node.getColWidths() === undefined) { + const oldColWidths = node.getColWidths(); + if (!oldColWidths) { return node; } const usableWidth = parentCellWidth - borderBoxInsets; - const tableWidth = $getTotalTableWidth(node); + const tableWidth = $getTotalTableWidth(oldColWidths); if (tableWidth <= usableWidth) { return node; } const proportionalWidth = usableWidth / tableWidth; - const oldColWidths = node.getColWidths(); - if (oldColWidths) { - node.setColWidths(oldColWidths.map((width) => width * proportionalWidth)); - } + node.setColWidths(oldColWidths.map((width) => width * proportionalWidth)); const rowChildren = node.getChildren().filter($isTableRowNode); for (const rowChild of rowChildren) {