From ad53d4c9cc9f6e1583fa9385f2c5e9d5696702cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D0=94?= Date: Fri, 24 Jul 2026 00:02:46 +0200 Subject: [PATCH] Preserve structurally empty table rows ProseMirror's `mergeCells` command can result in table rows that contain no cells. Previously, these rows were filtered out during conversion to `FlowBlock[]`, which could lead to incorrect row counts and rendering when a cell with a `rowspan` attribute covered these empty rows. This change modifies `parseTableRow` to ensure that structurally empty rows are preserved, preventing rendering issues with vertically merged cells. Fixes https://github.com/superdoc-dev/superdoc/issues/3782 --- .../layout-adapter/converters/table.test.ts | 49 +++++++++++++++++++ .../core/layout-adapter/converters/table.ts | 19 +++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.test.ts index 5b78a60e19..5502ccf06e 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.test.ts @@ -985,6 +985,55 @@ describe('table converter', () => { expect(result.rows[0].cells[0].colSpan).toBe(3); }); + it('preserves content-less rows covered by a full-width rowspan', () => { + const cell = (text: string): PMNode => ({ + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text }] }], + }); + const node: PMNode = { + type: 'table', + attrs: { + grid: [{ col: 1000 }, { col: 1000 }, { col: 1000 }], + }, + content: [ + { + type: 'tableRow', + content: [ + { + ...cell('Merged'), + attrs: { rowspan: 2, colspan: 3 }, + }, + ], + }, + // ProseMirror's mergeCells command leaves the covered tableRow in + // place but removes all of its cells. Node.toJSON() then omits the + // empty content property entirely. + { type: 'tableRow' }, + { + type: 'tableRow', + content: [cell('A'), cell('B'), cell('C')], + }, + ], + }; + + const result = tableNodeToBlock( + node, + mockBlockIdGenerator, + mockPositionMap, + 'Arial', + 16, + undefined, + undefined, + undefined, + undefined, + mockParagraphConverter, + ) as TableBlock; + + expect(result.rows).toHaveLength(3); + expect(result.rows[1].cells).toEqual([]); + expect(result.rows[2].cells).toHaveLength(3); + }); + it('extracts cell borders when present', () => { const node: PMNode = { type: 'table', diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts index 759cd9c65d..bf89e15736 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts @@ -778,7 +778,8 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { * @param args.context - Parser dependencies (block ID generator, converters, style context) * @param args.defaultCellPadding - Optional default padding from table style to pass to cells * @param args.tableStyleId - Optional table style ID for paragraph style cascade in cells - * @returns TableRow object with cells and attributes, or null if the row contains no valid cells + * @returns TableRow object with cells and attributes, including structurally empty rowspan continuation rows, + * or null when the node is not a table row * * @example * // Row with cells @@ -790,13 +791,14 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { * // Returns: { id: 'row-0', cells: [...], attrs: {...} } * * @example - * // Row with no valid cells returns null + * // A structurally empty row is preserved. ProseMirror emits these rows when + * // a full-width cell spans vertically across them. * parseTableRow({ * rowNode: { type: 'tableRow', content: [] }, * rowIndex: 0, * context: parserDeps, * }); - * // Returns: null + * // Returns: { id: 'row-0', cells: [] } */ /** * Builds shared {@link TrackedChangeMeta} for a structural row-level tracked @@ -842,15 +844,16 @@ const buildRowTrackedChangeMeta = (rowNode: PMNode, storyKey?: string): TrackedC const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { const { rowNode, rowIndex, context, defaultCellPadding, tableProperties, numRows } = args; - if (!isTableRowNode(rowNode) || !Array.isArray(rowNode.content)) { + if (!isTableRowNode(rowNode)) { return null; } + const rowContent = Array.isArray(rowNode.content) ? rowNode.content : []; const cells: TableCell[] = []; const rowCnfStyle = (rowNode.attrs?.tableRowProperties as Record | undefined)?.cnfStyle as | Record | undefined; - rowNode.content.forEach((cellNode, cellIndex) => { + rowContent.forEach((cellNode, cellIndex) => { if (isTableCellNode(cellNode) && isTableSkipPlaceholderCell(cellNode)) { return; } @@ -862,7 +865,7 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { context, defaultCellPadding, tableProperties, - numCells: rowNode?.content?.length || 1, + numCells: rowContent.length || 1, numRows, rowCnfStyle, gridPlacement: args.cellGridPlacements?.[cellIndex] ?? null, @@ -873,8 +876,6 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { } }); - if (cells.length === 0) return null; - const rowProps = rowNode.attrs?.tableRowProperties; const rowHeight = normalizeRowHeight(rowProps as Record | undefined); // Structural row-level tracked change (inserted/deleted whole row). Only @@ -1162,7 +1163,7 @@ export function tableNodeToBlock( } }); - if (rows.length === 0) return null; + if (rows.every((row) => row.cells.length === 0)) return null; const tableAttrs: Record = {};