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"]'); diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 1284efb2da1..958549b007e 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, @@ -239,6 +240,7 @@ export default function Editor(): JSX.Element { hasCellMerge={tableCellMerge} hasCellBackgroundColor={tableCellBackgroundColor} hasHorizontalScroll={tableHorizontalScroll} + hasFitNestedTables={hasFitNestedTables} hasNestedTables={hasNestedTables} /> 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..a04023ac9e9 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, diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index 46fa97f7d37..4b72ae4eef6 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, @@ -19,7 +19,7 @@ import { TableCellNode, TableNode, } from '@lexical/table'; -import {useEffect, useMemo} from 'react'; +import {useEffect, useState} from 'react'; export interface TablePluginProps { /** @@ -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] = useState(() => signal(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 a5b80a3b522..f3bf4e0eadf 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -6,8 +6,9 @@ * */ -import {Signal, signal} from '@lexical/extension'; +import {NamedSignalsOutput, Signal, signal} from '@lexical/extension'; import { + $dfs, $findMatchingParent, $insertFirst, $insertNodeToNearestRoot, @@ -16,6 +17,7 @@ import { } from '@lexical/utils'; import { $createParagraphNode, + $getEditor, $getNearestNodeFromDOMNode, $getPreviousSelection, $getRoot, @@ -31,12 +33,15 @@ import { ElementNode, isDOMNode, LexicalEditor, + LexicalNode, NodeKey, + RangeSelection, SELECT_ALL_COMMAND, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, } from 'lexical'; import invariant from 'shared/invariant'; +import {PIXEL_VALUE_REG_EXP} from './constants'; import { $createTableCellNode, $isTableCellNode, @@ -46,12 +51,14 @@ 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'; import { $createTableSelectionFrom, $isTableSelection, + TableSelection, } from './LexicalTableSelection'; import { $findTableNode, @@ -65,6 +72,8 @@ import { $computeTableMapSkipCellCheck, $createTableNodeWithDimensions, $getNodeTriplet, + $getTableCellNodeRect, + $getTableNodeFromLexicalNodeOrThrow, $insertTableColumnAtNode, $insertTableRowAtNode, $mergeCells, @@ -388,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( @@ -406,24 +419,15 @@ 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, + hasFitNestedTables, + ); }, COMMAND_PRIORITY_EDITOR, ), @@ -447,9 +451,19 @@ function $tableSelectionInsertClipboardNodesCommand( selectionPayload: CommandPayloadType< typeof SELECTION_INSERT_CLIPBOARD_NODES_COMMAND >, + hasNestedTables: Signal, + hasFitNestedTables: Signal, ) { const {nodes, selection} = selectionPayload; - const anchorAndFocus = selection.getStartEndPoints(); + + 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; + } + const isTableSelection = $isTableSelection(selection); const isRangeSelection = $isRangeSelection(selection); const isSelectionInsideOfGrid = @@ -462,12 +476,37 @@ 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; + } + + // 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 $insertTableIntoGrid(nodes[0], selection); + } + + // When pasting multiple nodes (including tables) into a cell, update the table to fit. + if (isRangeSelection && hasNestedTables.peek()) { + return $insertTableNodesIntoCells( + nodes, + selection, + hasFitNestedTables.peek(), + ); + } + + // If we reached this point, there's a table in the selection and nested tables are not allowed - reject the paste. + return true; +} + +function $insertTableIntoGrid( + tableNode: TableNode, + selection: RangeSelection | TableSelection, +) { + const anchorAndFocus = selection.getStartEndPoints(); + const isTableSelection = $isTableSelection(selection); + + if (anchorAndFocus === null) { return false; } @@ -486,14 +525,13 @@ function $tableSelectionInsertClipboardNodesCommand( return false; } - const templateGrid = nodes[0]; const [initialGridMap, anchorCellMap, focusCellMap] = $computeTableMap( gridNode, anchorCellNode, focusCellNode, ); const [templateGridMap] = $computeTableMapSkipCellCheck( - templateGrid, + tableNode, null, null, ); @@ -633,3 +671,143 @@ function $tableSelectionInsertClipboardNodesCommand( return true; } + +// Inserts the given nodes (which will include TableNodes) into the table at the given selection. +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 = + $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; + } + + if (!hasFitNestedTables) { + return false; + } + + const focusNode = selection.focus.getNode(); + const parentCell = $findMatchingParent(focusNode, $isTableCellNode); + if (!parentCell) { + return false; + } + + const cellWidth = $getCellWidth(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. + $resizeTableToFitCell(table, cellWidth, borderBoxInsets); + } + + return false; +} + +/** + * Return the width of a specific cell, using the table-level colWidths. + */ +function $getCellWidth(cell: TableCellNode) { + const destinationTableNode = $getTableNodeFromLexicalNodeOrThrow(cell); + + const cellRect = $getTableCellNodeRect(cell); + const colWidths = destinationTableNode.getColWidths(); + if (!cellRect || !colWidths) { + return undefined; + } + const {columnIndex, colSpan} = cellRect; + let totalWidth = 0; + for (let i = columnIndex; i < columnIndex + colSpan; i++) { + totalWidth += colWidths[i]; + } + return totalWidth; +} + +/** + * Returns horizontal insets of the given cell (padding + border). + */ +function $calculateCellInsets(cell: TableCellNode) { + const cellDOM = $getEditor().getElementByKey(cell.getKey()); + if (cellDOM === null) { + return 0; + } + const computedStyle = window.getComputedStyle(cellDOM); + const paddingLeft = computedStyle.getPropertyValue('padding-left') || '0px'; + const paddingRight = computedStyle.getPropertyValue('padding-right') || '0px'; + const borderLeftWidth = + computedStyle.getPropertyValue('border-left-width') || '0px'; + const borderRightWidth = + computedStyle.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 0; + } + const paddingLeftPx = parseFloat(paddingLeft); + const paddingRightPx = parseFloat(paddingRight); + const borderLeftWidthPx = parseFloat(borderLeftWidth); + const borderRightWidthPx = parseFloat(borderRightWidth); + + return ( + paddingLeftPx + paddingRightPx + borderLeftWidthPx + borderRightWidthPx + ); +} + +function $getTotalTableWidth(colWidths: readonly number[]) { + return colWidths.reduce((curWidth, width) => curWidth + width, 0); +} + +/** + * Recursively resizes table cells to fit a given width. + * + * @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) + */ +function $resizeTableToFitCell( + node: TableNode, + parentCellWidth: number, + borderBoxInsets: number, +) { + const oldColWidths = node.getColWidths(); + if (!oldColWidths) { + return node; + } + + const usableWidth = parentCellWidth - borderBoxInsets; + const tableWidth = $getTotalTableWidth(oldColWidths); + if (tableWidth <= usableWidth) { + return node; + } + + const proportionalWidth = usableWidth / tableWidth; + node.setColWidths(oldColWidths.map((width) => width * proportionalWidth)); + + 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); + } + } + } +} diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 6d8d53c192f..3525da4119c 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,16 +162,16 @@ 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); }); }); 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 +188,22 @@ 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 tableNode = $createTableNode().append( + $createTableRowNode().append( + $createTableCellNode().append($createParagraphNode()), + ), + ); 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}, ); @@ -207,14 +214,70 @@ 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); }); }); + 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().append( + $createTableRowNode().append( + $createTableCellNode().append($createParagraphNode()), + ), + ); + 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($isTableRowNode(row), 'Expected row node'); + const cell = row.getFirstChild(); + assert($isTableCellNode(cell), 'Expected cell node'); + const cellChildren = cell.getChildren(); + expect(cellChildren.some($isTableNode)).toBe(true); + }); + }); + test('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler allows extending table when hasNestedTables is true', () => { const extension = getExtensionDependencyFromEditor( editor, @@ -354,6 +417,288 @@ describe('TableExtension', () => { expect(table.getColWidths()).toEqual([10, 20]); }); }); + + 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( + () => { + 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); + + // 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($isTableRowNode(row), 'Expected outer row node'); + const cell = row.getFirstChild(); + assert($isTableCellNode(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); + + // 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($isTableRowNode(row), 'Expected outer row node'); + const cell = row.getFirstChild(); + 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. + 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($isTableRowNode(row), 'Expected outer row node'); + const cell = row.getFirstChild(); + 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. + 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]); + }); + }); + + 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($isTableRowNode(row), 'Expected outer row node'); + const cell = row.getFirstChild(); + 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. + expect(innerTableNode.getColWidths()).toEqual([375, 125]); + }); + }); }); describe('SELECT_ALL_COMMAND', () => { @@ -370,7 +715,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},