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);
+ });
+ });
+});