Skip to content

Commit 55886b0

Browse files
committed
Merge branch 'main' into nick/sd-2045-bug-tablessplitcell-removes-neighboring-cell-content-instead
2 parents 42dde97 + 456f60e commit 55886b0

3 files changed

Lines changed: 200 additions & 3 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/* @vitest-environment jsdom */
2+
3+
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
4+
import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js';
5+
import DocxZipper from '@core/DocxZipper.js';
6+
import type { Editor } from '../core/Editor.js';
7+
import { createTableAdapter, tablesSplitAdapter } from './tables-adapter.js';
8+
9+
type LoadedDocData = Awaited<ReturnType<typeof loadTestDataForEditorTests>>;
10+
11+
const DIRECT_MUTATION_OPTIONS = { changeMode: 'direct' } as const;
12+
13+
function mapExportedFiles(files: Array<{ name: string; content: string }>): Record<string, string> {
14+
const byName: Record<string, string> = {};
15+
for (const file of files) {
16+
byName[file.name] = file.content;
17+
}
18+
return byName;
19+
}
20+
21+
async function exportDocxFiles(editor: Editor): Promise<Record<string, string>> {
22+
const zipper = new DocxZipper();
23+
const exportedBuffer = await editor.exportDocx();
24+
const exportedFiles = await zipper.getDocxData(exportedBuffer, true);
25+
return mapExportedFiles(exportedFiles);
26+
}
27+
28+
function resolveTableNodeId(result: ReturnType<typeof createTableAdapter>): string {
29+
if (!result.success || result.table?.kind !== 'block' || result.table.nodeType !== 'table' || !result.table.nodeId) {
30+
throw new Error('Expected create.table to return a table nodeId.');
31+
}
32+
return result.table.nodeId;
33+
}
34+
35+
describe('tables adapter DOCX integration', () => {
36+
let docData: LoadedDocData;
37+
let editor: Editor | undefined;
38+
39+
beforeAll(async () => {
40+
docData = await loadTestDataForEditorTests('blank-doc.docx');
41+
});
42+
43+
afterEach(() => {
44+
editor?.destroy();
45+
editor = undefined;
46+
});
47+
48+
it('exports a paragraph separator between split tables', async () => {
49+
({ editor } = initTestEditor({
50+
content: docData.docx,
51+
media: docData.media,
52+
mediaFiles: docData.mediaFiles,
53+
fonts: docData.fonts,
54+
useImmediateSetTimeout: false,
55+
}));
56+
57+
const createResult = createTableAdapter(
58+
editor,
59+
{ rows: 3, columns: 3, at: { kind: 'documentEnd' } },
60+
DIRECT_MUTATION_OPTIONS,
61+
);
62+
const tableNodeId = resolveTableNodeId(createResult);
63+
64+
const splitResult = tablesSplitAdapter(editor, { nodeId: tableNodeId, atRowIndex: 1 }, DIRECT_MUTATION_OPTIONS);
65+
expect(splitResult.success).toBe(true);
66+
67+
const exportedFiles = await exportDocxFiles(editor);
68+
const documentXml = exportedFiles['word/document.xml'];
69+
70+
expect(documentXml).toBeTruthy();
71+
expect(documentXml).not.toMatch(/<\/w:tbl>\s*<w:tbl>/);
72+
expect(documentXml).toMatch(/<\/w:tbl>\s*<w:p\b[^>]*(?:\/>|>[\s\S]*?<\/w:p>)\s*<w:tbl>/);
73+
});
74+
});

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
tablesSetBorderAdapter,
1212
tablesSetShadingAdapter,
1313
tablesSplitCellAdapter,
14+
tablesSplitAdapter,
1415
} from './tables-adapter.js';
1516

1617
vi.mock('prosemirror-tables', () => ({
@@ -238,7 +239,7 @@ function makeTableEditor(options: TableEditorOptions = {}): Editor {
238239
? ([content] as ProseMirrorNode[])
239240
: [];
240241
return createNode('paragraph', children, {
241-
attrs,
242+
attrs: { paragraphProperties: {}, ...attrs },
242243
isBlock: true,
243244
inlineContent: true,
244245
});
@@ -361,6 +362,27 @@ describe('tables-adapter regressions', () => {
361362
expect(tr.insert).toHaveBeenCalledWith(expectedInsertPos, expect.anything());
362363
});
363364

365+
it('inserts a separator paragraph before the split-off table', () => {
366+
const editor = makeTableEditor();
367+
const tr = editor.state.tr as unknown as { insert: ReturnType<typeof vi.fn> };
368+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
369+
const expectedInsertPos = tableNode.nodeSize;
370+
371+
const result = tablesSplitAdapter(editor, { nodeId: 'table-1', atRowIndex: 1 });
372+
expect(result.success).toBe(true);
373+
expect(tr.insert).toHaveBeenCalledTimes(2);
374+
375+
const firstInsertCall = tr.insert.mock.calls[0] as [number, ProseMirrorNode];
376+
const secondInsertCall = tr.insert.mock.calls[1] as [number, ProseMirrorNode];
377+
const insertedSeparator = firstInsertCall[1];
378+
const insertedTable = secondInsertCall[1];
379+
380+
expect(firstInsertCall[0]).toBe(expectedInsertPos);
381+
expect(insertedSeparator.type.name).toBe('paragraph');
382+
expect(secondInsertCall[0]).toBe(expectedInsertPos + insertedSeparator.nodeSize);
383+
expect(insertedTable.type.name).toBe('table');
384+
});
385+
364386
it('deletes shiftLeft cells without appending a trailing replacement cell', () => {
365387
const editor = makeTableEditor();
366388
const tr = editor.state.tr as unknown as {
@@ -634,6 +656,56 @@ describe('tables-adapter regressions', () => {
634656
});
635657
});
636658

659+
it('applies table shading to all cells when target is a table', () => {
660+
const editor = makeTableEditor();
661+
const tr = editor.state.tr as unknown as { setNodeMarkup: ReturnType<typeof vi.fn> };
662+
663+
const result = tablesSetShadingAdapter(editor, {
664+
nodeId: 'table-1',
665+
color: 'FFFF00',
666+
});
667+
668+
expect(result.success).toBe(true);
669+
670+
const cellUpdates = tr.setNodeMarkup.mock.calls.filter(
671+
(call) =>
672+
typeof call[2] === 'object' &&
673+
call[2] != null &&
674+
(call[2] as { tableCellProperties?: { shading?: { fill?: string } } }).tableCellProperties?.shading?.fill ===
675+
'FFFF00',
676+
);
677+
678+
expect(cellUpdates).toHaveLength(4);
679+
for (const call of cellUpdates) {
680+
expect((call[2] as { background?: { color?: string } }).background).toEqual({ color: 'FFFF00' });
681+
}
682+
});
683+
684+
it('does not write cell background when table shading color is auto', () => {
685+
const editor = makeTableEditor();
686+
const tr = editor.state.tr as unknown as { setNodeMarkup: ReturnType<typeof vi.fn> };
687+
688+
const result = tablesSetShadingAdapter(editor, {
689+
nodeId: 'table-1',
690+
color: 'auto',
691+
});
692+
693+
expect(result.success).toBe(true);
694+
695+
const cellUpdates = tr.setNodeMarkup.mock.calls.filter(
696+
(call) =>
697+
typeof call[2] === 'object' &&
698+
call[2] != null &&
699+
(call[2] as { tableCellProperties?: { shading?: { fill?: string } } }).tableCellProperties?.shading?.fill ===
700+
'auto',
701+
);
702+
703+
expect(cellUpdates).toHaveLength(4);
704+
for (const call of cellUpdates) {
705+
expect((call[2] as { background?: unknown }).background).toBeUndefined();
706+
}
707+
});
708+
637709
it.each([
638710
{
639711
name: 'tables.setBorder',

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

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ function generateParaId(): string {
8484
.toUpperCase();
8585
}
8686

87+
function createSeparatorParagraph(schema: Editor['state']['schema']): import('prosemirror-model').Node | null {
88+
const paragraphType = schema.nodes.paragraph;
89+
if (!paragraphType) return null;
90+
91+
// Keep separator paragraphs addressable/stable for downstream DOCX roundtrip.
92+
const separatorAttrs = {
93+
sdBlockId: uuidv4(),
94+
paraId: generateParaId(),
95+
};
96+
97+
return paragraphType.createAndFill(separatorAttrs) ?? paragraphType.createAndFill();
98+
}
99+
87100
function notYetImplemented(operationName: string): never {
88101
throw new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', `${operationName} is not yet implemented.`, {
89102
reason: 'not_implemented',
@@ -1811,10 +1824,16 @@ export function tablesSplitAdapter(
18111824
delete newTableAttrs.paraId; // Avoid duplicate w14:paraId after split.
18121825
delete newTableAttrs.textId; // Avoid duplicate w14:textId after split.
18131826
const newTable = schema.nodes.table.create(newTableAttrs, secondTableRows);
1827+
const separatorParagraph = createSeparatorParagraph(schema);
1828+
if (!separatorParagraph) {
1829+
return toTableFailure('INVALID_TARGET', 'Table split could not create a separator paragraph.');
1830+
}
18141831

1815-
// Insert the new table after the original.
1832+
// Insert an empty paragraph between tables. Without this block separator,
1833+
// Word merges adjacent <w:tbl> nodes into one visual table.
18161834
const insertPos = tr.mapping.slice(mapFrom).map(tablePos + tableNode.nodeSize);
1817-
tr.insert(insertPos, newTable);
1835+
tr.insert(insertPos, separatorParagraph);
1836+
tr.insert(insertPos + separatorParagraph.nodeSize, newTable);
18181837

18191838
applyDirectMutationMeta(tr);
18201839
editor.dispatch(tr);
@@ -3013,6 +3032,38 @@ export function tablesSetShadingAdapter(
30133032
currentProps.shading = { fill: input.color, val: 'clear', color: 'auto' };
30143033
const syncAttrs = resolved.scope === 'table' ? syncExtractedTableAttrs(currentProps) : {};
30153034
tr.setNodeMarkup(resolved.pos, null, { ...currentAttrs, [propsKey]: currentProps, ...syncAttrs });
3035+
3036+
if (resolved.scope === 'table') {
3037+
const tableNode = resolved.node;
3038+
const tableStart = resolved.pos + 1;
3039+
const map = TableMap.get(tableNode);
3040+
const seen = new Set<number>();
3041+
const mapFrom = tr.mapping.maps.length;
3042+
3043+
for (let i = 0; i < map.map.length; i++) {
3044+
const relPos = map.map[i]!;
3045+
if (seen.has(relPos)) continue;
3046+
seen.add(relPos);
3047+
3048+
const cellNode = tableNode.nodeAt(relPos);
3049+
if (!cellNode) continue;
3050+
3051+
const cellAttrs = cellNode.attrs as Record<string, unknown>;
3052+
const cellProps = { ...((cellAttrs.tableCellProperties ?? {}) as Record<string, unknown>) };
3053+
cellProps.shading = { fill: input.color, val: 'clear', color: 'auto' };
3054+
3055+
const nextCellAttrs: Record<string, unknown> = {
3056+
...cellAttrs,
3057+
tableCellProperties: cellProps,
3058+
};
3059+
3060+
if (input.color === 'auto') delete nextCellAttrs.background;
3061+
else nextCellAttrs.background = { color: input.color };
3062+
3063+
tr.setNodeMarkup(tr.mapping.slice(mapFrom).map(tableStart + relPos), null, nextCellAttrs);
3064+
}
3065+
}
3066+
30163067
applyDirectMutationMeta(tr);
30173068
editor.dispatch(tr);
30183069
clearIndexCache(editor);

0 commit comments

Comments
 (0)