diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index ae0e6a82e3a..3563d2d414a 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -192,8 +192,8 @@ test.describe.parallel('Tables', () => { }); test.describe - .parallel(`Can exit tables with the horizontal arrow keys`, () => { - test(`Can exit the first cell of a non-nested table`, async ({ + .parallel(`Can exit table with the horizontal arrow keys`, () => { + test(`Can exit the first cell of a table`, async ({ page, isPlainText, isCollab, @@ -239,7 +239,7 @@ test.describe.parallel('Tables', () => { }); }); - test(`Can exit the last cell of a non-nested table`, async ({ + test(`Can exit the last cell of a table`, async ({ page, isPlainText, isCollab, @@ -284,62 +284,9 @@ test.describe.parallel('Tables', () => { }); }); - test(`Can exit the first cell of a nested table into the parent table cell`, async ({ - page, - isPlainText, - isCollab, - }) => { - test.skip(isPlainText); - await initialize({isCollab, page}); - - await focusEditor(page); - await insertTable(page, 2, 2); - await insertTable(page, 2, 2); - - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1, ...WRAPPER, 1, 0, 1, ...WRAPPER, 1, 0, 0], - focusOffset: 0, - focusPath: [1, ...WRAPPER, 1, 0, 1, ...WRAPPER, 1, 0, 0], - }); - - await moveLeft(page, 1); - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1, ...WRAPPER, 1, 0, 0], - focusOffset: 0, - focusPath: [1, ...WRAPPER, 1, 0, 0], - }); - }); - - test(`Can exit the last cell of a nested table into the parent table cell`, async ({ - page, - isPlainText, - isCollab, - }) => { - test.skip(isPlainText); - await initialize({isCollab, page}); - - await focusEditor(page); - await insertTable(page, 2, 2); - await insertTable(page, 2, 2); - - await moveRight(page, 3); - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1, ...WRAPPER, 1, 0, 1, ...WRAPPER, 2, 1, 0], - focusOffset: 0, - focusPath: [1, ...WRAPPER, 1, 0, 1, ...WRAPPER, 2, 1, 0], - }); - - await moveRight(page, 1); - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [1, ...WRAPPER, 1, 0, 2], - focusOffset: 0, - focusPath: [1, ...WRAPPER, 1, 0, 2], - }); - }); + // Note: Tests for nested table navigation ("Can exit the first/last cell of a nested table into the parent table cell") + // have been removed since nested tables are no longer supported. + // See: https://github.com/facebook/lexical/issues/7154 }); test(`Can insert a paragraph after a table, that is the last node, with the "Enter" key`, async ({ @@ -5587,4 +5534,110 @@ test.describe.parallel('Tables', () => { ); }); }); + + test(`Cannot insert nested tables`, async ({page, isPlainText, isCollab}) => { + test.skip(isPlainText); + await initialize({isCollab, page}); + await focusEditor(page); + + // Insert a table + await insertTable(page, 2, 2); + + // Focus inside the first cell + await click(page, '.PlaygroundEditorTheme__tableCell:first-child'); + + // Try to insert another table inside the cell + await insertTable(page, 2, 2); + + // Verify no nested table was created + await assertHTML( + page, + html` +


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


+
+


+
+


+
+


+
+


+ `, + undefined, + {ignoreClasses: true}, + ); + }); + + test(`Cannot paste tables inside table cells`, async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + await initialize({isCollab, page}); + await focusEditor(page); + + // Create and copy a table + await insertTable(page, 2, 2); + await page.keyboard.type('test'); + 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 no content was pasted into the cell + await assertHTML( + page, + html` +


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


+
+


+
+


+
+


+
+


+ `, + undefined, + {ignoreClasses: true}, + ); + }); }); diff --git a/packages/lexical-table/README.md b/packages/lexical-table/README.md index abefc449c91..eae4a673ea9 100644 --- a/packages/lexical-table/README.md +++ b/packages/lexical-table/README.md @@ -4,4 +4,64 @@ This package contains the functionality for the Tables feature of Lexical. -More documentation coming soon. +# Lexical Table Plugin + +A plugin for handling tables in Lexical. + +## Installation + +```bash +npm install @lexical/table +``` + +## Usage + +```js +import {TablePlugin} from '@lexical/table'; + +// In your editor +const editor = createEditor({ + // ...other config + nodes: [...TablePlugin.nodes], +}); + +// In your React component +function MyEditor() { + return ( + +
+ + +
+
+ ); +} +``` + +## Features + +### Tables +- Create and edit tables with customizable rows and columns +- Support for table headers +- Cell selection and navigation +- Copy and paste support + +### Limitations + +#### Nested Tables +Nested tables (tables within table cells) are not supported in the editor. The following behaviors are enforced: + +1. When attempting to paste a table inside an existing table cell, the paste operation is blocked. +2. The editor actively prevents the creation of nested tables through the UI or programmatically. + +Note: When pasting HTML content with nested tables, the nested content will be removed by default. Make sure to implement appropriate `importDOM` handling if you need to preserve this content in some form. + +This approach allows you to: +1. Detect nested tables in the imported HTML +2. Extract their content before it gets removed +3. Preserve the content in a format that works for your use case + +Choose an approach that best fits your needs: +- Flatten nested tables into a single table +- Convert nested tables to a different format (e.g., lists or paragraphs) +- Store nested content as metadata for future processing \ No newline at end of file diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts index ae7ee4547e2..4d5a1a64cdb 100644 --- a/packages/lexical-table/src/LexicalTablePluginHelpers.ts +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -14,10 +14,13 @@ import { } from '@lexical/utils'; import { $createParagraphNode, + $getSelection, + $isRangeSelection, $isTextNode, COMMAND_PRIORITY_EDITOR, LexicalEditor, NodeKey, + SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, } from 'lexical'; import invariant from 'shared/invariant'; @@ -34,6 +37,7 @@ import {$isTableNode, TableNode} from './LexicalTableNode'; import {$getTableAndElementByKey, TableObserver} from './LexicalTableObserver'; import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode'; import { + $findTableNode, applyTableHandlers, getTableElement, HTMLTableElementWithWithTableSelectionState, @@ -50,6 +54,16 @@ function $insertTableCommandListener({ columns, includeHeaders, }: InsertTableCommandPayload): boolean { + const selection = $getSelection(); + if (!selection || !$isRangeSelection(selection)) { + return false; + } + + // Prevent nested tables by checking if we're already inside a table + if ($findTableNode(selection.anchor.getNode())) { + return false; + } + const tableNode = $createTableNodeWithDimensions( Number(rows), Number(columns), @@ -268,6 +282,18 @@ export function registerTablePlugin(editor: LexicalEditor): () => void { $insertTableCommandListener, COMMAND_PRIORITY_EDITOR, ), + editor.registerCommand( + SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, + ({nodes, selection}) => { + if (!$isRangeSelection(selection)) { + return false; + } + const isInsideTableCell = + $findTableNode(selection.anchor.getNode()) !== null; + return isInsideTableCell && nodes.some($isTableNode); + }, + COMMAND_PRIORITY_EDITOR, + ), editor.registerNodeTransform(TableNode, $tableTransform), editor.registerNodeTransform(TableRowNode, $tableRowTransform), editor.registerNodeTransform(TableCellNode, $tableCellTransform), diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTablePlugin.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTablePlugin.test.tsx new file mode 100644 index 00000000000..ee813acf839 --- /dev/null +++ b/packages/lexical-table/src/__tests__/unit/LexicalTablePlugin.test.tsx @@ -0,0 +1,128 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $createTableCellNode, + $createTableNode, + $createTableRowNode, + $isTableNode, + INSERT_TABLE_COMMAND, + TableCellNode, + TableNode, + TableRowNode, +} from '@lexical/table'; +import { + $getRoot, + $getSelection, + $isElementNode, + createEditor, + LexicalEditor, + SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, +} from 'lexical'; + +describe('LexicalTablePlugin', () => { + let editor: LexicalEditor; + + beforeEach(async () => { + const testConfig = { + namespace: 'test', + nodes: [TableNode, TableCellNode, TableRowNode], + onError: (error: Error) => { + throw error; + }, + theme: {}, + }; + editor = createEditor(testConfig); + editor._headless = true; + }); + + test('INSERT_TABLE_COMMAND handler prevents nested tables', async () => { + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + row.append(cell); + table.append(row); + root.append(table); + cell.select(); + }); + + // Try to insert a table inside the cell + await editor.update(() => { + editor.dispatchCommand(INSERT_TABLE_COMMAND, { + columns: '2', + rows: '2', + }); + }); + + // Verify no nested table was created + await editor.getEditorState().read(() => { + const root = $getRoot(); + const table = root.getFirstChild(); + if (!$isTableNode(table)) { + throw new Error('Expected table node'); + } + const row = table.getFirstChild(); + if (!$isElementNode(row)) { + throw new Error('Expected row node'); + } + const cell = row.getFirstChild(); + if (!$isElementNode(cell)) { + throw new Error('Expected cell node'); + } + const cellChildren = cell.getChildren(); + expect(cellChildren.some($isTableNode)).toBe(false); + }); + }); + + test('SELECTION_INSERT_CLIPBOARD_NODES_COMMAND handler prevents pasting tables in cells', async () => { + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNode(); + const row = $createTableRowNode(); + const cell = $createTableCellNode(); + row.append(cell); + table.append(row); + root.append(table); + cell.select(); + }); + + // Try to paste a table inside the cell + await editor.update(() => { + const tableNode = $createTableNode(); + const selection = $getSelection(); + if (selection === null) { + throw new Error('Expected valid selection'); + } + editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, { + nodes: [tableNode], + selection, + }); + }); + + // Verify no nested table was created + await editor.getEditorState().read(() => { + const root = $getRoot(); + const table = root.getFirstChild(); + if (!$isTableNode(table)) { + throw new Error('Expected table node'); + } + const row = table.getFirstChild(); + if (!$isElementNode(row)) { + throw new Error('Expected row node'); + } + const cell = row.getFirstChild(); + if (!$isElementNode(cell)) { + throw new Error('Expected cell node'); + } + const cellChildren = cell.getChildren(); + expect(cellChildren.some($isTableNode)).toBe(false); + }); + }); +});