Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof loadTestDataForEditorTests>>;

const DIRECT_MUTATION_OPTIONS = { changeMode: 'direct' } as const;

function mapExportedFiles(files: Array<{ name: string; content: string }>): Record<string, string> {
const byName: Record<string, string> = {};
for (const file of files) {
byName[file.name] = file.content;
}
return byName;
}

async function exportDocxFiles(editor: Editor): Promise<Record<string, string>> {
const zipper = new DocxZipper();
const exportedBuffer = await editor.exportDocx();
const exportedFiles = await zipper.getDocxData(exportedBuffer, true);
return mapExportedFiles(exportedFiles);
}

function resolveTableNodeId(result: ReturnType<typeof createTableAdapter>): 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*<w:tbl>/);
expect(documentXml).toMatch(/<\/w:tbl>\s*<w:p\b[^>]*(?:\/>|>[\s\S]*?<\/w:p>)\s*<w:tbl>/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
tablesInsertCellAdapter,
tablesSetBorderAdapter,
tablesSetShadingAdapter,
tablesSplitAdapter,
} from './tables-adapter.js';

vi.mock('prosemirror-tables', () => ({
Expand Down Expand Up @@ -213,6 +214,20 @@ function makeTableEditor(): Editor {
tr,
schema: {
nodes: {
paragraph: {
createAndFill: vi.fn((attrs: Record<string, unknown> = {}, 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<string, unknown> = {}, content?: unknown) => {
const children = Array.isArray(content)
Expand Down Expand Up @@ -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<typeof vi.fn> };
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 <w:tbl> 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);
Expand Down
Loading