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
151 changes: 117 additions & 34 deletions packages/super-editor/src/extensions/table/table.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ import {
} from 'prosemirror-tables';
import { cellAround } from './tableHelpers/cellAround.js';
import { cellWrapping } from './tableHelpers/cellWrapping.js';
import { createTableBoundaryNavigationPlugin } from './tableHelpers/tableBoundaryNavigation.js';
import { toggleHeaderRow as toggleHeaderRowCommand } from './tableHelpers/toggleHeaderRow.js';
import {
resolveTable,
Expand All @@ -230,22 +231,92 @@ import {
*
* @param {import('prosemirror-model').Node} doc
* @param {number} pos - Absolute insertion position (between top-level blocks)
* @param {{ from?: number, to?: number }} [replaceRange]
* @returns {{ before: boolean, after: boolean }}
*/
function tableSeparatorNeeds(doc, pos) {
const $pos = doc.resolve(pos);
if ($pos.depth !== 0) return { before: false, after: false };
function tableSeparatorNeeds(doc, pos, replaceRange = {}) {
const boundaryBefore = replaceRange.from ?? pos;
const boundaryAfter = replaceRange.to ?? pos;

const indexAfter = $pos.index(0);
const nodeAfter = indexAfter < doc.childCount ? doc.child(indexAfter) : null;
const nodeBefore = indexAfter > 0 ? doc.child(indexAfter - 1) : null;
const $before = doc.resolve(boundaryBefore);
const $after = doc.resolve(boundaryAfter);
if ($before.depth !== 0 || $after.depth !== 0) return { before: false, after: false };

const beforeIndex = $before.index(0);
const afterIndex = $after.index(0);
const nodeBefore = beforeIndex > 0 ? doc.child(beforeIndex - 1) : null;
const nodeAfter = afterIndex < doc.childCount ? doc.child(afterIndex) : null;

return {
before: nodeBefore?.type.name === 'table',
after: !nodeAfter || nodeAfter.type.name === 'table',
};
}

/**
* Creates the separator paragraph used to keep top-level tables from being
* adjacent to another table or the document boundary.
*
* @param {import('prosemirror-model').Schema} schema
* @returns {import('prosemirror-model').Node | null}
*/
function createTableSeparatorParagraph(schema) {
const attrs = { sdBlockId: uuidv4(), paraId: generateDocxHexId() };
return schema.nodes.paragraph.createAndFill(attrs);
}

/**
* Inserts a top-level table, adding separator paragraphs before/after when
* required by the surrounding document structure.
*
* @param {import('prosemirror-state').Transaction} tr
* @param {import('prosemirror-model').Node} doc
* @param {number} pos
* @param {import('prosemirror-model').Node} tableNode
* @param {{ from?: number, to?: number }} [replaceRange]
* @returns {{ inserted: boolean }}
*/
function insertTopLevelTableWithSeparators(tr, doc, pos, tableNode, replaceRange = {}) {
Comment thread
harbournick marked this conversation as resolved.
const replaceFrom = replaceRange.from ?? pos;
const replaceTo = replaceRange.to ?? pos;
const sep = tableSeparatorNeeds(doc, pos, replaceRange);
if (!sep.before && !sep.after) {
tr.replaceWith(replaceFrom, replaceTo, tableNode);
return { inserted: true };
}

const nodes = [];

if (sep.before) {
const before = createTableSeparatorParagraph(doc.type.schema);
if (!before) return { inserted: false };
nodes.push(before);
}

nodes.push(tableNode);

if (sep.after) {
const after = createTableSeparatorParagraph(doc.type.schema);
if (!after) return { inserted: false };
nodes.push(after);
}

tr.replaceWith(replaceFrom, replaceTo, Fragment.from(nodes));
return { inserted: true };
}

/**
* Returns a text position inside the first table cell.
*
* @param {number} tablePos
* @param {import('prosemirror-model').Node} tableNode
* @returns {number}
*/
function getFirstTableCellTextPos(tablePos, tableNode) {
const map = TableMap.get(tableNode);
return tablePos + 1 + map.map[0] + 2;
}

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 @@ -647,7 +718,7 @@ export const Table = Node.create({
*/
insertTable:
({ rows = 3, cols = 3, withHeaderRow = false, columnWidths = null } = {}) =>
({ tr, dispatch, editor }) => {
({ tr, dispatch, editor, state }) => {
const widths = columnWidths ?? computeColumnWidths(editor, cols);

const resolved = normalizeNewTableAttrs(editor);
Expand All @@ -660,14 +731,42 @@ export const Table = Node.create({
const node = createTable(editor.schema, rows, cols, withHeaderRow, null, widths, tableAttrs);

if (dispatch) {
let offset = tr.selection.$from.end() + 1;
if (tr.selection.$from.parent?.type?.name === 'run') {
// If in a run, we need to insert after the parent paragraph
offset = tr.selection.$from.after(tr.selection.$from.depth - 1);
let offset;
let replaceRange = undefined;

if (tr.selection.$from.depth === 0) {
// Selection is at the document root (e.g. AllSelection via Ctrl+A,
// or NodeSelection on a top-level block). Replace the selected
// range with the new table.
offset = tr.selection.from;
replaceRange = { from: tr.selection.from, to: tr.selection.to };
} else {
offset = tr.selection.$from.end() + 1;
const paragraphDepth =
tr.selection.$from.parent?.type?.name === 'run'
? tr.selection.$from.depth - 1
: tr.selection.$from.depth;
const paragraph = tr.selection.$from.node(paragraphDepth);
const isTopLevelParagraph = paragraphDepth === 1;
const isEmptyParagraph = paragraph.type.name === 'paragraph' && paragraph.textContent === '';

if (isTopLevelParagraph && isEmptyParagraph) {
offset = tr.selection.$from.before(paragraphDepth);
replaceRange = {
from: tr.selection.$from.before(paragraphDepth),
to: tr.selection.$from.after(paragraphDepth),
};
} else if (tr.selection.$from.parent?.type?.name === 'run') {
// If in a run, insert after the parent paragraph.
offset = tr.selection.$from.after(paragraphDepth);
}
}
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset)));

const { inserted } = insertTopLevelTableWithSeparators(tr, state.doc, offset, node, replaceRange);
if (!inserted) return false;

const selectionPos = getFirstTableCellTextPos(offset, node);
tr.scrollIntoView().setSelection(TextSelection.near(tr.doc.resolve(selectionPos)));
}

return true;
Expand Down Expand Up @@ -723,26 +822,8 @@ export const Table = Node.create({
const tableNode = tableType.createChecked(tableAttrs, rowNodes);

if (dispatch) {
const sep = tableSeparatorNeeds(state.doc, pos);
const makeSep = () => {
const attrs = { sdBlockId: uuidv4(), paraId: generateDocxHexId() };
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);
}
const { inserted } = insertTopLevelTableWithSeparators(tr, state.doc, pos, tableNode);
if (!inserted) return false;
tr.setMeta('inputType', 'programmatic');
if (tracked === true) tr.setMeta('forceTrackChanges', true);
else if (tracked === false) tr.setMeta('skipTrackChanges', true);
Expand Down Expand Up @@ -1363,6 +1444,8 @@ export const Table = Node.create({
allowTableNodeSelection: this.options.allowTableNodeSelection,
}),

createTableBoundaryNavigationPlugin(),

// Normalize table style on paste / setContent / insertContent.
// Only tables explicitly marked with needsTableStyleNormalization
// are normalized, so DOCX-imported tables with tableStyleId=null keep
Expand Down
169 changes: 168 additions & 1 deletion packages/super-editor/src/extensions/table/table.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { EditorState, TextSelection } from 'prosemirror-state';
import { AllSelection, EditorState, NodeSelection, TextSelection } from 'prosemirror-state';
import { CellSelection, TableMap } from 'prosemirror-tables';
import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js';
import { createTable } from './tableHelpers/createTable.js';
Expand Down Expand Up @@ -1173,6 +1173,173 @@ describe('Table commands', async () => {
});
});

describe('insertTable trailing separator paragraph', () => {
it('inserts table followed by a trailing paragraph when inserted at document end', async () => {
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));

editor.commands.insertTable({ rows: 2, cols: 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('places the selection in the first table cell after insertion', async () => {
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));

editor.commands.insertTable({ rows: 2, cols: 2 });

const tablePos = findTablePos(editor.state.doc);
const table = editor.state.doc.nodeAt(tablePos);
const map = TableMap.get(table);
const firstCellTextPos = tablePos + 1 + map.map[0] + 2;

const { $from } = editor.state.selection;
expect(editor.state.selection.from).toBe(firstCellTextPos);
expect($from.parent.type.name).toBe('paragraph');
expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell');
});

it('places the selection in the first table cell when sep.before is true', async () => {
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));

// Insert a first table — produces [table, paragraph]
editor.commands.insertTable({ rows: 2, cols: 2 });

// The cursor is now inside the first table cell. Move it to the
// trailing empty paragraph so the next insertTable triggers sep.before.
const doc = editor.state.doc;
const lastChild = doc.child(doc.childCount - 1);
expect(lastChild.type.name).toBe('paragraph');
const trailingParaPos = doc.content.size - lastChild.nodeSize + 1;
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.near(doc.resolve(trailingParaPos))));

// Insert a second table from the trailing paragraph (previous sibling is a table → sep.before = true)
editor.commands.insertTable({ rows: 2, cols: 2 });

// Find the SECOND table
let tableCount = 0;
let secondTablePos = null;
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'table') {
tableCount++;
if (tableCount === 2) {
secondTablePos = pos;
return false;
}
}
return true;
});
expect(secondTablePos).not.toBeNull();

const secondTable = editor.state.doc.nodeAt(secondTablePos);
const map = TableMap.get(secondTable);
const expectedPos = secondTablePos + 1 + map.map[0] + 2;

const { $from } = editor.state.selection;
expect(editor.state.selection.from).toBe(expectedPos);
expect($from.parent.type.name).toBe('paragraph');
expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell');
});

it('replaces the initial empty paragraph instead of keeping it before the table', async () => {
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));

editor.commands.insertTable({ rows: 2, cols: 2 });

expect(editor.state.doc.child(0).type.name).toBe('table');
expect(editor.state.doc.child(1).type.name).toBe('paragraph');
expect(editor.state.doc.childCount).toBe(2);
});

it('does not throw when insertTable is called with a NodeSelection on a top-level block', async () => {
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));

// Insert a documentSection (atom: true, group: 'block') to get a
// selectable top-level block node. When selected as a NodeSelection,
// $from.depth is 0 and $from.end() returns doc.content.size, which
// previously caused insertTable to compute an out-of-range offset.
const { schema } = editor.state;
const sectionNode = schema.nodes.documentSection.create(null, [schema.nodes.paragraph.create()]);
const { tr } = editor.state;
const insertPos = tr.selection.$from.before(1);
tr.insert(insertPos, sectionNode);
tr.setSelection(NodeSelection.create(tr.doc, insertPos));
editor.view.dispatch(tr);

expect(editor.state.selection).toBeInstanceOf(NodeSelection);
expect(editor.state.selection.$from.depth).toBe(0);

// Inserting a table while a top-level node is selected should not throw
expect(() => editor.commands.insertTable({ rows: 2, cols: 2 })).not.toThrow();

// Verify a table was actually inserted
const tablePos = findTablePos(editor.state.doc);
expect(tablePos).not.toBeNull();

// Verify the cursor is inside the first table cell
const table = editor.state.doc.nodeAt(tablePos);
const map = TableMap.get(table);
const firstCellTextPos = tablePos + 1 + map.map[0] + 2;

const { $from } = editor.state.selection;
expect(editor.state.selection.from).toBe(firstCellTextPos);
expect($from.parent.type.name).toBe('paragraph');
expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell');
});

it('places cursor in first cell and adds trailing paragraph when inserting table with AllSelection', async () => {
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));

// Type some text so the paragraph is non-empty (simulates a real document)
editor.commands.insertContent('This is a test');

// Select all content (Ctrl+A equivalent)
editor.view.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc)));
expect(editor.state.selection).toBeInstanceOf(AllSelection);

// Insert a table while everything is selected
editor.commands.insertTable({ rows: 2, cols: 2 });

// The table should be followed by a trailing separator paragraph
const doc = editor.state.doc;
const tablePos = findTablePos(doc);
expect(tablePos).not.toBeNull();
const table = doc.nodeAt(tablePos);
const tableEndPos = tablePos + table.nodeSize;
const $afterTable = doc.resolve(tableEndPos);
const nodeAfterTable = $afterTable.nodeAfter;
expect(nodeAfterTable?.type.name).toBe('paragraph');

// The cursor should be in the first table cell, not the last
const map = TableMap.get(table);
const firstCellTextPos = tablePos + 1 + map.map[0] + 2;

const { $from } = editor.state.selection;
expect(editor.state.selection.from).toBe(firstCellTextPos);
expect($from.parent.type.name).toBe('paragraph');
expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell');
});
});

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
Loading