diff --git a/packages/super-editor/src/core/commands/insertTableAt.js b/packages/super-editor/src/core/commands/insertTableAt.js index 4fdc6d9d06..9eaf708472 100644 --- a/packages/super-editor/src/core/commands/insertTableAt.js +++ b/packages/super-editor/src/core/commands/insertTableAt.js @@ -1,3 +1,25 @@ +import { Fragment } from 'prosemirror-model'; + +/** + * Determines which sides of a table inserted at `pos` need a separator + * paragraph to prevent adjacency with an existing table. + * + * @param {import('prosemirror-model').Node} doc + * @param {number} pos + * @returns {{ before: boolean, after: boolean }} + */ +function tableSeparatorNeeds(doc, pos) { + const $pos = doc.resolve(pos); + if ($pos.depth !== 0) return { before: false, after: false }; + const indexAfter = $pos.index(0); + const nodeAfter = indexAfter < doc.childCount ? doc.child(indexAfter) : null; + const nodeBefore = indexAfter > 0 ? doc.child(indexAfter - 1) : null; + return { + before: nodeBefore?.type.name === 'table', + after: !nodeAfter || nodeAfter.type.name === 'table', + }; +} + /** * Insert a table node at an absolute document position. * @@ -35,7 +57,24 @@ export const insertTableAt = const tableAttrs = sdBlockId ? { sdBlockId } : undefined; const tableNode = tableType.createChecked(tableAttrs, rowNodes); - const tr = state.tr.insert(pos, tableNode); + const sep = tableSeparatorNeeds(state.doc, pos); + let tr; + if (sep.before || sep.after) { + const makeSep = () => state.schema.nodes.paragraph.createAndFill(); + const nodes = []; + if (sep.before) { + const s = makeSep(); + if (s) nodes.push(s); + } + nodes.push(tableNode); + if (sep.after) { + const s = makeSep(); + if (s) nodes.push(s); + } + tr = state.tr.insert(pos, Fragment.from(nodes)); + } else { + tr = state.tr.insert(pos, tableNode); + } if (!dispatch) return true; tr.setMeta('inputType', 'programmatic'); if (tracked === true) tr.setMeta('forceTrackChanges', true); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/insert-structured-wrapper.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/insert-structured-wrapper.test.ts index 0a8a04a0ce..5b80265b2c 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/insert-structured-wrapper.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/insert-structured-wrapper.test.ts @@ -153,6 +153,52 @@ describe('insertStructuredWrapper — markdown', () => { }); }); +describe('insertStructuredWrapper — table separators', () => { + it('inserts a trailing separator paragraph after a markdown table', () => { + const result = insertStructuredWrapper(editor, { + value: '| A | B |\n| --- | --- |\n| foo | bar |', + type: 'markdown', + }); + + expect(result.success).toBe(true); + + const doc = editor.state.doc; + let foundTable = false; + let nodeAfterTable: import('prosemirror-model').Node | null = null; + for (let i = 0; i < doc.childCount; i++) { + if (doc.child(i).type.name === 'table') { + foundTable = true; + if (i + 1 < doc.childCount) { + nodeAfterTable = doc.child(i + 1); + } + break; + } + } + + expect(foundTable).toBe(true); + expect(nodeAfterTable).not.toBeNull(); + expect(nodeAfterTable!.type.name).toBe('paragraph'); + }); + + it('two consecutive markdown table inserts produce non-adjacent tables', () => { + insertStructuredWrapper(editor, { + value: '| A | B |\n| --- | --- |\n| 1 | 2 |', + type: 'markdown', + }); + insertStructuredWrapper(editor, { + value: '| C | D |\n| --- | --- |\n| 3 | 4 |', + type: 'markdown', + }); + + const doc = editor.state.doc; + for (let i = 0; i < doc.childCount - 1; i++) { + if (doc.child(i).type.name === 'table' && doc.child(i + 1).type.name === 'table') { + throw new Error(`Adjacent tables at children ${i} and ${i + 1}`); + } + } + }); +}); + describe('insertStructuredWrapper — list numbering rollback', () => { it('rolls back numbering allocations when insertContentAt fails after markdown parsing', () => { // This test exercises the actual rollback branch: markdown with list diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index 1f7271aecd..27d8ac358b 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -56,6 +56,20 @@ function editorHasDom(editor: Editor): boolean { return !!(opts?.document ?? opts?.mockDocument ?? (typeof document !== 'undefined' ? document : null)); } +/** + * Mutate `jsonNodes` in place so that consecutive table nodes within the + * array are separated by an empty paragraph. Only handles within-fragment + * adjacency — document-context separators (leading/trailing) are handled + * by the caller after inspecting the insertion position. + */ +function ensureTableSeparators(jsonNodes: Record[]): void { + for (let i = jsonNodes.length - 2; i >= 0; i--) { + if (jsonNodes[i].type === 'table' && jsonNodes[i + 1].type === 'table') { + jsonNodes.splice(i + 1, 0, { type: 'paragraph' }); + } + } +} + // --------------------------------------------------------------------------- // Locator normalization (same validation as the old adapters) // --------------------------------------------------------------------------- @@ -641,6 +655,33 @@ export function insertStructuredWrapper( const jsonNodes: Record[] = []; fragment.forEach((node) => jsonNodes.push(node.toJSON())); + // Word always separates adjacent tables with a paragraph. Without a + // trailing separator, consecutive markdown inserts produce adjacent + // elements that Word merges into one visual table. + ensureTableSeparators(jsonNodes); + + // insertContentAt replaces empty textblocks when inserting block + // content. Check whether the replaced paragraph's neighbors are tables + // and add separators to prevent adjacency in the result. + if (from === to) { + const $pos = editor.state.doc.resolve(from); + const parent = $pos.parent; + if (parent.isTextblock && !parent.childCount) { + const grandparent = $pos.node($pos.depth - 1); + const idx = $pos.index($pos.depth - 1); + const prevIsTable = idx > 0 && grandparent.child(idx - 1).type.name === 'table'; + const nextIsTable = idx + 1 < grandparent.childCount && grandparent.child(idx + 1).type.name === 'table'; + const atEnd = idx + 1 >= grandparent.childCount; + + if (jsonNodes[0]?.type === 'table' && prevIsTable) { + jsonNodes.unshift({ type: 'paragraph' }); + } + if (jsonNodes[jsonNodes.length - 1]?.type === 'table' && (nextIsTable || atEnd)) { + jsonNodes.push({ type: 'paragraph' }); + } + } + } + const ok = Boolean(editor.commands.insertContentAt({ from, to }, jsonNodes)); if (!ok) { insertFailure = { 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 index 686fb8aef0..71ee761beb 100644 --- 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 @@ -5,6 +5,9 @@ import { initTestEditor, loadTestDataForEditorTests } from '@tests/helpers/helpe import DocxZipper from '@core/DocxZipper.js'; import type { Editor } from '../core/Editor.js'; import { createTableAdapter, tablesSplitAdapter } from './tables-adapter.js'; +import { insertStructuredWrapper } from './plan-engine/plan-wrappers.js'; +import { clearExecutorRegistry } from './plan-engine/executor-registry.js'; +import { registerBuiltInExecutors } from './plan-engine/register-executors.js'; type LoadedDocData = Awaited>; @@ -38,6 +41,8 @@ describe('tables adapter DOCX integration', () => { beforeAll(async () => { docData = await loadTestDataForEditorTests('blank-doc.docx'); + clearExecutorRegistry(); + registerBuiltInExecutors(); }); afterEach(() => { @@ -45,6 +50,52 @@ describe('tables adapter DOCX integration', () => { editor = undefined; }); + it('two consecutive create.table calls produce non-adjacent tables in DOCX', async () => { + ({ editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + useImmediateSetTimeout: false, + })); + + createTableAdapter(editor, { rows: 2, columns: 2, at: { kind: 'documentEnd' } }, DIRECT_MUTATION_OPTIONS); + createTableAdapter(editor, { rows: 2, columns: 2, at: { kind: 'documentEnd' } }, DIRECT_MUTATION_OPTIONS); + + 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* { + ({ editor } = initTestEditor({ + content: docData.docx, + media: docData.media, + mediaFiles: docData.mediaFiles, + fonts: docData.fonts, + useImmediateSetTimeout: false, + })); + + insertStructuredWrapper(editor, { + value: '| A | B |\n| --- | --- |\n| foo | bar |', + type: 'markdown', + }); + insertStructuredWrapper(editor, { + value: '| C | D |\n| --- | --- |\n| baz | qux |', + type: 'markdown', + }); + + 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* { ({ editor } = initTestEditor({ content: docData.docx, diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index 619bc181fb..1b6ee9b840 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -169,6 +169,8 @@ * @property {import('prosemirror-model').Node[]} rows - Row nodes to append */ +import { v4 as uuidv4 } from 'uuid'; +import { Fragment } from 'prosemirror-model'; import { Node, Attribute } from '@core/index.js'; import { callOrGet } from '@core/utilities/callOrGet.js'; import { getExtensionConfigField } from '@core/helpers/getExtensionConfigField.js'; @@ -221,6 +223,28 @@ import { insertRowAtIndex, } from './tableHelpers/appendRows.js'; +/** + * Determines which sides of a table inserted at `pos` need a separator + * paragraph to prevent adjacency with an existing table. + * + * @param {import('prosemirror-model').Node} doc + * @param {number} pos - Absolute insertion position (between top-level blocks) + * @returns {{ before: boolean, after: boolean }} + */ +function tableSeparatorNeeds(doc, pos) { + const $pos = doc.resolve(pos); + if ($pos.depth !== 0) return { before: false, after: false }; + + const indexAfter = $pos.index(0); + const nodeAfter = indexAfter < doc.childCount ? doc.child(indexAfter) : null; + const nodeBefore = indexAfter > 0 ? doc.child(indexAfter - 1) : null; + + return { + before: nodeBefore?.type.name === 'table', + after: !nodeAfter || nodeAfter.type.name === 'table', + }; +} + const IMPORT_CONTEXT_SELECTOR = '[data-superdoc-import="true"]'; const IMPORT_DEFAULT_TABLE_WIDTH_PCT = 5000; // OOXML percent units where 5000 == 100% @@ -704,7 +728,26 @@ export const Table = Node.create({ const tableNode = tableType.createChecked(tableAttrs, rowNodes); if (dispatch) { - tr.insert(pos, tableNode); + const sep = tableSeparatorNeeds(state.doc, pos); + const makeSep = () => { + const attrs = { sdBlockId: uuidv4(), paraId: genParaId() }; + return state.schema.nodes.paragraph.createAndFill(attrs); + }; + if (sep.before || sep.after) { + const nodes = []; + if (sep.before) { + const s = makeSep(); + if (s) nodes.push(s); + } + nodes.push(tableNode); + if (sep.after) { + const s = makeSep(); + if (s) nodes.push(s); + } + tr.insert(pos, Fragment.from(nodes)); + } else { + tr.insert(pos, tableNode); + } tr.setMeta('inputType', 'programmatic'); if (tracked === true) tr.setMeta('forceTrackChanges', true); else if (tracked === false) tr.setMeta('skipTrackChanges', true); diff --git a/packages/super-editor/src/extensions/table/table.test.js b/packages/super-editor/src/extensions/table/table.test.js index 72bd73318a..87cf18bb3d 100644 --- a/packages/super-editor/src/extensions/table/table.test.js +++ b/packages/super-editor/src/extensions/table/table.test.js @@ -1083,6 +1083,75 @@ describe('Table commands', async () => { }); }); + describe('insertTableAt trailing separator paragraph', () => { + it('inserts table followed by a trailing paragraph', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const pos = editor.state.doc.content.size; + editor.commands.insertTableAt({ pos, rows: 2, columns: 2 }); + + const doc = editor.state.doc; + let foundTable = false; + let nodeAfterTable = null; + for (let i = 0; i < doc.childCount; i++) { + if (doc.child(i).type.name === 'table' && !foundTable) { + foundTable = true; + if (i + 1 < doc.childCount) { + nodeAfterTable = doc.child(i + 1); + } + } + } + + expect(foundTable).toBe(true); + expect(nodeAfterTable).not.toBeNull(); + expect(nodeAfterTable.type.name).toBe('paragraph'); + }); + + it('does not insert separator when table is placed between paragraphs', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + // Insert some text so we have paragraphs in the doc + editor.commands.insertContent('Hello'); + editor.commands.splitBlock(); + editor.commands.insertContent('World'); + + const docBefore = editor.state.doc; + // Find position between the two paragraphs (after first paragraph) + const firstParaEnd = docBefore.child(0).nodeSize; + + editor.commands.insertTableAt({ pos: firstParaEnd, rows: 2, columns: 2 }); + + const doc = editor.state.doc; + // The table should be at index 1 (between the two paragraphs) + // There should NOT be an extra separator paragraph injected + let tableCount = 0; + let paragraphCount = 0; + for (let i = 0; i < doc.childCount; i++) { + if (doc.child(i).type.name === 'table') tableCount++; + if (doc.child(i).type.name === 'paragraph') paragraphCount++; + } + + expect(tableCount).toBe(1); + // Original 2 paragraphs, no extra separator + expect(paragraphCount).toBe(2); + }); + + it('removes both table and separator paragraph on single undo', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + const docBefore = editor.state.doc; + const pos = editor.state.doc.content.size; + editor.commands.insertTableAt({ pos, rows: 2, columns: 2 }); + + editor.commands.undo(); + + expect(editor.state.doc.toJSON()).toEqual(docBefore.toJSON()); + }); + }); + describe('normalizeNewTableAttrs tblLook (SD-2086)', async () => { it('includes DEFAULT_TBL_LOOK in tableProperties when a style is resolved', async () => { const { docx, media, mediaFiles, fonts } = cachedBlankDoc;