From 5f8e19bd4477b961a68758e1500c72fb56d6c5df Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 14:48:23 -0300 Subject: [PATCH 01/10] refactor: extract helper for identifying inline nodes --- .../v3/handlers/helpers/is-inline-node.js | 24 +++++++++++ .../handlers/helpers/is-inline-node.test.js | 40 +++++++++++++++++++ .../helpers/legacy-handle-table-cell-node.js | 24 ++--------- 3 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js new file mode 100644 index 0000000000..4ee8f6a34b --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js @@ -0,0 +1,24 @@ +/** + * Determine whether a translated PM JSON node should be treated as inline. + * + * Falls back to known inline leaf types when schema metadata is unavailable. + * + * @param {unknown} node + * @param {import('prosemirror-model').Schema | undefined} schema + * @returns {boolean} + */ +export function isInlineNode(node, schema) { + if (!node || typeof node !== 'object' || typeof node.type !== 'string') return false; + if (node.type === 'text') return true; + if (node.type === 'bookmarkStart' || node.type === 'bookmarkEnd') return true; + + const nodeType = schema?.nodes?.[node.type]; + if (nodeType) { + if (typeof nodeType.isInline === 'boolean') return nodeType.isInline; + if (nodeType.spec?.group && typeof nodeType.spec.group === 'string') { + return nodeType.spec.group.split(' ').includes('inline'); + } + } + + return false; +} diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js new file mode 100644 index 0000000000..7c01c2373c --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { isInlineNode } from './is-inline-node.js'; + +describe('isInlineNode', () => { + it('treats text and bookmark nodes as inline without schema metadata', () => { + expect(isInlineNode({ type: 'text', text: 'x' })).toBe(true); + expect(isInlineNode({ type: 'bookmarkStart', attrs: { id: '1' } })).toBe(true); + expect(isInlineNode({ type: 'bookmarkEnd', attrs: { id: '1' } })).toBe(true); + }); + + it('uses nodeType.isInline when available', () => { + const schema = { + nodes: { + mention: { isInline: true, spec: {} }, + table: { isInline: false, spec: {} }, + }, + }; + + expect(isInlineNode({ type: 'mention', attrs: { id: 'm1' } }, schema)).toBe(true); + expect(isInlineNode({ type: 'table', content: [] }, schema)).toBe(false); + }); + + it('falls back to schema group metadata when isInline is unavailable', () => { + const schema = { + nodes: { + customInline: { spec: { group: 'inline custom-inline' } }, + customBlock: { spec: { group: 'block' } }, + }, + }; + + expect(isInlineNode({ type: 'customInline' }, schema)).toBe(true); + expect(isInlineNode({ type: 'customBlock' }, schema)).toBe(false); + }); + + it('returns false for missing or unknown node types', () => { + expect(isInlineNode(null)).toBe(false); + expect(isInlineNode({})).toBe(false); + expect(isInlineNode({ type: 'unknownNode' }, { nodes: {} })).toBe(false); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js index 934fe0454e..2c010d964f 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tc/helpers/legacy-handle-table-cell-node.js @@ -1,5 +1,6 @@ import { twipsToPixels, resolveShadingFillColor } from '@converter/helpers'; import { translator as tcPrTranslator } from '../../tcPr'; +import { isInlineNode } from '../../../helpers/is-inline-node.js'; /** * @param {Object} options @@ -149,7 +150,6 @@ function normalizeTableCellContent(content, editor) { const normalized = []; const pendingForNextBlock = []; - const schema = editor?.schema; const cloneBlock = (node) => { if (!node) return node; @@ -169,29 +169,13 @@ function normalizeTableCellContent(content, editor) { return node.content; }; - const isInlineNode = (node) => { - if (!node || typeof node.type !== 'string') return false; - if (node.type === 'text') return true; - if (node.type === 'bookmarkStart' || node.type === 'bookmarkEnd') return true; - - const nodeType = schema?.nodes?.[node.type]; - if (nodeType) { - if (typeof nodeType.isInline === 'boolean') return nodeType.isInline; - if (nodeType.spec?.group && typeof nodeType.spec.group === 'string') { - return nodeType.spec.group.split(' ').includes('inline'); - } - } - - return false; - }; - for (const node of content) { if (!node || typeof node.type !== 'string') { normalized.push(node); continue; } - if (!isInlineNode(node)) { + if (!isInlineNode(node, editor?.schema)) { const blockNode = cloneBlock(node); if (pendingForNextBlock.length) { const blockContent = ensureArray(blockNode); @@ -211,7 +195,7 @@ function normalizeTableCellContent(content, editor) { } else { const lastIndex = normalized.length - 1; const lastNode = normalized[lastIndex]; - if (!lastNode || typeof lastNode.type !== 'string' || isInlineNode(lastNode)) { + if (!lastNode || typeof lastNode.type !== 'string' || isInlineNode(lastNode, editor?.schema)) { pendingForNextBlock.push(node); continue; } @@ -228,7 +212,7 @@ function normalizeTableCellContent(content, editor) { if (normalized.length) { const lastIndex = normalized.length - 1; const lastNode = normalized[lastIndex]; - if (lastNode && typeof lastNode.type === 'string' && !isInlineNode(lastNode)) { + if (lastNode && typeof lastNode.type === 'string' && !isInlineNode(lastNode, editor?.schema)) { const blockContent = ensureArray(lastNode); blockContent.push(...pendingForNextBlock); pendingForNextBlock.length = 0; From aa88fd6a2baaf066826017b1600ff8b1b048b9bb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 14:59:28 -0300 Subject: [PATCH 02/10] fix(super-converter): preserve paragraph attrs across fragment output Allow paragraph translators to return fragment arrays during import and normalize them in the v2 paragraph importer. When the legacy paragraph handler returns mixed fragment output, apply encoded paragraph attributes only to paragraph nodes so embedded documentPartObject fragments remain unchanged. Add coverage for the array-return path in the paragraph translator tests. --- .../v2/importer/paragraphNodeImporter.js | 2 +- .../v3/handlers/w/p/p-translator.js | 9 +++++++ .../v3/handlers/w/p/p-translator.test.js | 25 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js index 243d5c3428..999c38c3fe 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -16,7 +16,7 @@ export const handleParagraphNode = (params) => { return { nodes: [], consumed: 0 }; } const schemaNode = wPNodeTranslator.encode(params); - const newNodes = schemaNode ? [schemaNode] : []; + const newNodes = Array.isArray(schemaNode) ? schemaNode : schemaNode ? [schemaNode] : []; return { nodes: newNodes, consumed: 1 }; }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js index 7b2f815d6f..59a9b0972e 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js @@ -21,6 +21,15 @@ const encode = (params, encodedAttrs = {}) => { const node = legacyHandleParagraphNode(params); if (!node) return undefined; if (encodedAttrs && Object.keys(encodedAttrs).length) { + if (Array.isArray(node)) { + return node.map((child) => { + if (child?.type !== 'paragraph') return child; + return { + ...child, + attrs: { ...(child.attrs || {}), ...encodedAttrs }, + }; + }); + } node.attrs = { ...node.attrs, ...encodedAttrs }; } return node; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js index f2b41d63ee..7b7b05492b 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js @@ -103,6 +103,31 @@ describe('w/p p-translator', () => { }); }); + it('encode() applies encoded paragraph attrs to paragraph fragments only when legacy handler returns an array', () => { + handleParagraphNode.mockReturnValueOnce([ + { type: 'paragraph', attrs: { fromLegacy: true }, content: [] }, + { type: 'documentPartObject', attrs: { id: '123' }, content: [] }, + ]); + + const result = translator.encode({ + nodes: [{ name: 'w:p', attributes: { 'w14:paraId': 'X' } }], + docx: {}, + nodeListHandler: { handlerEntities: [] }, + }); + + expect(result).toEqual([ + expect.objectContaining({ + type: 'paragraph', + attrs: expect.objectContaining({ + fromLegacy: true, + paraId: 'ENC_PARAID', + textId: 'ENC_TEXTID', + }), + }), + { type: 'documentPartObject', attrs: { id: '123' }, content: [] }, + ]); + }); + it('decode() delegates to exporter and merges decoded attributes', () => { const params = { node: { type: 'paragraph', attrs: { any: 'thing' } }, From 17882ca2e4d054d62ccad1bbf8dabdad2ae7eb8e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 15:05:33 -0300 Subject: [PATCH 03/10] fix(super-editor): hoist docPart SDTs out of paragraph inline content --- .../v2/importer/docxImporter.test.js | 139 ++++++++++++++++++ .../p/helpers/legacy-handle-paragraph-node.js | 63 ++++++-- .../legacy-handle-paragraph-node.test.js | 78 ++++++++++ 3 files changed, 270 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js index f632d4d2bb..12faceddc3 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { collapseWhitespaceNextToInlinePassthrough, + defaultNodeListHandler, filterOutRootInlineNodes, normalizeTableBookmarksInContent, } from './docxImporter.js'; @@ -324,3 +325,141 @@ describe('normalizeTableBookmarksInContent', () => { expect(innerCellParagraphContent[2]).toMatchObject({ type: 'bookmarkEnd', attrs: { id: 'n1' } }); }); }); + +describe('docPartObj paragraph import regression', () => { + const createEditorStub = () => ({ + schema: { + nodes: { + run: { isInline: true, spec: { group: 'inline' } }, + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }); + + it('hoists a docPartObj SDT out of paragraph inline content', () => { + const nodeListHandler = defaultNodeListHandler(); + const paragraphNode = { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'AAA111' }, + elements: [ + { + name: 'w:sdt', + elements: [ + { + name: 'w:sdtPr', + elements: [ + { name: 'w:id', attributes: { 'w:val': '123456789' } }, + { + name: 'w:docPartObj', + elements: [ + { name: 'w:docPartGallery', attributes: { 'w:val': 'Table of Figures' } }, + { name: 'w:docPartUnique' }, + ], + }, + ], + }, + { + name: 'w:sdtContent', + elements: [ + { + name: 'w:p', + attributes: { 'w14:paraId': '11111111', 'w14:textId': '11111111' }, + elements: [ + { + name: 'w:r', + elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Table of Figures' }] }], + }, + ], + }, + { + name: 'w:p', + attributes: { 'w14:paraId': '22222222', 'w14:textId': '22222222' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Figure 1' }] }] }, + { name: 'w:r', elements: [{ name: 'w:tab' }] }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: '1' }] }] }, + ], + }, + ], + }, + ], + }, + ], + }; + + const result = nodeListHandler.handler({ + nodes: [paragraphNode], + docx: {}, + editor: createEditorStub(), + path: [], + }); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('documentPartObject'); + expect(result[0].attrs).toMatchObject({ + id: '123456789', + docPartGallery: 'Table of Figures', + docPartUnique: true, + }); + expect(result[0].content).toHaveLength(2); + expect(result[0].content[0].type).toBe('paragraph'); + expect(result[0].content[1].type).toBe('paragraph'); + }); + + it('splits inline text around a docPartObj SDT into sibling paragraphs', () => { + const nodeListHandler = defaultNodeListHandler(); + const paragraphNode = { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'BBB222' }, + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Before' }] }] }, + { + name: 'w:sdt', + elements: [ + { + name: 'w:sdtPr', + elements: [ + { name: 'w:id', attributes: { 'w:val': '123456789' } }, + { + name: 'w:docPartObj', + elements: [{ name: 'w:docPartGallery', attributes: { 'w:val': 'Table of Figures' } }], + }, + ], + }, + { + name: 'w:sdtContent', + elements: [ + { + name: 'w:p', + elements: [ + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Figure 1' }] }] }, + ], + }, + ], + }, + ], + }, + { name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'After' }] }] }, + ], + }; + + const result = nodeListHandler.handler({ + nodes: [paragraphNode], + docx: {}, + editor: createEditorStub(), + path: [], + }); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe('paragraph'); + expect(result[0].content?.[0]?.type).toBe('run'); + expect(result[0].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'Before' }); + expect(result[1]).toMatchObject({ + type: 'documentPartObject', + attrs: { id: '123456789', docPartGallery: 'Table of Figures' }, + }); + expect(result[2].type).toBe('paragraph'); + expect(result[2].content?.[0]?.type).toBe('run'); + expect(result[2].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'After' }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 68913d2f94..2dfb92df30 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -3,6 +3,7 @@ import { mergeTextNodes } from '@converter/v2/importer/index.js'; import { parseProperties } from '@converter/v2/importer/importerHelpers.js'; import { resolveParagraphProperties } from '@converter/styles'; import { translator as w_pPrTranslator } from '@converter/v3/handlers/w/pPr'; +import { isInlineNode } from '../../../helpers/is-inline-node.js'; function getTableStyleId(path) { const tbl = path.find((ancestor) => ancestor.name === 'w:tbl'); @@ -20,13 +21,42 @@ function getTableStyleId(path) { return tblStyle.attributes?.['w:val']; } +function normalizeParagraphChildren(children, schema, textblockAttrs) { + const normalized = []; + let pendingInline = []; + + const flushInline = () => { + if (!pendingInline.length) return; + normalized.push({ + type: 'paragraph', + attrs: { ...textblockAttrs }, + content: pendingInline, + marks: [], + }); + pendingInline = []; + }; + + for (const child of children || []) { + if (isInlineNode(child, schema)) { + pendingInline.push(child); + continue; + } + + flushInline(); + if (child != null) normalized.push(child); + } + + flushInline(); + return normalized; +} + /** * Paragraph node handler * @param {import('@translator').SCEncoderConfig} params * @returns {Object} Handler result */ export const handleParagraphNode = (params) => { - const { nodes, nodeListHandler, filename } = params; + const { nodes, nodeListHandler, filename, editor } = params; const node = carbonCopy(nodes[0]); let schemaNode; @@ -78,14 +108,6 @@ export const handleParagraphNode = (params) => { schemaNode.attrs.rsidRDefault = node.attributes?.['w:rsidRDefault']; schemaNode.attrs.filename = filename; - // Normalize text nodes. - if (schemaNode && schemaNode.content) { - schemaNode = { - ...schemaNode, - content: mergeTextNodes(schemaNode.content), - }; - } - // Pass through this paragraph's sectPr, if any const sectPr = pPr?.elements?.find((el) => el.name === 'w:sectPr'); if (sectPr) { @@ -93,5 +115,26 @@ export const handleParagraphNode = (params) => { schemaNode.attrs.pageBreakSource = 'sectPr'; } - return schemaNode; + const normalizedNodes = normalizeParagraphChildren(schemaNode.content, editor?.schema, schemaNode.attrs).map( + (node) => { + if (node?.type !== 'paragraph' || !Array.isArray(node.content)) return node; + return { + ...node, + content: mergeTextNodes(node.content), + }; + }, + ); + + if (!normalizedNodes.length) { + return { + ...schemaNode, + content: mergeTextNodes(schemaNode.content || []), + }; + } + + if (normalizedNodes.length === 1 && normalizedNodes[0]?.type === 'paragraph') { + return normalizedNodes[0]; + } + + return normalizedNodes; }; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js index ad40117084..046dc9d74c 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js @@ -157,6 +157,7 @@ describe('legacy-handle-paragraph-node', () => { }, ], }, + { name: 'w:r', elements: [] }, ]; const out = handleParagraphNode(params); @@ -169,4 +170,81 @@ describe('legacy-handle-paragraph-node', () => { { tab: { tabType: 'center', pos: undefined } }, ]); }); + + it('returns a block node directly when translated paragraph content is block-only', () => { + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [docPart], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [{ name: 'w:sdt', elements: [] }], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out).toEqual([docPart]); + expect(mergeTextNodes).not.toHaveBeenCalled(); + }); + + it('splits mixed inline and block children into sibling paragraph and block nodes', () => { + mergeTextNodes.mockImplementation((content) => content); + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [{ type: 'text', text: 'Before' }, docPart, { type: 'text', text: 'After' }], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [ + { name: 'w:r', elements: [] }, + { name: 'w:sdt', elements: [] }, + { name: 'w:r', elements: [] }, + ], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out).toEqual([ + expect.objectContaining({ + type: 'paragraph', + content: [{ type: 'text', text: 'Before' }], + }), + docPart, + expect.objectContaining({ + type: 'paragraph', + content: [{ type: 'text', text: 'After' }], + }), + ]); + expect(mergeTextNodes).toHaveBeenCalledTimes(2); + }); }); From e81a175c6ca3e906f3ec6cd0a4a18f49341e836c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 15:21:55 -0300 Subject: [PATCH 04/10] fix(super-editor): preserve inline paragraph content without schema metadata --- .../v2/importer/docxImporter.test.js | 21 ++++++++++++ .../v3/handlers/helpers/is-inline-node.js | 33 +++++++++++++++++-- .../handlers/helpers/is-inline-node.test.js | 5 ++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js index 12faceddc3..889ebfadc9 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js @@ -462,4 +462,25 @@ describe('docPartObj paragraph import regression', () => { expect(result[2].content?.[0]?.type).toBe('run'); expect(result[2].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'After' }); }); + + it('keeps normal paragraphs intact when schema metadata is unavailable', () => { + const nodeListHandler = defaultNodeListHandler(); + const paragraphNode = { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'CCC333' }, + elements: [{ name: 'w:r', elements: [{ name: 'w:t', elements: [{ type: 'text', text: 'Header text' }] }] }], + }; + + const result = nodeListHandler.handler({ + nodes: [paragraphNode], + docx: {}, + editor: {}, + path: [], + }); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('paragraph'); + expect(result[0].content?.[0]?.type).toBe('run'); + expect(result[0].content?.[0]?.content?.[0]).toMatchObject({ type: 'text', text: 'Header text' }); + }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js index 4ee8f6a34b..9e0e581114 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.js @@ -7,10 +7,37 @@ * @param {import('prosemirror-model').Schema | undefined} schema * @returns {boolean} */ +const INLINE_FALLBACK_TYPES = new Set([ + 'text', + 'run', + 'bookmarkStart', + 'bookmarkEnd', + 'tab', + 'lineBreak', + 'hardBreak', + 'commentRangeStart', + 'commentRangeEnd', + 'commentReference', + 'permStart', + 'permEnd', + 'footnoteReference', + 'endnoteReference', + 'fieldAnnotation', + 'structuredContent', + 'passthroughInline', + 'page-number', + 'total-page-number', + 'pageReference', + 'crossReference', + 'citation', + 'authorityEntry', + 'sequenceField', + 'indexEntry', + 'tableOfContentsEntry', +]); + export function isInlineNode(node, schema) { if (!node || typeof node !== 'object' || typeof node.type !== 'string') return false; - if (node.type === 'text') return true; - if (node.type === 'bookmarkStart' || node.type === 'bookmarkEnd') return true; const nodeType = schema?.nodes?.[node.type]; if (nodeType) { @@ -20,5 +47,5 @@ export function isInlineNode(node, schema) { } } - return false; + return INLINE_FALLBACK_TYPES.has(node.type); } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js index 7c01c2373c..04bb399e29 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/helpers/is-inline-node.test.js @@ -2,10 +2,13 @@ import { describe, expect, it } from 'vitest'; import { isInlineNode } from './is-inline-node.js'; describe('isInlineNode', () => { - it('treats text and bookmark nodes as inline without schema metadata', () => { + it('treats common importer inline nodes as inline without schema metadata', () => { expect(isInlineNode({ type: 'text', text: 'x' })).toBe(true); + expect(isInlineNode({ type: 'run', content: [] })).toBe(true); expect(isInlineNode({ type: 'bookmarkStart', attrs: { id: '1' } })).toBe(true); expect(isInlineNode({ type: 'bookmarkEnd', attrs: { id: '1' } })).toBe(true); + expect(isInlineNode({ type: 'tab' })).toBe(true); + expect(isInlineNode({ type: 'footnoteReference', attrs: { id: '1' } })).toBe(true); }); it('uses nodeType.isInline when available', () => { From 83250365819e7f67f6362bcd3862d4584b8a7643 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 15:27:57 -0300 Subject: [PATCH 05/10] fix(super-editor): keep sectPr on the last split paragraph fragment --- .../p/helpers/legacy-handle-paragraph-node.js | 34 +++++++++++- .../legacy-handle-paragraph-node.test.js | 52 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 2dfb92df30..88388b631b 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -21,6 +21,24 @@ function getTableStyleId(path) { return tblStyle.attributes?.['w:val']; } +function cloneParagraphAttrsForFragment(attrs, { keepSectPr = false } = {}) { + if (!attrs) return {}; + + const nextAttrs = { ...attrs }; + if (attrs.paragraphProperties && typeof attrs.paragraphProperties === 'object') { + nextAttrs.paragraphProperties = { ...attrs.paragraphProperties }; + if (!keepSectPr) { + delete nextAttrs.paragraphProperties.sectPr; + } + } + + if (!keepSectPr) { + delete nextAttrs.pageBreakSource; + } + + return nextAttrs; +} + function normalizeParagraphChildren(children, schema, textblockAttrs) { const normalized = []; let pendingInline = []; @@ -29,7 +47,7 @@ function normalizeParagraphChildren(children, schema, textblockAttrs) { if (!pendingInline.length) return; normalized.push({ type: 'paragraph', - attrs: { ...textblockAttrs }, + attrs: null, content: pendingInline, marks: [], }); @@ -47,6 +65,20 @@ function normalizeParagraphChildren(children, schema, textblockAttrs) { } flushInline(); + + const paragraphIndexes = normalized.reduce((indexes, node, index) => { + if (node?.type === 'paragraph') indexes.push(index); + return indexes; + }, []); + const lastParagraphIndex = paragraphIndexes.length ? paragraphIndexes[paragraphIndexes.length - 1] : -1; + + paragraphIndexes.forEach((index) => { + normalized[index] = { + ...normalized[index], + attrs: cloneParagraphAttrsForFragment(textblockAttrs, { keepSectPr: index === lastParagraphIndex }), + }; + }); + return normalized; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js index 046dc9d74c..45fcfd6e28 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js @@ -247,4 +247,56 @@ describe('legacy-handle-paragraph-node', () => { ]); expect(mergeTextNodes).toHaveBeenCalledTimes(2); }); + + it('keeps sectPr only on the last paragraph fragment after splitting', () => { + mergeTextNodes.mockImplementation((content) => content); + const sectPr = { name: 'w:sectPr', elements: [] }; + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [{ type: 'text', text: 'Before' }, docPart, { type: 'text', text: 'After' }], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [ + { + name: 'w:pPr', + elements: [{ name: 'w:sectPr', elements: [] }], + }, + { name: 'w:r', elements: [] }, + { name: 'w:sdt', elements: [] }, + { name: 'w:r', elements: [] }, + ], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out[0].type).toBe('paragraph'); + expect(out[0].attrs).not.toHaveProperty('pageBreakSource'); + expect(out[0].attrs.paragraphProperties).not.toHaveProperty('sectPr'); + expect(out[1]).toEqual(docPart); + expect(out[2]).toMatchObject({ + type: 'paragraph', + attrs: { + pageBreakSource: 'sectPr', + paragraphProperties: { + sectPr, + }, + }, + }); + }); }); From 905b2fd883540a5aa12345784dcf037f5b541727 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 15:53:50 -0300 Subject: [PATCH 06/10] fix(super-editor): keep paraId/textId on only one split paragraph --- .../v3/handlers/w/p/p-translator.js | 28 +++++++++++++++- .../v3/handlers/w/p/p-translator.test.js | 33 ++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js index 59a9b0972e..383a4c7633 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.js @@ -10,6 +10,23 @@ const XML_NODE_NAME = 'w:p'; /** @type {import('@translator').SuperDocNodeOrKeyName} */ const SD_NODE_NAME = 'paragraph'; +const IDENTITY_ATTR_NAMES = new Set(['paraId', 'textId']); + +function partitionEncodedParagraphAttrs(encodedAttrs = {}) { + const identityAttrs = {}; + const shareableAttrs = {}; + + Object.entries(encodedAttrs).forEach(([key, value]) => { + if (IDENTITY_ATTR_NAMES.has(key)) { + identityAttrs[key] = value; + return; + } + shareableAttrs[key] = value; + }); + + return { identityAttrs, shareableAttrs }; +} + /** * Encode a node as a SuperDoc paragraph node. * @param {import('@translator').SCEncoderConfig} params @@ -22,11 +39,20 @@ const encode = (params, encodedAttrs = {}) => { if (!node) return undefined; if (encodedAttrs && Object.keys(encodedAttrs).length) { if (Array.isArray(node)) { + const { identityAttrs, shareableAttrs } = partitionEncodedParagraphAttrs(encodedAttrs); + let appliedIdentityAttrs = false; + return node.map((child) => { if (child?.type !== 'paragraph') return child; + const attrs = { ...(child.attrs || {}), ...shareableAttrs }; + if (!appliedIdentityAttrs) { + Object.assign(attrs, identityAttrs); + appliedIdentityAttrs = true; + } + return { ...child, - attrs: { ...(child.attrs || {}), ...encodedAttrs }, + attrs, }; }); } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js index 7b7b05492b..8a8d36708b 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/p-translator.test.js @@ -103,10 +103,11 @@ describe('w/p p-translator', () => { }); }); - it('encode() applies encoded paragraph attrs to paragraph fragments only when legacy handler returns an array', () => { + it('encode() applies identity attrs only to the first paragraph fragment in split results', () => { handleParagraphNode.mockReturnValueOnce([ { type: 'paragraph', attrs: { fromLegacy: true }, content: [] }, { type: 'documentPartObject', attrs: { id: '123' }, content: [] }, + { type: 'paragraph', attrs: { trailing: true }, content: [] }, ]); const result = translator.encode({ @@ -122,10 +123,40 @@ describe('w/p p-translator', () => { fromLegacy: true, paraId: 'ENC_PARAID', textId: 'ENC_TEXTID', + rsidR: 'ENC_RSIDR', + rsidRDefault: 'ENC_RSIDRDEF', + rsidP: 'ENC_RSIDP', + rsidRPr: 'ENC_RSIDRPR', + rsidDel: 'ENC_RSIDDEL', }), }), { type: 'documentPartObject', attrs: { id: '123' }, content: [] }, + expect.objectContaining({ + type: 'paragraph', + attrs: expect.objectContaining({ + trailing: true, + rsidR: 'ENC_RSIDR', + rsidRDefault: 'ENC_RSIDRDEF', + rsidP: 'ENC_RSIDP', + rsidRPr: 'ENC_RSIDRPR', + rsidDel: 'ENC_RSIDDEL', + }), + }), ]); + expect(result[2].attrs.paraId).toBeUndefined(); + expect(result[2].attrs.textId).toBeUndefined(); + }); + + it('encode() does not stamp paragraph identity attrs onto block-only results', () => { + handleParagraphNode.mockReturnValueOnce([{ type: 'documentPartObject', attrs: { id: '123' }, content: [] }]); + + const result = translator.encode({ + nodes: [{ name: 'w:p', attributes: { 'w14:paraId': 'X' } }], + docx: {}, + nodeListHandler: { handlerEntities: [] }, + }); + + expect(result).toEqual([{ type: 'documentPartObject', attrs: { id: '123' }, content: [] }]); }); it('decode() delegates to exporter and merges decoded attributes', () => { From 3fe3d40a4e07031ed244976ad4d77e9e5d32e93d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 16:21:48 -0300 Subject: [PATCH 07/10] fix(super-editor): preserve sectPr after hoisted docPart blocks --- .../p/helpers/legacy-handle-paragraph-node.js | 25 +++++- .../legacy-handle-paragraph-node.test.js | 86 ++++++++++++++++++- .../helpers/translate-document-part-obj.js | 33 ++++++- .../translate-document-part-obj.test.js | 32 +++++++ .../document-part-object.js | 3 + .../src/extensions/types/node-attributes.ts | 2 + 6 files changed, 175 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 88388b631b..30ac112e16 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -39,6 +39,14 @@ function cloneParagraphAttrsForFragment(attrs, { keepSectPr = false } = {}) { return nextAttrs; } +function hasSectionBreakAttrs(attrs) { + return Boolean(attrs?.paragraphProperties?.sectPr); +} + +function cloneWrapperParagraphAttrs(attrs) { + return cloneParagraphAttrsForFragment(attrs, { keepSectPr: true }); +} + function normalizeParagraphChildren(children, schema, textblockAttrs) { const normalized = []; let pendingInline = []; @@ -66,19 +74,34 @@ function normalizeParagraphChildren(children, schema, textblockAttrs) { flushInline(); + const lastNodeIndex = normalized.length - 1; const paragraphIndexes = normalized.reduce((indexes, node, index) => { if (node?.type === 'paragraph') indexes.push(index); return indexes; }, []); const lastParagraphIndex = paragraphIndexes.length ? paragraphIndexes[paragraphIndexes.length - 1] : -1; + const shouldAttachWrapperParagraph = hasSectionBreakAttrs(textblockAttrs) && lastNodeIndex !== lastParagraphIndex; paragraphIndexes.forEach((index) => { normalized[index] = { ...normalized[index], - attrs: cloneParagraphAttrsForFragment(textblockAttrs, { keepSectPr: index === lastParagraphIndex }), + attrs: cloneParagraphAttrsForFragment(textblockAttrs, { + keepSectPr: !shouldAttachWrapperParagraph && index === lastParagraphIndex, + }), }; }); + if (shouldAttachWrapperParagraph) { + const lastNode = normalized[lastNodeIndex]; + normalized[lastNodeIndex] = { + ...lastNode, + attrs: { + ...(lastNode?.attrs || {}), + wrapperParagraph: cloneWrapperParagraphAttrs(textblockAttrs), + }, + }; + } + return normalized; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js index 45fcfd6e28..6f13ae4ac1 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js @@ -171,12 +171,13 @@ describe('legacy-handle-paragraph-node', () => { ]); }); - it('returns a block node directly when translated paragraph content is block-only', () => { + it('preserves sectPr on wrapper metadata when translated paragraph content is block-only', () => { const docPart = { type: 'documentPartObject', attrs: { id: '123', docPartGallery: 'Table of Figures' }, content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], }; + const sectPr = { name: 'w:sectPr', elements: [] }; const out = handleParagraphNode( makeParams({ @@ -185,7 +186,13 @@ describe('legacy-handle-paragraph-node', () => { { name: 'w:p', attributes: { 'w:rsidRDefault': 'ABCDEF' }, - elements: [{ name: 'w:sdt', elements: [] }], + elements: [ + { + name: 'w:pPr', + elements: [{ name: 'w:sectPr', elements: [] }], + }, + { name: 'w:sdt', elements: [] }, + ], }, ], editor: { @@ -198,7 +205,22 @@ describe('legacy-handle-paragraph-node', () => { }), ); - expect(out).toEqual([docPart]); + expect(out).toMatchObject([ + { + ...docPart, + attrs: { + ...docPart.attrs, + wrapperParagraph: { + filename: 'source.docx', + pageBreakSource: 'sectPr', + paragraphProperties: { + sectPr, + }, + rsidRDefault: 'ABCDEF', + }, + }, + }, + ]); expect(mergeTextNodes).not.toHaveBeenCalled(); }); @@ -248,7 +270,7 @@ describe('legacy-handle-paragraph-node', () => { expect(mergeTextNodes).toHaveBeenCalledTimes(2); }); - it('keeps sectPr only on the last paragraph fragment after splitting', () => { + it('keeps sectPr on the last paragraph fragment when content continues after a hoisted block', () => { mergeTextNodes.mockImplementation((content) => content); const sectPr = { name: 'w:sectPr', elements: [] }; const docPart = { @@ -299,4 +321,60 @@ describe('legacy-handle-paragraph-node', () => { }, }); }); + + it('stores sectPr on a trailing block when it is the last emitted node', () => { + mergeTextNodes.mockImplementation((content) => content); + const sectPr = { name: 'w:sectPr', elements: [] }; + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [{ type: 'text', text: 'Before' }, docPart], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [ + { + name: 'w:pPr', + elements: [{ name: 'w:sectPr', elements: [] }], + }, + { name: 'w:r', elements: [] }, + { name: 'w:sdt', elements: [] }, + ], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out[0]).toMatchObject({ + type: 'paragraph', + attrs: { + paragraphProperties: {}, + }, + }); + expect(out[0].attrs).not.toHaveProperty('pageBreakSource'); + expect(out[1]).toMatchObject({ + type: 'documentPartObject', + attrs: { + wrapperParagraph: { + pageBreakSource: 'sectPr', + paragraphProperties: { + sectPr, + }, + }, + }, + }); + }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js index d1dc4206e7..27382ba2ec 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js @@ -1,4 +1,5 @@ import { translateChildNodes } from '@converter/v2/exporter/helpers/translateChildNodes'; +import { generateParagraphProperties } from '../../p/helpers/generate-paragraph-properties.js'; /** * Translate a document part object node to its XML representation. @@ -27,7 +28,37 @@ export function translateDocumentPartObj(params) { elements: nodeElements, }; - return result; + if (!attrs.wrapperParagraph) { + return result; + } + + return wrapDocumentPartInParagraph(result, attrs.wrapperParagraph); +} + +function wrapDocumentPartInParagraph(sdtNode, wrapperParagraphAttrs) { + const elements = []; + const pPr = generateParagraphProperties({ + node: { + type: 'paragraph', + attrs: wrapperParagraphAttrs, + }, + }); + + if (pPr) { + elements.push(pPr); + } + elements.push(sdtNode); + + const attributes = {}; + if (wrapperParagraphAttrs?.rsidRDefault) { + attributes['w:rsidRDefault'] = wrapperParagraphAttrs.rsidRDefault; + } + + return { + name: 'w:p', + elements, + ...(Object.keys(attributes).length ? { attributes } : {}), + }; } function sanitizeId(id) { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js index 702ef8cdb1..24819c986b 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js @@ -92,4 +92,36 @@ describe('translateDocumentPartObj', () => { expect(sdtPr.elements.find((el) => el.name === 'w:foo')).toBeDefined(); expect(passthroughSdtPr.elements.find((el) => el.name === 'w:id')).toBeDefined(); }); + + it('rewraps the document part in a paragraph when wrapper paragraph attrs are present', () => { + const sectPr = { name: 'w:sectPr', elements: [] }; + const node = { + type: 'documentPartObject', + content: [], + attrs: { + id: '123', + docPartGallery: 'Table of Figures', + docPartUnique: true, + wrapperParagraph: { + rsidRDefault: 'ABCDEF', + pageBreakSource: 'sectPr', + paragraphProperties: { + sectPr, + }, + }, + }, + }; + + const result = translateDocumentPartObj({ node }); + + expect(result.name).toBe('w:p'); + expect(result.attributes).toEqual({ 'w:rsidRDefault': 'ABCDEF' }); + expect(result.elements[0]).toMatchObject({ + name: 'w:pPr', + elements: [sectPr], + }); + expect(result.elements[1]).toMatchObject({ + name: 'w:sdt', + }); + }); }); diff --git a/packages/super-editor/src/extensions/structured-content/document-part-object.js b/packages/super-editor/src/extensions/structured-content/document-part-object.js index 254b15b3ed..a59b5a34a2 100644 --- a/packages/super-editor/src/extensions/structured-content/document-part-object.js +++ b/packages/super-editor/src/extensions/structured-content/document-part-object.js @@ -46,6 +46,9 @@ export const DocumentPartObject = Node.create({ docPartUnique: { default: true, }, + wrapperParagraph: { + default: null, + }, }; }, }); diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index b3f9c11acf..03743ac696 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -1070,6 +1070,8 @@ export interface DocumentPartObjectAttrs extends BlockNodeAttributes { docPartGallery?: unknown; /** Whether document part is unique */ docPartUnique?: boolean; + /** @internal Original wrapper paragraph attrs for export preservation */ + wrapperParagraph?: unknown; } // ============================================ From af037ebeea0ef77ad69fe8ebde4ce96137827992 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 16:32:29 -0300 Subject: [PATCH 08/10] fix(super-editor): preserve wrapper paragraph formatting for block docParts --- .../p/helpers/legacy-handle-paragraph-node.js | 4 +- .../legacy-handle-paragraph-node.test.js | 56 +++++++++++++++++++ .../translate-document-part-obj.test.js | 40 +++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js index 30ac112e16..50939d74e4 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.js @@ -75,12 +75,14 @@ function normalizeParagraphChildren(children, schema, textblockAttrs) { flushInline(); const lastNodeIndex = normalized.length - 1; + const isSingleBlockResult = normalized.length === 1 && normalized[0] != null && normalized[0]?.type !== 'paragraph'; const paragraphIndexes = normalized.reduce((indexes, node, index) => { if (node?.type === 'paragraph') indexes.push(index); return indexes; }, []); const lastParagraphIndex = paragraphIndexes.length ? paragraphIndexes[paragraphIndexes.length - 1] : -1; - const shouldAttachWrapperParagraph = hasSectionBreakAttrs(textblockAttrs) && lastNodeIndex !== lastParagraphIndex; + const shouldAttachWrapperParagraph = + isSingleBlockResult || (hasSectionBreakAttrs(textblockAttrs) && lastNodeIndex !== lastParagraphIndex); paragraphIndexes.forEach((index) => { normalized[index] = { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js index 6f13ae4ac1..d77d58ebd2 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/p/helpers/legacy-handle-paragraph-node.test.js @@ -224,6 +224,62 @@ describe('legacy-handle-paragraph-node', () => { expect(mergeTextNodes).not.toHaveBeenCalled(); }); + it('preserves wrapper paragraph formatting when translated paragraph content is block-only', () => { + const docPart = { + type: 'documentPartObject', + attrs: { id: '123', docPartGallery: 'Table of Figures' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Figure 1' }] }], + }; + + const out = handleParagraphNode( + makeParams({ + _mockContent: [docPart], + nodes: [ + { + name: 'w:p', + attributes: { 'w:rsidRDefault': 'ABCDEF' }, + elements: [ + { + name: 'w:pPr', + elements: [ + { name: 'w:pStyle', attributes: { 'w:val': 'TOCHeading' } }, + { name: 'w:spacing', attributes: { 'w:after': '120', 'w:line': '240', 'w:lineRule': 'auto' } }, + { name: 'w:keepNext', attributes: { 'w:val': 'true' } }, + ], + }, + { name: 'w:sdt', elements: [] }, + ], + }, + ], + editor: { + schema: { + nodes: { + documentPartObject: { isInline: false, spec: { group: 'block' } }, + }, + }, + }, + }), + ); + + expect(out).toMatchObject([ + { + ...docPart, + attrs: { + ...docPart.attrs, + wrapperParagraph: { + filename: 'source.docx', + rsidRDefault: 'ABCDEF', + paragraphProperties: { + styleId: 'TOCHeading', + keepNext: true, + spacing: { after: 120, line: 240, lineRule: 'auto' }, + }, + }, + }, + }, + ]); + }); + it('splits mixed inline and block children into sibling paragraph and block nodes', () => { mergeTextNodes.mockImplementation((content) => content); const docPart = { diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js index 24819c986b..06bced1581 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js @@ -124,4 +124,44 @@ describe('translateDocumentPartObj', () => { name: 'w:sdt', }); }); + + it('rewraps the document part in a paragraph for non-sectPr wrapper formatting', () => { + const node = { + type: 'documentPartObject', + content: [], + attrs: { + id: '123', + docPartGallery: 'Table of Figures', + docPartUnique: true, + wrapperParagraph: { + rsidRDefault: 'ABCDEF', + paragraphProperties: { + styleId: 'TOCHeading', + keepNext: true, + }, + }, + }, + }; + + const result = translateDocumentPartObj({ node }); + + expect(result.name).toBe('w:p'); + expect(result.attributes).toEqual({ 'w:rsidRDefault': 'ABCDEF' }); + expect(result.elements[0]).toMatchObject({ + name: 'w:pPr', + elements: expect.arrayContaining([ + { + name: 'w:pStyle', + attributes: { 'w:val': 'TOCHeading' }, + }, + { + name: 'w:keepNext', + attributes: {}, + }, + ]), + }); + expect(result.elements[1]).toMatchObject({ + name: 'w:sdt', + }); + }); }); From 3e3dfdcba53c3ec5bb3cdfbceb1d2130fce6c27c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 17:22:01 -0300 Subject: [PATCH 09/10] fix(super-editor): preserve paragraph XML attrs on wrapped docPart export --- .../helpers/translate-document-part-obj.js | 13 +++++--- .../translate-document-part-obj.test.js | 30 +++++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js index 27382ba2ec..ee4021e5af 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.js @@ -1,5 +1,6 @@ import { translateChildNodes } from '@converter/v2/exporter/helpers/translateChildNodes'; import { generateParagraphProperties } from '../../p/helpers/generate-paragraph-properties.js'; +import { translator as pTranslator } from '../../p/index.js'; /** * Translate a document part object node to its XML representation. @@ -49,10 +50,10 @@ function wrapDocumentPartInParagraph(sdtNode, wrapperParagraphAttrs) { } elements.push(sdtNode); - const attributes = {}; - if (wrapperParagraphAttrs?.rsidRDefault) { - attributes['w:rsidRDefault'] = wrapperParagraphAttrs.rsidRDefault; - } + const attributes = { + ...extractRawParagraphXmlAttributes(wrapperParagraphAttrs), + ...pTranslator.decodeAttributes({ node: { attrs: wrapperParagraphAttrs } }), + }; return { name: 'w:p', @@ -61,6 +62,10 @@ function wrapDocumentPartInParagraph(sdtNode, wrapperParagraphAttrs) { }; } +function extractRawParagraphXmlAttributes(attrs = {}) { + return Object.fromEntries(Object.entries(attrs).filter(([key]) => key.includes(':'))); +} + function sanitizeId(id) { if (typeof id === 'string' && id.trim() !== '') { return id.trim(); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js index 06bced1581..c698273c1c 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/sdt/helpers/translate-document-part-obj.test.js @@ -103,7 +103,13 @@ describe('translateDocumentPartObj', () => { docPartGallery: 'Table of Figures', docPartUnique: true, wrapperParagraph: { + paraId: '41964671', + textId: '04598795', + rsidR: '00233D7B', rsidRDefault: 'ABCDEF', + rsidP: '003104CE', + rsidRPr: '003104CE', + rsidDel: '00DEAD00', pageBreakSource: 'sectPr', paragraphProperties: { sectPr, @@ -115,7 +121,15 @@ describe('translateDocumentPartObj', () => { const result = translateDocumentPartObj({ node }); expect(result.name).toBe('w:p'); - expect(result.attributes).toEqual({ 'w:rsidRDefault': 'ABCDEF' }); + expect(result.attributes).toEqual({ + 'w14:paraId': '41964671', + 'w14:textId': '04598795', + 'w:rsidR': '00233D7B', + 'w:rsidRDefault': 'ABCDEF', + 'w:rsidP': '003104CE', + 'w:rsidRPr': '003104CE', + 'w:rsidDel': '00DEAD00', + }); expect(result.elements[0]).toMatchObject({ name: 'w:pPr', elements: [sectPr], @@ -134,7 +148,12 @@ describe('translateDocumentPartObj', () => { docPartGallery: 'Table of Figures', docPartUnique: true, wrapperParagraph: { + paraId: '41964671', + textId: '04598795', + rsidR: '00233D7B', rsidRDefault: 'ABCDEF', + rsidP: '003104CE', + rsidRPr: '003104CE', paragraphProperties: { styleId: 'TOCHeading', keepNext: true, @@ -146,7 +165,14 @@ describe('translateDocumentPartObj', () => { const result = translateDocumentPartObj({ node }); expect(result.name).toBe('w:p'); - expect(result.attributes).toEqual({ 'w:rsidRDefault': 'ABCDEF' }); + expect(result.attributes).toEqual({ + 'w14:paraId': '41964671', + 'w14:textId': '04598795', + 'w:rsidR': '00233D7B', + 'w:rsidRDefault': 'ABCDEF', + 'w:rsidP': '003104CE', + 'w:rsidRPr': '003104CE', + }); expect(result.elements[0]).toMatchObject({ name: 'w:pPr', elements: expect.arrayContaining([ From 5f090594fb7b8c2732472163143938b6fffeff84 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 20 Mar 2026 09:55:14 -0300 Subject: [PATCH 10/10] fix(super-editor): default docPartUnique to false per OOXML spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ยง17.5.2.14, the absence of w:docPartUnique means the SDT is not a built-in document part container. Defaulting to true caused the exporter to emit on every SDT even when the original XML didn't have it. --- .../src/extensions/structured-content/document-part-object.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/super-editor/src/extensions/structured-content/document-part-object.js b/packages/super-editor/src/extensions/structured-content/document-part-object.js index a59b5a34a2..d023f33cac 100644 --- a/packages/super-editor/src/extensions/structured-content/document-part-object.js +++ b/packages/super-editor/src/extensions/structured-content/document-part-object.js @@ -44,7 +44,7 @@ export const DocumentPartObject = Node.create({ id: {}, docPartGallery: {}, docPartUnique: { - default: true, + default: false, }, wrapperParagraph: { default: null,