Skip to content

Commit 441fe09

Browse files
fix: handle document-root selections (AllSelection, NodeSelection) in insertTable
When the selection is at depth 0 (e.g. Ctrl+A or a selected top-level block like documentSection), $from.end() + 1 overflowed past doc.content.size, causing a RangeError in insertTopLevelTableWithSeparators. Guard on $from.depth === 0 and replace the selected range directly so the table is inserted correctly with a trailing separator paragraph and the cursor lands in the first cell. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1fdfaf2 commit 441fe09

2 files changed

Lines changed: 101 additions & 18 deletions

File tree

packages/super-editor/src/extensions/table/table.js

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ import {
188188
} from '../table-cell/helpers/legacyBorderMigration.js';
189189
import { isInTable } from '@helpers/isInTable.js';
190190
import { findParentNode } from '@helpers/findParentNode.js';
191-
import { TextSelection, Plugin, PluginKey } from 'prosemirror-state';
191+
import { NodeSelection, TextSelection, Plugin, PluginKey } from 'prosemirror-state';
192192
import { isCellSelection } from './tableHelpers/isCellSelection.js';
193193
import {
194194
addColumnBefore as originalAddColumnBefore,
@@ -733,23 +733,35 @@ export const Table = Node.create({
733733
const node = createTable(editor.schema, rows, cols, withHeaderRow, null, widths, tableAttrs);
734734

735735
if (dispatch) {
736-
let offset = tr.selection.$from.end() + 1;
736+
let offset;
737737
let replaceRange = undefined;
738-
const paragraphDepth =
739-
tr.selection.$from.parent?.type?.name === 'run' ? tr.selection.$from.depth - 1 : tr.selection.$from.depth;
740-
const paragraph = tr.selection.$from.node(paragraphDepth);
741-
const isTopLevelParagraph = paragraphDepth === 1;
742-
const isEmptyParagraph = paragraph.type.name === 'paragraph' && paragraph.textContent === '';
743-
744-
if (isTopLevelParagraph && isEmptyParagraph) {
745-
offset = tr.selection.$from.before(paragraphDepth);
746-
replaceRange = {
747-
from: tr.selection.$from.before(paragraphDepth),
748-
to: tr.selection.$from.after(paragraphDepth),
749-
};
750-
} else if (tr.selection.$from.parent?.type?.name === 'run') {
751-
// If in a run, insert after the parent paragraph.
752-
offset = tr.selection.$from.after(paragraphDepth);
738+
739+
if (tr.selection.$from.depth === 0) {
740+
// Selection is at the document root (e.g. AllSelection via Ctrl+A,
741+
// or NodeSelection on a top-level block). Replace the selected
742+
// range with the new table.
743+
offset = tr.selection.from;
744+
replaceRange = { from: tr.selection.from, to: tr.selection.to };
745+
} else {
746+
offset = tr.selection.$from.end() + 1;
747+
const paragraphDepth =
748+
tr.selection.$from.parent?.type?.name === 'run'
749+
? tr.selection.$from.depth - 1
750+
: tr.selection.$from.depth;
751+
const paragraph = tr.selection.$from.node(paragraphDepth);
752+
const isTopLevelParagraph = paragraphDepth === 1;
753+
const isEmptyParagraph = paragraph.type.name === 'paragraph' && paragraph.textContent === '';
754+
755+
if (isTopLevelParagraph && isEmptyParagraph) {
756+
offset = tr.selection.$from.before(paragraphDepth);
757+
replaceRange = {
758+
from: tr.selection.$from.before(paragraphDepth),
759+
to: tr.selection.$from.after(paragraphDepth),
760+
};
761+
} else if (tr.selection.$from.parent?.type?.name === 'run') {
762+
// If in a run, insert after the parent paragraph.
763+
offset = tr.selection.$from.after(paragraphDepth);
764+
}
753765
}
754766

755767
const { inserted } = insertTopLevelTableWithSeparators(tr, state.doc, offset, node, replaceRange);

packages/super-editor/src/extensions/table/table.test.js

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
2-
import { EditorState, TextSelection } from 'prosemirror-state';
2+
import { AllSelection, EditorState, NodeSelection, TextSelection } from 'prosemirror-state';
33
import { CellSelection, TableMap } from 'prosemirror-tables';
44
import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js';
55
import { createTable } from './tableHelpers/createTable.js';
@@ -1267,6 +1267,77 @@ describe('Table commands', async () => {
12671267
expect(editor.state.doc.child(1).type.name).toBe('paragraph');
12681268
expect(editor.state.doc.childCount).toBe(2);
12691269
});
1270+
1271+
it('does not throw when insertTable is called with a NodeSelection on a top-level block', async () => {
1272+
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
1273+
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));
1274+
1275+
// Insert a documentSection (atom: true, group: 'block') to get a
1276+
// selectable top-level block node. When selected as a NodeSelection,
1277+
// $from.depth is 0 and $from.end() returns doc.content.size, which
1278+
// previously caused insertTable to compute an out-of-range offset.
1279+
const { schema } = editor.state;
1280+
const sectionNode = schema.nodes.documentSection.create(null, [schema.nodes.paragraph.create()]);
1281+
const { tr } = editor.state;
1282+
const insertPos = tr.selection.$from.before(1);
1283+
tr.insert(insertPos, sectionNode);
1284+
tr.setSelection(NodeSelection.create(tr.doc, insertPos));
1285+
editor.view.dispatch(tr);
1286+
1287+
expect(editor.state.selection).toBeInstanceOf(NodeSelection);
1288+
expect(editor.state.selection.$from.depth).toBe(0);
1289+
1290+
// Inserting a table while a top-level node is selected should not throw
1291+
expect(() => editor.commands.insertTable({ rows: 2, cols: 2 })).not.toThrow();
1292+
1293+
// Verify a table was actually inserted
1294+
const tablePos = findTablePos(editor.state.doc);
1295+
expect(tablePos).not.toBeNull();
1296+
1297+
// Verify the cursor is inside the first table cell
1298+
const table = editor.state.doc.nodeAt(tablePos);
1299+
const map = TableMap.get(table);
1300+
const firstCellTextPos = tablePos + 1 + map.map[0] + 2;
1301+
1302+
const { $from } = editor.state.selection;
1303+
expect(editor.state.selection.from).toBe(firstCellTextPos);
1304+
expect($from.parent.type.name).toBe('paragraph');
1305+
expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell');
1306+
});
1307+
1308+
it('places cursor in first cell and adds trailing paragraph when inserting table with AllSelection', async () => {
1309+
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
1310+
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));
1311+
1312+
// Type some text so the paragraph is non-empty (simulates a real document)
1313+
editor.commands.insertContent('This is a test');
1314+
1315+
// Select all content (Ctrl+A equivalent)
1316+
editor.view.dispatch(editor.state.tr.setSelection(new AllSelection(editor.state.doc)));
1317+
expect(editor.state.selection).toBeInstanceOf(AllSelection);
1318+
1319+
// Insert a table while everything is selected
1320+
editor.commands.insertTable({ rows: 2, cols: 2 });
1321+
1322+
// The table should be followed by a trailing separator paragraph
1323+
const doc = editor.state.doc;
1324+
const tablePos = findTablePos(doc);
1325+
expect(tablePos).not.toBeNull();
1326+
const table = doc.nodeAt(tablePos);
1327+
const tableEndPos = tablePos + table.nodeSize;
1328+
const $afterTable = doc.resolve(tableEndPos);
1329+
const nodeAfterTable = $afterTable.nodeAfter;
1330+
expect(nodeAfterTable?.type.name).toBe('paragraph');
1331+
1332+
// The cursor should be in the first table cell, not the last
1333+
const map = TableMap.get(table);
1334+
const firstCellTextPos = tablePos + 1 + map.map[0] + 2;
1335+
1336+
const { $from } = editor.state.selection;
1337+
expect(editor.state.selection.from).toBe(firstCellTextPos);
1338+
expect($from.parent.type.name).toBe('paragraph');
1339+
expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell');
1340+
});
12701341
});
12711342

12721343
describe('normalizeNewTableAttrs tblLook (SD-2086)', async () => {

0 commit comments

Comments
 (0)