Skip to content

Commit ec31699

Browse files
authored
fix(document-api): split table command (#2214)
1 parent cd7a69b commit ec31699

3 files changed

Lines changed: 131 additions & 2 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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
tablesInsertCellAdapter,
1111
tablesSetBorderAdapter,
1212
tablesSetShadingAdapter,
13+
tablesSplitAdapter,
1314
} from './tables-adapter.js';
1415

1516
vi.mock('prosemirror-tables', () => ({
@@ -213,6 +214,20 @@ function makeTableEditor(): Editor {
213214
tr,
214215
schema: {
215216
nodes: {
217+
paragraph: {
218+
createAndFill: vi.fn((attrs: Record<string, unknown> = {}, content?: unknown) => {
219+
const children = Array.isArray(content)
220+
? (content as ProseMirrorNode[])
221+
: content
222+
? ([content] as ProseMirrorNode[])
223+
: [];
224+
return createNode('paragraph', children, {
225+
attrs: { paragraphProperties: {}, ...attrs },
226+
isBlock: true,
227+
inlineContent: true,
228+
});
229+
}),
230+
},
216231
tableCell: {
217232
createAndFill: vi.fn((attrs: Record<string, unknown> = {}, content?: unknown) => {
218233
const children = Array.isArray(content)
@@ -330,6 +345,27 @@ describe('tables-adapter regressions', () => {
330345
expect(tr.insert).toHaveBeenCalledWith(expectedInsertPos, expect.anything());
331346
});
332347

348+
it('inserts a separator paragraph before the split-off table', () => {
349+
const editor = makeTableEditor();
350+
const tr = editor.state.tr as unknown as { insert: ReturnType<typeof vi.fn> };
351+
const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode;
352+
const expectedInsertPos = tableNode.nodeSize;
353+
354+
const result = tablesSplitAdapter(editor, { nodeId: 'table-1', atRowIndex: 1 });
355+
expect(result.success).toBe(true);
356+
expect(tr.insert).toHaveBeenCalledTimes(2);
357+
358+
const firstInsertCall = tr.insert.mock.calls[0] as [number, ProseMirrorNode];
359+
const secondInsertCall = tr.insert.mock.calls[1] as [number, ProseMirrorNode];
360+
const insertedSeparator = firstInsertCall[1];
361+
const insertedTable = secondInsertCall[1];
362+
363+
expect(firstInsertCall[0]).toBe(expectedInsertPos);
364+
expect(insertedSeparator.type.name).toBe('paragraph');
365+
expect(secondInsertCall[0]).toBe(expectedInsertPos + insertedSeparator.nodeSize);
366+
expect(insertedTable.type.name).toBe('table');
367+
});
368+
333369
it('deletes shiftLeft cells without appending a trailing replacement cell', () => {
334370
const editor = makeTableEditor();
335371
const tr = editor.state.tr as unknown as {

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

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

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

1498-
// Insert the new table after the original.
1515+
// Insert an empty paragraph between tables. Without this block separator,
1516+
// Word merges adjacent <w:tbl> nodes into one visual table.
14991517
const insertPos = tr.mapping.slice(mapFrom).map(tablePos + tableNode.nodeSize);
1500-
tr.insert(insertPos, newTable);
1518+
tr.insert(insertPos, separatorParagraph);
1519+
tr.insert(insertPos + separatorParagraph.nodeSize, newTable);
15011520

15021521
applyDirectMutationMeta(tr);
15031522
editor.dispatch(tr);

0 commit comments

Comments
 (0)