Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/table-pad-rows-after-row-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@lexical/table': patch
---

[fix] Pad the rows of a table again when an edit removes cells from some of its rows, such as a rich paste over a selection across cells that hold lists, so the table keeps a full grid. Only the rows that lost cells in the edit are padded: rows that gained cells (a column insert, an app's own row padding) and short rows the edit did not touch (a table ragged by collaborators' concurrent row and column inserts) are left to their authors

Verify: select from the middle of one cell to the middle of a cell in the next row, in a table whose cells hold lists, paste HTML that holds a list, and check that every row keeps as many cells as the table has columns (the padding cells are appended at the end of the rows that lost cells, as before).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: per CONTRIBUTING.md (a fix that changes observable behavior gets a Verify: paragraph), please also cover the narrowed padding. A table whose rows are already short keeps them through table-only updates and when TablePlugin mounts, until a table command, a row insert, delete or move, or an edit that removes cells from those rows.

5 changes: 4 additions & 1 deletion packages/lexical-table/flow/LexicalTable.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -488,4 +488,7 @@ declare export function $moveTableRow(
declare export function $mergeCells(cellNodes: TableCellNode[]): TableCellNode | null;

/** @experimental */
declare export function $padTableRows(tableNode: TableNode): boolean;
declare export function $padTableRows(
tableNode: TableNode,
$shouldPad?: (row: TableRowNode) => boolean,
): boolean;
103 changes: 99 additions & 4 deletions packages/lexical-table/src/LexicalTablePluginHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
$createParagraphNode,
$findMatchingParent,
$getEditor,
$getNearestNodeFromDOMNode,
$getPreviousSelection,
$getRoot,
Expand All @@ -33,6 +34,7 @@ import {
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_LOW,
type CommandPayloadType,
type EditorState,
type ElementNode,
isDOMNode,
type LexicalEditor,
Expand Down Expand Up @@ -107,32 +109,125 @@ function $insertTable(
return true;
}

/**
* Per update (its pending editor state), by table, the rows that lost cells
* in the update (see $tableRowTransform): what the table's transform pads
* when the update did not change the table's rows (see $tableTransform).
*/
const rowsLost = new WeakMap<EditorState, Map<NodeKey, Set<NodeKey>>>();

/** The rows of `table` that lost cells in the running update, if any. */
function $rowsLost(table: TableNode): Set<NodeKey> | undefined {
const pending = $getEditor()._pendingEditorState;
const tables = pending === null ? undefined : rowsLost.get(pending);
return tables === undefined ? undefined : tables.get(table.getKey());
}

function $tableCellTransform(node: TableCellNode) {
if (!$isTableRowNode(node.getParent())) {
const row = node.getParent();
if (!$isTableRowNode(row)) {
// TableCellNode must be a child of TableRowNode.
node.remove();
} else if (node.isEmpty()) {
return;
}
if (node.isEmpty()) {
// TableCellNode should never be empty
node.append($createParagraphNode());
}
// A cell changed (its span raised) after rows of its table lost cells in
// this update: the table's width may have changed, so the table's
// transform pads those rows again.
const table = row.getParent();
if ($isTableNode(table) && $rowsLost(table) !== undefined) {
table.markDirty();
}
}

function $tableRowTransform(node: TableRowNode) {
if (!$isTableNode(node.getParent())) {
const table = node.getParent();
if (!$isTableNode(table)) {
// TableRowNode must be a child of TableNode.
// TODO: Future support of tbody/thead/tfoot may change this
node.remove();
} else {
$unwrapAndFilterDescendants(node, $isTableCellNode);
// A row that lost cells (a delete across cells removes some) can be
// shorter than the table. The table's transform pads it, and Lexical
// runs that only on a table marked dirty itself, which a change to one
// of its rows does not do. A row that gained cells (a column insert, an
// app's own padding of its rows, which may count cells where the table
// counts grid positions) is left as its author made it: padding it to
// the table's width had two clients that each pad the rows they created
// pad each other's rows again without end. A row that changed after
// rows lost cells in this update (it grew) marks the table again, so
// those rows are padded to the width the table has now.
const editor = $getEditor();
const pending = editor._pendingEditorState;
const previous = editor._editorState._nodeMap.get(node.getKey());
if (
pending !== null &&
$isTableRowNode(previous) &&
node.getChildrenSize() < previous.__size
) {
let tables = rowsLost.get(pending);
if (tables === undefined) {
tables = new Map();
rowsLost.set(pending, tables);
}
let rows = tables.get(table.getKey());
if (rows === undefined) {
rows = new Set();
tables.set(table.getKey(), rows);
}
rows.add(node.getKey());
table.markDirty();
} else if ($rowsLost(table) !== undefined) {
table.markDirty();
}
}
}

/**
* Whether the running update left the rows of `table` as they were (the
* same rows in the same order, in a table that was there before it).
*/
function $sameRows(table: TableNode): boolean {
const before = $getEditor()._editorState._nodeMap;
const previous = before.get(table.getKey());
if (!$isTableNode(previous)) {
return false;
}
const keys = table.getChildrenKeys();
let key = previous.__first;
for (const current of keys) {
if (key !== current) {
return false;
}
const row = before.get(key);
key = row === undefined ? null : row.__next;
}
return key === null;
}

function $tableTransform(node: TableNode) {
// TableRowNode is the only valid child for TableNode
// TODO: Future support of tbody/thead/tfoot/caption may change this
$unwrapAndFilterDescendants(node, $isTableRowNode);

$padTableRows(node);
// A new table, or one whose rows the update changed (a row added,
// removed or moved), gets all its rows padded. One whose rows are the ones
// it had gets the rows that lost cells padded, and no other, also when
// the update changed only the table itself (its striping): the other
// short rows (a table ragged by collaborators' concurrent row and column
// inserts) are their authors' to pad, and a client padding them at the
// same time as their authors duplicates cells, which makes the other rows
// short in turn.
const rows = $rowsLost(node);
if (!$sameRows(node)) {
$padTableRows(node);
} else if (rows !== undefined) {
$padTableRows(node, row => rows.has(row.getKey()));
}
Comment on lines +226 to +230

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: with the rows unchanged, this pads only the rows that lost cells, which also covers the pass Lexical makes when a transform is registered over loaded content. registerTablePlugin after setEditorState (what <TablePlugin /> does when it mounts under a LexicalComposer with a JSON editorState) now leaves a stored 3,2,3 table at 3,2,3; main squares it. A TableSelection across that table then throws Cannot destructure property 'cell' of 'map[i][j]' in TableSelection.getNodes() (LexicalTableSelection.ts:382). Skipping undefined map entries there would make ragged tables safe however they arise.

const colWidths = node.getColWidths();
const columnCount = node.getColumnCount();
if (colWidths && colWidths.length !== columnCount) {
Expand Down
11 changes: 9 additions & 2 deletions packages/lexical-table/src/LexicalTableUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1182,9 +1182,16 @@ export function $computeTableMapSkipCellCheck(
* a column) are merged, since remote updates are applied with transforms
* skipped.
*
* `$shouldPad` limits the padding to the rows it accepts (all by default):
* the table transform pads only the rows an update changed when nothing
* else about the table changed.
Comment on lines +1185 to +1187

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: the transform pads the rows that lost cells, and it does so whenever the table's rows are unchanged, including when the table's own fields changed (LexicalTablePluginHelpers.ts:225-230). Line 1172's "the same repair the table node transform ... does" could say that too.

*
* @experimental
*/
export function $padTableRows(tableNode: TableNode): boolean {
export function $padTableRows(
tableNode: TableNode,
$shouldPad: (row: TableRowNode) => boolean = () => true,
): boolean {
const [gridMap] = $computeTableMapSkipCellCheck(tableNode, null, null);
const maxRowLength = gridMap.reduce(
(curLength, row) => Math.max(curLength, row.length),
Expand All @@ -1194,7 +1201,7 @@ export function $padTableRows(tableNode: TableNode): boolean {
let changed = false;
for (let i = 0; i < gridMap.length; ++i) {
const rowNode = rowNodes[i];
if (!$isTableRowNode(rowNode)) {
if (!$isTableRowNode(rowNode) || !$shouldPad(rowNode)) {
continue;
}
// Count the grid positions this row covers, including those covered by a
Expand Down
Loading
Loading