Skip to content

[lexical][@lexical/table] Prevent nested table pastes while preserving table-selection pasting#8082

Open
aldoprogrammer wants to merge 10 commits intofacebook:mainfrom
aldoprogrammer:fix/table-paste-no-nesting-8077
Open

[lexical][@lexical/table] Prevent nested table pastes while preserving table-selection pasting#8082
aldoprogrammer wants to merge 10 commits intofacebook:mainfrom
aldoprogrammer:fix/table-paste-no-nesting-8077

Conversation

@aldoprogrammer
Copy link
Contributor

@aldoprogrammer aldoprogrammer commented Jan 17, 2026

Summary

Prevent nested tables when pasting into table cells.

Testing

Original issue demo: #8077
After having conflict on this pr, pull latest and merged in main: issue still persist
Demo fix: video

Closes #8077

@vercel
Copy link

vercel bot commented Jan 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Jan 30, 2026 11:41pm
lexical-playground Ready Ready Preview, Comment Jan 30, 2026 11:41pm

Request Review

@meta-cla meta-cla bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jan 17, 2026
@randal-atticus
Copy link
Contributor

randal-atticus commented Jan 23, 2026

@aldoprogrammer Apologies, I might have caused the conflicts in this PR due to #8088, which I did preparation for another change relating to nested tables. That PR doesn't change any behaviour but it restructured SELECTION_INSERT_CLIPBOARD_NODES_COMMAND in a similar way.

For the avoidance of doubt, when hasNestedTables: true, a mixed-node clipboard should still insert a table into a cell (rather than pasting over the table) - that's consistent with how other editors seem to handle nested tables. Is that behaviour preserved here?

Copy link
Collaborator

@etrepum etrepum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR has conflicts with main due to another PR being merged, those conflicts need to be resolved before it can be considered for merge.

@aldoprogrammer
Copy link
Contributor Author

Please do check again

…cture and cleaning up unused imports in LexicalTablePluginHelpers and LexicalTableSelectionHelpers.
…ClipboardNodesCommand for improved clipboard node insertion within table selections. Refactor command payload structure for clarity and maintainability.
@aldoprogrammer
Copy link
Contributor Author

@aldoprogrammer Apologies, I might have caused the conflicts in this PR due to #8088, which I did preparation for another change relating to nested tables. That PR doesn't change any behaviour but it restructured SELECTION_INSERT_CLIPBOARD_NODES_COMMAND in a similar way.

For the avoidance of doubt, when hasNestedTables: true, a mixed-node clipboard should still insert a table into a cell (rather than pasting over the table) - that's consistent with how other editors seem to handle nested tables. Is that behaviour preserved here?

Yes @randal-atticus , the behavior is preserved. When hasNestedTables: true, mixed-node clipboards return false early, allowing the default paste handler to insert the table into the cell as a nested table rather than pasting over it.

Comment on lines 119 to 137
type TableSelectionLike = BaseSelection & {
anchor: RangeSelection['anchor'];
focus: RangeSelection['focus'];
tableKey: NodeKey;
};

function isTableSelectionLike(
selection: BaseSelection,
): selection is TableSelectionLike {
return (
$isTableSelection(selection) ||
(typeof selection === 'object' &&
selection !== null &&
'tableKey' in selection &&
'anchor' in selection &&
'focus' in selection)
);
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what situation does $isTableSelection not work for this use case?

Comment on lines 205 to 209
$isTableNode(node) || node.getType() === 'table',
);
const hasOnlyTableNodes =
nodes.length > 0 &&
nodes.every((node) => $isTableNode(node) || node.getType() === 'table');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getType should never be used for this use case, what situation are you trying to handle where $isTableNode doesn't work?

@aldoprogrammer
Copy link
Contributor Author

Thanks for the feedback! Removed the unnecessary isTableSelectionLike function (using $isTableSelection directly) and replaced getType() === 'table' with $isTableNode(). Fixed in the latest commit.
@etrepum

Comment on lines +174 to +188
const isSelectionInsideOfGrid =
(isRangeSelection &&
anchorAndFocus !== null &&
$findMatchingParent(selection.anchor.getNode(), (n) =>
$isTableCellNode(n),
) !== null &&
$findMatchingParent(selection.focus.getNode(), (n) =>
$isTableCellNode(n),
) !== null) ||
isTableSelection;
const templateGrid = nodes.find((node): node is TableNode =>
$isTableNode(node),
);
const hasOnlyTableNodes =
nodes.length > 0 && nodes.every((node) => $isTableNode(node));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of these guards can be used as-is, you don't have to wrap them in another function

Suggested change
const isSelectionInsideOfGrid =
(isRangeSelection &&
anchorAndFocus !== null &&
$findMatchingParent(selection.anchor.getNode(), (n) =>
$isTableCellNode(n),
) !== null &&
$findMatchingParent(selection.focus.getNode(), (n) =>
$isTableCellNode(n),
) !== null) ||
isTableSelection;
const templateGrid = nodes.find((node): node is TableNode =>
$isTableNode(node),
);
const hasOnlyTableNodes =
nodes.length > 0 && nodes.every((node) => $isTableNode(node));
const isSelectionInsideOfGrid =
(isRangeSelection &&
anchorAndFocus !== null &&
$findMatchingParent(selection.anchor.getNode(), $isTableCellNode) !== null &&
$findMatchingParent(selection.focus.getNode(), $isTableCellNode) !== null) ||
isTableSelection;
const templateGrid = nodes.find($isTableNode);
const hasOnlyTableNodes =
nodes.length > 0 && nodes.every($isTableNode);

Comment on lines +201 to +203
const focusCellNode = $findMatchingParent(focus.getNode(), (n) =>
$isTableCellNode(n),
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const focusCellNode = $findMatchingParent(focus.getNode(), (n) =>
$isTableCellNode(n),
);
const focusCellNode = $findMatchingParent(focus.getNode(), $isTableCellNode);

}

let [interimGridMap] = $computeTableMapSkipCellCheck(
gridNode.getWritable(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getWritable here is redundant, all of the accessor methods will already do this as needed. The identity of gridNode or direct access to its properties are not used here

Suggested change
gridNode.getWritable(),
gridNode,

}

[interimGridMap] = $computeTableMapSkipCellCheck(
gridNode.getWritable(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
gridNode.getWritable(),
gridNode,

Comment on lines +331 to +342
const originalChildren = cell.getChildren();
templateCell.getChildren().forEach((child) => {
if ($isTextNode(child)) {
const paragraphNode = $createParagraphNode();
paragraphNode.append(child);
cell.append(child);
} else {
cell.append(child);
}
});
originalChildren.forEach((n) => n.remove());
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is wrong, for example the paragraphNode code does absolutely nothing because the child is moved out of the paragraphNode into the cell so the paragraphNode just becomes garbage. It's probably better to just assume that the template table is already normalized (or the destination will be normalized after the fact) than to try and do some ad-hoc normalization here.

Suggested change
const originalChildren = cell.getChildren();
templateCell.getChildren().forEach((child) => {
if ($isTextNode(child)) {
const paragraphNode = $createParagraphNode();
paragraphNode.append(child);
cell.append(child);
} else {
cell.append(child);
}
});
originalChildren.forEach((n) => n.remove());
}
cell.splice(0, cell.getChildrenSize(), templateCell.getChildren());

// reset the table selection in case the anchor or focus cell was
// removed via merge operations
const [finalGridMap] = $computeTableMapSkipCellCheck(
gridNode.getWritable(),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
gridNode.getWritable(),
gridNode,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Paste tables in tables even if hasNestedTables: false

3 participants

Comments