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 e224b4b909..fd376dddef 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 @@ -7,6 +7,7 @@ import { tablesClearShadingAdapter, tablesDeleteCellAdapter, tablesDistributeColumnsAdapter, + tablesInsertColumnAdapter, tablesInsertCellAdapter, tablesSetBorderAdapter, tablesSetShadingAdapter, @@ -42,8 +43,11 @@ type NodeOptions = { type TableEditorOptions = { firstRowAsHeaders?: boolean; firstRowBorders?: Record | null; + lastColumnAsHeaders?: boolean; }; +const mockSchema: { nodes: Record } = { nodes: {} }; + function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { const attrs = options.attrs ?? {}; const text = options.text ?? ''; @@ -59,6 +63,7 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: const node = { type: { name: typeName, + schema: mockSchema, create(newAttrs: Record) { return createNode(typeName, [], { attrs: newAttrs, isBlock, inlineContent }); }, @@ -125,12 +130,17 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options: }, }; + mockSchema.nodes[typeName] = node.type; + return node as unknown as ProseMirrorNode; } function makeTableEditor(options: TableEditorOptions = {}): Editor { const firstRowAsHeaders = options.firstRowAsHeaders ?? false; + const lastColumnAsHeaders = options.lastColumnAsHeaders ?? false; const firstRowType = firstRowAsHeaders ? 'tableHeader' : 'tableCell'; + const secondColumnType = lastColumnAsHeaders ? 'tableHeader' : firstRowType; + const lastCellType = lastColumnAsHeaders ? 'tableHeader' : 'tableCell'; const firstRowAttrs = options.firstRowBorders === undefined ? {} @@ -163,7 +173,7 @@ function makeTableEditor(options: TableEditorOptions = {}): Editor { isBlock: true, inlineContent: false, }); - const cell2 = createNode(firstRowType, [paragraph2], { + const cell2 = createNode(secondColumnType, [paragraph2], { attrs: { sdBlockId: 'cell-2', colspan: 1, rowspan: 1, colwidth: [200], ...firstRowAttrs }, isBlock: true, inlineContent: false, @@ -173,7 +183,7 @@ function makeTableEditor(options: TableEditorOptions = {}): Editor { isBlock: true, inlineContent: false, }); - const cell4 = createNode('tableCell', [paragraph4], { + const cell4 = createNode(lastCellType, [paragraph4], { attrs: { sdBlockId: 'cell-4', colspan: 1, rowspan: 1, colwidth: [200] }, isBlock: true, inlineContent: false, @@ -383,6 +393,37 @@ describe('tables-adapter regressions', () => { expect(insertedTable.type.name).toBe('table'); }); + it('SD-2127: inserts a new cell in every row when appending a column to the right of the last column', () => { + const editor = makeTableEditor(); + const tr = editor.state.tr as unknown as { insert: ReturnType }; + + const result = tablesInsertColumnAdapter( + editor, + { tableNodeId: 'table-1', columnIndex: 1, position: 'right' }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(tr.insert).toHaveBeenCalledTimes(2); + }); + + it('SD-2127: appending right of a header edge inserts body cells, not cloned header cells', () => { + const editor = makeTableEditor({ lastColumnAsHeaders: true }); + const tr = editor.state.tr as unknown as { insert: ReturnType }; + + const result = tablesInsertColumnAdapter( + editor, + { tableNodeId: 'table-1', columnIndex: 1, position: 'right' }, + { changeMode: 'direct' }, + ); + + expect(result.success).toBe(true); + expect(tr.insert).toHaveBeenCalledTimes(2); + + const insertedTypeNames = tr.insert.mock.calls.map(([, node]) => (node as ProseMirrorNode).type.name); + expect(insertedTypeNames).toEqual(['tableCell', 'tableCell']); + }); + it('deletes shiftLeft cells without appending a trailing replacement cell', () => { const editor = makeTableEditor(); const tr = editor.state.tr as unknown as { 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 3c59a0eee1..b5657ba647 100644 --- a/packages/super-editor/src/document-api-adapters/tables-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/tables-adapter.ts @@ -1,3 +1,4 @@ +import type { Node as ProseMirrorNode, NodeType } from 'prosemirror-model'; import type { Editor } from '../core/Editor.js'; import { v4 as uuidv4 } from 'uuid'; import type { @@ -541,6 +542,33 @@ function addColSpan(attrs: Record, pos: number, n = 1): Record< return result; } +function isHeaderColumn(tableNode: ProseMirrorNode, map: ReturnType<(typeof TableMap)['get']>, col: number): boolean { + for (let row = 0; row < map.height; row++) { + const cell = tableNode.nodeAt(map.map[col + row * map.width]); + if (!cell || cell.type.name !== 'tableHeader') return false; + } + return true; +} + +function resolveInsertedColumnCellType( + tableNode: ProseMirrorNode, + map: ReturnType<(typeof TableMap)['get']>, + index: number, + col: number, +): NodeType | null { + let refColumn: number | null = col > 0 ? -1 : 0; + if (isHeaderColumn(tableNode, map, col + refColumn)) { + refColumn = col === 0 || col === map.width ? null : 0; + } + + if (refColumn == null) { + return tableNode.type.schema.nodes.tableCell ?? null; + } + + const refPos = map.map[index + refColumn]; + return refPos != null ? (tableNode.nodeAt(refPos)?.type ?? null) : null; +} + /** Inserts a column at `col` in the table (before that column index). Follows prosemirror-tables addColumn pattern. */ function addColumnToTable(tr: Transaction, tablePos: number, col: number): void { const tableNode = tr.doc.nodeAt(tablePos); @@ -551,12 +579,11 @@ function addColumnToTable(tr: Transaction, tablePos: number, col: number): void 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; - - if (col > 0 && map.map[index - 1] === pos) { + if (col > 0 && col < map.width && map.map[index - 1] === map.map[index]) { // Cell spans from the left — expand colspan + const pos = map.map[index]; + const cell = tableNode.nodeAt(pos); + if (!cell) continue; tr.setNodeMarkup( tr.mapping.slice(mapStart).map(tableStart + pos), null, @@ -565,10 +592,10 @@ function addColumnToTable(tr: Transaction, tablePos: number, col: number): void row += (((cell.attrs as Record).rowspan as number) || 1) - 1; } else { // Insert a new empty cell - const refType = col > 0 ? (tableNode.nodeAt(map.map[index - 1])?.type ?? cell.type) : cell.type; + const refType = resolveInsertedColumnCellType(tableNode, map, index, col); + if (!refType) continue; 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; } } }