Skip to content

Commit 8407cda

Browse files
authored
fix: tables inside sdt field (#2712)
1 parent 61e6f38 commit 8407cda

2 files changed

Lines changed: 227 additions & 7 deletions

File tree

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

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ import {
189189
} from '../table-cell/helpers/legacyBorderMigration.js';
190190
import { isInTable } from '@helpers/isInTable.js';
191191
import { findParentNode } from '@helpers/findParentNode.js';
192-
import { TextSelection, Plugin, PluginKey } from 'prosemirror-state';
192+
import { TextSelection, NodeSelection, Plugin, PluginKey } from 'prosemirror-state';
193193
import { isCellSelection } from './tableHelpers/isCellSelection.js';
194194
import {
195195
addColumnBefore as originalAddColumnBefore,
@@ -318,6 +318,113 @@ function getFirstTableCellTextPos(tablePos, tableNode) {
318318
return tablePos + 1 + map.map[0] + 2;
319319
}
320320

321+
/**
322+
* Resolves the table targeted by a command.
323+
*
324+
* When a block SDT that wraps a single table is selected, table commands should
325+
* still operate on that inner table rather than an ancestor wrapper table.
326+
*
327+
* @param {import('prosemirror-state').Selection} selection
328+
* @returns {{ node: import('prosemirror-model').Node, pos: number } | null}
329+
*/
330+
function resolveCommandTargetTable(selection) {
331+
if (selection?.node?.type?.name === 'structuredContentBlock') {
332+
let found = null;
333+
let count = 0;
334+
selection.node.descendants((node, pos) => {
335+
if (node.type?.name !== 'table') return true;
336+
count += 1;
337+
found = {
338+
node,
339+
pos: selection.from + 1 + pos,
340+
};
341+
return false;
342+
});
343+
344+
if (count === 1 && found) {
345+
return found;
346+
}
347+
}
348+
349+
return findParentNode((node) => node.type.name === 'table')(selection);
350+
}
351+
352+
/**
353+
* Resolves insertion coordinates when a table is inserted inside a block SDT.
354+
*
355+
* Block structured content allows block children (`content: 'block*'`), so
356+
* table insertion should operate on its inner block list rather than using the
357+
* top-level document insertion path.
358+
*
359+
* @param {import('prosemirror-state').Selection} selection
360+
* @returns {{ from: number, to: number, tablePos: number } | null}
361+
*/
362+
function resolveStructuredContentBlockTableInsertion(selection) {
363+
if (selection instanceof NodeSelection && selection.node?.type?.name === 'structuredContentBlock') {
364+
const contentStart = selection.from + 1;
365+
const firstChild = selection.node.firstChild;
366+
const shouldReplaceOnlyEmptyParagraph =
367+
selection.node.childCount === 1 && firstChild?.type?.name === 'paragraph' && firstChild.textContent === '';
368+
369+
if (shouldReplaceOnlyEmptyParagraph) {
370+
return {
371+
from: contentStart,
372+
to: contentStart + firstChild.nodeSize,
373+
tablePos: contentStart,
374+
};
375+
}
376+
377+
const contentEnd = contentStart + selection.node.content.size;
378+
return {
379+
from: contentEnd,
380+
to: contentEnd,
381+
tablePos: contentEnd,
382+
};
383+
}
384+
385+
const $from = selection.$from;
386+
let structuredContentDepth = -1;
387+
for (let depth = $from.depth; depth > 0; depth--) {
388+
if ($from.node(depth).type?.name === 'structuredContentBlock') {
389+
structuredContentDepth = depth;
390+
break;
391+
}
392+
}
393+
394+
if (structuredContentDepth === -1) {
395+
return null;
396+
}
397+
398+
const paragraphDepth = $from.parent?.type?.name === 'run' ? $from.depth - 1 : $from.depth;
399+
if (paragraphDepth <= structuredContentDepth) {
400+
const contentEnd = $from.end(structuredContentDepth);
401+
return {
402+
from: contentEnd,
403+
to: contentEnd,
404+
tablePos: contentEnd,
405+
};
406+
}
407+
408+
const paragraph = $from.node(paragraphDepth);
409+
const isEmptyParagraph = paragraph.type.name === 'paragraph' && paragraph.textContent === '';
410+
411+
if (isEmptyParagraph) {
412+
const from = $from.before(paragraphDepth);
413+
return {
414+
from,
415+
to: $from.after(paragraphDepth),
416+
tablePos: from,
417+
};
418+
}
419+
420+
const from = $from.after(paragraphDepth);
421+
return {
422+
from,
423+
to: from,
424+
tablePos: from,
425+
};
426+
}
427+
321428
const IMPORT_CONTEXT_SELECTOR = '[data-superdoc-import="true"]';
322429
const IMPORT_DEFAULT_TABLE_WIDTH_PCT = 5000; // OOXML percent units where 5000 == 100%
323430

@@ -732,6 +839,15 @@ export const Table = Node.create({
732839
const node = createTable(editor.schema, rows, cols, withHeaderRow, null, widths, tableAttrs);
733840

734841
if (dispatch) {
842+
const structuredContentInsertion = resolveStructuredContentBlockTableInsertion(tr.selection);
843+
if (structuredContentInsertion) {
844+
const { from, to, tablePos } = structuredContentInsertion;
845+
tr.replaceWith(from, to, node);
846+
const selectionPos = getFirstTableCellTextPos(tablePos, node);
847+
tr.scrollIntoView().setSelection(TextSelection.near(tr.doc.resolve(selectionPos)));
848+
return true;
849+
}
850+
735851
let offset;
736852
let replaceRange = undefined;
737853

@@ -1348,18 +1464,15 @@ export const Table = Node.create({
13481464
return false;
13491465
}
13501466

1351-
const table = findParentNode((node) => node.type.name === this.name)(state.selection);
1467+
const table = resolveCommandTargetTable(state.selection);
13521468

13531469
if (!table) {
13541470
return false;
13551471
}
13561472

1357-
const from = table.pos;
1358-
const to = table.pos + table.node.nodeSize;
1359-
13601473
// remove from cells — write nil borders to tableCellProperties.borders (canonical source)
13611474
const nilBorder = { val: 'nil', size: 0, space: 0, color: 'auto' };
1362-
state.doc.nodesBetween(from, to, (node, pos) => {
1475+
table.node.descendants((node, pos) => {
13631476
if (['tableCell', 'tableHeader'].includes(node.type.name)) {
13641477
const nextTableCellProperties = {
13651478
...(node.attrs.tableCellProperties ?? {}),
@@ -1371,13 +1484,14 @@ export const Table = Node.create({
13711484
},
13721485
};
13731486
const nextInlineKeys = [...new Set([...(node.attrs.tableCellPropertiesInlineKeys || []), 'borders'])];
1374-
tr.setNodeMarkup(pos, undefined, {
1487+
tr.setNodeMarkup(table.pos + 1 + pos, undefined, {
13751488
...node.attrs,
13761489
borders: null,
13771490
tableCellProperties: nextTableCellProperties,
13781491
tableCellPropertiesInlineKeys: nextInlineKeys,
13791492
});
13801493
}
1494+
return true;
13811495
});
13821496

13831497
// remove from table

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,67 @@ describe('Table commands', async () => {
963963

964964
sharedTests();
965965
});
966+
967+
it('targets the inner table when a structuredContentBlock wrapper around it is selected', async () => {
968+
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
969+
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));
970+
({ schema } = editor);
971+
972+
const nilBorders = Object.assign(
973+
{},
974+
...['top', 'left', 'bottom', 'right'].map((side) => ({
975+
[side]: { color: 'auto', size: 0, space: 0, val: 'nil' },
976+
})),
977+
);
978+
979+
const outerBorders = {
980+
top: { val: 'single', size: 8, space: 0, color: '000000' },
981+
bottom: { val: 'single', size: 8, space: 0, color: '000000' },
982+
left: { val: 'single', size: 8, space: 0, color: '000000' },
983+
right: { val: 'single', size: 8, space: 0, color: '000000' },
984+
};
985+
986+
const innerTable = createTable(schema, 2, 2, false);
987+
const sdt = schema.nodes.structuredContentBlock.create({ id: '2001', tag: 'block_table_sdt' }, [innerTable]);
988+
const outerCell = schema.nodes.tableCell.create(
989+
{
990+
tableCellProperties: { borders: outerBorders },
991+
tableCellPropertiesInlineKeys: ['borders'],
992+
},
993+
[sdt],
994+
);
995+
const outerRow = schema.nodes.tableRow.create({ paraId: 'A1B2C3D4' }, [outerCell]);
996+
const outerTable = schema.nodes.table.create({}, [outerRow]);
997+
const doc = schema.nodes.doc.create(null, [outerTable]);
998+
editor.setState(EditorState.create({ schema, doc, plugins: editor.state.plugins }));
999+
1000+
let sdtPos = null;
1001+
editor.state.doc.descendants((node, pos) => {
1002+
if (node.type.name === 'structuredContentBlock') {
1003+
sdtPos = pos;
1004+
return false;
1005+
}
1006+
return true;
1007+
});
1008+
expect(sdtPos).not.toBeNull();
1009+
1010+
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, sdtPos)));
1011+
1012+
const success = editor.commands.deleteCellAndTableBorders(editor);
1013+
expect(success).toBe(true);
1014+
1015+
const updatedOuterTable = editor.state.doc.child(0);
1016+
const updatedOuterCell = updatedOuterTable.firstChild.firstChild;
1017+
expect(updatedOuterCell.attrs.tableCellProperties?.borders).toEqual(outerBorders);
1018+
1019+
const updatedInnerTable = updatedOuterCell.firstChild.firstChild;
1020+
expect(updatedInnerTable.type.name).toBe('table');
1021+
updatedInnerTable.forEach((row) => {
1022+
row.forEach((cell) => {
1023+
expect(cell.attrs.tableCellProperties?.borders).toEqual(nilBorders);
1024+
});
1025+
});
1026+
});
9661027
});
9671028

9681029
describe('table style normalization', async () => {
@@ -1305,6 +1366,51 @@ describe('Table commands', async () => {
13051366
expect($from.node($from.depth - 1).type.spec.tableRole).toBe('cell');
13061367
});
13071368

1369+
it('inserts the table inside a selected structuredContentBlock instead of creating a sibling block', async () => {
1370+
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
1371+
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));
1372+
1373+
const { schema } = editor.state;
1374+
const emptyParagraph = schema.nodes.paragraph.create();
1375+
const sdt = schema.nodes.structuredContentBlock.create({ id: '1001', tag: 'block_table_sdt' }, [emptyParagraph]);
1376+
const doc = schema.nodes.doc.create(null, [sdt]);
1377+
editor.setState(EditorState.create({ schema, doc, plugins: editor.state.plugins }));
1378+
1379+
editor.view.dispatch(editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, 0)));
1380+
editor.commands.insertTable({ rows: 2, cols: 2 });
1381+
1382+
expect(editor.state.doc.childCount).toBe(1);
1383+
expect(editor.state.doc.child(0).type.name).toBe('structuredContentBlock');
1384+
1385+
const insertedSdt = editor.state.doc.child(0);
1386+
expect(insertedSdt.childCount).toBe(1);
1387+
expect(insertedSdt.child(0).type.name).toBe('table');
1388+
});
1389+
1390+
it('inserts the table inside structuredContentBlock content for text selections', async () => {
1391+
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
1392+
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));
1393+
1394+
const { schema } = editor.state;
1395+
const paragraph = schema.nodes.paragraph.create(null, schema.text('Hello'));
1396+
const sdt = schema.nodes.structuredContentBlock.create({ id: '1002', tag: 'block_table_sdt' }, [paragraph]);
1397+
const doc = schema.nodes.doc.create(null, [sdt]);
1398+
editor.setState(EditorState.create({ schema, doc, plugins: editor.state.plugins }));
1399+
1400+
const textSelection = TextSelection.create(editor.state.doc, 2, 7);
1401+
editor.view.dispatch(editor.state.tr.setSelection(textSelection));
1402+
1403+
editor.commands.insertTable({ rows: 2, cols: 2 });
1404+
1405+
expect(editor.state.doc.childCount).toBe(1);
1406+
expect(editor.state.doc.child(0).type.name).toBe('structuredContentBlock');
1407+
1408+
const insertedSdt = editor.state.doc.child(0);
1409+
expect(insertedSdt.childCount).toBe(2);
1410+
expect(insertedSdt.child(0).type.name).toBe('paragraph');
1411+
expect(insertedSdt.child(1).type.name).toBe('table');
1412+
});
1413+
13081414
it('places cursor in first cell and adds trailing paragraph when inserting table with AllSelection', async () => {
13091415
const { docx, media, mediaFiles, fonts } = cachedBlankDoc;
13101416
({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }));

0 commit comments

Comments
 (0)