diff --git a/packages/super-editor/src/document-api-adapters/tables-adapter.regressions.test.ts b/packages/super-editor/src/document-api-adapters/tables-adapter.regressions.test.ts index e957457bc2..e224b4b909 100644 --- a/packages/super-editor/src/document-api-adapters/tables-adapter.regressions.test.ts +++ b/packages/super-editor/src/document-api-adapters/tables-adapter.regressions.test.ts @@ -10,6 +10,7 @@ import { tablesInsertCellAdapter, tablesSetBorderAdapter, tablesSetShadingAdapter, + tablesSplitCellAdapter, tablesSplitAdapter, } from './tables-adapter.js'; @@ -38,6 +39,11 @@ type NodeOptions = { nodeSize?: number; }; +type TableEditorOptions = { + firstRowAsHeaders?: boolean; + firstRowBorders?: Record | null; +}; + function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { const attrs = options.attrs ?? {}; const text = options.text ?? ''; @@ -122,7 +128,15 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: return node as unknown as ProseMirrorNode; } -function makeTableEditor(): Editor { +function makeTableEditor(options: TableEditorOptions = {}): Editor { + const firstRowAsHeaders = options.firstRowAsHeaders ?? false; + const firstRowType = firstRowAsHeaders ? 'tableHeader' : 'tableCell'; + const firstRowAttrs = + options.firstRowBorders === undefined + ? {} + : { + borders: options.firstRowBorders, + }; const paragraph1 = createNode('paragraph', [createNode('text', [], { text: 'Hello' })], { attrs: { sdBlockId: 'p1', paraId: 'p1', paragraphProperties: {} }, isBlock: true, @@ -144,13 +158,13 @@ function makeTableEditor(): Editor { inlineContent: true, }); - const cell1 = createNode('tableCell', [paragraph1], { - attrs: { sdBlockId: 'cell-1', colspan: 1, rowspan: 1, colwidth: [100] }, + const cell1 = createNode(firstRowType, [paragraph1], { + attrs: { sdBlockId: 'cell-1', colspan: 1, rowspan: 1, colwidth: [100], ...firstRowAttrs }, isBlock: true, inlineContent: false, }); - const cell2 = createNode('tableCell', [paragraph2], { - attrs: { sdBlockId: 'cell-2', colspan: 1, rowspan: 1, colwidth: [200] }, + const cell2 = createNode(firstRowType, [paragraph2], { + attrs: { sdBlockId: 'cell-2', colspan: 1, rowspan: 1, colwidth: [200], ...firstRowAttrs }, isBlock: true, inlineContent: false, }); @@ -199,6 +213,8 @@ function makeTableEditor(): Editor { insert: vi.fn().mockReturnThis(), replaceWith: vi.fn().mockReturnThis(), setNodeMarkup: vi.fn().mockReturnThis(), + setSelection: vi.fn().mockReturnThis(), + setStoredMarks: vi.fn().mockReturnThis(), setMeta: vi.fn().mockReturnThis(), mapping: { maps: [] as unknown[], @@ -213,6 +229,7 @@ function makeTableEditor(): Editor { doc, tr, schema: { + text: (text: string) => createNode('text', [], { text }), nodes: { paragraph: { createAndFill: vi.fn((attrs: Record = {}, content?: unknown) => { @@ -554,6 +571,75 @@ describe('tables-adapter regressions', () => { }); }); + it('splits a cell by structural row/column expansion without deleting neighboring cells', () => { + const editor = makeTableEditor(); + const tr = editor.state.tr as unknown as { + delete: ReturnType; + insert: ReturnType; + setNodeMarkup: ReturnType; + }; + + const result = tablesSplitCellAdapter(editor, { + nodeId: 'cell-1', + rows: 2, + columns: 2, + }); + + expect(result.success).toBe(true); + expect(tr.delete).not.toHaveBeenCalled(); + expect(tr.insert).toHaveBeenCalled(); + expect(getTableGridUpdateAttrs(tr)).toMatchObject({ + userEdited: true, + grid: [{ col: 1200 }, { col: 3000 }, { col: 3000 }], + }); + }); + + it('does not copy header-only null borders when split inserts a body row from a header source row', () => { + const editor = makeTableEditor({ firstRowAsHeaders: true, firstRowBorders: null }); + const tr = editor.state.tr as unknown as { insert: ReturnType }; + + const result = tablesSplitCellAdapter(editor, { + nodeId: 'cell-1', + rows: 2, + columns: 1, + }); + + expect(result.success).toBe(true); + + const insertedRow = tr.insert.mock.calls.find(([, node]) => node?.type?.name === 'tableRow')?.[1] as + | ProseMirrorNode + | undefined; + expect(insertedRow).toBeDefined(); + + const insertedCells = ((insertedRow as unknown as { _children?: ProseMirrorNode[] })._children ?? []).filter( + (node) => node.type.name === 'tableCell', + ); + expect(insertedCells.length).toBeGreaterThan(0); + for (const cell of insertedCells) { + expect((cell.attrs as Record).borders).toBeUndefined(); + } + }); + + it('preserves non-target rows when split inserts columns by widening adjacent cells', () => { + const editor = makeTableEditor(); + const tr = editor.state.tr as unknown as { setNodeMarkup: ReturnType }; + + const result = tablesSplitCellAdapter(editor, { + nodeId: 'cell-1', + rows: 1, + columns: 2, + }); + + expect(result.success).toBe(true); + expect(tr.setNodeMarkup).toHaveBeenCalledWith( + expect.any(Number), + null, + expect.objectContaining({ + colspan: 2, + }), + ); + }); + it('rejects paragraph targets for tables.setBorder', () => { const editor = makeTableEditor(); const result = tablesSetBorderAdapter(editor, { diff --git a/packages/super-editor/src/document-api-adapters/tables-adapter.ts b/packages/super-editor/src/document-api-adapters/tables-adapter.ts index d8ec4885a9..b93a595812 100644 --- a/packages/super-editor/src/document-api-adapters/tables-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/tables-adapter.ts @@ -67,7 +67,6 @@ import { collectTrackInsertRefsInRange } from './helpers/tracked-change-refs.js' import { applyDirectMutationMeta, applyTrackedMutationMeta } from './helpers/transaction-meta.js'; import { DocumentApiAdapterError } from './errors.js'; import { toBlockAddress, findBlockById, findBlockByNodeIdOnly } from './helpers/node-address-resolver.js'; -import { insertRowAtIndex } from '../extensions/table/tableHelpers/appendRows.js'; import { twipsToPixels } from '../core/super-converter/helpers.js'; // --------------------------------------------------------------------------- @@ -249,6 +248,96 @@ function removeGridColumnWidth(grid: unknown, deleteIndex: number): unknown | nu return serializeGridColumns(grid, { ...normalized, columns: colWidths }); } +function normalizeCellAttrsForSingleCell(attrs: Record): Record { + const currentColwidth = Array.isArray(attrs.colwidth) ? (attrs.colwidth as number[]) : null; + const tableCellProperties = { + ...((attrs.tableCellProperties ?? {}) as Record), + }; + + delete tableCellProperties.gridSpan; + delete tableCellProperties.vMerge; + + return { + ...attrs, + colspan: 1, + rowspan: 1, + colwidth: currentColwidth && currentColwidth.length > 0 ? [currentColwidth[0] ?? 0] : currentColwidth, + tableCellProperties, + }; +} + +function normalizeClonedRowInsertCellAttrs( + sourceAttrs: Record, + fromHeaderToBody: boolean, +): Record { + const normalizedAttrs: Record = { + ...sourceAttrs, + rowspan: 1, + }; + + // Header rows can carry explicit `borders: null` to suppress drawing. + // Drop that sentinel when cloning into body cells so tableCell defaults apply. + if (fromHeaderToBody && normalizedAttrs.borders == null) { + delete normalizedAttrs.borders; + } + + return normalizedAttrs; +} + +type ExpandMergedCellParams = { + tr: Transaction; + tablePos: number; + tableNode: import('prosemirror-model').Node; + cellPos: number; + cellNode: import('prosemirror-model').Node; + rowIndex: number; + columnIndex: number; + rowspan: number; + colspan: number; + schema: Editor['state']['schema']; +}; + +function expandMergedCellIntoSingles({ + tr, + tablePos, + tableNode, + cellPos, + cellNode, + rowIndex, + columnIndex, + rowspan, + colspan, + schema, +}: ExpandMergedCellParams): void { + const tableStart = tablePos + 1; + const map = TableMap.get(tableNode); + const resetCell = cellNode.type.create( + normalizeCellAttrsForSingleCell(cellNode.attrs as Record), + cellNode.content, + ); + tr.replaceWith(cellPos, cellPos + cellNode.nodeSize, resetCell); + + // Fill the previously merged region with empty cells, preserving top-left content cell. + const mapFrom = tr.mapping.maps.length; + for (let row = rowIndex + rowspan - 1; row >= rowIndex; row--) { + for (let col = columnIndex + colspan - 1; col >= columnIndex; col--) { + if (row === rowIndex && col === columnIndex) continue; + + const newCell = schema.nodes.tableCell.createAndFill()!; + + let insertRelPos: number; + if (row === rowIndex) { + const baseRelPos = map.positionAt(rowIndex, columnIndex, tableNode); + insertRelPos = baseRelPos + resetCell.nodeSize; + } else { + insertRelPos = map.positionAt(row, col, tableNode); + } + + tr.insert(tr.mapping.slice(mapFrom).map(tableStart + insertRelPos), newCell); + } + } +} + type TableBorderEdgeForCells = 'top' | 'bottom' | 'left' | 'right' | 'insideH' | 'insideV'; type CellBorderSide = 'top' | 'bottom' | 'left' | 'right'; @@ -501,6 +590,232 @@ function removeColumnFromTable(tr: Transaction, tablePos: number, col: number): } } +/** Inserts a row at `insertIndex`, cloning cell structure from `sourceRowIndex` and preserving rowspan integrity. */ +function insertRowInTable( + tr: Transaction, + tablePos: number, + sourceRowIndex: number, + insertIndex: number, + schema: Editor['state']['schema'], +): boolean { + const tableNode = tr.doc.nodeAt(tablePos); + if (!tableNode || tableNode.type.name !== 'table') return false; + + const rowCount = tableNode.childCount; + if (rowCount === 0) return false; + + const map = TableMap.get(tableNode); + const boundedInsertIndex = Math.max(0, Math.min(insertIndex, rowCount)); + const boundedSourceRowIndex = Math.max(0, Math.min(sourceRowIndex, rowCount - 1)); + const sourceRow = tableNode.child(boundedSourceRowIndex); + if (!sourceRow) return false; + + const rowType = schema.nodes.tableRow; + const defaultCellType = schema.nodes.tableCell; + if (!rowType || !defaultCellType) return false; + + const newCells: import('prosemirror-model').Node[] = []; + const cellsToExtend: Array<{ pos: number; attrs: Record }> = []; + + for (let col = 0; col < map.width; ) { + if (boundedInsertIndex > 0 && boundedInsertIndex < map.height) { + const indexAbove = (boundedInsertIndex - 1) * map.width + col; + const indexAtInsert = boundedInsertIndex * map.width + col; + + if (map.map[indexAbove] === map.map[indexAtInsert]) { + const spanningPos = map.map[indexAbove]; + const spanningCell = tableNode.nodeAt(spanningPos); + if (spanningCell) { + const spanningAttrs = spanningCell.attrs as Record; + const rowspan = (spanningAttrs.rowspan as number) || 1; + const colspan = (spanningAttrs.colspan as number) || 1; + cellsToExtend.push({ + pos: tablePos + 1 + spanningPos, + attrs: { ...spanningAttrs, rowspan: rowspan + 1 }, + }); + col += colspan; + continue; + } + } + } + + const sourceMapIndex = boundedSourceRowIndex * map.width + col; + const sourceCellPos = map.map[sourceMapIndex]; + const sourceCell = tableNode.nodeAt(sourceCellPos) ?? sourceRow.firstChild; + if (!sourceCell) { + col += 1; + continue; + } + + const colspan = ((sourceCell.attrs as Record).colspan as number) || 1; + const fromHeaderToBody = sourceCell.type.name === 'tableHeader'; + const targetCellType = fromHeaderToBody ? defaultCellType : sourceCell.type; + const newCell = targetCellType.createAndFill( + normalizeClonedRowInsertCellAttrs(sourceCell.attrs as Record, fromHeaderToBody), + ); + if (newCell) newCells.push(newCell); + col += colspan; + } + + for (const { pos, attrs } of cellsToExtend) { + tr.setNodeMarkup(pos, null, attrs); + } + + if (newCells.length === 0) return true; + + const newRow = rowType.createAndFill(null, newCells); + if (!newRow) return false; + + let insertPos = tablePos + 1; + for (let row = 0; row < boundedInsertIndex; row++) { + insertPos += tableNode.child(row).nodeSize; + } + tr.insert(insertPos, newRow); + return true; +} + +function addColumnToTableForSplit( + tr: Transaction, + tablePos: number, + col: number, + splitRowStart: number, + splitRowEnd: number, +): void { + const tableNode = tr.doc.nodeAt(tablePos); + if (!tableNode || tableNode.type.name !== 'table') return; + const map = TableMap.get(tableNode); + const tableStart = tablePos + 1; + const mapStart = tr.mapping.maps.length; + const widenedOutsideSplit = new Set(); + + for (let row = 0; row < map.height; row++) { + const index = row * map.width + col; + const pos = map.map[index]; + const cell = tableNode.nodeAt(pos); + if (!cell) continue; + + const inSplitRows = row >= splitRowStart && row < splitRowEnd; + if (!inSplitRows && col > 0) { + const leftPos = map.map[index - 1]!; + const leftCell = tableNode.nodeAt(leftPos); + if (leftCell && !widenedOutsideSplit.has(leftPos)) { + tr.setNodeMarkup( + tr.mapping.slice(mapStart).map(tableStart + leftPos), + null, + addColSpan(leftCell.attrs as Record, col - map.colCount(leftPos)), + ); + widenedOutsideSplit.add(leftPos); + } + row += ((cell.attrs?.rowspan as number) || 1) - 1; + continue; + } + + if (col > 0 && map.map[index - 1] === pos) { + tr.setNodeMarkup( + tr.mapping.slice(mapStart).map(tableStart + pos), + null, + addColSpan(cell.attrs as Record, col - map.colCount(pos)), + ); + row += (((cell.attrs as Record).rowspan as number) || 1) - 1; + } else { + const refType = col > 0 ? (tableNode.nodeAt(map.map[index - 1])?.type ?? cell.type) : cell.type; + const cellPos = map.positionAt(row, col, tableNode); + tr.insert(tr.mapping.slice(mapStart).map(tableStart + cellPos), refType.createAndFill()!); + row += ((cell.attrs?.rowspan as number) || 1) - 1; + } + } +} + +function insertRowInTableForSplit( + tr: Transaction, + tablePos: number, + sourceRowIndex: number, + insertIndex: number, + splitColStart: number, + splitColEnd: number, + schema: Editor['state']['schema'], +): boolean { + const tableNode = tr.doc.nodeAt(tablePos); + if (!tableNode || tableNode.type.name !== 'table') return false; + + const rowCount = tableNode.childCount; + if (rowCount === 0) return false; + + const map = TableMap.get(tableNode); + const boundedInsertIndex = Math.max(0, Math.min(insertIndex, rowCount)); + const boundedSourceRowIndex = Math.max(0, Math.min(sourceRowIndex, rowCount - 1)); + const sourceRow = tableNode.child(boundedSourceRowIndex); + if (!sourceRow) return false; + + const rowType = schema.nodes.tableRow; + const defaultCellType = schema.nodes.tableCell; + if (!rowType || !defaultCellType) return false; + + const newCells: import('prosemirror-model').Node[] = []; + const cellsToExtend = new Map>(); + + for (let col = 0; col < map.width; ) { + if (boundedInsertIndex > 0 && boundedInsertIndex < map.height) { + const indexAbove = (boundedInsertIndex - 1) * map.width + col; + const indexAtInsert = boundedInsertIndex * map.width + col; + + if (map.map[indexAbove] === map.map[indexAtInsert]) { + const spanningPos = map.map[indexAbove]; + const spanningCell = tableNode.nodeAt(spanningPos); + if (spanningCell) { + const spanningAttrs = spanningCell.attrs as Record; + const rowspan = (spanningAttrs.rowspan as number) || 1; + const colspan = (spanningAttrs.colspan as number) || 1; + cellsToExtend.set(tablePos + 1 + spanningPos, { ...spanningAttrs, rowspan: rowspan + 1 }); + col += colspan; + continue; + } + } + } + + const sourceMapIndex = boundedSourceRowIndex * map.width + col; + const sourceCellPos = map.map[sourceMapIndex]; + const sourceCell = tableNode.nodeAt(sourceCellPos) ?? sourceRow.firstChild; + if (!sourceCell) { + col += 1; + continue; + } + + const sourceAttrs = sourceCell.attrs as Record; + const colspan = (sourceAttrs.colspan as number) || 1; + const overlapsSplitRange = col < splitColEnd && col + colspan > splitColStart; + + if (!overlapsSplitRange) { + const sourceRowspan = (sourceAttrs.rowspan as number) || 1; + cellsToExtend.set(tablePos + 1 + sourceCellPos, { ...sourceAttrs, rowspan: sourceRowspan + 1 }); + col += colspan; + continue; + } + + const fromHeaderToBody = sourceCell.type.name === 'tableHeader'; + const targetCellType = fromHeaderToBody ? defaultCellType : sourceCell.type; + const newCell = targetCellType.createAndFill(normalizeClonedRowInsertCellAttrs(sourceAttrs, fromHeaderToBody)); + if (newCell) newCells.push(newCell); + col += colspan; + } + + for (const [pos, attrs] of cellsToExtend.entries()) { + tr.setNodeMarkup(pos, null, attrs); + } + + if (newCells.length === 0) return true; + + const newRow = rowType.createAndFill(null, newCells); + if (!newRow) return false; + + let insertPos = tablePos + 1; + for (let row = 0; row < boundedInsertIndex; row++) { + insertPos += tableNode.child(row).nodeSize; + } + tr.insert(insertPos, newRow); + return true; +} + // --------------------------------------------------------------------------- // Batch 2 — Table lifecycle + layout // --------------------------------------------------------------------------- @@ -781,14 +1096,16 @@ export function tablesInsertRowAdapter( const insertIdx = input.position === 'above' ? rowIndex + i : rowIndex + 1 + i; const sourceIdx = input.position === 'above' ? rowIndex + i : rowIndex; - insertRowAtIndex({ + const didInsertRow = insertRowInTable( tr, tablePos, - tableNode: currentTableNode, - sourceRowIndex: Math.min(sourceIdx, currentTableNode.childCount - 1), - insertIndex: Math.min(insertIdx, currentTableNode.childCount), + Math.min(sourceIdx, currentTableNode.childCount - 1), + Math.min(insertIdx, currentTableNode.childCount), schema, - }); + ); + if (!didInsertRow) { + return toTableFailure('INVALID_TARGET', 'Row insertion could not be applied.'); + } } if (mode === 'tracked') applyTrackedMutationMeta(tr); @@ -2003,51 +2320,20 @@ export function tablesUnmergeCellsAdapter( try { const tr = editor.state.tr; const tablePos = table.candidate.pos; - const tableStart = tablePos + 1; const tableNode = table.candidate.node; - const map = TableMap.get(tableNode); const schema = editor.state.schema; - - // Replace the merged cell with a 1x1 cell while preserving content. - const currentColwidth = Array.isArray(attrs.colwidth) ? (attrs.colwidth as number[]) : null; - const tableCellProperties = { - ...((attrs.tableCellProperties ?? {}) as Record), - }; - delete tableCellProperties.gridSpan; - delete tableCellProperties.vMerge; - - const resetCell = cellNode.type.create( - { - ...attrs, - colspan: 1, - rowspan: 1, - colwidth: currentColwidth && currentColwidth.length > 0 ? [currentColwidth[0] ?? 0] : currentColwidth, - tableCellProperties, - }, - cellNode.content, - ); - tr.replaceWith(cellPos, cellPos + cellNode.nodeSize, resetCell); - - // Fill the previously spanned area with empty cells. - const mapFrom = tr.mapping.maps.length; - for (let row = rowIndex + rowspan - 1; row >= rowIndex; row--) { - for (let col = columnIndex + colspan - 1; col >= columnIndex; col--) { - if (row === rowIndex && col === columnIndex) continue; - - const newCell = schema.nodes.tableCell.createAndFill()!; - - let insertRelPos: number; - if (row === rowIndex) { - // For the top row, insert after the original cell so it stays top-left. - const baseRelPos = map.positionAt(rowIndex, columnIndex, tableNode); - insertRelPos = baseRelPos + resetCell.nodeSize; - } else { - insertRelPos = map.positionAt(row, col, tableNode); - } - - tr.insert(tr.mapping.slice(mapFrom).map(tableStart + insertRelPos), newCell); - } - } + expandMergedCellIntoSingles({ + tr, + tablePos, + tableNode, + cellPos, + cellNode, + rowIndex, + columnIndex, + rowspan, + colspan, + schema, + }); applyDirectMutationMeta(tr); editor.dispatch(tr); @@ -2083,10 +2369,6 @@ export function tablesSplitCellAdapter( const currentColspan = (attrs.colspan as number) || 1; const currentRowspan = (attrs.rowspan as number) || 1; - if ((currentColspan > 1 || currentRowspan > 1) && input.rows === currentRowspan && input.columns === currentColspan) { - return tablesUnmergeCellsAdapter(editor, input, options); - } - if (input.rows === 1 && input.columns === 1 && currentColspan === 1 && currentRowspan === 1) { return toTableFailure('NO_OP', 'Cell is already a single cell and split target is 1×1.'); } @@ -2098,33 +2380,114 @@ export function tablesSplitCellAdapter( try { const tr = editor.state.tr; const tablePos = table.candidate.pos; - const tableStart = tablePos + 1; - const tableNode = table.candidate.node; - const map = TableMap.get(tableNode); const schema = editor.state.schema; + const targetColumns = Math.max(input.columns, currentColspan); + const targetRows = Math.max(input.rows, currentRowspan); + const additionalColumns = Math.max(0, targetColumns - currentColspan); + const additionalRows = Math.max(0, targetRows - currentRowspan); + let updatedGrid = (table.candidate.node.attrs as Record).grid; - // Target span: if the cell already spans more than requested, we reduce. - // If the cell spans less, the split creates sub-cells within the current span. - const targetColspan = Math.max(input.columns, currentColspan); - const targetRowspan = Math.max(input.rows, currentRowspan); + // If the target is already merged, first normalize it to single cells in its current span. + // This preserves all non-target cells while creating a stable base region to expand from. + if (currentColspan > 1 || currentRowspan > 1) { + const currentTableNode = tr.doc.nodeAt(tablePos); + if (!currentTableNode || currentTableNode.type.name !== 'table') { + return toTableFailure('INVALID_TARGET', 'Cell split target table is unavailable.'); + } - // Replace the original cell with a 1×1 cell keeping the content. - const splitAttrs = { ...attrs, colspan: 1, rowspan: 1 }; - tr.setNodeMarkup(cellPos, null, splitAttrs); + const currentCellPos = tr.mapping.map(cellPos, 1); + const currentCellNode = tr.doc.nodeAt(currentCellPos); + if ( + !currentCellNode || + (currentCellNode.type.name !== 'tableCell' && currentCellNode.type.name !== 'tableHeader') + ) { + return toTableFailure('INVALID_TARGET', 'Split target cell is unavailable.'); + } - // Insert empty cells for the rest of the grid. - const mapFrom = tr.mapping.maps.length; - for (let row = rowIndex + targetRowspan - 1; row >= rowIndex; row--) { - for (let col = columnIndex + targetColspan - 1; col >= columnIndex; col--) { - if (row === rowIndex && col === columnIndex) continue; + expandMergedCellIntoSingles({ + tr, + tablePos, + tableNode: currentTableNode, + cellPos: currentCellPos, + cellNode: currentCellNode, + rowIndex, + columnIndex, + rowspan: currentRowspan, + colspan: currentColspan, + schema, + }); + } - const newCell = schema.nodes.tableCell.createAndFill()!; - // Use positionAt if within original map bounds, otherwise insert at row end. - if (row < map.height && col < map.width) { - const insertPos = map.positionAt(row, col, tableNode); - tr.insert(tr.mapping.slice(mapFrom).map(tableStart + insertPos), newCell); - } + for (let columnOffset = 0; columnOffset < additionalColumns; columnOffset++) { + const insertColumnIndex = columnIndex + currentColspan + columnOffset; + addColumnToTableForSplit(tr, tablePos, insertColumnIndex, rowIndex, rowIndex + targetRows); + updatedGrid = insertGridColumnWidth(updatedGrid, insertColumnIndex) ?? updatedGrid; + } + + for (let rowOffset = 0; rowOffset < additionalRows; rowOffset++) { + const currentTableNode = tr.doc.nodeAt(tablePos); + if (!currentTableNode || currentTableNode.type.name !== 'table') { + return toTableFailure('INVALID_TARGET', 'Cell split target table is unavailable.'); } + + const insertIndex = rowIndex + currentRowspan + rowOffset; + const boundedInsertIndex = Math.max(0, Math.min(insertIndex, currentTableNode.childCount)); + const sourceRowIndex = Math.max(0, Math.min(boundedInsertIndex - 1, currentTableNode.childCount - 1)); + const didInsertRow = insertRowInTableForSplit( + tr, + tablePos, + sourceRowIndex, + boundedInsertIndex, + columnIndex, + columnIndex + targetColumns, + schema, + ); + + if (!didInsertRow) { + return toTableFailure('INVALID_TARGET', 'Cell split could not insert required rows.'); + } + } + + const finalTableNode = tr.doc.nodeAt(tablePos); + if (!finalTableNode || finalTableNode.type.name !== 'table') { + return toTableFailure('INVALID_TARGET', 'Cell split target table is unavailable.'); + } + + const mappedTargetCellPos = tr.mapping.map(cellPos, 1); + let finalTargetCellPos = mappedTargetCellPos; + let finalTargetCell = tr.doc.nodeAt(finalTargetCellPos); + + if ( + !finalTargetCell || + (finalTargetCell.type.name !== 'tableCell' && finalTargetCell.type.name !== 'tableHeader') + ) { + const tableStart = tablePos + 1; + const finalMap = TableMap.get(finalTableNode); + const finalTargetRelPos = finalMap.positionAt(rowIndex, columnIndex, finalTableNode); + finalTargetCellPos = tableStart + finalTargetRelPos; + finalTargetCell = tr.doc.nodeAt(finalTargetCellPos); + } + + if ( + !finalTargetCell || + (finalTargetCell.type.name !== 'tableCell' && finalTargetCell.type.name !== 'tableHeader') + ) { + return toTableFailure('INVALID_TARGET', 'Split target cell is unavailable.'); + } + + tr.setNodeMarkup( + finalTargetCellPos, + null, + normalizeCellAttrsForSingleCell(finalTargetCell.attrs as Record), + ); + + if (updatedGrid) { + const currentTableAttrs = finalTableNode.attrs as Record; + tr.setNodeMarkup(tablePos, null, { + ...currentTableAttrs, + grid: updatedGrid, + userEdited: true, + }); } applyDirectMutationMeta(tr); diff --git a/tests/doc-api-stories/tests/tables/all-commands.ts b/tests/doc-api-stories/tests/tables/all-commands.ts index 12c006446e..f2291c2942 100644 --- a/tests/doc-api-stories/tests/tables/all-commands.ts +++ b/tests/doc-api-stories/tests/tables/all-commands.ts @@ -80,6 +80,8 @@ describe('document-api story: all table commands', () => { const insertCellInitialRowsBySession = new Map(); const deleteCellBySession = new Map(); const deleteCellTableBySession = new Map(); + const splitCellBySession = new Map(); + const splitTableBySession = new Map(); function makeSessionId(prefix: string): string { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -337,10 +339,58 @@ describe('document-api story: all table commands', () => { }, { operationId: 'tables.split', - setup: 'table', - run: async (sessionId, fixture) => { - const f = requireFixture('tables.split', fixture); - return unwrap(await api.doc.tables.split({ sessionId, nodeId: f.tableNodeId, atRowIndex: 1 })); + setup: 'blank', + prepare: async (sessionId) => { + await api.doc.insert({ sessionId, value: 'a\tb\tc' }); + + const secondRowResult = unwrap( + await api.doc.create.paragraph({ + sessionId, + at: { kind: 'documentEnd' }, + text: 'c\td\te', + }), + ); + if (secondRowResult?.success !== true) { + const code = secondRowResult?.failure?.code ?? 'UNKNOWN'; + throw new Error(`tables.split setup failed while creating second row paragraph (code: ${code}).`); + } + + const thirdRowResult = unwrap( + await api.doc.create.paragraph({ + sessionId, + at: { kind: 'documentEnd' }, + text: 'f\tg\th', + }), + ); + if (thirdRowResult?.success !== true) { + const code = thirdRowResult?.failure?.code ?? 'UNKNOWN'; + throw new Error(`tables.split setup failed while creating third row paragraph (code: ${code}).`); + } + + const paragraphNodeId = await firstNodeId(sessionId, 'paragraph'); + const convertResult = unwrap( + await api.doc.tables.convertFromText({ + sessionId, + nodeId: paragraphNodeId, + delimiter: 'tab', + }), + ); + assertMutationSuccess('tables.convertFromText', convertResult); + + const tableNodeId = convertResult?.table?.nodeId; + if (!tableNodeId) { + throw new Error('tables.split setup failed: converted table nodeId was not returned.'); + } + splitTableBySession.set(sessionId, tableNodeId); + }, + run: async (sessionId) => { + const tableNodeId = splitTableBySession.get(sessionId); + if (!tableNodeId) { + throw new Error('tables.split setup failed: prepared table nodeId was not found.'); + } + splitTableBySession.delete(sessionId); + + return unwrap(await api.doc.tables.split({ sessionId, nodeId: tableNodeId, atRowIndex: 1 })); }, }, { @@ -778,29 +828,99 @@ describe('document-api story: all table commands', () => { }, { operationId: 'tables.splitCell', - setup: 'table', - prepare: async (sessionId, fixture) => { - const f = requireFixture('tables.splitCell', fixture); - const mergeResult = unwrap( - await api.doc.tables.mergeCells({ + setup: 'blank', + prepare: async (sessionId) => { + await api.doc.insert({ sessionId, value: 'a\tb\tc' }); + + const secondRowResult = unwrap( + await api.doc.create.paragraph({ sessionId, - tableNodeId: f.tableNodeId, - start: { rowIndex: 0, columnIndex: 0 }, - end: { rowIndex: 0, columnIndex: 1 }, + at: { kind: 'documentEnd' }, + text: 'c\td\te', }), ); - assertMutationSuccess('tables.mergeCells', mergeResult); + if (secondRowResult?.success !== true) { + const code = secondRowResult?.failure?.code ?? 'UNKNOWN'; + throw new Error(`tables.splitCell setup failed while creating second row paragraph (code: ${code}).`); + } + + const thirdRowResult = unwrap( + await api.doc.create.paragraph({ + sessionId, + at: { kind: 'documentEnd' }, + text: 'f\tg\th', + }), + ); + if (thirdRowResult?.success !== true) { + const code = thirdRowResult?.failure?.code ?? 'UNKNOWN'; + throw new Error(`tables.splitCell setup failed while creating third row paragraph (code: ${code}).`); + } + + const paragraphNodeId = await firstNodeId(sessionId, 'paragraph'); + const convertResult = unwrap( + await api.doc.tables.convertFromText({ + sessionId, + nodeId: paragraphNodeId, + delimiter: 'tab', + }), + ); + assertMutationSuccess('tables.convertFromText', convertResult); + + const tableNodeId = convertResult?.table?.nodeId; + if (!tableNodeId) { + throw new Error('tables.splitCell setup failed: converted table nodeId was not returned.'); + } + + const cellsResult = unwrap( + await api.doc.tables.getCells({ + sessionId, + nodeId: tableNodeId, + rowIndex: 0, + }), + ); + const firstCell = Array.isArray(cellsResult?.cells) + ? cellsResult.cells.find((cell: any) => cell?.rowIndex === 0 && cell?.columnIndex === 0) + : null; + const cellNodeId = firstCell?.nodeId; + if (!cellNodeId) { + throw new Error('tables.splitCell setup failed: first cell nodeId was not found.'); + } + + splitCellBySession.set(sessionId, { tableNodeId, cellNodeId }); }, - run: async (sessionId, fixture) => { - const f = requireFixture('tables.splitCell', fixture); - return unwrap( + run: async (sessionId) => { + const prepared = splitCellBySession.get(sessionId); + if (!prepared) { + throw new Error('tables.splitCell setup failed: prepared target cell was not found.'); + } + splitCellBySession.delete(sessionId); + + const result = unwrap( await api.doc.tables.splitCell({ sessionId, - nodeId: f.cellNodeId, - rows: 1, + nodeId: prepared.cellNodeId, + rows: 2, columns: 2, }), ); + + assertMutationSuccess('tables.splitCell', result); + + const tableAfter = unwrap( + await api.doc.tables.get({ + sessionId, + nodeId: prepared.tableNodeId, + }), + ); + const rows = Number(tableAfter?.rows ?? 0); + const columns = Number(tableAfter?.columns ?? 0); + if (rows !== 4 || columns !== 4) { + throw new Error( + `tables.splitCell postcondition failed: expected 4x4 after split, received ${rows}x${columns}.`, + ); + } + + return result; }, }, { diff --git a/tests/doc-api-stories/tests/toc/all-commands.ts b/tests/doc-api-stories/tests/toc/all-commands.ts index 0b7ecaf60c..8259ad1c07 100644 --- a/tests/doc-api-stories/tests/toc/all-commands.ts +++ b/tests/doc-api-stories/tests/toc/all-commands.ts @@ -96,7 +96,7 @@ describe('document-api story: all toc commands', () => { } async function seedHeadingContent(sessionId: string): Promise { - const insertResult = unwrap(await api.doc.insert({ sessionId, text: 'TOC story seed paragraph.' })); + const insertResult = unwrap(await api.doc.insert({ sessionId, value: 'TOC story seed paragraph.' })); expect(insertResult?.receipt?.success).toBe(true); const h1 = unwrap(