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
41 changes: 40 additions & 1 deletion packages/super-editor/src/core/commands/insertTableAt.js
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[]): 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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -641,6 +655,33 @@ export function insertStructuredWrapper(
const jsonNodes: Record<string, unknown>[] = [];
fragment.forEach((node) => jsonNodes.push(node.toJSON()));

// Word always separates adjacent tables with a paragraph. Without a
// trailing separator, consecutive markdown inserts produce adjacent
// <w:tbl> 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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof loadTestDataForEditorTests>>;

Expand Down Expand Up @@ -38,13 +41,61 @@ describe('tables adapter DOCX integration', () => {

beforeAll(async () => {
docData = await loadTestDataForEditorTests('blank-doc.docx');
clearExecutorRegistry();
registerBuiltInExecutors();
});

afterEach(() => {
editor?.destroy();
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*<w:tbl>/);
expect(documentXml).toMatch(/<\/w:tbl>\s*<w:p\b[^>]*?\/?>\s*<w:tbl\b/);
});

it('two consecutive markdown table inserts produce non-adjacent tables in DOCX', async () => {
({ 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*<w:tbl>/);
expect(documentXml).toMatch(/<\/w:tbl>\s*<w:p\b[^>]*?\/?>\s*<w:tbl\b/);
});

it('exports a paragraph separator between split tables', async () => {
({ editor } = initTestEditor({
content: docData.docx,
Expand Down
45 changes: 44 additions & 1 deletion packages/super-editor/src/extensions/table/table.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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%

Expand Down Expand Up @@ -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);
Expand Down
69 changes: 69 additions & 0 deletions packages/super-editor/src/extensions/table/table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading