diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js index 39a5e3554c..e4d6fc84eb 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js @@ -4,6 +4,37 @@ import { getHexColorFromDocxSystem, isValidHexColor, twipsToInches, twipsToLines import { translator as wRPrTranslator } from '../../v3/handlers/w/rpr/index.js'; import { encodeMarksFromRPr } from '@converter/styles.js'; import { resolveTrackedChangeImportIds, stampImportTrackingAttrs } from './importTrackingContext.js'; +import { + ParagraphSplitSnapshotType, + SuperDocParagraphSplitAnchorAttr, + SuperDocParagraphSplitAttr, +} from '../../v3/handlers/helpers.js'; + +function getInlineParagraphChange(params) { + return params?.extraParams?.inlineParagraphProperties?.change || null; +} + +function isMatchingParagraphSplitChange(change, ids) { + if (!change || typeof change !== 'object') return false; + if (String(change[SuperDocParagraphSplitAttr] ?? '') !== '1') return false; + + const changeId = change.id; + if (changeId == null) return false; + return ids.some((id) => id != null && String(id) === String(changeId)); +} + +function createParagraphSplitSnapshots(change) { + const anchor = change?.[SuperDocParagraphSplitAnchorAttr] === 'source' ? 'source' : 'inserted'; + const snapshot = { + type: ParagraphSplitSnapshotType, + attrs: { anchor }, + }; + + return { + before: [snapshot], + after: [{ ...snapshot, attrs: { ...snapshot.attrs } }], + }; +} /** * @@ -135,6 +166,12 @@ export function handleStyleChangeMarksV2(rPrChange, currentMarks, params) { submarks = encodeMarksFromRPr(runProperties, params?.docx); } + const paragraphChange = getInlineParagraphChange(params); + if (isMatchingParagraphSplitChange(paragraphChange, [attributes['w:id'], sourceId, logicalId])) { + const snapshots = createParagraphSplitSnapshots(paragraphChange); + return [{ type: TrackFormatMarkName, attrs: { ...mappedAttributes, ...snapshots } }]; + } + return [{ type: TrackFormatMarkName, attrs: { ...mappedAttributes, before: submarks, after: [...currentMarks] } }]; } diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js index 09995acfdc..473b34a891 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js @@ -190,6 +190,38 @@ describe('handleStyleChangeMarksV2', () => { expect(result[0].attrs.after).toEqual([]); }); + it('restores paragraphSplit snapshots from matching SuperDoc paragraph metadata', () => { + const currentMarks = [{ type: 'bold', attrs: { value: true } }]; + const rPrChange = { + name: 'w:rPrChange', + attributes: { + 'w:id': '7', + 'w:date': '2026-06-01T17:00:00Z', + 'w:author': 'Reviewer', + }, + elements: [{ name: 'w:rPr', elements: [] }], + }; + + const result = handleStyleChangeMarksV2(rPrChange, currentMarks, { + docx: {}, + extraParams: { + inlineParagraphProperties: { + change: { + id: '7', + superdocParagraphSplit: '1', + superdocParagraphSplitAnchor: 'source', + paragraphProperties: {}, + }, + }, + }, + }); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe(TrackFormatMarkName); + expect(result[0].attrs.before).toEqual([{ type: 'paragraphSplit', attrs: { anchor: 'source' } }]); + expect(result[0].attrs.after).toEqual([{ type: 'paragraphSplit', attrs: { anchor: 'source' } }]); + }); + it('remaps format change ids and stamps overlapParentId through import tracking context', () => { const context = createImportTrackingContext({}); context.pushParent({ diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js index 20b26f5828..4bfe33f673 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js @@ -1,11 +1,27 @@ import { processOutputMarks } from '@converter/exporter.js'; import { TrackFormatMarkName } from '@extensions/track-changes/constants.js'; +export const ParagraphSplitSnapshotType = 'paragraphSplit'; +export const SuperDocRevisionNamespace = 'https://superdoc.dev/ooxml/revisions/2026'; +export const SuperDocRevisionNamespaceAttr = 'superdocXmlns'; +export const SuperDocParagraphSplitAttr = 'superdocParagraphSplit'; +export const SuperDocParagraphSplitAnchorAttr = 'superdocParagraphSplitAnchor'; + const getMarkType = (mark) => mark?.type?.name ?? mark?.type ?? null; +const getSnapshotType = (snapshot) => snapshot?.type?.name ?? snapshot?.type ?? null; const toRunPropertyElements = (marks = []) => processOutputMarks(marks).filter((element) => element && typeof element === 'object' && element.name); +const getTrackFormatChangeWordId = (trackFormatMark, options = {}) => { + const allocator = options?.wordIdAllocator || null; + const partPath = options?.partPath || 'word/document.xml'; + const sourceId = trackFormatMark.attrs?.sourceId; + const logicalId = trackFormatMark.attrs?.id; + + return allocator ? allocator.allocate({ partPath, sourceId, logicalId }) : sourceId || logicalId; +}; + /** * Return the first trackFormat mark from a mark list. * @@ -15,6 +31,52 @@ const toRunPropertyElements = (marks = []) => export const findTrackFormatMark = (marks = []) => marks.find((mark) => getMarkType(mark) === TrackFormatMarkName) ?? null; +export const findSnapshotByType = (snapshots = [], type) => + Array.isArray(snapshots) ? (snapshots.find((snapshot) => getSnapshotType(snapshot) === type) ?? null) : null; + +export const findParagraphSplitSnapshot = (trackFormatMark) => { + if (!trackFormatMark) return null; + return ( + findSnapshotByType(trackFormatMark.attrs?.before, ParagraphSplitSnapshotType) || + findSnapshotByType(trackFormatMark.attrs?.after, ParagraphSplitSnapshotType) + ); +}; + +export const isParagraphSplitTrackFormatMark = (mark) => + getMarkType(mark) === TrackFormatMarkName && Boolean(findParagraphSplitSnapshot(mark)); + +export const createParagraphSplitPropertiesChange = (trackFormatMark, options = {}) => { + const paragraphSplit = findParagraphSplitSnapshot(trackFormatMark); + if (!paragraphSplit) return undefined; + + const anchor = paragraphSplit.attrs?.anchor === 'source' ? 'source' : 'inserted'; + + return { + id: getTrackFormatChangeWordId(trackFormatMark, options), + author: trackFormatMark.attrs?.author, + date: trackFormatMark.attrs?.date, + [SuperDocRevisionNamespaceAttr]: SuperDocRevisionNamespace, + [SuperDocParagraphSplitAttr]: '1', + [SuperDocParagraphSplitAnchorAttr]: anchor, + paragraphProperties: {}, + }; +}; + +export const createParagraphSplitInsertionElement = (trackFormatMark, options = {}) => { + const paragraphSplit = findParagraphSplitSnapshot(trackFormatMark); + if (!paragraphSplit) return undefined; + + return { + type: 'element', + name: 'w:ins', + attributes: { + 'w:id': getTrackFormatChangeWordId(trackFormatMark, options), + 'w:author': trackFormatMark.attrs?.author, + 'w:date': trackFormatMark.attrs?.date, + }, + }; +}; + /** * Build a valid OOXML node from a trackFormat mark. * @@ -38,11 +100,7 @@ export const createRunPropertiesChangeElement = (trackFormatMark, options = {}) // Phase 005 — if an allocator was passed in, mint a Word-native decimal // `w:id`. Legacy callers (no `options.wordIdAllocator`) keep the prior // `sourceId || id` behavior so the exported byte stream is unchanged. - const allocator = options?.wordIdAllocator || null; - const partPath = options?.partPath || 'word/document.xml'; - const sourceId = trackFormatMark.attrs?.sourceId; - const logicalId = trackFormatMark.attrs?.id; - const wordId = allocator ? allocator.allocate({ partPath, sourceId, logicalId }) : sourceId || logicalId; + const wordId = getTrackFormatChangeWordId(trackFormatMark, options); return { type: 'element', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js index 886f5f923b..048d4cd552 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js @@ -1,5 +1,70 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js'; import { translator as wPPrNodeTranslator } from '../../pPr/pPr-translator.js'; +import { + createParagraphSplitInsertionElement, + createParagraphSplitPropertiesChange, + isParagraphSplitTrackFormatMark, +} from '../../../helpers.js'; + +function resolveExportPartPath(params = {}) { + if (typeof params.currentPartPath === 'string' && params.currentPartPath.length > 0) return params.currentPartPath; + if (typeof params.filename === 'string' && params.filename.length > 0) { + return params.filename.startsWith('word/') ? params.filename : `word/${params.filename}`; + } + return 'word/document.xml'; +} + +function findParagraphSplitTrackFormatMark(node) { + if (!node || typeof node !== 'object') return null; + + const marks = Array.isArray(node.marks) ? node.marks : []; + const directMark = marks.find((mark) => isParagraphSplitTrackFormatMark(mark)); + if (directMark) return directMark; + + if (typeof node.descendants === 'function') { + let found = null; + node.descendants((child) => { + const childMarks = Array.isArray(child?.marks) ? child.marks : []; + found = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark)) || null; + return !found; + }); + if (found) return found; + } + + const content = Array.isArray(node.content) ? node.content : []; + for (const child of content) { + const childMark = findParagraphSplitTrackFormatMark(child); + if (childMark) return childMark; + } + + return null; +} + +function ensureParagraphPropertiesNode(pPr) { + return ( + pPr || { + type: 'element', + name: 'w:pPr', + elements: [], + } + ); +} + +function prependParagraphSplitInsertion(pPr, insertionElement) { + if (!pPr || !insertionElement) return pPr; + if (!Array.isArray(pPr.elements)) pPr.elements = []; + const existingRunProperties = pPr.elements.find((element) => element?.name === 'w:rPr'); + const runProperties = existingRunProperties || { + type: 'element', + name: 'w:rPr', + elements: [], + }; + if (!Array.isArray(runProperties.elements)) runProperties.elements = []; + const hasParagraphInsertion = runProperties.elements.some((element) => element?.name === 'w:ins'); + if (!hasParagraphInsertion) runProperties.elements.unshift(insertionElement); + if (!existingRunProperties) pPr.elements.unshift(runProperties); + return pPr; +} /** * Generate the w:pPr props for a paragraph node @@ -33,7 +98,28 @@ export function generateParagraphProperties(params) { } } + const paragraphSplitTrackFormatMark = findParagraphSplitTrackFormatMark(node); + const paragraphSplitWordIdOptions = paragraphSplitTrackFormatMark + ? { + wordIdAllocator: params?.converter?.wordIdAllocator || null, + partPath: resolveExportPartPath(params), + } + : null; + if (!params?.isFinalDoc && paragraphSplitTrackFormatMark && !paragraphProperties.change) { + const change = createParagraphSplitPropertiesChange(paragraphSplitTrackFormatMark, paragraphSplitWordIdOptions); + if (change) paragraphProperties.change = change; + } + let pPr = wPPrNodeTranslator.decode({ node: { ...node, attrs: { paragraphProperties } } }); + if (!params?.isFinalDoc && paragraphSplitTrackFormatMark) { + const insertionElement = createParagraphSplitInsertionElement( + paragraphSplitTrackFormatMark, + paragraphSplitWordIdOptions, + ); + if (insertionElement) { + pPr = prependParagraphSplitInsertion(ensureParagraphPropertiesNode(pPr), insertionElement); + } + } const sectPr = node.attrs?.paragraphProperties?.sectPr; if (sectPr) { if (!pPr) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js index b4d098c543..0421813241 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.test.js @@ -8,6 +8,7 @@ vi.mock('../../pPr/pPr-translator.js', () => ({ import { generateParagraphProperties } from './generate-paragraph-properties.js'; import { translator as wPPrNodeTranslator } from '../../pPr/pPr-translator.js'; +import { TrackFormatMarkName } from '@extensions/track-changes/constants.js'; describe('generateParagraphProperties', () => { beforeEach(() => { @@ -179,4 +180,150 @@ describe('generateParagraphProperties', () => { generateParagraphProperties({ node }); }); + + it('adds a Word-visible paragraph insertion and SuperDoc paragraphSplit metadata', () => { + const node = { + type: 'paragraph', + attrs: { paragraphProperties: {} }, + content: [ + { + type: 'text', + text: 'llo', + marks: [ + { + type: TrackFormatMarkName, + attrs: { + id: 'logical-change-id', + author: 'Reviewer', + date: '2026-06-01T17:00:00Z', + before: [{ type: 'paragraphSplit', attrs: { anchor: 'inserted', offset: 2 } }], + after: [{ type: 'paragraphSplit', attrs: { anchor: 'inserted' } }], + }, + }, + ], + }, + ], + }; + wPPrNodeTranslator.decode.mockImplementation(({ node: decodeNode }) => { + expect(decodeNode.attrs.paragraphProperties.change).toMatchObject({ + id: 'logical-change-id', + author: 'Reviewer', + date: '2026-06-01T17:00:00Z', + superdocXmlns: 'https://superdoc.dev/ooxml/revisions/2026', + superdocParagraphSplit: '1', + superdocParagraphSplitAnchor: 'inserted', + paragraphProperties: {}, + }); + return { + type: 'element', + name: 'w:pPr', + elements: [{ name: 'w:pPrChange' }], + }; + }); + + const result = generateParagraphProperties({ + node, + }); + + expect(result.elements).toEqual([ + { + type: 'element', + name: 'w:rPr', + elements: [ + { + type: 'element', + name: 'w:ins', + attributes: { + 'w:id': 'logical-change-id', + 'w:author': 'Reviewer', + 'w:date': '2026-06-01T17:00:00Z', + }, + }, + ], + }, + { name: 'w:pPrChange' }, + ]); + }); + + it('uses the Word revision id allocator for paragraphSplit export elements', () => { + const allocate = vi.fn(() => '12'); + const node = { + type: 'paragraph', + attrs: { paragraphProperties: {} }, + content: [ + { + type: 'text', + text: 'Beta', + marks: [ + { + type: TrackFormatMarkName, + attrs: { + id: 'logical-change-id', + sourceId: '', + author: 'Reviewer', + date: '2026-06-01T17:00:00Z', + before: [{ type: 'paragraphSplit', attrs: { anchor: 'inserted', offset: 2 } }], + after: [{ type: 'paragraphSplit', attrs: { anchor: 'inserted' } }], + }, + }, + ], + }, + ], + }; + wPPrNodeTranslator.decode.mockImplementation(({ node: decodeNode }) => { + expect(decodeNode.attrs.paragraphProperties.change.id).toBe('12'); + return { + type: 'element', + name: 'w:pPr', + elements: [{ name: 'w:pPrChange' }], + }; + }); + + generateParagraphProperties({ + node, + converter: { wordIdAllocator: { allocate } }, + currentPartPath: 'word/header1.xml', + }); + + expect(allocate).toHaveBeenCalledTimes(2); + expect(allocate).toHaveBeenNthCalledWith(1, { + partPath: 'word/header1.xml', + sourceId: '', + logicalId: 'logical-change-id', + }); + expect(allocate).toHaveBeenNthCalledWith(2, { + partPath: 'word/header1.xml', + sourceId: '', + logicalId: 'logical-change-id', + }); + }); + + it('does not add paragraphSplit export elements for ordinary tracked formatting', () => { + const node = { + type: 'paragraph', + attrs: { paragraphProperties: {} }, + content: [ + { + type: 'text', + text: 'Hello', + marks: [ + { + type: TrackFormatMarkName, + attrs: { + id: 'format-change-id', + before: [{ type: 'bold', attrs: { value: true } }], + after: [], + }, + }, + ], + }, + ], + }; + wPPrNodeTranslator.decode.mockImplementation(({ node: decodeNode }) => { + expect(decodeNode.attrs.paragraphProperties.change).toBeUndefined(); + return { type: 'element', name: 'w:pPr', elements: [] }; + }); + + generateParagraphProperties({ node }); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js index dc716b0a10..c17a98f834 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.js @@ -2,6 +2,11 @@ import { NodeTranslator } from '@translator'; import { carbonCopy } from '@core/utilities/carbonCopy.js'; import { createNestedPropertiesTranslator, createAttributeHandler } from '@converter/v3/handlers/utils.js'; import { basePropertyTranslators } from '../pPr/pPr-base-translators.js'; +import { + SuperDocParagraphSplitAnchorAttr, + SuperDocParagraphSplitAttr, + SuperDocRevisionNamespaceAttr, +} from '../../helpers.js'; const pPrTranslator = NodeTranslator.from( createNestedPropertiesTranslator('w:pPr', 'paragraphProperties', basePropertyTranslators), @@ -11,6 +16,9 @@ const ATTRIBUTE_HANDLERS = [ createAttributeHandler('w:id'), createAttributeHandler('w:author'), createAttributeHandler('w:date'), + createAttributeHandler('xmlns:sd', SuperDocRevisionNamespaceAttr), + createAttributeHandler('sd:paragraphSplit', SuperDocParagraphSplitAttr), + createAttributeHandler('sd:paragraphSplitAnchor', SuperDocParagraphSplitAnchorAttr), ]; function getSectPr(pPrNode) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js index 94c042b3c0..cb683d1949 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/pPrChange/pPrChange-translator.test.js @@ -84,6 +84,29 @@ describe('w:pPrChange translator', () => { }); }); + it('should encode SuperDoc paragraphSplit metadata attributes', () => { + const xmlNode = { + name: 'w:pPrChange', + attributes: { + 'w:id': '7', + 'xmlns:sd': 'https://superdoc.dev/ooxml/revisions/2026', + 'sd:paragraphSplit': '1', + 'sd:paragraphSplitAnchor': 'source', + }, + elements: [{ name: 'w:pPr', elements: [] }], + }; + + const result = translator.encode({ nodes: [xmlNode] }); + + expect(result).toEqual({ + id: '7', + superdocXmlns: 'https://superdoc.dev/ooxml/revisions/2026', + superdocParagraphSplit: '1', + superdocParagraphSplitAnchor: 'source', + paragraphProperties: {}, + }); + }); + it('should encode nested sectPr from the changed paragraph properties', () => { const sectPr = { name: 'w:sectPr', @@ -216,6 +239,34 @@ describe('w:pPrChange translator', () => { }); }); + it('should decode SuperDoc paragraphSplit metadata attributes', () => { + const superDocNode = { + attrs: { + change: { + id: '7', + superdocXmlns: 'https://superdoc.dev/ooxml/revisions/2026', + superdocParagraphSplit: '1', + superdocParagraphSplitAnchor: 'source', + paragraphProperties: {}, + }, + }, + }; + + const result = translator.decode({ node: superDocNode }); + + expect(result).toEqual({ + name: 'w:pPrChange', + type: 'element', + attributes: { + 'w:id': '7', + 'xmlns:sd': 'https://superdoc.dev/ooxml/revisions/2026', + 'sd:paragraphSplit': '1', + 'sd:paragraphSplitAnchor': 'source', + }, + elements: [{ name: 'w:pPr', type: 'element', attributes: {}, elements: [] }], + }); + }); + it('should return undefined if change is empty', () => { const superDocNode = { attrs: { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js index 234512c4e5..e9eee7aca8 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.js @@ -10,6 +10,14 @@ import { translator as wRPrNodeTranslator } from '../../rpr/rpr-translator.js'; import { combineRunProperties, decodeRPrFromMarks } from '@converter/styles.js'; import { appendTrackFormatChangeToRunProperties, findTrackFormatMark } from '@converter/v3/handlers/helpers.js'; +function resolveExportPartPath(params = {}) { + if (typeof params.currentPartPath === 'string' && params.currentPartPath.length > 0) return params.currentPartPath; + if (typeof params.filename === 'string' && params.filename.length > 0) { + return params.filename.startsWith('word/') ? params.filename : `word/${params.filename}`; + } + return 'word/document.xml'; +} + export function getTextNodeForExport(text, marks, params) { const normalizedMarks = Array.isArray(marks) ? marks : []; const hasLeadingOrTrailingSpace = /^\s|\s$/.test(text); @@ -31,7 +39,10 @@ export function getTextNodeForExport(text, marks, params) { }; } - appendTrackFormatChangeToRunProperties(rPrNode, normalizedMarks); + appendTrackFormatChangeToRunProperties(rPrNode, normalizedMarks, { + wordIdAllocator: params?.converter?.wordIdAllocator || null, + partPath: resolveExportPartPath(params), + }); textNodes.push({ name: 'w:t', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js index b86af2ec02..5be931cd3d 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/t/helpers/translate-text-node.test.js @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { getTextNodeForExport } from './translate-text-node.js'; const buildParams = (runProperties = {}) => ({ @@ -71,4 +71,36 @@ describe('getTextNodeForExport', () => { }), ]); }); + + it('uses the Word revision id allocator for trackFormat export ids', () => { + const allocate = vi.fn(() => '7'); + const trackFormatMark = { + type: 'trackFormat', + attrs: { + id: 'format-allocated', + sourceId: '', + author: 'Missy Fox', + authorEmail: '', + date: '2026-01-07T20:24:39Z', + before: [], + after: [{ type: 'bold', attrs: { value: true } }], + }, + }; + + const result = getTextNodeForExport('styles', [trackFormatMark], { + ...buildParams(), + converter: { wordIdAllocator: { allocate } }, + currentPartPath: 'word/header1.xml', + }); + + expect(allocate).toHaveBeenCalledWith({ + partPath: 'word/header1.xml', + sourceId: '', + logicalId: 'format-allocated', + }); + + const runProperties = result.elements.find((element) => element.name === 'w:rPr'); + const runPropertiesChange = runProperties.elements.find((element) => element.name === 'w:rPrChange'); + expect(runPropertiesChange.attributes['w:id']).toBe('7'); + }); }); diff --git a/packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.js b/packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.js index caae2ebdb8..42c1104f49 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.js @@ -10,6 +10,12 @@ export const HyperlinkAddedDisplayType = 'hyperlinkAdded'; */ export const HyperlinkModifiedDisplayType = 'hyperlinkModified'; +/** + * Display token for tracked format changes that represent splitting a paragraph + * from the UI Enter path. + */ +export const ParagraphSplitDisplayType = 'paragraphSplit'; + const getMarkSnapshots = (attrs = {}) => { const before = Array.isArray(attrs.before) ? attrs.before : []; const after = Array.isArray(attrs.after) ? attrs.after : []; @@ -109,6 +115,14 @@ const snapshotAttrsEqual = (a, b) => { */ export const resolveTrackedFormatDisplay = ({ attrs = {}, nodes = [] }) => { const { before, after } = getMarkSnapshots(attrs); + const paragraphSplit = findSnapshotByType(before, 'paragraphSplit') || findSnapshotByType(after, 'paragraphSplit'); + if (paragraphSplit) { + return { + trackedChangeDisplayType: ParagraphSplitDisplayType, + trackedChangeText: 'new line', + }; + } + const beforeLink = findSnapshotByType(before, 'link'); const afterLink = findSnapshotByType(after, 'link'); const inferredLiveLink = diff --git a/packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.test.js b/packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.test.js index daf43e4e71..8111b82f3d 100644 --- a/packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.test.js +++ b/packages/super-editor/src/editors/v1/extensions/comment/tracked-change-display.test.js @@ -3,6 +3,7 @@ import { resolveTrackedFormatDisplay, HyperlinkAddedDisplayType, HyperlinkModifiedDisplayType, + ParagraphSplitDisplayType, } from './tracked-change-display.js'; const makeNode = ({ text = '', marks = [] } = {}) => ({ @@ -42,6 +43,20 @@ describe('resolveTrackedFormatDisplay', () => { }); }); + it('detects paragraph splits as new-line display changes', () => { + const result = resolveTrackedFormatDisplay({ + attrs: { + before: [{ type: 'paragraphSplit', attrs: { anchor: 'inserted', offset: 2 } }], + after: [{ type: 'paragraphSplit', attrs: { anchor: 'inserted' } }], + }, + nodes: [makeNode({ text: 'llo' })], + }); + expect(result).toEqual({ + trackedChangeDisplayType: ParagraphSplitDisplayType, + trackedChangeText: 'new line', + }); + }); + it('returns hyperlinkModified when link exists in both before and after (link edit)', () => { const result = resolveTrackedFormatDisplay({ attrs: { diff --git a/packages/super-editor/src/editors/v1/extensions/run/commands/split-run.js b/packages/super-editor/src/editors/v1/extensions/run/commands/split-run.js index fa5b58a13a..5238b11020 100644 --- a/packages/super-editor/src/editors/v1/extensions/run/commands/split-run.js +++ b/packages/super-editor/src/editors/v1/extensions/run/commands/split-run.js @@ -1,6 +1,7 @@ // @ts-check import { NodeSelection, TextSelection, AllSelection } from 'prosemirror-state'; import { canSplit } from 'prosemirror-transform'; +import { v4 as uuidv4 } from 'uuid'; import { Attribute } from '@core/Attribute.js'; import { defaultBlockAt } from '@core/helpers/defaultBlockAt.js'; import { getSplitRunProperties, syncSplitParagraphRunProperties } from '@core/helpers/splitParagraphRunProperties.js'; @@ -11,6 +12,9 @@ import { } from '@core/commands/linkedStyleSplitHelpers.js'; import { resolveRunProperties, encodeMarksFromRPr } from '@core/super-converter/styles.js'; import { extractTableInfo } from '../calculateInlineRunPropertiesPlugin.js'; +import { TrackFormatMarkName } from '../../track-changes/constants.js'; +import { TrackChangesBasePluginKey } from '../../track-changes/plugins/index.js'; +import { CommentsPluginKey } from '../../comment/comments-plugin.js'; function isHeadingStyleId(styleId) { return typeof styleId === 'string' && /^heading\s*[1-6]$/i.test(styleId.trim()); @@ -31,6 +35,134 @@ function clearHeadingStyleId(attrs) { }; } +function isTrackChangesActive(state) { + return TrackChangesBasePluginKey.getState(state)?.isTrackChangesActive === true; +} + +function findTextblockAt(doc, pos) { + const bounded = Math.max(0, Math.min(pos, doc.content.size)); + const $pos = doc.resolve(bounded); + for (let depth = $pos.depth; depth > 0; depth -= 1) { + const node = $pos.node(depth); + if (node.isTextblock) { + return { pos: $pos.before(depth), node }; + } + } + return null; +} + +function findPreviousTextblock(doc, beforePos) { + let found = null; + doc.descendants((node, pos) => { + if (pos >= beforePos) return false; + if (node.isTextblock) found = { pos, node }; + return true; + }); + return found; +} + +function findTrackableTextRange(doc, block) { + let from = null; + let to = null; + doc.nodesBetween(block.pos + 1, block.pos + block.node.nodeSize - 1, (node, pos) => { + if (!node.isText || !node.text) return true; + from ??= pos; + to = pos + node.nodeSize; + return true; + }); + const resolvedFrom = from; + const resolvedTo = to; + if (typeof resolvedFrom !== 'number' || typeof resolvedTo !== 'number') return null; + if (resolvedFrom >= resolvedTo) return null; + return { from: resolvedFrom, to: resolvedTo }; +} + +function textOffsetInBlock($from, blockDepth) { + return $from.doc.textBetween($from.start(blockDepth), $from.pos, '', '\ufffc').length; +} + +function collectMarkedTextNodes(doc, range, changeId) { + const nodes = []; + doc.nodesBetween(range.from, range.to, (node) => { + if ( + node.isText && + node.marks.some((mark) => mark.type.name === TrackFormatMarkName && mark.attrs?.id === changeId) + ) { + nodes.push(node); + } + return true; + }); + return nodes; +} + +function addTrackedParagraphSplitMark({ state, tr, editor, splitSelection, sourceBlockDepth }) { + if (!isTrackChangesActive(state)) return; + + const markType = state.schema.marks[TrackFormatMarkName]; + const user = editor?.options?.user; + if (!markType || !user) return; + + const insertedBlock = findTextblockAt(tr.doc, tr.selection.from); + if (!insertedBlock) return; + + const sourceBlock = findPreviousTextblock(tr.doc, insertedBlock.pos); + let anchor = 'source'; + let anchorBlock = sourceBlock; + let range = anchorBlock ? findTrackableTextRange(tr.doc, anchorBlock) : null; + + if (!range) { + anchor = 'inserted'; + anchorBlock = insertedBlock; + range = findTrackableTextRange(tr.doc, anchorBlock); + } + + if (!range) return; + + const changeId = uuidv4(); + const mark = markType.create({ + id: changeId, + author: user.name || '', + authorId: user.id || '', + authorEmail: user.email || '', + authorImage: user.image || '', + date: new Date().toISOString(), + before: [ + { + type: 'paragraphSplit', + attrs: { + anchor, + offset: textOffsetInBlock(splitSelection, sourceBlockDepth), + }, + }, + ], + after: [ + { + type: 'paragraphSplit', + attrs: { + anchor, + }, + }, + ], + revisionGroupId: changeId, + changeType: 'formatting', + origin: 'superdoc', + }); + + tr.addMark(range.from, range.to, mark); + tr.setMeta('skipTrackChanges', true); + tr.setMeta(TrackChangesBasePluginKey, { + formatMark: mark, + step: { + slice: { + content: { + content: collectMarkedTextNodes(tr.doc, range, changeId), + }, + }, + }, + }); + tr.setMeta(CommentsPluginKey, { type: 'force' }); +} + /** * Splits a run node at the current selection into two paragraphs. * @returns {import('../../../core/commands/types/index.js').Command} @@ -157,6 +289,15 @@ export function splitBlockPatch(state, dispatch, editor) { } applyStyleMarks(state, tr, editor, paragraphAttrs, tableInfo); + if (splitDepth) { + addTrackedParagraphSplitMark({ + state, + tr, + editor, + splitSelection: $from, + sourceBlockDepth: splitDepth, + }); + } if (dispatch) dispatch(tr.scrollIntoView()); return true; diff --git a/packages/super-editor/src/editors/v1/extensions/run/commands/split-run.test.js b/packages/super-editor/src/editors/v1/extensions/run/commands/split-run.test.js index 556e4fa051..a76c9e5002 100644 --- a/packages/super-editor/src/editors/v1/extensions/run/commands/split-run.test.js +++ b/packages/super-editor/src/editors/v1/extensions/run/commands/split-run.test.js @@ -2,9 +2,12 @@ import { describe, it, expect, beforeEach, afterEach, beforeAll, vi } from 'vite import { TextSelection, EditorState } from 'prosemirror-state'; import { initTestEditor } from '@tests/helpers/helpers.js'; import * as converterStyles from '@core/super-converter/styles.js'; +import { TrackFormatMarkName } from '../../track-changes/constants.js'; +import { buildReviewGraph } from '../../track-changes/review-model/review-graph.js'; let splitRunToParagraph; let splitRunAtCursor; +const ALICE = { name: 'Alice', email: 'alice@example.com' }; beforeAll(async () => { ({ splitRunToParagraph, splitRunAtCursor } = await import('@extensions/run/commands/split-run.js')); @@ -55,13 +58,23 @@ const getRunTexts = (doc) => { return texts; }; +const getOnlyTrackedChange = (state) => { + const graph = buildReviewGraph({ state }); + expect(graph.changes.size).toBe(1); + return [...graph.changes.values()][0]; +}; + describe('splitRunToParagraph command', () => { let editor; let originalMatchMedia; - const loadDoc = (json) => { + const loadDoc = (json, { plugins = false } = {}) => { const docNode = editor.schema.nodeFromJSON(json); - const state = EditorState.create({ schema: editor.schema, doc: docNode }); + const state = EditorState.create({ + schema: editor.schema, + doc: docNode, + ...(plugins ? { plugins: editor.state.plugins } : {}), + }); editor.setState(state); }; @@ -105,7 +118,7 @@ describe('splitRunToParagraph command', () => { }); it('returns false when selection is not empty', () => { - loadDoc(RUN_DOC); + loadDoc(RUN_DOC, { plugins: true }); const start = findTextPos('Hello'); expect(start).not.toBeNull(); @@ -127,7 +140,7 @@ describe('splitRunToParagraph command', () => { }); it('delegates to splitBlock when cursor is inside a run', () => { - loadDoc(RUN_DOC); + loadDoc(RUN_DOC, { plugins: true }); const start = findTextPos('Hello'); expect(start).not.toBeNull(); @@ -143,6 +156,63 @@ describe('splitRunToParagraph command', () => { expect(paragraphTexts).toEqual(['He', 'llo']); }); + it('records Enter as a tracked paragraph split in suggesting mode', () => { + editor.options.user = ALICE; + loadDoc(RUN_DOC, { plugins: true }); + editor.commands.enableTrackChanges(); + + const start = findTextPos('Hello'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 2); + + const handled = editor.commands.splitRunToParagraph(); + + expect(handled).toBe(true); + expect(getParagraphTexts(editor.view.state.doc)).toEqual(['He', 'llo']); + + const change = getOnlyTrackedChange(editor.view.state); + expect(change.type).toBe('formatting'); + expect(change.before).toEqual([{ type: 'paragraphSplit', attrs: { anchor: 'source', offset: 2 } }]); + expect(change.formattingSegments[0]?.mark.type.name).toBe(TrackFormatMarkName); + }); + + it('rejects a tracked paragraph split from the UI Enter path', () => { + editor.options.user = ALICE; + loadDoc(RUN_DOC, { plugins: true }); + editor.commands.enableTrackChanges(); + + const start = findTextPos('Hello'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 2); + + expect(editor.commands.splitRunToParagraph()).toBe(true); + const change = getOnlyTrackedChange(editor.view.state); + + expect(editor.commands.rejectTrackedChangeById(change.id)).toBe(true); + expect(getParagraphTexts(editor.view.state.doc)).toEqual(['Hello']); + expect(buildReviewGraph({ state: editor.view.state }).changes.size).toBe(0); + }); + + it('rejects a tracked paragraph split that created an empty tail paragraph', () => { + editor.options.user = ALICE; + loadDoc(RUN_DOC, { plugins: true }); + editor.commands.enableTrackChanges(); + + const start = findTextPos('Hello'); + expect(start).not.toBeNull(); + updateSelection((start ?? 0) + 'Hello'.length); + + expect(editor.commands.splitRunToParagraph()).toBe(true); + expect(getParagraphTexts(editor.view.state.doc)).toEqual(['Hello', '']); + + const change = getOnlyTrackedChange(editor.view.state); + expect(change.before).toEqual([{ type: 'paragraphSplit', attrs: { anchor: 'source', offset: 5 } }]); + + expect(editor.commands.rejectTrackedChangeById(change.id)).toBe(true); + expect(getParagraphTexts(editor.view.state.doc)).toEqual(['Hello']); + expect(buildReviewGraph({ state: editor.view.state }).changes.size).toBe(0); + }); + it('uses paragraph split metadata instead of copying DOCX identities', () => { loadDoc({ type: 'doc', @@ -334,9 +404,13 @@ describe('splitRunToParagraph with style marks', () => { }, }); - const loadDoc = (json) => { + const loadDoc = (json, { plugins = false } = {}) => { const docNode = editor.schema.nodeFromJSON(json); - const state = EditorState.create({ schema: editor.schema, doc: docNode }); + const state = EditorState.create({ + schema: editor.schema, + doc: docNode, + ...(plugins ? { plugins: editor.state.plugins } : {}), + }); editor.setState(state); }; diff --git a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js index b27a04d0e6..3860bf44d9 100644 --- a/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js +++ b/packages/super-editor/src/editors/v1/extensions/track-changes/review-model/decision-engine.js @@ -21,7 +21,7 @@ */ import { Slice } from 'prosemirror-model'; -import { AddMarkStep, RemoveMarkStep, ReplaceStep, Mapping } from 'prosemirror-transform'; +import { AddMarkStep, RemoveMarkStep, ReplaceStep, Mapping, canJoin } from 'prosemirror-transform'; import { TrackInsertMarkName, TrackDeleteMarkName, TrackFormatMarkName } from '../constants.js'; import { CommentsPluginKey } from '../../comment/comments-plugin.js'; @@ -376,7 +376,7 @@ const runPermissionPreflight = ({ editor, decision, selections }) => { /** * @typedef {Object} MutationOp - * @property {'removeContent'|'removeMark'|'addMark'|'unwrapInsert'|'restoreFormat'|'removeFormat'} kind + * @property {'removeContent'|'removeMark'|'addMark'|'unwrapInsert'|'restoreFormat'|'removeFormat'|'rejectParagraphSplit'} kind * @property {number} from * @property {number} to * @property {string} [changeId] @@ -384,6 +384,7 @@ const runPermissionPreflight = ({ editor, decision, selections }) => { * @property {import('prosemirror-model').Mark} [mark] * @property {Array} [beforeMarks] * @property {Array} [afterMarks] + * @property {'inserted'|'source'} [anchor] */ /** @@ -726,8 +727,22 @@ const planFormattingDecision = ({ ops, change, decision, retired }) => { changeId: change.id, side: SegmentSide.Formatting, }); - } else { - for (const run of getSegmentMarkRuns(seg)) { + continue; + } + + for (const run of getSegmentMarkRuns(seg)) { + const paragraphSplit = snapshotAttrsForType(run.mark.attrs?.before, 'paragraphSplit'); + if (paragraphSplit) { + ops.push({ + kind: 'rejectParagraphSplit', + from: run.from, + to: run.to, + changeId: change.id, + side: SegmentSide.Formatting, + mark: run.mark, + anchor: paragraphSplit.anchor === 'source' ? 'source' : 'inserted', + }); + } else { ops.push({ kind: 'restoreFormat', from: run.from, @@ -744,6 +759,12 @@ const planFormattingDecision = ({ ops, change, decision, retired }) => { retired.add(change.id); }; +const snapshotAttrsForType = (snapshots, type) => { + if (!Array.isArray(snapshots)) return null; + const snapshot = snapshots.find((entry) => entry?.type === type); + return snapshot?.attrs && typeof snapshot.attrs === 'object' ? snapshot.attrs : null; +}; + const planPartialTextDecision = ({ ops, change, selection, decision, removedRanges, retired }) => { const side = change.type === CanonicalChangeType.Insertion ? SegmentSide.Inserted : SegmentSide.Deleted; const segments = side === SegmentSide.Inserted ? change.insertedSegments : change.deletedSegments; @@ -898,6 +919,27 @@ const deterministicSuccessorId = ({ sourceId, revisionGroupId, side, offsetStart return `${sourceId}~${side}~${(hash >>> 0).toString(36)}`; }; +const findTextblockAt = (doc, pos) => { + const bounded = Math.max(0, Math.min(pos, doc.content.size)); + const $pos = doc.resolve(bounded); + for (let depth = $pos.depth; depth > 0; depth -= 1) { + const node = $pos.node(depth); + if (node.isTextblock) { + return { pos: $pos.before(depth), node }; + } + } + return null; +}; + +const rejectParagraphSplitAt = (tr, from, anchor = 'inserted') => { + const block = findTextblockAt(tr.doc, from); + if (!block) return false; + const joinPos = anchor === 'source' ? block.pos + block.node.nodeSize : block.pos; + if (joinPos <= 0 || joinPos >= tr.doc.content.size || !canJoin(tr.doc, joinPos)) return false; + tr.join(joinPos); + return true; +}; + // --------------------------------------------------------------------------- // Plan application // --------------------------------------------------------------------------- @@ -947,6 +989,13 @@ const applyPlan = ({ state, plan }) => { tr.step(new RemoveMarkStep(op.from, op.to, op.mark)); continue; } + if (op.kind === 'rejectParagraphSplit' && op.mark) { + tr.step(new RemoveMarkStep(op.from, op.to, op.mark)); + if (!rejectParagraphSplitAt(tr, op.from, op.anchor)) { + throw new Error(`could not join paragraph split for tracked change "${op.changeId ?? ''}".`); + } + continue; + } } for (const op of contentOps) { tr.step(new ReplaceStep(op.from, op.to, Slice.empty)); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js index 99117c1210..5574d352f1 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js @@ -812,6 +812,22 @@ describe('CommentDialog.vue', () => { expect(trackedChange.text()).not.toContain('underline'); }); + it('renders paragraph splits as new-line changes without a format label', async () => { + const { wrapper } = await mountDialog({ + baseCommentOverrides: { + trackedChange: true, + trackedChangeType: 'trackFormat', + trackedChangeDisplayType: 'paragraphSplit', + trackedChangeText: 'new line', + }, + }); + + const trackedChange = wrapper.find('.tracked-change'); + expect(trackedChange.text()).toContain('Added new line'); + expect(trackedChange.text()).not.toContain('Format:'); + expect(trackedChange.text()).not.toContain('formatting'); + }); + it('calls custom accept handler instead of default behavior when configured', async () => { const customAcceptHandler = vi.fn(); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index ebb6b05386..6f04259edb 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -865,6 +865,9 @@ watch(editingCommentId, (commentId) => { Changed hyperlink to "{{ comment.trackedChangeText }}" +
+ Added new line +
Format: {{ comment.trackedChangeText }}