From 75973c1eca74bc2b43d8ae05e55ea82dd69b09bd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 29 May 2026 09:51:53 -0300 Subject: [PATCH 1/6] feat(converter): preserve row-level SDT wrappers in table round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content controls that wrap a whole table row ( containing a , ECMA-376 §17.5.2.30 CT_SdtRow) were dropped on import, breaking round-trip. Add normalizeTableRowChildren to unwrap single-row SDT wrappers and attach their w:sdtPr/w:sdtEndPr as rowSdt metadata on the tableRow node, then rebuild the envelope on export. Multi-row wrappers are imported defensively by emitting inner rows in order without wrapper metadata. Extract the shared getSdtEnvelopeParts helper and reuse it from the cell-level path. Route table-row enumeration through the normalizer in the table translator and legacy cell handler so rowspan and width calculations see SDT-wrapped rows. Generalize CellSdtMetadata into a scoped SdtMetadata type and add RowSdtMetadata. --- .../v3/handlers/w/sdt/helpers/sdt-envelope.js | 16 ++ .../v3/handlers/w/tbl/table-row-children.js | 45 +++++ .../handlers/w/tbl/table-row-children.test.js | 58 ++++++ .../v3/handlers/w/tbl/tbl-translator.js | 28 ++- ...tbl-translator.row-sdt.integration.test.js | 181 ++++++++++++++++++ .../v3/handlers/w/tbl/tbl-translator.test.js | 74 +++++++ .../helpers/legacy-handle-table-cell-node.js | 3 +- .../legacy-handle-table-cell-node.test.js | 51 +++++ .../v3/handlers/w/tr/row-cell-children.js | 5 +- .../v1/extensions/table-row/table-row.js | 11 ++ .../v1/extensions/types/node-attributes.ts | 33 +++- 11 files changed, 489 insertions(+), 16 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.test.js create mode 100644 packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.row-sdt.integration.test.js diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js new file mode 100644 index 0000000000..20b05594e5 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js @@ -0,0 +1,16 @@ +// @ts-check + +/** + * Extract the common OOXML structured document tag envelope pieces. + * + * @param {any} node + * @returns {{ sdtPr: any, sdtEndPr: any, sdtContent: any }} + */ +export const getSdtEnvelopeParts = (node) => { + const elements = Array.isArray(node?.elements) ? node.elements : []; + return { + sdtPr: elements.find((el) => el?.name === 'w:sdtPr') ?? null, + sdtEndPr: elements.find((el) => el?.name === 'w:sdtEndPr') ?? null, + sdtContent: elements.find((el) => el?.name === 'w:sdtContent') ?? null, + }; +}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js new file mode 100644 index 0000000000..3ce65a2d1a --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js @@ -0,0 +1,45 @@ +// @ts-check +import { getSdtEnvelopeParts } from '../sdt/helpers/sdt-envelope.js'; + +/** + * Normalize a `` element's children into the row stream the table encoder + * iterates. Direct `` children pass through unchanged. A row-level + * `` (ECMA-376 §17.5.2.30, CT_SdtRow) is unwrapped: its inner `` + * is emitted in document order, and when the wrapper contains exactly one row + * the wrapper's `w:sdtPr` / `w:sdtEndPr` are attached as metadata so export can + * rebuild the `` envelope. + * + * Multi-row SDT wrappers are imported defensively: every inner row is emitted in + * order, but wrapper metadata is dropped because exact multi-row grouping needs + * a representation SuperDoc does not currently model. + * + * @param {any} table + * @returns {Array<{ node: any, rowSdt: any }>} + */ +export const normalizeTableRowChildren = (table) => { + /** @type {Array<{ node: any, rowSdt: any }>} */ + const out = []; + const children = Array.isArray(table?.elements) ? table.elements : []; + for (const child of children) { + if (!child || typeof child.name !== 'string') continue; + if (child.name === 'w:tr') { + out.push({ node: child, rowSdt: null }); + continue; + } + if (child.name === 'w:sdt') { + const { sdtPr, sdtEndPr, sdtContent } = getSdtEnvelopeParts(child); + const innerRows = sdtContent?.elements?.filter((el) => el?.name === 'w:tr') ?? []; + if (innerRows.length === 1 && sdtPr) { + out.push({ + node: innerRows[0], + rowSdt: { scope: 'row', sdtPr, sdtEndPr }, + }); + } else { + for (const innerTr of innerRows) { + out.push({ node: innerTr, rowSdt: null }); + } + } + } + } + return out; +}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.test.js new file mode 100644 index 0000000000..79ad8a4efa --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.test.js @@ -0,0 +1,58 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { normalizeTableRowChildren } from './table-row-children.js'; + +const SDT_PR = { name: 'w:sdtPr', elements: [{ name: 'w:id', attributes: { 'w:val': '849213029' } }] }; +const SDT_END_PR = { name: 'w:sdtEndPr', elements: [] }; +const FIRST_ROW = { name: 'w:tr', elements: [{ name: 'w:tc', elements: [] }] }; +const SECOND_ROW = { name: 'w:tr', elements: [{ name: 'w:tc', elements: [] }] }; + +describe('normalizeTableRowChildren', () => { + it('emits direct table rows unchanged', () => { + const table = { name: 'w:tbl', elements: [FIRST_ROW] }; + + expect(normalizeTableRowChildren(table)).toEqual([{ node: FIRST_ROW, rowSdt: null }]); + }); + + it('unwraps a single-row row-level SDT and preserves wrapper metadata', () => { + const table = { + name: 'w:tbl', + elements: [ + { + name: 'w:sdt', + elements: [SDT_PR, SDT_END_PR, { name: 'w:sdtContent', elements: [FIRST_ROW] }], + }, + ], + }; + + expect(normalizeTableRowChildren(table)).toEqual([ + { + node: FIRST_ROW, + rowSdt: { scope: 'row', sdtPr: SDT_PR, sdtEndPr: SDT_END_PR }, + }, + ]); + }); + + it('imports multi-row wrappers without applying wrapper metadata to individual rows', () => { + const table = { + name: 'w:tbl', + elements: [ + { + name: 'w:sdt', + elements: [SDT_PR, { name: 'w:sdtContent', elements: [FIRST_ROW, SECOND_ROW] }], + }, + ], + }; + + expect(normalizeTableRowChildren(table)).toEqual([ + { node: FIRST_ROW, rowSdt: null }, + { node: SECOND_ROW, rowSdt: null }, + ]); + }); + + it('skips row-level SDTs without row content', () => { + const table = { name: 'w:tbl', elements: [{ name: 'w:sdt', elements: [SDT_PR] }] }; + + expect(normalizeTableRowChildren(table)).toEqual([]); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js index 2399460cab..f9e02acfb5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js @@ -9,6 +9,8 @@ import { NodeTranslator } from '@translator'; import { translator as tblGridTranslator } from '../tblGrid'; import { translator as tblPrTranslator } from '../tblPr'; import { translator as trTranslator } from '../tr'; +import { normalizeRowCellChildren } from '../tr/row-cell-children.js'; +import { normalizeTableRowChildren } from './table-row-children.js'; /** * Legacy table identity attributes imported from older SuperDoc exports. @@ -49,10 +51,10 @@ const sumColumnTwips = (columns = []) => * @returns {number | null} Total cell width in twips, or null if incomplete */ const getFirstRowCellWidthSumTwips = (rows = []) => { - const firstRow = rows.find((row) => row?.elements?.some((el) => el.name === 'w:tc')); + const firstRow = rows.find((row) => normalizeRowCellChildren(row).length > 0); if (!firstRow?.elements) return null; - const cells = firstRow.elements.filter((el) => el.name === 'w:tc'); + const cells = normalizeRowCellChildren(firstRow).map((entry) => entry.node); if (!cells.length) return null; let sum = 0; @@ -162,7 +164,8 @@ const encode = (params, encodedAttrs) => { }; // Process each row - const rows = node.elements.filter((el) => el.name === 'w:tr'); + const rowEntries = normalizeTableRowChildren(node); + const rows = rowEntries.map((entry) => entry.node); let columnWidths = Array.isArray(encodedAttrs['grid']) ? encodedAttrs['grid'].map((item) => twipsToPixels(item.col)) : []; @@ -206,7 +209,7 @@ const encode = (params, encodedAttrs) => { const totalColumns = columnWidths.length; const totalRows = rows.length; const activeRowSpans = totalColumns > 0 ? new Array(totalColumns).fill(0) : []; - rows.forEach((row, rowIndex) => { + rowEntries.forEach(({ node: row, rowSdt }, rowIndex) => { const result = trTranslator.encode({ ...params, path: [...(params.path || []), node], @@ -225,6 +228,9 @@ const encode = (params, encodedAttrs) => { }, }); if (result) { + if (rowSdt) { + result.attrs = { ...(result.attrs || {}), rowSdt }; + } content.push(result); if (totalColumns > 0) { @@ -302,6 +308,20 @@ const decode = (params, decodedAttrs) => { const elements = translateChildNodes({ ...params, extraParams }); + let rowCursor = 0; + for (let i = 0; i < elements.length; i += 1) { + const exportedEl = elements[i]; + if (!exportedEl || exportedEl.name !== 'w:tr') continue; + const sourceRow = node.content?.[rowCursor]; + rowCursor += 1; + const rowSdt = sourceRow?.attrs?.rowSdt; + if (!rowSdt || rowSdt.scope !== 'row' || !rowSdt.sdtPr) continue; + const sdtChildren = [rowSdt.sdtPr]; + if (rowSdt.sdtEndPr) sdtChildren.push(rowSdt.sdtEndPr); + sdtChildren.push({ name: 'w:sdtContent', elements: [exportedEl] }); + elements[i] = { name: 'w:sdt', elements: sdtChildren }; + } + // Table grid - generate if not present const firstRow = node.content?.find((n) => n.type === 'tableRow'); const element = tblGridTranslator.decode({ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.row-sdt.integration.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.row-sdt.integration.test.js new file mode 100644 index 0000000000..3bcfbd3340 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.row-sdt.integration.test.js @@ -0,0 +1,181 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { translator as tblTranslator } from './tbl-translator.js'; +import { exportSchemaToJson } from '../../../../exporter.js'; +import { defaultNodeListHandler } from '../../../../v2/importer/docxImporter.js'; + +const WRAPPED_ROW_TEXT = 'Row inside SDT'; +const WRAPPED_ROW_CELL_TEXT = 'Another cell'; +const BARE_ROW_TEXT = 'Another row'; +const BARE_ROW_CELL_TEXT = 'Hello'; + +const SDT_PR = { + name: 'w:sdtPr', + elements: [ + { name: 'w:rPr', elements: [{ name: 'w:rFonts', attributes: { 'w:cs': 'Arial' } }] }, + { name: 'w:id', attributes: { 'w:val': '849213029' } }, + { + name: 'w:date', + elements: [ + { name: 'w:dateFormat', attributes: { 'w:val': 'd MMMM yyyy' } }, + { name: 'w:lid', attributes: { 'w:val': 'en-AU' } }, + { name: 'w:storeMappedDataAs', attributes: { 'w:val': 'dateTime' } }, + { name: 'w:calendar', attributes: { 'w:val': 'gregorian' } }, + ], + }, + ], +}; + +const textCell = (width, text) => ({ + name: 'w:tc', + elements: [ + { + name: 'w:tcPr', + elements: [{ name: 'w:tcW', attributes: { 'w:w': width, 'w:type': 'dxa' } }], + }, + { + name: 'w:p', + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text }] }] }], + }, + ], +}); + +const buildFixtureTable = () => ({ + name: 'w:tbl', + elements: [ + { + name: 'w:tblPr', + elements: [ + { name: 'w:tblW', attributes: { 'w:w': '9180', 'w:type': 'dxa' } }, + { name: 'w:tblLayout', attributes: { 'w:type': 'fixed' } }, + ], + }, + { + name: 'w:tblGrid', + elements: [ + { name: 'w:gridCol', attributes: { 'w:w': '3260' } }, + { name: 'w:gridCol', attributes: { 'w:w': '5920' } }, + ], + }, + { + name: 'w:sdt', + elements: [ + SDT_PR, + { + name: 'w:sdtContent', + elements: [ + { + name: 'w:tr', + elements: [textCell('3260', WRAPPED_ROW_TEXT), textCell('5920', WRAPPED_ROW_CELL_TEXT)], + }, + ], + }, + ], + }, + { + name: 'w:tr', + elements: [textCell('3260', BARE_ROW_TEXT), textCell('5920', BARE_ROW_CELL_TEXT)], + }, + ], +}); + +const minimalDocx = { + 'word/styles.xml': { elements: [{ name: 'w:styles', elements: [] }] }, +}; + +const editorStub = { + schema: { + nodes: { + doc: { spec: { group: 'block' } }, + paragraph: { spec: { group: 'block' } }, + run: { isInline: true, spec: { group: 'inline' } }, + text: { isInline: true, spec: { group: 'inline' } }, + table: { spec: { group: 'block' } }, + tableRow: { spec: { group: 'block' } }, + tableCell: { spec: { group: 'block' } }, + }, + }, + converter: { addedMediaFiles: {} }, +}; + +const findFirst = (xml, name) => { + if (!xml) return null; + if (xml.name === name) return xml; + for (const child of xml.elements || []) { + const hit = findFirst(child, name); + if (hit) return hit; + } + return null; +}; + +const findAll = (xml, name) => { + if (!xml) return []; + const acc = []; + if (xml.name === name) acc.push(xml); + for (const child of xml.elements || []) acc.push(...findAll(child, name)); + return acc; +}; + +const collectText = (node) => { + if (!node) return ''; + if (node.type === 'text') return node.text || ''; + return (node.content || []).map(collectText).join(''); +}; + +const collectXmlText = (xml) => { + if (!xml) return ''; + if (xml.type === 'text') return xml.text || ''; + return (xml.elements || []).map(collectXmlText).join(''); +}; + +describe('row-level SDT round-trip (SD-3291)', () => { + it('imports and exports a CT_SdtRow-wrapped table row with metadata intact', () => { + const tbl = buildFixtureTable(); + const { handler, handlerEntities } = defaultNodeListHandler(); + + const tablePm = tblTranslator.encode( + { + nodes: [tbl], + docx: minimalDocx, + nodeListHandler: { handler, handlerEntities }, + editor: editorStub, + path: [], + }, + {}, + ); + + expect(tablePm).toBeTruthy(); + expect(tablePm.type).toBe('table'); + const rows = (tablePm.content || []).filter((node) => node.type === 'tableRow'); + expect(rows).toHaveLength(2); + expect(collectText(rows[0])).toContain(WRAPPED_ROW_TEXT); + expect(collectText(rows[0])).toContain(WRAPPED_ROW_CELL_TEXT); + expect(collectText(rows[1])).toContain(BARE_ROW_TEXT); + expect(collectText(rows[1])).toContain(BARE_ROW_CELL_TEXT); + + expect(rows[0].attrs?.rowSdt).toBeTruthy(); + expect(rows[0].attrs.rowSdt.scope).toBe('row'); + expect(rows[0].attrs.rowSdt.sdtPr?.name).toBe('w:sdtPr'); + expect(findFirst(rows[0].attrs.rowSdt.sdtPr, 'w:date')).toBeTruthy(); + expect(rows[1].attrs?.rowSdt ?? null).toBeNull(); + + const exported = exportSchemaToJson({ node: tablePm }); + const tblEl = findFirst(exported, 'w:tbl'); + const rowChildren = (tblEl.elements || []).filter((el) => el?.name === 'w:sdt' || el?.name === 'w:tr'); + expect(rowChildren.map((el) => el.name)).toEqual(['w:sdt', 'w:tr']); + + const sdtEl = rowChildren[0]; + expect((sdtEl.elements || []).map((el) => el.name)).toEqual(['w:sdtPr', 'w:sdtContent']); + expect(findFirst(sdtEl, 'w:date')).toBeTruthy(); + + const sdtContent = (sdtEl.elements || []).find((el) => el.name === 'w:sdtContent'); + const wrappedRows = (sdtContent.elements || []).filter((el) => el.name === 'w:tr'); + expect(wrappedRows).toHaveLength(1); + expect(collectXmlText(wrappedRows[0])).toContain(WRAPPED_ROW_TEXT); + expect(collectXmlText(wrappedRows[0])).toContain(WRAPPED_ROW_CELL_TEXT); + + const allRows = findAll(tblEl, 'w:tr'); + const rowsWithWrappedText = allRows.filter((row) => collectXmlText(row).includes(WRAPPED_ROW_TEXT)); + expect(rowsWithWrappedText).toHaveLength(1); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js index 4e1ea4fac8..435f59b40e 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js @@ -32,6 +32,15 @@ import { NodeTranslator } from '@translator'; import { translator as trTranslator } from '../tr'; import { translateChildNodes } from '@core/super-converter/v2/exporter/helpers/index.js'; +const ROW_SDT_PR = { + name: 'w:sdtPr', + elements: [ + { name: 'w:id', attributes: { 'w:val': '849213029' } }, + { name: 'w:date', elements: [{ name: 'w:dateFormat', attributes: { 'w:val': 'd MMMM yyyy' } }] }, + ], +}; +const ROW_SDT_END_PR = { name: 'w:sdtEndPr', elements: [] }; + describe('w:tbl translator', () => { beforeEach(() => { vi.clearAllMocks(); @@ -167,6 +176,40 @@ describe('w:tbl translator', () => { }); }); + it('imports row-level SDT wrappers as rows with rowSdt metadata', () => { + const wrappedRow = { name: 'w:tr', elements: [{ name: 'w:tc', attributes: {}, elements: [] }] }; + const bareRow = { name: 'w:tr', elements: [{ name: 'w:tc', attributes: {}, elements: [] }] }; + const table = { + name: 'w:tbl', + elements: [ + { name: 'w:tblPr', elements: [] }, + { + name: 'w:sdt', + elements: [ROW_SDT_PR, ROW_SDT_END_PR, { name: 'w:sdtContent', elements: [wrappedRow] }], + }, + bareRow, + ], + }; + + const result = translator.encode({ nodes: [table], docx: mockDocx }, {}); + + expect(trTranslator.encode).toHaveBeenCalledTimes(2); + expect(trTranslator.encode).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + nodes: [wrappedRow], + extraParams: expect.objectContaining({ row: wrappedRow }), + }), + ); + expect(result.content).toHaveLength(2); + expect(result.content[0].attrs.rowSdt).toEqual({ + scope: 'row', + sdtPr: ROW_SDT_PR, + sdtEndPr: ROW_SDT_END_PR, + }); + expect(result.content[1].attrs.rowSdt).toBeUndefined(); + }); + it('handles tables with no properties or rows', () => { const simpleTable = { name: 'w:tbl', @@ -398,6 +441,37 @@ describe('w:tbl translator', () => { expect(tblGrid.elements).toEqual([expect.objectContaining({ name: 'w:gridCol', attributes: { 'w:w': '2000' } })]); }); + it('wraps exported rows carrying rowSdt metadata in row-level SDT envelopes', () => { + const bareTr = { name: 'w:tr', comment: 'bare row' }; + const wrappedTr = { name: 'w:tr', comment: 'wrapped row' }; + vi.mocked(translateChildNodes).mockReturnValueOnce([wrappedTr, bareTr]); + + const tableNode = { + type: 'table', + attrs: {}, + content: [ + { + type: 'tableRow', + attrs: { rowSdt: { scope: 'row', sdtPr: ROW_SDT_PR, sdtEndPr: ROW_SDT_END_PR } }, + content: [], + }, + { type: 'tableRow', attrs: {}, content: [] }, + ], + }; + + const result = translator.decode({ node: tableNode, extraParams: {} }); + const rowChildren = result.elements.filter((el) => el.name === 'w:sdt' || el.name === 'w:tr'); + + expect(rowChildren).toHaveLength(2); + expect(rowChildren[0].name).toBe('w:sdt'); + expect(rowChildren[0].elements).toEqual([ + ROW_SDT_PR, + ROW_SDT_END_PR, + { name: 'w:sdtContent', elements: [wrappedTr] }, + ]); + expect(rowChildren[1]).toBe(bareTr); + }); + it('should generate a grid if not present', () => { const mockNode = { type: 'table', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js index df3ab3e21e..87afce4301 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js @@ -2,6 +2,7 @@ import { twipsToPixels, resolveShadingFillColor } from '@converter/helpers'; import { translator as tcPrTranslator } from '../../tcPr'; import { isInlineNode } from '../../../helpers/is-inline-node.js'; import { normalizeRowCellChildren } from '../../tr/row-cell-children.js'; +import { normalizeTableRowChildren } from '../../tbl/table-row-children.js'; /** * @param {Object} options @@ -105,7 +106,7 @@ export function handleTableCellNode({ // Rowspan - tables can have vertically merged cells if (tableCellProperties.vMerge === 'restart') { - const rows = table.elements.filter((el) => el.name === 'w:tr'); + const rows = normalizeTableRowChildren(table).map((entry) => entry.node); const currentRowIndex = rows.findIndex((r) => r === row); const remainingRows = rows.slice(currentRowIndex + 1); let rowspan = 1; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js index 4af309e4aa..39c75078d9 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.test.js @@ -337,6 +337,57 @@ describe('legacy-handle-table-cell-node', () => { expect(continuationCell._vMergeConsumed).toBe(true); }); + it('finds vMerge continuations inside row-level SDT wrappers (SD-3291)', () => { + const restartCell = { + name: 'w:tc', + elements: [ + { name: 'w:tcPr', elements: [{ name: 'w:vMerge', attributes: { 'w:val': 'restart' } }] }, + { name: 'w:p' }, + ], + }; + const row1 = { name: 'w:tr', elements: [restartCell] }; + + const continuationCell = { + name: 'w:tc', + elements: [{ name: 'w:tcPr', elements: [{ name: 'w:vMerge' }] }, { name: 'w:p' }], + }; + const wrappedRow2 = { name: 'w:tr', elements: [continuationCell] }; + const table = { + name: 'w:tbl', + elements: [ + row1, + { + name: 'w:sdt', + elements: [ + { name: 'w:sdtPr', elements: [{ name: 'w:id', attributes: { 'w:val': '849213029' } }] }, + { name: 'w:sdtContent', elements: [wrappedRow2] }, + ], + }, + ], + }; + + const params = { + docx: {}, + nodeListHandler: { handler: vi.fn(() => 'CONTENT') }, + path: [], + editor: createEditorStub(), + }; + + const out = handleTableCellNode({ + params, + node: restartCell, + table, + row: row1, + columnIndex: 0, + columnWidth: null, + allColumnWidths: [90], + _referencedStyles: null, + }); + + expect(out.attrs.rowspan).toBe(2); + expect(continuationCell._vMergeConsumed).toBe(true); + }); + it('blends percentage table shading into a solid background color', () => { const cellNode = { name: 'w:tc', elements: [{ name: 'w:p' }] }; const row = { name: 'w:tr', elements: [cellNode] }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js index 484385093c..e8aa6359ea 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js @@ -1,4 +1,5 @@ // @ts-check +import { getSdtEnvelopeParts } from '../sdt/helpers/sdt-envelope.js'; /** * Normalize a `` element's children into the cell stream the row encoder @@ -35,9 +36,7 @@ export const normalizeRowCellChildren = (row) => { continue; } if (child.name === 'w:sdt') { - const sdtPr = child.elements?.find((/** @type {any} */ el) => el?.name === 'w:sdtPr') ?? null; - const sdtEndPr = child.elements?.find((/** @type {any} */ el) => el?.name === 'w:sdtEndPr') ?? null; - const sdtContent = child.elements?.find((/** @type {any} */ el) => el?.name === 'w:sdtContent'); + const { sdtPr, sdtEndPr, sdtContent } = getSdtEnvelopeParts(child); const innerCells = sdtContent?.elements?.filter((/** @type {any} */ el) => el?.name === 'w:tc') ?? []; if (innerCells.length === 1 && sdtPr) { out.push({ diff --git a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js index 623a5ca113..62c3a7e3f0 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js +++ b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js @@ -64,6 +64,7 @@ import { parseRowHeight } from './helpers/parseRowHeight.js'; * @property {string} [rsidTr] @internal - Editing session ID for properties modification * @property {string} [paraId] @internal - Unique identifier for the row * @property {string} [textId] @internal - Unique identifier for row text + * @property {Object} [rowSdt] @internal - Row-level structured document tag metadata */ /** @@ -158,6 +159,16 @@ export const TableRow = Node.create({ * @see {@link https://learn.microsoft.com/en-us/openspecs/office_standards/ms-docx/b7eeddec-7c50-47fb-88b6-1feec3ed832c} */ textId: { rendered: false }, + /** + * @private + * Row-level structured document tag metadata (ECMA-376 §17.5.2.30, CT_SdtRow). + * Set when the source OOXML wrapped this row in ``; reconstructed on export. + * Shape: `{ scope: 'row', sdtPr, sdtEndPr }`. + */ + rowSdt: { + default: null, + rendered: false, + }, }; }, diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 7bd04f9c8e..744ddead9e 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -299,6 +299,25 @@ export interface TableRowProperties { jc?: 'center' | 'end' | 'left' | 'right' | 'start'; } +/** Structured document tag metadata preserved for opaque OOXML round-trip. */ +export interface SdtMetadata { + /** Discriminator for structured document tag scope. */ + scope: Scope; + /** Raw `` element preserved from import for opaque round-trip. */ + sdtPr: unknown; + /** Raw `` element if present, otherwise null. */ + sdtEndPr: unknown | null; +} + +/** + * Row-level structured document tag metadata, preserved on a `tableRow` when + * the source OOXML wrapped the row in `` (ECMA-376 §17.5.2.30, CT_SdtRow). + * + * The wrapper is reconstructed on export. Rows carrying this metadata are not + * exposed through the content-controls Document API in v1. + */ +export type RowSdtMetadata = SdtMetadata<'row'>; + /** Table row node attributes */ export interface TableRowAttrs extends TableNodeAttributes { /** Row properties */ @@ -307,6 +326,11 @@ export interface TableRowAttrs extends TableNodeAttributes { rsidRPr?: string | null; /** Tracking revision save ID */ rsidTr?: string | null; + /** + * Row-level structured document tag metadata preserved from OOXML import + * when the source `` was wrapped in ``. Reconstructed on export. + */ + rowSdt?: RowSdtMetadata | null; } // ============================================ @@ -373,14 +397,7 @@ export interface CellBackground { * The wrapper is reconstructed on export. Cells carrying this metadata are not * exposed through the content-controls Document API in v1. */ -export interface CellSdtMetadata { - /** Discriminator for future SDT scope variants (row, block) on the same slot. */ - scope: 'cell'; - /** Raw `` element preserved from import for opaque round-trip. */ - sdtPr: unknown; - /** Raw `` element if present, otherwise null. */ - sdtEndPr: unknown | null; -} +export type CellSdtMetadata = SdtMetadata<'cell'>; /** Table cell node attributes */ export interface TableCellAttrs extends TableNodeAttributes { From fa19f605ff6977e2f1d9119c4b0b6cc6a61b2b6c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 29 May 2026 10:08:02 -0300 Subject: [PATCH 2/6] refactor(converter): share SDT child normalization --- .../v3/handlers/w/sdt/helpers/sdt-envelope.js | 35 ++++++++++++++++ .../v3/handlers/w/tbl/table-row-children.js | 28 +------------ .../v3/handlers/w/tbl/tbl-translator.js | 4 ++ .../v3/handlers/w/tbl/tbl-translator.test.js | 42 +++++++++++++++++++ .../v3/handlers/w/tr/row-cell-children.js | 30 +------------ 5 files changed, 85 insertions(+), 54 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js index 20b05594e5..3b19538221 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js @@ -14,3 +14,38 @@ export const getSdtEnvelopeParts = (node) => { sdtContent: elements.find((el) => el?.name === 'w:sdtContent') ?? null, }; }; + +/** + * Normalize direct children plus same-level SDT wrappers into the child stream + * a parent translator consumes. + * + * @param {any} parent + * @param {{ childName: string, metadataKey: string, scope: string }} config + * @returns {Array<{ node: any } & Record>} + */ +export const normalizeSdtContentChildren = (parent, { childName, metadataKey, scope }) => { + const out = []; + const children = Array.isArray(parent?.elements) ? parent.elements : []; + for (const child of children) { + if (!child || typeof child.name !== 'string') continue; + if (child.name === childName) { + out.push({ node: child, [metadataKey]: null }); + continue; + } + if (child.name === 'w:sdt') { + const { sdtPr, sdtEndPr, sdtContent } = getSdtEnvelopeParts(child); + const innerChildren = sdtContent?.elements?.filter((el) => el?.name === childName) ?? []; + if (innerChildren.length === 1 && sdtPr) { + out.push({ + node: innerChildren[0], + [metadataKey]: { scope, sdtPr, sdtEndPr }, + }); + } else { + for (const innerChild of innerChildren) { + out.push({ node: innerChild, [metadataKey]: null }); + } + } + } + } + return out; +}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js index 3ce65a2d1a..bbde4b5fdc 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js @@ -1,5 +1,5 @@ // @ts-check -import { getSdtEnvelopeParts } from '../sdt/helpers/sdt-envelope.js'; +import { normalizeSdtContentChildren } from '../sdt/helpers/sdt-envelope.js'; /** * Normalize a `` element's children into the row stream the table encoder @@ -17,29 +17,5 @@ import { getSdtEnvelopeParts } from '../sdt/helpers/sdt-envelope.js'; * @returns {Array<{ node: any, rowSdt: any }>} */ export const normalizeTableRowChildren = (table) => { - /** @type {Array<{ node: any, rowSdt: any }>} */ - const out = []; - const children = Array.isArray(table?.elements) ? table.elements : []; - for (const child of children) { - if (!child || typeof child.name !== 'string') continue; - if (child.name === 'w:tr') { - out.push({ node: child, rowSdt: null }); - continue; - } - if (child.name === 'w:sdt') { - const { sdtPr, sdtEndPr, sdtContent } = getSdtEnvelopeParts(child); - const innerRows = sdtContent?.elements?.filter((el) => el?.name === 'w:tr') ?? []; - if (innerRows.length === 1 && sdtPr) { - out.push({ - node: innerRows[0], - rowSdt: { scope: 'row', sdtPr, sdtEndPr }, - }); - } else { - for (const innerTr of innerRows) { - out.push({ node: innerTr, rowSdt: null }); - } - } - } - } - return out; + return normalizeSdtContentChildren(table, { childName: 'w:tr', metadataKey: 'rowSdt', scope: 'row' }); }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js index f9e02acfb5..eff71279fb 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js @@ -308,6 +308,10 @@ const decode = (params, decodedAttrs) => { const elements = translateChildNodes({ ...params, extraParams }); + // Re-wrap rows that were originally imported as row-level SDT + // (ECMA-376 §17.5.2.30, CT_SdtRow). The table schema contains only tableRow + // children, so each exported `` advances the source row cursor once; + // table properties/grid are inserted after this pass and cannot shift it. let rowCursor = 0; for (let i = 0; i < elements.length; i += 1) { const exportedEl = elements[i]; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js index 435f59b40e..724a686e61 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js @@ -472,6 +472,48 @@ describe('w:tbl translator', () => { expect(rowChildren[1]).toBe(bareTr); }); + it('keeps rowSdt wrappers aligned across alternating bare and wrapped rows', () => { + const bareTr1 = { name: 'w:tr', comment: 'bare row 1' }; + const wrappedTr1 = { name: 'w:tr', comment: 'wrapped row 1' }; + const bareTr2 = { name: 'w:tr', comment: 'bare row 2' }; + const wrappedTr2 = { name: 'w:tr', comment: 'wrapped row 2' }; + vi.mocked(translateChildNodes).mockReturnValueOnce([bareTr1, wrappedTr1, bareTr2, wrappedTr2]); + + const tableNode = { + type: 'table', + attrs: {}, + content: [ + { type: 'tableRow', attrs: {}, content: [] }, + { + type: 'tableRow', + attrs: { rowSdt: { scope: 'row', sdtPr: ROW_SDT_PR, sdtEndPr: null } }, + content: [], + }, + { type: 'tableRow', attrs: {}, content: [] }, + { + type: 'tableRow', + attrs: { rowSdt: { scope: 'row', sdtPr: ROW_SDT_PR, sdtEndPr: ROW_SDT_END_PR } }, + content: [], + }, + ], + }; + + const result = translator.decode({ node: tableNode, extraParams: {} }); + const rowChildren = result.elements.filter((el) => el.name === 'w:sdt' || el.name === 'w:tr'); + + expect(rowChildren).toHaveLength(4); + expect(rowChildren[0]).toBe(bareTr1); + expect(rowChildren[1]).toEqual({ + name: 'w:sdt', + elements: [ROW_SDT_PR, { name: 'w:sdtContent', elements: [wrappedTr1] }], + }); + expect(rowChildren[2]).toBe(bareTr2); + expect(rowChildren[3]).toEqual({ + name: 'w:sdt', + elements: [ROW_SDT_PR, ROW_SDT_END_PR, { name: 'w:sdtContent', elements: [wrappedTr2] }], + }); + }); + it('should generate a grid if not present', () => { const mockNode = { type: 'table', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js index e8aa6359ea..e8f38d5abf 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js @@ -1,5 +1,5 @@ // @ts-check -import { getSdtEnvelopeParts } from '../sdt/helpers/sdt-envelope.js'; +import { normalizeSdtContentChildren } from '../sdt/helpers/sdt-envelope.js'; /** * Normalize a `` element's children into the cell stream the row encoder @@ -26,31 +26,5 @@ import { getSdtEnvelopeParts } from '../sdt/helpers/sdt-envelope.js'; * @returns {Array<{ node: any, cellSdt: any }>} */ export const normalizeRowCellChildren = (row) => { - /** @type {Array<{ node: any, cellSdt: any }>} */ - const out = []; - const children = Array.isArray(row?.elements) ? row.elements : []; - for (const child of children) { - if (!child || typeof child.name !== 'string') continue; - if (child.name === 'w:tc') { - out.push({ node: child, cellSdt: null }); - continue; - } - if (child.name === 'w:sdt') { - const { sdtPr, sdtEndPr, sdtContent } = getSdtEnvelopeParts(child); - const innerCells = sdtContent?.elements?.filter((/** @type {any} */ el) => el?.name === 'w:tc') ?? []; - if (innerCells.length === 1 && sdtPr) { - out.push({ - node: innerCells[0], - cellSdt: { scope: 'cell', sdtPr, sdtEndPr }, - }); - } else { - // Multi-cell wrapper or wrapper without sdtPr: import inner cells without - // wrapper metadata so the row is not dropped. - for (const innerTc of innerCells) { - out.push({ node: innerTc, cellSdt: null }); - } - } - } - } - return out; + return normalizeSdtContentChildren(row, { childName: 'w:tc', metadataKey: 'cellSdt', scope: 'cell' }); }; From 10b6bf4f59f066d9df72020d0c9e4d859fe856fc Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 29 May 2026 10:21:54 -0300 Subject: [PATCH 3/6] refactor(converter): share SDT envelope helpers --- .../w/sdt/helpers/handle-annotation-node.js | 4 +-- .../w/sdt/helpers/handle-doc-part-obj.js | 12 ++++----- .../helpers/handle-document-section-node.js | 4 +-- .../helpers/handle-structured-content-node.js | 4 +-- .../v3/handlers/w/sdt/helpers/sdt-envelope.js | 25 +++++++++++++++++++ .../w/sdt/helpers/sdt-node-type-strategy.js | 4 +-- .../v3/handlers/w/tbl/tbl-translator.js | 15 ++--------- .../v3/handlers/w/tr/tr-translator.js | 15 ++--------- 8 files changed, 43 insertions(+), 40 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-annotation-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-annotation-node.js index ce508acab9..f732c75ed2 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-annotation-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-annotation-node.js @@ -1,6 +1,7 @@ import { parseTagValueJSON } from './parse-tag-value-json'; import { parseMarks } from '@converter/v2/importer/markImporter'; import { generateDocxRandomId } from '@core/helpers/generateDocxRandomId'; +import { getSdtEnvelopeParts } from './sdt-envelope'; /** * @param {Object} params @@ -14,8 +15,7 @@ export function handleAnnotationNode(params) { } const node = nodes[0]; - const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr'); - const sdtContent = node.elements.find((el) => el.name === 'w:sdtContent'); + const { sdtPr, sdtContent } = getSdtEnvelopeParts(node); const sdtId = sdtPr?.elements?.find((el) => el.name === 'w:id'); const alias = sdtPr?.elements.find((el) => el.name === 'w:alias'); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-doc-part-obj.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-doc-part-obj.js index cdd7d6afdd..528b2503ab 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-doc-part-obj.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-doc-part-obj.js @@ -1,3 +1,5 @@ +import { getSdtEnvelopeParts } from './sdt-envelope'; + /** * @param {Object} params * @returns {Array|null} @@ -10,23 +12,21 @@ export function handleDocPartObj(params) { } const node = nodes[0]; - const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr'); + const { sdtPr, sdtContent } = getSdtEnvelopeParts(node); const docPartObj = sdtPr?.elements.find((el) => el.name === 'w:docPartObj'); const docPartGallery = docPartObj?.elements.find((el) => el.name === 'w:docPartGallery'); const docPartGalleryType = docPartGallery?.attributes?.['w:val'] ?? null; - const content = node?.elements.find((el) => el.name === 'w:sdtContent'); - // SD-1333: emit inline only when the SDT both sits inside a w:p AND its // sdtContent has no direct w:p/w:tbl children. Word emits Table-of-Figures // SDTs inside a w:p with real w:p children inside sdtContent — those must // stay block so the paragraph translator can hoist them. const isInsideParagraph = (params.path || []).some((p) => p?.name === 'w:p'); - const hasBlockChild = !!content?.elements?.some((el) => el?.name === 'w:p' || el?.name === 'w:tbl'); + const hasBlockChild = !!sdtContent?.elements?.some((el) => el?.name === 'w:p' || el?.name === 'w:tbl'); if (isInsideParagraph && !hasBlockChild) { return inlineDocPartHandler({ ...params, - nodes: [content], + nodes: [sdtContent], extraParams: { ...(params.extraParams || {}), sdtPr, docPartGalleryType }, }); } @@ -35,7 +35,7 @@ export function handleDocPartObj(params) { const handler = validGalleryTypeMap[docPartGalleryType] || genericDocPartHandler; const result = handler({ ...params, - nodes: [content], + nodes: [sdtContent], extraParams: { ...(params.extraParams || {}), sdtPr, docPartGalleryType }, }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-document-section-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-document-section-node.js index 6cd347cb56..84c6a85883 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-document-section-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-document-section-node.js @@ -1,4 +1,5 @@ import { parseTagValueJSON } from './parse-tag-value-json'; +import { getSdtEnvelopeParts } from './sdt-envelope'; /** * Handle document section node. Special case of w:sdt nodes @@ -13,7 +14,7 @@ export function handleDocumentSectionNode(params) { } const node = nodes[0]; - const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr'); + const { sdtPr, sdtContent } = getSdtEnvelopeParts(node); const tag = sdtPr?.elements.find((el) => el.name === 'w:tag'); const tagValue = parseTagValueJSON(tag?.attributes?.['w:val']); @@ -30,7 +31,6 @@ export function handleDocumentSectionNode(params) { const lockValue = lockTag?.attributes?.['w:val']; const isLocked = lockValue === 'sdtContentLocked'; - const sdtContent = node.elements.find((el) => el.name === 'w:sdtContent'); const translatedContent = nodeListHandler.handler({ ...params, nodes: sdtContent?.elements, diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js index c3d0232104..a12de1d527 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/handle-structured-content-node.js @@ -1,5 +1,6 @@ import { parseAnnotationMarks } from './handle-annotation-node'; import { parseStrictStOnOff } from '../../../utils.js'; +import { getSdtEnvelopeParts } from './sdt-envelope'; /** * Detect the semantic control type from sdtPr child elements. @@ -87,8 +88,7 @@ export function handleStructuredContentNode(params) { } const node = nodes[0]; - const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr'); - const sdtContent = node.elements.find((el) => el.name === 'w:sdtContent'); + const { sdtPr, sdtContent } = getSdtEnvelopeParts(node); const id = sdtPr?.elements?.find((el) => el.name === 'w:id'); const tag = sdtPr?.elements?.find((el) => el.name === 'w:tag'); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js index 3b19538221..82b3d19a3c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js @@ -49,3 +49,28 @@ export const normalizeSdtContentChildren = (parent, { childName, metadataKey, sc } return out; }; + +/** + * Re-wrap exported child elements that carry preserved SDT envelope metadata. + * + * @param {any[]} elements + * @param {any[]} sourceChildren + * @param {{ childName: string, metadataKey: string, scope: string }} config + * @returns {any[]} + */ +export const wrapSdtContentChildren = (elements, sourceChildren, { childName, metadataKey, scope }) => { + let sourceCursor = 0; + for (let i = 0; i < elements.length; i += 1) { + const exportedEl = elements[i]; + if (!exportedEl || exportedEl.name !== childName) continue; + const sourceChild = sourceChildren?.[sourceCursor]; + sourceCursor += 1; + const sdtMetadata = sourceChild?.attrs?.[metadataKey]; + if (!sdtMetadata || sdtMetadata.scope !== scope || !sdtMetadata.sdtPr) continue; + const sdtChildren = [sdtMetadata.sdtPr]; + if (sdtMetadata.sdtEndPr) sdtChildren.push(sdtMetadata.sdtEndPr); + sdtChildren.push({ name: 'w:sdtContent', elements: [exportedEl] }); + elements[i] = { name: 'w:sdt', elements: sdtChildren }; + } + return elements; +}; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-node-type-strategy.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-node-type-strategy.js index b50649b02f..68009f204c 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-node-type-strategy.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-node-type-strategy.js @@ -3,6 +3,7 @@ import { handleAnnotationNode } from './handle-annotation-node'; import { handleDocPartObj } from './handle-doc-part-obj'; import { handleDocumentSectionNode } from './handle-document-section-node'; import { handleStructuredContentNode } from './handle-structured-content-node'; +import { getSdtEnvelopeParts } from './sdt-envelope'; /** * There are multiple types of w:sdt nodes. @@ -13,8 +14,7 @@ import { handleStructuredContentNode } from './handle-structured-content-node'; * @returns {Object} */ export function sdtNodeTypeStrategy(node) { - const sdtContent = node.elements.find((el) => el.name === 'w:sdtContent'); - const sdtPr = node.elements.find((el) => el.name === 'w:sdtPr'); + const { sdtPr, sdtContent } = getSdtEnvelopeParts(node); const tag = sdtPr?.elements.find((el) => el.name === 'w:tag'); const tagValue = tag?.attributes?.['w:val']; const docPartObj = sdtPr?.elements.find((el) => el.name === 'w:docPartObj'); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js index eff71279fb..74424d45f8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.js @@ -10,6 +10,7 @@ import { translator as tblGridTranslator } from '../tblGrid'; import { translator as tblPrTranslator } from '../tblPr'; import { translator as trTranslator } from '../tr'; import { normalizeRowCellChildren } from '../tr/row-cell-children.js'; +import { wrapSdtContentChildren } from '../sdt/helpers/sdt-envelope.js'; import { normalizeTableRowChildren } from './table-row-children.js'; /** @@ -312,19 +313,7 @@ const decode = (params, decodedAttrs) => { // (ECMA-376 §17.5.2.30, CT_SdtRow). The table schema contains only tableRow // children, so each exported `` advances the source row cursor once; // table properties/grid are inserted after this pass and cannot shift it. - let rowCursor = 0; - for (let i = 0; i < elements.length; i += 1) { - const exportedEl = elements[i]; - if (!exportedEl || exportedEl.name !== 'w:tr') continue; - const sourceRow = node.content?.[rowCursor]; - rowCursor += 1; - const rowSdt = sourceRow?.attrs?.rowSdt; - if (!rowSdt || rowSdt.scope !== 'row' || !rowSdt.sdtPr) continue; - const sdtChildren = [rowSdt.sdtPr]; - if (rowSdt.sdtEndPr) sdtChildren.push(rowSdt.sdtEndPr); - sdtChildren.push({ name: 'w:sdtContent', elements: [exportedEl] }); - elements[i] = { name: 'w:sdt', elements: sdtChildren }; - } + wrapSdtContentChildren(elements, node.content || [], { childName: 'w:tr', metadataKey: 'rowSdt', scope: 'row' }); // Table grid - generate if not present const firstRow = node.content?.find((n) => n.type === 'tableRow'); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.js index bfb967b014..1bffd6730f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.js @@ -7,6 +7,7 @@ import { translateChildNodes } from '@core/super-converter/v2/exporter/helpers/i import { translator as tcTranslator } from '../tc'; import { translator as tblBordersTranslator } from '../tblBorders'; import { translator as trPrTranslator } from '../trPr'; +import { wrapSdtContentChildren } from '../sdt/helpers/sdt-envelope.js'; import { advancePastRowSpans, fillPlaceholderColumns, isPlaceholderCell } from './tr-helpers.js'; import { normalizeRowCellChildren } from './row-cell-children.js'; @@ -228,19 +229,7 @@ const decode = (params, decodedAttrs) => { // using the preserved `sdtPr` (and `sdtEndPr` if present) on the source cell. // Done here (not inside `tc-translator.decode`) so callers checking // `el.name === 'w:tc'` on the decoder result remain correct. - let cellCursor = 0; - for (let i = 0; i < elements.length; i += 1) { - const exportedEl = elements[i]; - if (!exportedEl || exportedEl.name !== 'w:tc') continue; - const sourceCell = trimmedContent[cellCursor]; - cellCursor += 1; - const cellSdt = sourceCell?.attrs?.cellSdt; - if (!cellSdt || cellSdt.scope !== 'cell' || !cellSdt.sdtPr) continue; - const sdtChildren = [cellSdt.sdtPr]; - if (cellSdt.sdtEndPr) sdtChildren.push(cellSdt.sdtEndPr); - sdtChildren.push({ name: 'w:sdtContent', elements: [exportedEl] }); - elements[i] = { name: 'w:sdt', elements: sdtChildren }; - } + wrapSdtContentChildren(elements, trimmedContent, { childName: 'w:tc', metadataKey: 'cellSdt', scope: 'cell' }); if (node.attrs?.tableRowProperties) { const tableRowProperties = { ...node.attrs.tableRowProperties }; From ca93292e1c5867894c060766df0c9fe035c0e263 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 29 May 2026 10:27:25 -0300 Subject: [PATCH 4/6] fix(converter): preserve SDT content siblings --- .../v3/handlers/w/sdt/helpers/sdt-envelope.js | 18 +++++- .../handlers/w/tbl/table-row-children.test.js | 27 ++++++++ .../v3/handlers/w/tbl/tbl-translator.test.js | 37 +++++++++++ .../w/tr/tr-translator.cell-sdt.test.js | 61 +++++++++++++++++++ 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js index 82b3d19a3c..77ef3146f6 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/sdt/helpers/sdt-envelope.js @@ -34,11 +34,21 @@ export const normalizeSdtContentChildren = (parent, { childName, metadataKey, sc } if (child.name === 'w:sdt') { const { sdtPr, sdtEndPr, sdtContent } = getSdtEnvelopeParts(child); - const innerChildren = sdtContent?.elements?.filter((el) => el?.name === childName) ?? []; + const sdtContentElements = Array.isArray(sdtContent?.elements) ? sdtContent.elements : []; + const innerChildren = sdtContentElements.filter((el) => el?.name === childName); if (innerChildren.length === 1 && sdtPr) { + const childIndex = sdtContentElements.indexOf(innerChildren[0]); + const contentBefore = sdtContentElements.slice(0, childIndex); + const contentAfter = sdtContentElements.slice(childIndex + 1); out.push({ node: innerChildren[0], - [metadataKey]: { scope, sdtPr, sdtEndPr }, + [metadataKey]: { + scope, + sdtPr, + sdtEndPr, + ...(contentBefore.length > 0 && { contentBefore }), + ...(contentAfter.length > 0 && { contentAfter }), + }, }); } else { for (const innerChild of innerChildren) { @@ -69,7 +79,9 @@ export const wrapSdtContentChildren = (elements, sourceChildren, { childName, me if (!sdtMetadata || sdtMetadata.scope !== scope || !sdtMetadata.sdtPr) continue; const sdtChildren = [sdtMetadata.sdtPr]; if (sdtMetadata.sdtEndPr) sdtChildren.push(sdtMetadata.sdtEndPr); - sdtChildren.push({ name: 'w:sdtContent', elements: [exportedEl] }); + const contentBefore = Array.isArray(sdtMetadata.contentBefore) ? sdtMetadata.contentBefore : []; + const contentAfter = Array.isArray(sdtMetadata.contentAfter) ? sdtMetadata.contentAfter : []; + sdtChildren.push({ name: 'w:sdtContent', elements: [...contentBefore, exportedEl, ...contentAfter] }); elements[i] = { name: 'w:sdt', elements: sdtChildren }; } return elements; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.test.js index 79ad8a4efa..9094f9a647 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.test.js @@ -6,6 +6,8 @@ const SDT_PR = { name: 'w:sdtPr', elements: [{ name: 'w:id', attributes: { 'w:va const SDT_END_PR = { name: 'w:sdtEndPr', elements: [] }; const FIRST_ROW = { name: 'w:tr', elements: [{ name: 'w:tc', elements: [] }] }; const SECOND_ROW = { name: 'w:tr', elements: [{ name: 'w:tc', elements: [] }] }; +const BOOKMARK_START = { name: 'w:bookmarkStart', attributes: { 'w:id': '1', 'w:name': 'row-start' } }; +const BOOKMARK_END = { name: 'w:bookmarkEnd', attributes: { 'w:id': '1' } }; describe('normalizeTableRowChildren', () => { it('emits direct table rows unchanged', () => { @@ -33,6 +35,31 @@ describe('normalizeTableRowChildren', () => { ]); }); + it('preserves non-row SDT content siblings around a single imported row', () => { + const table = { + name: 'w:tbl', + elements: [ + { + name: 'w:sdt', + elements: [SDT_PR, { name: 'w:sdtContent', elements: [BOOKMARK_START, FIRST_ROW, BOOKMARK_END] }], + }, + ], + }; + + expect(normalizeTableRowChildren(table)).toEqual([ + { + node: FIRST_ROW, + rowSdt: { + scope: 'row', + sdtPr: SDT_PR, + sdtEndPr: null, + contentBefore: [BOOKMARK_START], + contentAfter: [BOOKMARK_END], + }, + }, + ]); + }); + it('imports multi-row wrappers without applying wrapper metadata to individual rows', () => { const table = { name: 'w:tbl', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js index 724a686e61..438a98146f 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/tbl-translator.test.js @@ -472,6 +472,43 @@ describe('w:tbl translator', () => { expect(rowChildren[1]).toBe(bareTr); }); + it('preserves rowSdt content siblings inside the exported SDT content', () => { + const bookmarkStart = { name: 'w:bookmarkStart', attributes: { 'w:id': '1', 'w:name': 'row-start' } }; + const bookmarkEnd = { name: 'w:bookmarkEnd', attributes: { 'w:id': '1' } }; + const wrappedTr = { name: 'w:tr', comment: 'wrapped row' }; + vi.mocked(translateChildNodes).mockReturnValueOnce([wrappedTr]); + + const tableNode = { + type: 'table', + attrs: {}, + content: [ + { + type: 'tableRow', + attrs: { + rowSdt: { + scope: 'row', + sdtPr: ROW_SDT_PR, + sdtEndPr: null, + contentBefore: [bookmarkStart], + contentAfter: [bookmarkEnd], + }, + }, + content: [], + }, + ], + }; + + const result = translator.decode({ node: tableNode, extraParams: {} }); + const rowChildren = result.elements.filter((el) => el.name === 'w:sdt' || el.name === 'w:tr'); + + expect(rowChildren).toEqual([ + { + name: 'w:sdt', + elements: [ROW_SDT_PR, { name: 'w:sdtContent', elements: [bookmarkStart, wrappedTr, bookmarkEnd] }], + }, + ]); + }); + it('keeps rowSdt wrappers aligned across alternating bare and wrapped rows', () => { const bareTr1 = { name: 'w:tr', comment: 'bare row 1' }; const wrappedTr1 = { name: 'w:tr', comment: 'wrapped row 1' }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.test.js index fdf3244d7c..53fbbdd74d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/tr-translator.cell-sdt.test.js @@ -141,6 +141,31 @@ describe('w:tr translator — cell-level SDT (SD-3289 / IT-1119)', () => { }); }); + it('preserves non-cell SDT content siblings around a single imported cell', () => { + const bookmarkStart = { name: 'w:bookmarkStart', attributes: { 'w:id': '1', 'w:name': 'cell-start' } }; + const bookmarkEnd = { name: 'w:bookmarkEnd', attributes: { 'w:id': '1' } }; + const row = { + name: 'w:tr', + elements: [ + { + name: 'w:sdt', + elements: [SDT_PR, { name: 'w:sdtContent', elements: [bookmarkStart, DATE_CELL, bookmarkEnd] }], + }, + ], + }; + const params = { nodes: [row], extraParams: { row, columnWidths: [296] } }; + + const result = translator.encode(params, {}); + + expect(result.content[0].attrs.cellSdt).toEqual({ + scope: 'cell', + sdtPr: SDT_PR, + sdtEndPr: null, + contentBefore: [bookmarkStart], + contentAfter: [bookmarkEnd], + }); + }); + it('routes the inner w:tc through the existing tc-translator', () => { const row = { name: 'w:tr', @@ -265,6 +290,42 @@ describe('w:tr translator — cell-level SDT (SD-3289 / IT-1119)', () => { expect(wrapped.elements[2]).toEqual({ name: 'w:sdtContent', elements: [exportedTc] }); }); + it('preserves cellSdt content siblings inside the exported SDT content', () => { + const bookmarkStart = { name: 'w:bookmarkStart', attributes: { 'w:id': '1', 'w:name': 'cell-start' } }; + const bookmarkEnd = { name: 'w:bookmarkEnd', attributes: { 'w:id': '1' } }; + const exportedTc = { name: 'w:tc', comment: 'cell xml' }; + vi.mocked(translateChildNodes).mockReturnValueOnce([exportedTc]); + + const row = { + type: 'tableRow', + attrs: {}, + content: [ + { + type: 'tableCell', + attrs: { + cellSdt: { + scope: 'cell', + sdtPr: SDT_PR, + sdtEndPr: null, + contentBefore: [bookmarkStart], + contentAfter: [bookmarkEnd], + }, + }, + content: [], + }, + ], + }; + + const result = translator.decode({ node: row, extraParams: {} }, {}); + + expect(result.elements).toEqual([ + { + name: 'w:sdt', + elements: [SDT_PR, { name: 'w:sdtContent', elements: [bookmarkStart, exportedTc, bookmarkEnd] }], + }, + ]); + }); + it('does not wrap cells without cellSdt metadata', () => { const tc1 = { name: 'w:tc', comment: 'first' }; const tc2 = { name: 'w:tc', comment: 'second' }; From 32951d00a4b95c9e164c0af6616881782c0c3b8d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 29 May 2026 10:40:51 -0300 Subject: [PATCH 5/6] fix(converter): type SDT content siblings --- .../src/editors/v1/extensions/table-cell/table-cell.js | 2 +- .../src/editors/v1/extensions/table-header/table-header.js | 2 +- .../src/editors/v1/extensions/table-row/table-row.js | 2 +- .../src/editors/v1/extensions/types/node-attributes.ts | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js index c865363bed..6f84a880cc 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js +++ b/packages/super-editor/src/editors/v1/extensions/table-cell/table-cell.js @@ -254,7 +254,7 @@ export const TableCell = Node.create({ * @private * Cell-level structured document tag metadata (ECMA-376 §17.5.2.32, CT_SdtCell). * Set when the source OOXML wrapped this cell in ``; reconstructed on export. - * Shape: `{ scope: 'cell', sdtPr, sdtEndPr }`. + * Shape: `{ scope: 'cell', sdtPr, sdtEndPr, contentBefore?, contentAfter? }`. */ cellSdt: { default: null, diff --git a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js index 3ff7fdb997..db6ce4f7ab 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js +++ b/packages/super-editor/src/editors/v1/extensions/table-header/table-header.js @@ -168,7 +168,7 @@ export const TableHeader = Node.create({ * @private * Cell-level structured document tag metadata (ECMA-376 §17.5.2.32, CT_SdtCell). * Set when the source OOXML wrapped this cell in ``; reconstructed on export. - * Shape: `{ scope: 'cell', sdtPr, sdtEndPr }`. + * Shape: `{ scope: 'cell', sdtPr, sdtEndPr, contentBefore?, contentAfter? }`. */ cellSdt: { default: null, diff --git a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js index 62c3a7e3f0..72c22af2d1 100644 --- a/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js +++ b/packages/super-editor/src/editors/v1/extensions/table-row/table-row.js @@ -163,7 +163,7 @@ export const TableRow = Node.create({ * @private * Row-level structured document tag metadata (ECMA-376 §17.5.2.30, CT_SdtRow). * Set when the source OOXML wrapped this row in ``; reconstructed on export. - * Shape: `{ scope: 'row', sdtPr, sdtEndPr }`. + * Shape: `{ scope: 'row', sdtPr, sdtEndPr, contentBefore?, contentAfter? }`. */ rowSdt: { default: null, diff --git a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts index 744ddead9e..ba4470e7b5 100644 --- a/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/editors/v1/extensions/types/node-attributes.ts @@ -307,6 +307,10 @@ export interface SdtMetadata { sdtPr: unknown; /** Raw `` element if present, otherwise null. */ sdtEndPr: unknown | null; + /** Raw SDT content elements that appeared before the wrapped row/cell. */ + contentBefore?: unknown[]; + /** Raw SDT content elements that appeared after the wrapped row/cell. */ + contentAfter?: unknown[]; } /** From df441ae61c78d305260a69315328005b712b9c02 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 29 May 2026 12:18:45 -0300 Subject: [PATCH 6/6] fix(converter): narrow normalized SDT children to scope-specific types --- .../super-converter/v3/handlers/w/tbl/table-row-children.js | 4 +++- .../super-converter/v3/handlers/w/tr/row-cell-children.js | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js index bbde4b5fdc..08381eabc7 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tbl/table-row-children.js @@ -17,5 +17,7 @@ import { normalizeSdtContentChildren } from '../sdt/helpers/sdt-envelope.js'; * @returns {Array<{ node: any, rowSdt: any }>} */ export const normalizeTableRowChildren = (table) => { - return normalizeSdtContentChildren(table, { childName: 'w:tr', metadataKey: 'rowSdt', scope: 'row' }); + return /** @type {Array<{ node: any, rowSdt: any }>} */ ( + normalizeSdtContentChildren(table, { childName: 'w:tr', metadataKey: 'rowSdt', scope: 'row' }) + ); }; diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js index e8f38d5abf..b352d7aa60 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/tr/row-cell-children.js @@ -26,5 +26,7 @@ import { normalizeSdtContentChildren } from '../sdt/helpers/sdt-envelope.js'; * @returns {Array<{ node: any, cellSdt: any }>} */ export const normalizeRowCellChildren = (row) => { - return normalizeSdtContentChildren(row, { childName: 'w:tc', metadataKey: 'cellSdt', scope: 'cell' }); + return /** @type {Array<{ node: any, cellSdt: any }>} */ ( + normalizeSdtContentChildren(row, { childName: 'w:tc', metadataKey: 'cellSdt', scope: 'cell' }) + ); };