Skip to content

Commit 42dde97

Browse files
committed
fix(document-api): split cell logic
1 parent c42b637 commit 42dde97

3 files changed

Lines changed: 377 additions & 30 deletions

File tree

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

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ type NodeOptions = {
3838
nodeSize?: number;
3939
};
4040

41+
type TableEditorOptions = {
42+
firstRowAsHeaders?: boolean;
43+
firstRowBorders?: Record<string, unknown> | null;
44+
};
45+
4146
function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode {
4247
const attrs = options.attrs ?? {};
4348
const text = options.text ?? '';
@@ -122,7 +127,15 @@ function createNode(typeName: string, children: ProseMirrorNode[] = [], options:
122127
return node as unknown as ProseMirrorNode;
123128
}
124129

125-
function makeTableEditor(): Editor {
130+
function makeTableEditor(options: TableEditorOptions = {}): Editor {
131+
const firstRowAsHeaders = options.firstRowAsHeaders ?? false;
132+
const firstRowType = firstRowAsHeaders ? 'tableHeader' : 'tableCell';
133+
const firstRowAttrs =
134+
options.firstRowBorders === undefined
135+
? {}
136+
: {
137+
borders: options.firstRowBorders,
138+
};
126139
const paragraph1 = createNode('paragraph', [createNode('text', [], { text: 'Hello' })], {
127140
attrs: { sdBlockId: 'p1', paraId: 'p1', paragraphProperties: {} },
128141
isBlock: true,
@@ -144,13 +157,13 @@ function makeTableEditor(): Editor {
144157
inlineContent: true,
145158
});
146159

147-
const cell1 = createNode('tableCell', [paragraph1], {
148-
attrs: { sdBlockId: 'cell-1', colspan: 1, rowspan: 1, colwidth: [100] },
160+
const cell1 = createNode(firstRowType, [paragraph1], {
161+
attrs: { sdBlockId: 'cell-1', colspan: 1, rowspan: 1, colwidth: [100], ...firstRowAttrs },
149162
isBlock: true,
150163
inlineContent: false,
151164
});
152-
const cell2 = createNode('tableCell', [paragraph2], {
153-
attrs: { sdBlockId: 'cell-2', colspan: 1, rowspan: 1, colwidth: [200] },
165+
const cell2 = createNode(firstRowType, [paragraph2], {
166+
attrs: { sdBlockId: 'cell-2', colspan: 1, rowspan: 1, colwidth: [200], ...firstRowAttrs },
154167
isBlock: true,
155168
inlineContent: false,
156169
});
@@ -559,6 +572,52 @@ describe('tables-adapter regressions', () => {
559572
});
560573
});
561574

575+
it('does not copy header-only null borders when split inserts a body row from a header source row', () => {
576+
const editor = makeTableEditor({ firstRowAsHeaders: true, firstRowBorders: null });
577+
const tr = editor.state.tr as unknown as { insert: ReturnType<typeof vi.fn> };
578+
579+
const result = tablesSplitCellAdapter(editor, {
580+
nodeId: 'cell-1',
581+
rows: 2,
582+
columns: 1,
583+
});
584+
585+
expect(result.success).toBe(true);
586+
587+
const insertedRow = tr.insert.mock.calls.find(([, node]) => node?.type?.name === 'tableRow')?.[1] as
588+
| ProseMirrorNode
589+
| undefined;
590+
expect(insertedRow).toBeDefined();
591+
592+
const insertedCells = ((insertedRow as unknown as { _children?: ProseMirrorNode[] })._children ?? []).filter(
593+
(node) => node.type.name === 'tableCell',
594+
);
595+
expect(insertedCells.length).toBeGreaterThan(0);
596+
for (const cell of insertedCells) {
597+
expect((cell.attrs as Record<string, unknown>).borders).toBeUndefined();
598+
}
599+
});
600+
601+
it('preserves non-target rows when split inserts columns by widening adjacent cells', () => {
602+
const editor = makeTableEditor();
603+
const tr = editor.state.tr as unknown as { setNodeMarkup: ReturnType<typeof vi.fn> };
604+
605+
const result = tablesSplitCellAdapter(editor, {
606+
nodeId: 'cell-1',
607+
rows: 1,
608+
columns: 2,
609+
});
610+
611+
expect(result.success).toBe(true);
612+
expect(tr.setNodeMarkup).toHaveBeenCalledWith(
613+
expect.any(Number),
614+
null,
615+
expect.objectContaining({
616+
colspan: 2,
617+
}),
618+
);
619+
});
620+
562621
it('rejects paragraph targets for tables.setBorder', () => {
563622
const editor = makeTableEditor();
564623
const result = tablesSetBorderAdapter(editor, {

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

Lines changed: 175 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,24 @@ function normalizeCellAttrsForSingleCell(attrs: Record<string, unknown>): Record
253253
};
254254
}
255255

256+
function normalizeClonedRowInsertCellAttrs(
257+
sourceAttrs: Record<string, unknown>,
258+
fromHeaderToBody: boolean,
259+
): Record<string, unknown> {
260+
const normalizedAttrs: Record<string, unknown> = {
261+
...sourceAttrs,
262+
rowspan: 1,
263+
};
264+
265+
// Header rows can carry explicit `borders: null` to suppress drawing.
266+
// Drop that sentinel when cloning into body cells so tableCell defaults apply.
267+
if (fromHeaderToBody && normalizedAttrs.borders == null) {
268+
delete normalizedAttrs.borders;
269+
}
270+
271+
return normalizedAttrs;
272+
}
273+
256274
type ExpandMergedCellParams = {
257275
tr: Transaction;
258276
tablePos: number;
@@ -617,11 +635,11 @@ function insertRowInTable(
617635
}
618636

619637
const colspan = ((sourceCell.attrs as Record<string, unknown>).colspan as number) || 1;
620-
const targetCellType = sourceCell.type.name === 'tableHeader' ? defaultCellType : sourceCell.type;
621-
const newCell = targetCellType.createAndFill({
622-
...(sourceCell.attrs as Record<string, unknown>),
623-
rowspan: 1,
624-
});
638+
const fromHeaderToBody = sourceCell.type.name === 'tableHeader';
639+
const targetCellType = fromHeaderToBody ? defaultCellType : sourceCell.type;
640+
const newCell = targetCellType.createAndFill(
641+
normalizeClonedRowInsertCellAttrs(sourceCell.attrs as Record<string, unknown>, fromHeaderToBody),
642+
);
625643
if (newCell) newCells.push(newCell);
626644
col += colspan;
627645
}
@@ -643,6 +661,148 @@ function insertRowInTable(
643661
return true;
644662
}
645663

664+
function addColumnToTableForSplit(
665+
tr: Transaction,
666+
tablePos: number,
667+
col: number,
668+
splitRowStart: number,
669+
splitRowEnd: number,
670+
): void {
671+
const tableNode = tr.doc.nodeAt(tablePos);
672+
if (!tableNode || tableNode.type.name !== 'table') return;
673+
const map = TableMap.get(tableNode);
674+
const tableStart = tablePos + 1;
675+
const mapStart = tr.mapping.maps.length;
676+
const widenedOutsideSplit = new Set<number>();
677+
678+
for (let row = 0; row < map.height; row++) {
679+
const index = row * map.width + col;
680+
const pos = map.map[index];
681+
const cell = tableNode.nodeAt(pos);
682+
if (!cell) continue;
683+
684+
const inSplitRows = row >= splitRowStart && row < splitRowEnd;
685+
if (!inSplitRows && col > 0) {
686+
const leftPos = map.map[index - 1]!;
687+
const leftCell = tableNode.nodeAt(leftPos);
688+
if (leftCell && !widenedOutsideSplit.has(leftPos)) {
689+
tr.setNodeMarkup(
690+
tr.mapping.slice(mapStart).map(tableStart + leftPos),
691+
null,
692+
addColSpan(leftCell.attrs as Record<string, unknown>, col - map.colCount(leftPos)),
693+
);
694+
widenedOutsideSplit.add(leftPos);
695+
}
696+
row += ((cell.attrs?.rowspan as number) || 1) - 1;
697+
continue;
698+
}
699+
700+
if (col > 0 && map.map[index - 1] === pos) {
701+
tr.setNodeMarkup(
702+
tr.mapping.slice(mapStart).map(tableStart + pos),
703+
null,
704+
addColSpan(cell.attrs as Record<string, unknown>, col - map.colCount(pos)),
705+
);
706+
row += (((cell.attrs as Record<string, unknown>).rowspan as number) || 1) - 1;
707+
} else {
708+
const refType = col > 0 ? (tableNode.nodeAt(map.map[index - 1])?.type ?? cell.type) : cell.type;
709+
const cellPos = map.positionAt(row, col, tableNode);
710+
tr.insert(tr.mapping.slice(mapStart).map(tableStart + cellPos), refType.createAndFill()!);
711+
row += ((cell.attrs?.rowspan as number) || 1) - 1;
712+
}
713+
}
714+
}
715+
716+
function insertRowInTableForSplit(
717+
tr: Transaction,
718+
tablePos: number,
719+
sourceRowIndex: number,
720+
insertIndex: number,
721+
splitColStart: number,
722+
splitColEnd: number,
723+
schema: Editor['state']['schema'],
724+
): boolean {
725+
const tableNode = tr.doc.nodeAt(tablePos);
726+
if (!tableNode || tableNode.type.name !== 'table') return false;
727+
728+
const rowCount = tableNode.childCount;
729+
if (rowCount === 0) return false;
730+
731+
const map = TableMap.get(tableNode);
732+
const boundedInsertIndex = Math.max(0, Math.min(insertIndex, rowCount));
733+
const boundedSourceRowIndex = Math.max(0, Math.min(sourceRowIndex, rowCount - 1));
734+
const sourceRow = tableNode.child(boundedSourceRowIndex);
735+
if (!sourceRow) return false;
736+
737+
const rowType = schema.nodes.tableRow;
738+
const defaultCellType = schema.nodes.tableCell;
739+
if (!rowType || !defaultCellType) return false;
740+
741+
const newCells: import('prosemirror-model').Node[] = [];
742+
const cellsToExtend = new Map<number, Record<string, unknown>>();
743+
744+
for (let col = 0; col < map.width; ) {
745+
if (boundedInsertIndex > 0 && boundedInsertIndex < map.height) {
746+
const indexAbove = (boundedInsertIndex - 1) * map.width + col;
747+
const indexAtInsert = boundedInsertIndex * map.width + col;
748+
749+
if (map.map[indexAbove] === map.map[indexAtInsert]) {
750+
const spanningPos = map.map[indexAbove];
751+
const spanningCell = tableNode.nodeAt(spanningPos);
752+
if (spanningCell) {
753+
const spanningAttrs = spanningCell.attrs as Record<string, unknown>;
754+
const rowspan = (spanningAttrs.rowspan as number) || 1;
755+
const colspan = (spanningAttrs.colspan as number) || 1;
756+
cellsToExtend.set(tablePos + 1 + spanningPos, { ...spanningAttrs, rowspan: rowspan + 1 });
757+
col += colspan;
758+
continue;
759+
}
760+
}
761+
}
762+
763+
const sourceMapIndex = boundedSourceRowIndex * map.width + col;
764+
const sourceCellPos = map.map[sourceMapIndex];
765+
const sourceCell = tableNode.nodeAt(sourceCellPos) ?? sourceRow.firstChild;
766+
if (!sourceCell) {
767+
col += 1;
768+
continue;
769+
}
770+
771+
const sourceAttrs = sourceCell.attrs as Record<string, unknown>;
772+
const colspan = (sourceAttrs.colspan as number) || 1;
773+
const overlapsSplitRange = col < splitColEnd && col + colspan > splitColStart;
774+
775+
if (!overlapsSplitRange) {
776+
const sourceRowspan = (sourceAttrs.rowspan as number) || 1;
777+
cellsToExtend.set(tablePos + 1 + sourceCellPos, { ...sourceAttrs, rowspan: sourceRowspan + 1 });
778+
col += colspan;
779+
continue;
780+
}
781+
782+
const fromHeaderToBody = sourceCell.type.name === 'tableHeader';
783+
const targetCellType = fromHeaderToBody ? defaultCellType : sourceCell.type;
784+
const newCell = targetCellType.createAndFill(normalizeClonedRowInsertCellAttrs(sourceAttrs, fromHeaderToBody));
785+
if (newCell) newCells.push(newCell);
786+
col += colspan;
787+
}
788+
789+
for (const [pos, attrs] of cellsToExtend.entries()) {
790+
tr.setNodeMarkup(pos, null, attrs);
791+
}
792+
793+
if (newCells.length === 0) return true;
794+
795+
const newRow = rowType.createAndFill(null, newCells);
796+
if (!newRow) return false;
797+
798+
let insertPos = tablePos + 1;
799+
for (let row = 0; row < boundedInsertIndex; row++) {
800+
insertPos += tableNode.child(row).nodeSize;
801+
}
802+
tr.insert(insertPos, newRow);
803+
return true;
804+
}
805+
646806
// ---------------------------------------------------------------------------
647807
// Batch 2 — Table lifecycle + layout
648808
// ---------------------------------------------------------------------------
@@ -2241,7 +2401,7 @@ export function tablesSplitCellAdapter(
22412401

22422402
for (let columnOffset = 0; columnOffset < additionalColumns; columnOffset++) {
22432403
const insertColumnIndex = columnIndex + currentColspan + columnOffset;
2244-
addColumnToTable(tr, tablePos, insertColumnIndex);
2404+
addColumnToTableForSplit(tr, tablePos, insertColumnIndex, rowIndex, rowIndex + targetRows);
22452405
updatedGrid = insertGridColumnWidth(updatedGrid, insertColumnIndex) ?? updatedGrid;
22462406
}
22472407

@@ -2254,7 +2414,15 @@ export function tablesSplitCellAdapter(
22542414
const insertIndex = rowIndex + currentRowspan + rowOffset;
22552415
const boundedInsertIndex = Math.max(0, Math.min(insertIndex, currentTableNode.childCount));
22562416
const sourceRowIndex = Math.max(0, Math.min(boundedInsertIndex - 1, currentTableNode.childCount - 1));
2257-
const didInsertRow = insertRowInTable(tr, tablePos, sourceRowIndex, boundedInsertIndex, schema);
2417+
const didInsertRow = insertRowInTableForSplit(
2418+
tr,
2419+
tablePos,
2420+
sourceRowIndex,
2421+
boundedInsertIndex,
2422+
columnIndex,
2423+
columnIndex + targetColumns,
2424+
schema,
2425+
);
22582426

22592427
if (!didInsertRow) {
22602428
return toTableFailure('INVALID_TARGET', 'Cell split could not insert required rows.');

0 commit comments

Comments
 (0)