Skip to content

Commit

Permalink
[Breaking Change][lexical-table] Bug Fix: Prevent nested tables (#7192)
Browse files Browse the repository at this point in the history
Co-authored-by: Bob Ippolito <[email protected]>
  • Loading branch information
kirandash and etrepum authored Feb 17, 2025
1 parent adaaf21 commit 7f4450f
Show file tree
Hide file tree
Showing 4 changed files with 327 additions and 60 deletions.
171 changes: 112 additions & 59 deletions packages/lexical-playground/__tests__/e2e/Tables.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ({
Expand Down Expand Up @@ -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`
<p><br /></p>
<table>
<colgroup>
<col style="width: 92px" />
<col style="width: 92px" />
</colgroup>
<tr>
<th>
<p><br /></p>
</th>
<th>
<p><br /></p>
</th>
</tr>
<tr>
<th>
<p><br /></p>
</th>
<td>
<p><br /></p>
</td>
</tr>
</table>
<p><br /></p>
`,
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`
<p><br /></p>
<table>
<colgroup>
<col style="width: 92px" />
<col style="width: 92px" />
</colgroup>
<tr>
<th>
<p><br /></p>
</th>
<th>
<p><br /></p>
</th>
</tr>
<tr>
<th>
<p><br /></p>
</th>
<td>
<p><br /></p>
</td>
</tr>
</table>
<p><br /></p>
`,
undefined,
{ignoreClasses: true},
);
});
});
62 changes: 61 additions & 1 deletion packages/lexical-table/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<LexicalComposer>
<div className="editor-container">
<PlainTextPlugin />
<TablePlugin />
</div>
</LexicalComposer>
);
}
```

## 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
26 changes: 26 additions & 0 deletions packages/lexical-table/src/LexicalTablePluginHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -34,6 +37,7 @@ import {$isTableNode, TableNode} from './LexicalTableNode';
import {$getTableAndElementByKey, TableObserver} from './LexicalTableObserver';
import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode';
import {
$findTableNode,
applyTableHandlers,
getTableElement,
HTMLTableElementWithWithTableSelectionState,
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
Loading

0 comments on commit 7f4450f

Please sign in to comment.