diff --git a/packages/super-editor/src/document-api-adapters/tables-adapter.integration.test.ts b/packages/super-editor/src/document-api-adapters/tables-adapter.integration.test.ts new file mode 100644 index 0000000000..686fb8aef0 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/tables-adapter.integration.test.ts @@ -0,0 +1,74 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpers.js'; +import DocxZipper from '@core/DocxZipper.js'; +import type { Editor } from '../core/Editor.js'; +import { createTableAdapter, tablesSplitAdapter } from './tables-adapter.js'; + +type LoadedDocData = Awaited>; + +const DIRECT_MUTATION_OPTIONS = { changeMode: 'direct' } as const; + +function mapExportedFiles(files: Array<{ name: string; content: string }>): Record { + const byName: Record = {}; + for (const file of files) { + byName[file.name] = file.content; + } + return byName; +} + +async function exportDocxFiles(editor: Editor): Promise> { + const zipper = new DocxZipper(); + const exportedBuffer = await editor.exportDocx(); + const exportedFiles = await zipper.getDocxData(exportedBuffer, true); + return mapExportedFiles(exportedFiles); +} + +function resolveTableNodeId(result: ReturnType): string { + if (!result.success || result.table?.kind !== 'block' || result.table.nodeType !== 'table' || !result.table.nodeId) { + throw new Error('Expected create.table to return a table nodeId.'); + } + return result.table.nodeId; +} + +describe('tables adapter DOCX integration', () => { + let docData: LoadedDocData; + let editor: Editor | undefined; + + beforeAll(async () => { + docData = await loadTestDataForEditorTests('blank-doc.docx'); + }); + + afterEach(() => { + editor?.destroy(); + editor = undefined; + }); + + it('exports a paragraph separator between split tables', async () => { + ({ editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + useImmediateSetTimeout: false, + })); + + const createResult = createTableAdapter( + editor, + { rows: 3, columns: 3, at: { kind: 'documentEnd' } }, + DIRECT_MUTATION_OPTIONS, + ); + const tableNodeId = resolveTableNodeId(createResult); + + const splitResult = tablesSplitAdapter(editor, { nodeId: tableNodeId, atRowIndex: 1 }, DIRECT_MUTATION_OPTIONS); + expect(splitResult.success).toBe(true); + + const exportedFiles = await exportDocxFiles(editor); + const documentXml = exportedFiles['word/document.xml']; + + expect(documentXml).toBeTruthy(); + expect(documentXml).not.toMatch(/<\/w:tbl>\s*/); + expect(documentXml).toMatch(/<\/w:tbl>\s*]*(?:\/>|>[\s\S]*?<\/w:p>)\s*/); + }); +}); 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 95b1d7a80f..6598327eb9 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, + tablesSplitAdapter, } from './tables-adapter.js'; vi.mock('prosemirror-tables', () => ({ @@ -213,6 +214,20 @@ function makeTableEditor(): Editor { tr, schema: { nodes: { + paragraph: { + createAndFill: vi.fn((attrs: Record = {}, content?: unknown) => { + const children = Array.isArray(content) + ? (content as ProseMirrorNode[]) + : content + ? ([content] as ProseMirrorNode[]) + : []; + return createNode('paragraph', children, { + attrs: { paragraphProperties: {}, ...attrs }, + isBlock: true, + inlineContent: true, + }); + }), + }, tableCell: { createAndFill: vi.fn((attrs: Record = {}, content?: unknown) => { const children = Array.isArray(content) @@ -330,6 +345,27 @@ describe('tables-adapter regressions', () => { expect(tr.insert).toHaveBeenCalledWith(expectedInsertPos, expect.anything()); }); + it('inserts a separator paragraph before the split-off table', () => { + const editor = makeTableEditor(); + const tr = editor.state.tr as unknown as { insert: ReturnType }; + const tableNode = editor.state.doc.nodeAt(0) as ProseMirrorNode; + const expectedInsertPos = tableNode.nodeSize; + + const result = tablesSplitAdapter(editor, { nodeId: 'table-1', atRowIndex: 1 }); + expect(result.success).toBe(true); + expect(tr.insert).toHaveBeenCalledTimes(2); + + const firstInsertCall = tr.insert.mock.calls[0] as [number, ProseMirrorNode]; + const secondInsertCall = tr.insert.mock.calls[1] as [number, ProseMirrorNode]; + const insertedSeparator = firstInsertCall[1]; + const insertedTable = secondInsertCall[1]; + + expect(firstInsertCall[0]).toBe(expectedInsertPos); + expect(insertedSeparator.type.name).toBe('paragraph'); + expect(secondInsertCall[0]).toBe(expectedInsertPos + insertedSeparator.nodeSize); + expect(insertedTable.type.name).toBe('table'); + }); + 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 40e99a4066..7fb4174878 100644 --- a/packages/super-editor/src/document-api-adapters/tables-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/tables-adapter.ts @@ -85,6 +85,19 @@ function generateParaId(): string { .toUpperCase(); } +function createSeparatorParagraph(schema: Editor['state']['schema']): import('prosemirror-model').Node | null { + const paragraphType = schema.nodes.paragraph; + if (!paragraphType) return null; + + // Keep separator paragraphs addressable/stable for downstream DOCX roundtrip. + const separatorAttrs = { + sdBlockId: uuidv4(), + paraId: generateParaId(), + }; + + return paragraphType.createAndFill(separatorAttrs) ?? paragraphType.createAndFill(); +} + function notYetImplemented(operationName: string): never { throw new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', `${operationName} is not yet implemented.`, { reason: 'not_implemented', @@ -1494,10 +1507,16 @@ export function tablesSplitAdapter( delete newTableAttrs.paraId; // Avoid duplicate w14:paraId after split. delete newTableAttrs.textId; // Avoid duplicate w14:textId after split. const newTable = schema.nodes.table.create(newTableAttrs, secondTableRows); + const separatorParagraph = createSeparatorParagraph(schema); + if (!separatorParagraph) { + return toTableFailure('INVALID_TARGET', 'Table split could not create a separator paragraph.'); + } - // Insert the new table after the original. + // Insert an empty paragraph between tables. Without this block separator, + // Word merges adjacent nodes into one visual table. const insertPos = tr.mapping.slice(mapFrom).map(tablePos + tableNode.nodeSize); - tr.insert(insertPos, newTable); + tr.insert(insertPos, separatorParagraph); + tr.insert(insertPos + separatorParagraph.nodeSize, newTable); applyDirectMutationMeta(tr); editor.dispatch(tr);