From 12e13fc5602c50772703d92ec62508fe4af4d0fa Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 14:48:23 -0300 Subject: [PATCH 1/9] 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 000000000..4ee8f6a34 --- /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 000000000..7c01c2373 --- /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 a81322d9d..6a7da9162 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 @@ -150,7 +151,6 @@ function normalizeTableCellContent(content, editor) { const normalized = []; const pendingForNextBlock = []; - const schema = editor?.schema; const cloneBlock = (node) => { if (!node) return node; @@ -170,29 +170,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); @@ -212,7 +196,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; } @@ -229,7 +213,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 010803da2c224d097b38f81110de1cf78af3c57d Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 14:59:28 -0300 Subject: [PATCH 2/9] 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 243d5c342..999c38c3f 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 7b2f815d6..59a9b0972 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 f2b41d63e..7b7b05492 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 f76dc1ae8e4cbdd00becad0d73a63c7362cbd59c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 15:05:33 -0300 Subject: [PATCH 3/9] 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 f632d4d2b..12faceddc 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 68913d2f9..2dfb92df3 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 ad4011708..046dc9d74 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 b0493458b4947d545d3d20953ea484da9e72a2c8 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 15:21:55 -0300 Subject: [PATCH 4/9] 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 12faceddc..889ebfadc 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 4ee8f6a34..9e0e58111 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 7c01c2373..04bb399e2 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 29bf838ff5dad182e2f38fd1f68c54f308831e97 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 15:27:57 -0300 Subject: [PATCH 5/9] 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 2dfb92df3..88388b631 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 046dc9d74..45fcfd6e2 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 77064a2f817e6c3969b58c6c4bd59842d548f11c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 15:53:50 -0300 Subject: [PATCH 6/9] 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 59a9b0972..383a4c763 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 7b7b05492..8a8d36708 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 415e412375ee6ffa7c7cdc909bcba6a8aedd26b7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 16:21:48 -0300 Subject: [PATCH 7/9] 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 88388b631..30ac112e1 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 45fcfd6e2..6f13ae4ac 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 c63282113..0d4cdf920 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 } : {}), + }; } /** 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 f56936350..a72ff4150 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 @@ -33,4 +33,36 @@ describe('translateDocumentPartObj', () => { ), ).toBeUndefined(); }); + + 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 cf0c50857..9414a8155 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 @@ -45,6 +45,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 fa14eb46c..08bb8e292 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -1069,6 +1069,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 2772dfcb842eb2bc95f92bb01cb7c9eb752617ec Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 16:32:29 -0300 Subject: [PATCH 8/9] 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 30ac112e1..50939d74e 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 6f13ae4ac..d77d58ebd 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 a72ff4150..dc3bf3b44 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 @@ -65,4 +65,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 d01e38d7688c6f705711b3a4c77852650bebb558 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 12 Mar 2026 17:22:01 -0300 Subject: [PATCH 9/9] fix(super-editor): preserve paragraph XML attrs on wrapped docPart export --- .../helpers/translate-document-part-obj.js | 28 ++++++++++++++--- .../translate-document-part-obj.test.js | 30 +++++++++++++++++-- 2 files changed, 52 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 0d4cdf920..91f581134 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 paragraphXmlAttributes from '../../p/attributes/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), + ...decodeParagraphXmlAttributes(wrapperParagraphAttrs), + }; return { name: 'w:p', @@ -61,6 +62,25 @@ function wrapDocumentPartInParagraph(sdtNode, wrapperParagraphAttrs) { }; } +function extractRawParagraphXmlAttributes(attrs = {}) { + return Object.fromEntries(Object.entries(attrs).filter(([key]) => key.includes(':'))); +} + +function decodeParagraphXmlAttributes(attrs = {}) { + const decoded = {}; + + paragraphXmlAttributes.forEach(({ xmlName, decode }) => { + if (!decode) return; + + const value = decode(attrs); + if (value !== undefined && value !== null) { + decoded[xmlName] = value; + } + }); + + return decoded; +} + /** * Generate sdtPr element for document part object with passthrough support. * Builds core w:id and w:docPartObj elements, then appends any additional 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 dc3bf3b44..4f0665840 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 @@ -44,7 +44,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, @@ -56,7 +62,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], @@ -75,7 +89,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, @@ -87,7 +106,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([