Skip to content

Commit 5e5c43f

Browse files
authored
fix(document-api): delete table cell fix (#2209)
* fix(document-api): delete table cell fix * fix(document-api): remove double-mapping of rowEndPos in shiftLeft vMerge fallback
1 parent 57e44ed commit 5e5c43f

3 files changed

Lines changed: 234 additions & 14 deletions

File tree

packages/super-editor/src/document-api-adapters/tables-adapter.regressions.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,137 @@ describe('tables-adapter regressions', () => {
330330
expect(tr.insert).toHaveBeenCalledWith(expectedInsertPos, expect.anything());
331331
});
332332

333+
it('deletes shiftLeft cells without appending a trailing replacement cell', () => {
334+
const editor = makeTableEditor();
335+
const tr = editor.state.tr as unknown as {
336+
delete: ReturnType<typeof vi.fn>;
337+
insert: ReturnType<typeof vi.fn>;
338+
setNodeMarkup: ReturnType<typeof vi.fn>;
339+
};
340+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
341+
const targetCellOffset = TableMap.get(tableNode).map[0]!;
342+
const targetCellNode = tableNode.nodeAt(targetCellOffset) as ProseMirrorNode;
343+
const expectedStart = 1 + targetCellOffset;
344+
const expectedEnd = expectedStart + targetCellNode.nodeSize;
345+
346+
const result = tablesDeleteCellAdapter(editor, { nodeId: 'cell-1', mode: 'shiftLeft' });
347+
expect(result.success).toBe(true);
348+
expect(tr.delete).toHaveBeenCalledWith(expectedStart, expectedEnd);
349+
expect(tr.insert).not.toHaveBeenCalled();
350+
expect(tr.setNodeMarkup).toHaveBeenCalledWith(
351+
expect.any(Number),
352+
null,
353+
expect.objectContaining({
354+
colspan: 2,
355+
}),
356+
);
357+
});
358+
359+
it('deletes the row trailing cell for shiftLeft without appending a replacement cell', () => {
360+
const editor = makeTableEditor();
361+
const tr = editor.state.tr as unknown as {
362+
delete: ReturnType<typeof vi.fn>;
363+
insert: ReturnType<typeof vi.fn>;
364+
setNodeMarkup: ReturnType<typeof vi.fn>;
365+
};
366+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
367+
const targetCellOffset = TableMap.get(tableNode).map[1]!;
368+
const targetCellNode = tableNode.nodeAt(targetCellOffset) as ProseMirrorNode;
369+
const expectedStart = 1 + targetCellOffset;
370+
const expectedEnd = expectedStart + targetCellNode.nodeSize;
371+
372+
const result = tablesDeleteCellAdapter(editor, { nodeId: 'cell-2', mode: 'shiftLeft' });
373+
expect(result.success).toBe(true);
374+
expect(tr.delete).toHaveBeenCalledWith(expectedStart, expectedEnd);
375+
expect(tr.insert).not.toHaveBeenCalled();
376+
expect(tr.setNodeMarkup).toHaveBeenCalledWith(
377+
expect.any(Number),
378+
null,
379+
expect.objectContaining({
380+
colspan: 2,
381+
}),
382+
);
383+
});
384+
385+
it('falls back to trailing replacement cell when shiftLeft would widen a vertically merged trailing cell', () => {
386+
const editor = makeTableEditor();
387+
const tr = editor.state.tr as unknown as {
388+
delete: ReturnType<typeof vi.fn>;
389+
insert: ReturnType<typeof vi.fn>;
390+
setNodeMarkup: ReturnType<typeof vi.fn>;
391+
};
392+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
393+
const firstRow = tableNode.child(0) as ProseMirrorNode;
394+
const trailingCell = firstRow.child(1) as unknown as { attrs: Record<string, unknown> };
395+
trailingCell.attrs.rowspan = 2;
396+
trailingCell.attrs.tableCellProperties = { vMerge: 'restart' };
397+
398+
const result = tablesDeleteCellAdapter(editor, { nodeId: 'cell-1', mode: 'shiftLeft' });
399+
expect(result.success).toBe(true);
400+
expect(tr.insert).toHaveBeenCalledWith(expect.any(Number), expect.anything());
401+
expect(tr.setNodeMarkup).not.toHaveBeenCalled();
402+
});
403+
404+
it('shiftLeft vMerge fallback inserts at the post-delete row end without double-mapping', () => {
405+
// Regression: rowEndPos was computed from the post-delete doc (tr.doc) but then
406+
// passed through tr.mapping.map() which maps old→new, double-shifting the position.
407+
const editor = makeTableEditor();
408+
409+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
410+
const firstRow = tableNode.child(0) as ProseMirrorNode;
411+
const trailingCell = firstRow.child(1) as unknown as { attrs: Record<string, unknown> };
412+
trailingCell.attrs.rowspan = 2;
413+
trailingCell.attrs.tableCellProperties = { vMerge: 'restart' };
414+
415+
const cell1 = firstRow.child(0);
416+
const deletionStart = 2; // absolute position of cell-1
417+
const deletionSize = cell1.nodeSize; // 9
418+
419+
// Build post-delete table: row 0 only contains the vMerge cell.
420+
const postDeleteRow0 = createNode('tableRow', [firstRow.child(1)], {
421+
attrs: { ...(firstRow.attrs as Record<string, unknown>) },
422+
isBlock: true,
423+
inlineContent: false,
424+
});
425+
const postDeleteTable = createNode('table', [postDeleteRow0, tableNode.child(1)], {
426+
attrs: { ...(tableNode.attrs as Record<string, unknown>) },
427+
isBlock: true,
428+
inlineContent: false,
429+
});
430+
const postDeleteDoc = createNode('doc', [postDeleteTable], { isBlock: false });
431+
432+
const trObj = editor.state.tr as unknown as {
433+
delete: ReturnType<typeof vi.fn>;
434+
insert: ReturnType<typeof vi.fn>;
435+
mapping: { map: (p: number) => number; maps: unknown[]; slice: () => { map: (p: number) => number } };
436+
doc: ProseMirrorNode;
437+
};
438+
439+
// Swap tr.doc to the post-delete document when delete is called.
440+
trObj.delete = vi.fn(() => {
441+
trObj.doc = postDeleteDoc;
442+
return trObj;
443+
});
444+
445+
// Simulate real deletion mapping: positions at or after the deleted range shift left.
446+
trObj.mapping.map = (pos: number) => {
447+
if (pos < deletionStart) return pos;
448+
if (pos < deletionStart + deletionSize) return deletionStart;
449+
return pos - deletionSize;
450+
};
451+
452+
const result = tablesDeleteCellAdapter(editor, { nodeId: 'cell-1', mode: 'shiftLeft' });
453+
expect(result.success).toBe(true);
454+
expect(trObj.insert).toHaveBeenCalled();
455+
456+
// Post-delete row 0 nodeSize = 2 + cell-2 size (9) = 11.
457+
// rowEndPos = tablePos(0) + 1 + 11 = 12.
458+
// Correct insert = 12 - 1 = 11 (just inside the row).
459+
// Old buggy code: tr.mapping.map(11) = 11 - 9 = 2 — wrong position!
460+
const insertPos = trObj.insert.mock.calls[0]![0];
461+
expect(insertPos).toBe(11);
462+
});
463+
333464
it('keeps table grid widths in sync when distributing columns', () => {
334465
const editor = makeTableEditor();
335466
const tr = editor.state.tr as unknown as { setNodeMarkup: ReturnType<typeof vi.fn> };

packages/super-editor/src/document-api-adapters/tables-adapter.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1742,8 +1742,9 @@ export function tablesInsertCellAdapter(
17421742
/**
17431743
* tables.deleteCell — delete a cell at a resolved position, shifting remaining cells.
17441744
*
1745-
* `shiftLeft`: removes the target cell and appends a new empty cell at the end of
1746-
* the row to maintain column count.
1745+
* `shiftLeft`: removes the target cell and shifts remaining cells in the row left.
1746+
* This reduces the row cell count by one and avoids a synthetic trailing cell unless
1747+
* widening the remaining trailing cell would conflict with vertical merges.
17471748
*
17481749
* `shiftUp`: removes the target cell and appends a new empty cell at the same column
17491750
* in the last row to maintain row count.
@@ -1757,6 +1758,15 @@ export function tablesDeleteCellAdapter(
17571758

17581759
const resolved = resolveCellLocator(editor, input, 'tables.deleteCell');
17591760
const { table, cellPos, cellNode, rowIndex, columnIndex } = resolved;
1761+
const row = table.candidate.node.child(rowIndex);
1762+
const deletedColspan = Math.max(1, ((cellNode.attrs as Record<string, unknown>).colspan as number) || 1);
1763+
const deletedColwidth = Array.isArray((cellNode.attrs as Record<string, unknown>).colwidth)
1764+
? [...((cellNode.attrs as Record<string, unknown>).colwidth as number[])]
1765+
: null;
1766+
1767+
if (input.mode === 'shiftLeft' && row.childCount <= 1) {
1768+
return toTableFailure('NO_OP', 'Cannot shift-left delete the last remaining cell in a row.');
1769+
}
17601770

17611771
if (options?.dryRun) {
17621772
return buildTableSuccess(table.address);
@@ -1774,17 +1784,61 @@ export function tablesDeleteCellAdapter(
17741784
tr.delete(cellPos, cellPos + cellNode.nodeSize);
17751785

17761786
if (input.mode === 'shiftLeft') {
1777-
// Append a new empty cell at the end of this row.
1778-
const row = tableNode.child(rowIndex);
1779-
const rowEnd = tr.mapping.map(tableStart + map.positionAt(rowIndex, map.width - 1, tableNode));
1780-
const lastCell = row.child(row.childCount - 1);
1781-
const insertPos = tr.mapping.map(cellPos + cellNode.nodeSize + lastCell.nodeSize - cellNode.nodeSize);
1782-
// Find the end of the row in the mapped doc — simpler: compute row end position.
1783-
let rowEndPos = table.candidate.pos + 1;
1784-
for (let i = 0; i <= rowIndex; i++) rowEndPos += tableNode.child(i).nodeSize;
1785-
const mappedRowEnd = tr.mapping.map(rowEndPos - 1); // -1 to be inside the row closing tag
1786-
const newCell = schema.nodes.tableCell.createAndFill()!;
1787-
tr.insert(mappedRowEnd, newCell);
1787+
// Prefer preserving fewer visual cells by widening the new trailing cell.
1788+
// Fall back to a trailing replacement cell when merged geometry would become invalid.
1789+
const currentTableNode = tr.doc.nodeAt(tablePos);
1790+
if (!currentTableNode || currentTableNode.type.name !== 'table') {
1791+
return toTableFailure('INVALID_TARGET', 'Cell deletion could not locate the updated table.');
1792+
}
1793+
1794+
const currentRow = currentTableNode.child(rowIndex);
1795+
const lastCellIndex = currentRow.childCount - 1;
1796+
const lastCell = currentRow.child(lastCellIndex);
1797+
const lastAttrs = lastCell.attrs as Record<string, unknown>;
1798+
const tableCellProperties = (lastAttrs.tableCellProperties ?? {}) as Record<string, unknown>;
1799+
const lastRowspan = Math.max(1, (lastAttrs.rowspan as number) || 1);
1800+
const hasVerticalMerge = tableCellProperties.vMerge != null;
1801+
1802+
if (lastRowspan > 1 || hasVerticalMerge) {
1803+
// Extending a vertically merged cell can overlap cells in lower rows.
1804+
let rowEndPos = tablePos + 1;
1805+
for (let i = 0; i <= rowIndex; i++) rowEndPos += currentTableNode.child(i).nodeSize;
1806+
const mappedRowEnd = rowEndPos - 1; // -1 to stay inside the row. No mapping needed — rowEndPos is already in post-delete doc space.
1807+
const newCell = schema.nodes.tableCell.createAndFill()!;
1808+
tr.insert(mappedRowEnd, newCell);
1809+
} else {
1810+
const lastColspan = Math.max(1, (lastAttrs.colspan as number) || 1);
1811+
const nextColspan = lastColspan + deletedColspan;
1812+
1813+
const nextTableCellProps = {
1814+
...tableCellProperties,
1815+
};
1816+
if (nextColspan > 1) nextTableCellProps.gridSpan = nextColspan;
1817+
else delete nextTableCellProps.gridSpan;
1818+
1819+
const nextColwidth = Array.isArray(lastAttrs.colwidth) ? [...(lastAttrs.colwidth as number[])] : null;
1820+
if (nextColwidth) {
1821+
if (deletedColwidth) {
1822+
for (const width of deletedColwidth) {
1823+
if (nextColwidth.length >= nextColspan) break;
1824+
nextColwidth.push(typeof width === 'number' ? width : 0);
1825+
}
1826+
}
1827+
while (nextColwidth.length < nextColspan) nextColwidth.push(0);
1828+
}
1829+
1830+
let rowOffset = 0;
1831+
for (let i = 0; i < rowIndex; i++) rowOffset += currentTableNode.child(i).nodeSize;
1832+
let lastCellOffset = rowOffset + 1;
1833+
for (let i = 0; i < lastCellIndex; i++) lastCellOffset += currentRow.child(i).nodeSize;
1834+
1835+
tr.setNodeMarkup(tableStart + lastCellOffset, null, {
1836+
...lastAttrs,
1837+
colspan: nextColspan,
1838+
colwidth: nextColwidth,
1839+
tableCellProperties: nextTableCellProps,
1840+
});
1841+
}
17881842
} else {
17891843
// shiftUp: insert a new empty cell at the same column in the last row.
17901844
const lastRowIndex = map.height - 1;

tests/doc-api-stories/tests/tables/all-commands.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ describe('document-api story: all table commands', () => {
7979
const insertCellTableBySession = new Map<string, string>();
8080
const insertCellInitialRowsBySession = new Map<string, number>();
8181
const deleteCellBySession = new Map<string, string>();
82+
const deleteCellTableBySession = new Map<string, string>();
8283

8384
function makeSessionId(prefix: string): string {
8485
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -690,20 +691,54 @@ describe('document-api story: all table commands', () => {
690691
}
691692

692693
const cellsResult = unwrap<any>(await api.doc.tables.getCells({ sessionId, nodeId: tableNodeId }));
694+
const firstRowBefore = Array.isArray(cellsResult?.cells)
695+
? cellsResult.cells.filter((cell: any) => cell?.rowIndex === 0)
696+
: [];
697+
if (firstRowBefore.length !== 3) {
698+
throw new Error(`tables.deleteCell setup expected 3 cells in first row, received ${firstRowBefore.length}.`);
699+
}
693700
const firstCellNodeId = cellsResult?.cells?.[0]?.nodeId;
694701
if (!firstCellNodeId) {
695702
throw new Error('tables.deleteCell setup failed: no table cell was returned from getCells.');
696703
}
697704

698705
deleteCellBySession.set(sessionId, firstCellNodeId);
706+
deleteCellTableBySession.set(sessionId, tableNodeId);
699707
},
700708
run: async (sessionId) => {
701709
const cellNodeId = deleteCellBySession.get(sessionId);
710+
const tableNodeId = deleteCellTableBySession.get(sessionId);
702711
if (!cellNodeId) {
703712
throw new Error('tables.deleteCell setup failed: prepared cell nodeId was not found.');
704713
}
714+
if (!tableNodeId) {
715+
throw new Error('tables.deleteCell setup failed: prepared table nodeId was not found.');
716+
}
705717
deleteCellBySession.delete(sessionId);
706-
return unwrap<any>(await api.doc.tables.deleteCell({ sessionId, nodeId: cellNodeId, mode: 'shiftLeft' }));
718+
deleteCellTableBySession.delete(sessionId);
719+
720+
const result = unwrap<any>(
721+
await api.doc.tables.deleteCell({ sessionId, nodeId: cellNodeId, mode: 'shiftLeft' }),
722+
);
723+
assertMutationSuccess('tables.deleteCell', result);
724+
725+
const postCells = unwrap<any>(await api.doc.tables.getCells({ sessionId, nodeId: tableNodeId, rowIndex: 0 }));
726+
const firstRowAfter = Array.isArray(postCells?.cells)
727+
? postCells.cells.filter((cell: any) => cell?.rowIndex === 0)
728+
: [];
729+
if (firstRowAfter.length !== 2) {
730+
throw new Error(
731+
`tables.deleteCell expected first-row cell count to be 2 after shiftLeft, received ${firstRowAfter.length}.`,
732+
);
733+
}
734+
const firstRowColumns = firstRowAfter.map((cell: any) => Number(cell?.columnIndex)).sort((a, b) => a - b);
735+
if (firstRowColumns.join(',') !== '0,1') {
736+
throw new Error(
737+
`tables.deleteCell expected first-row column indexes [0,1] after shiftLeft, received [${firstRowColumns.join(',')}].`,
738+
);
739+
}
740+
741+
return result;
707742
},
708743
},
709744
{

0 commit comments

Comments
 (0)