Skip to content

Commit 4c74ec6

Browse files
authored
fix: track new lines in suggested mode (#3602)
1 parent 36c81cb commit 4c74ec6

19 files changed

Lines changed: 1037 additions & 49 deletions

File tree

packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@ import { getHexColorFromDocxSystem, isValidHexColor, twipsToInches, twipsToLines
44
import { translator as wRPrTranslator } from '../../v3/handlers/w/rpr/index.js';
55
import { encodeMarksFromRPr } from '@converter/styles.js';
66
import { resolveTrackedChangeImportIds, stampImportTrackingAttrs } from './importTrackingContext.js';
7+
import { ParagraphSplitSnapshotType } from '../../v3/handlers/helpers.js';
8+
9+
function getInlineParagraphMarkInsertion(params) {
10+
return params?.extraParams?.inlineParagraphProperties?.runProperties?.trackInsert || null;
11+
}
12+
13+
function isMatchingParagraphMarkInsertion(trackInsert, ids) {
14+
if (!trackInsert || typeof trackInsert !== 'object') return false;
15+
16+
const insertionId = trackInsert.id;
17+
if (insertionId == null) return false;
18+
return ids.some((id) => id != null && String(id) === String(insertionId));
19+
}
20+
21+
function createParagraphSplitSnapshots() {
22+
const snapshot = {
23+
type: ParagraphSplitSnapshotType,
24+
attrs: { anchor: 'source' },
25+
};
26+
27+
return {
28+
before: [snapshot],
29+
after: [{ ...snapshot, attrs: { ...snapshot.attrs } }],
30+
};
31+
}
732

833
/**
934
*
@@ -135,6 +160,12 @@ export function handleStyleChangeMarksV2(rPrChange, currentMarks, params) {
135160
submarks = encodeMarksFromRPr(runProperties, params?.docx);
136161
}
137162

163+
const paragraphMarkInsertion = getInlineParagraphMarkInsertion(params);
164+
if (isMatchingParagraphMarkInsertion(paragraphMarkInsertion, [attributes['w:id'], sourceId, logicalId])) {
165+
const snapshots = createParagraphSplitSnapshots();
166+
return [{ type: TrackFormatMarkName, attrs: { ...mappedAttributes, ...snapshots } }];
167+
}
168+
138169
return [{ type: TrackFormatMarkName, attrs: { ...mappedAttributes, before: submarks, after: [...currentMarks] } }];
139170
}
140171

packages/super-editor/src/editors/v1/core/super-converter/v2/importer/markImporter.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,39 @@ describe('handleStyleChangeMarksV2', () => {
190190
expect(result[0].attrs.after).toEqual([]);
191191
});
192192

193+
it('restores paragraphSplit snapshots from matching paragraph mark insertion metadata', () => {
194+
const currentMarks = [{ type: 'bold', attrs: { value: true } }];
195+
const rPrChange = {
196+
name: 'w:rPrChange',
197+
attributes: {
198+
'w:id': '7',
199+
'w:date': '2026-06-01T17:00:00Z',
200+
'w:author': 'Reviewer',
201+
},
202+
elements: [{ name: 'w:rPr', elements: [] }],
203+
};
204+
205+
const result = handleStyleChangeMarksV2(rPrChange, currentMarks, {
206+
docx: {},
207+
extraParams: {
208+
inlineParagraphProperties: {
209+
runProperties: {
210+
trackInsert: {
211+
id: '7',
212+
author: 'Reviewer',
213+
date: '2026-06-01T17:00:00Z',
214+
},
215+
},
216+
},
217+
},
218+
});
219+
220+
expect(result).toHaveLength(1);
221+
expect(result[0].type).toBe(TrackFormatMarkName);
222+
expect(result[0].attrs.before).toEqual([{ type: 'paragraphSplit', attrs: { anchor: 'source' } }]);
223+
expect(result[0].attrs.after).toEqual([{ type: 'paragraphSplit', attrs: { anchor: 'source' } }]);
224+
});
225+
193226
it('remaps format change ids and stamps overlapParentId through import tracking context', () => {
194227
const context = createImportTrackingContext({});
195228
context.pushParent({

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/helpers.js

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
11
import { processOutputMarks } from '@converter/exporter.js';
22
import { TrackFormatMarkName } from '@extensions/track-changes/constants.js';
33

4+
export const ParagraphSplitSnapshotType = 'paragraphSplit';
5+
46
const getMarkType = (mark) => mark?.type?.name ?? mark?.type ?? null;
7+
const getSnapshotType = (snapshot) => snapshot?.type?.name ?? snapshot?.type ?? null;
8+
const isDecimalString = (value) => typeof value === 'string' && /^\d+$/.test(value);
59

610
const toRunPropertyElements = (marks = []) =>
711
processOutputMarks(marks).filter((element) => element && typeof element === 'object' && element.name);
812

13+
const hashStringToDecimalId = (value) => {
14+
const source = String(value || '0');
15+
let hash = 0;
16+
for (let i = 0; i < source.length; i++) {
17+
hash = (hash * 31 + source.charCodeAt(i)) >>> 0;
18+
}
19+
return String(hash || 1);
20+
};
21+
22+
const getTrackFormatChangeWordId = (trackFormatMark, options = {}) => {
23+
const allocator = options?.wordIdAllocator || null;
24+
const partPath = options?.partPath || 'word/document.xml';
25+
const sourceId = trackFormatMark.attrs?.sourceId == null ? '' : String(trackFormatMark.attrs.sourceId);
26+
const logicalId = trackFormatMark.attrs?.id == null ? '' : String(trackFormatMark.attrs.id);
27+
28+
if (allocator) return allocator.allocate({ partPath, sourceId, logicalId });
29+
if (isDecimalString(sourceId)) return sourceId;
30+
if (isDecimalString(logicalId)) return logicalId;
31+
return hashStringToDecimalId(sourceId || logicalId);
32+
};
33+
34+
const getTrackChangeAuthor = (trackFormatMark) => {
35+
const author = trackFormatMark.attrs?.author;
36+
return author == null ? '' : String(author);
37+
};
38+
939
/**
1040
* Return the first trackFormat mark from a mark list.
1141
*
@@ -15,6 +45,35 @@ const toRunPropertyElements = (marks = []) =>
1545
export const findTrackFormatMark = (marks = []) =>
1646
marks.find((mark) => getMarkType(mark) === TrackFormatMarkName) ?? null;
1747

48+
export const findSnapshotByType = (snapshots = [], type) =>
49+
Array.isArray(snapshots) ? (snapshots.find((snapshot) => getSnapshotType(snapshot) === type) ?? null) : null;
50+
51+
export const findParagraphSplitSnapshot = (trackFormatMark) => {
52+
if (!trackFormatMark) return null;
53+
return (
54+
findSnapshotByType(trackFormatMark.attrs?.before, ParagraphSplitSnapshotType) ||
55+
findSnapshotByType(trackFormatMark.attrs?.after, ParagraphSplitSnapshotType)
56+
);
57+
};
58+
59+
export const isParagraphSplitTrackFormatMark = (mark) =>
60+
getMarkType(mark) === TrackFormatMarkName && Boolean(findParagraphSplitSnapshot(mark));
61+
62+
export const createParagraphSplitInsertionElement = (trackFormatMark, options = {}) => {
63+
const paragraphSplit = findParagraphSplitSnapshot(trackFormatMark);
64+
if (!paragraphSplit) return undefined;
65+
66+
return {
67+
type: 'element',
68+
name: 'w:ins',
69+
attributes: {
70+
'w:id': getTrackFormatChangeWordId(trackFormatMark, options),
71+
'w:author': getTrackChangeAuthor(trackFormatMark),
72+
'w:date': trackFormatMark.attrs?.date,
73+
},
74+
};
75+
};
76+
1877
/**
1978
* Build a valid OOXML <w:rPrChange> node from a trackFormat mark.
2079
*
@@ -35,22 +94,21 @@ export const createRunPropertiesChangeElement = (trackFormatMark, options = {})
3594
elements: toRunPropertyElements(beforeMarks),
3695
};
3796

38-
// Phase 005 — if an allocator was passed in, mint a Word-native decimal
39-
// `w:id`. Legacy callers (no `options.wordIdAllocator`) keep the prior
40-
// `sourceId || id` behavior so the exported byte stream is unchanged.
41-
const allocator = options?.wordIdAllocator || null;
42-
const partPath = options?.partPath || 'word/document.xml';
43-
const sourceId = trackFormatMark.attrs?.sourceId;
44-
const logicalId = trackFormatMark.attrs?.id;
45-
const wordId = allocator ? allocator.allocate({ partPath, sourceId, logicalId }) : sourceId || logicalId;
97+
// Prefer the export allocator for Word-native revision ids. Legacy callers
98+
// without an allocator still need decimal OOXML ids, so they use a decimal
99+
// source/logical id when available and a deterministic decimal fallback
100+
// otherwise.
101+
const wordId = getTrackFormatChangeWordId(trackFormatMark, options);
46102

103+
// w:authorEmail is not part of the OOXML CT_TrackChange attribute set, so it is
104+
// intentionally omitted from <w:rPrChange>. The author email remains available on
105+
// the internal trackFormat mark attrs for editor-side use; it is just not serialized.
47106
return {
48107
type: 'element',
49108
name: 'w:rPrChange',
50109
attributes: {
51110
'w:id': wordId,
52-
'w:author': trackFormatMark.attrs?.author,
53-
'w:authorEmail': trackFormatMark.attrs?.authorEmail,
111+
'w:author': getTrackChangeAuthor(trackFormatMark),
54112
'w:date': trackFormatMark.attrs?.date,
55113
},
56114
elements: [previousRunProperties],

packages/super-editor/src/editors/v1/core/super-converter/v3/handlers/w/p/helpers/generate-paragraph-properties.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,87 @@
11
import { carbonCopy } from '@core/utilities/carbonCopy.js';
22
import { translator as wPPrNodeTranslator } from '../../pPr/pPr-translator.js';
3+
import { createParagraphSplitInsertionElement, isParagraphSplitTrackFormatMark } from '../../../helpers.js';
4+
5+
function resolveExportPartPath(params = {}) {
6+
if (typeof params.currentPartPath === 'string' && params.currentPartPath.length > 0) return params.currentPartPath;
7+
if (typeof params.filename === 'string' && params.filename.length > 0) {
8+
return params.filename.startsWith('word/') ? params.filename : `word/${params.filename}`;
9+
}
10+
return 'word/document.xml';
11+
}
12+
13+
function findParagraphSplitTrackFormatMark(node) {
14+
if (!node || typeof node !== 'object') return null;
15+
16+
const marks = Array.isArray(node.marks) ? node.marks : [];
17+
const directMark = marks.find((mark) => isParagraphSplitTrackFormatMark(mark));
18+
if (directMark) return directMark;
19+
20+
if (typeof node.descendants === 'function') {
21+
let found = null;
22+
node.descendants((child) => {
23+
// A paragraph-split mark is sticky: once a descendant carries it, keep it.
24+
// descendants() still visits later siblings, so a later unmarked child must
25+
// not clear a mark already discovered on an earlier child.
26+
if (found) return false;
27+
const childMarks = Array.isArray(child?.marks) ? child.marks : [];
28+
const match = childMarks.find((mark) => isParagraphSplitTrackFormatMark(mark));
29+
if (match) found = match;
30+
return !found;
31+
});
32+
if (found) return found;
33+
}
34+
35+
const content = Array.isArray(node.content) ? node.content : [];
36+
for (const child of content) {
37+
const childMark = findParagraphSplitTrackFormatMark(child);
38+
if (childMark) return childMark;
39+
}
40+
41+
return null;
42+
}
43+
44+
function ensureParagraphPropertiesNode(pPr) {
45+
return (
46+
pPr || {
47+
type: 'element',
48+
name: 'w:pPr',
49+
elements: [],
50+
}
51+
);
52+
}
53+
54+
function insertRunPropertiesInOrder(pPr, runProperties) {
55+
// Per CT_PPr, the paragraph-mark <w:rPr> comes after normal paragraph-level
56+
// properties and before any terminal <w:sectPr> / <w:pPrChange>. Inserting at
57+
// the front produces invalid ordering when the paragraph already has
58+
// properties such as <w:pStyle>, <w:spacing>, or <w:jc>.
59+
const terminalIdx = pPr.elements.findIndex(
60+
(element) => element?.name === 'w:sectPr' || element?.name === 'w:pPrChange',
61+
);
62+
if (terminalIdx === -1) {
63+
pPr.elements.push(runProperties);
64+
} else {
65+
pPr.elements.splice(terminalIdx, 0, runProperties);
66+
}
67+
}
68+
69+
function prependParagraphSplitInsertion(pPr, insertionElement) {
70+
if (!pPr || !insertionElement) return pPr;
71+
if (!Array.isArray(pPr.elements)) pPr.elements = [];
72+
const existingRunProperties = pPr.elements.find((element) => element?.name === 'w:rPr');
73+
const runProperties = existingRunProperties || {
74+
type: 'element',
75+
name: 'w:rPr',
76+
elements: [],
77+
};
78+
if (!Array.isArray(runProperties.elements)) runProperties.elements = [];
79+
const hasParagraphInsertion = runProperties.elements.some((element) => element?.name === 'w:ins');
80+
if (!hasParagraphInsertion) runProperties.elements.unshift(insertionElement);
81+
// Keep an existing <w:rPr> in place; only insert a freshly-created one in order.
82+
if (!existingRunProperties) insertRunPropertiesInOrder(pPr, runProperties);
83+
return pPr;
84+
}
385

486
/**
587
* Generate the w:pPr props for a paragraph node
@@ -33,7 +115,23 @@ export function generateParagraphProperties(params) {
33115
}
34116
}
35117

118+
const paragraphSplitTrackFormatMark = findParagraphSplitTrackFormatMark(node);
119+
const paragraphSplitWordIdOptions = paragraphSplitTrackFormatMark
120+
? {
121+
wordIdAllocator: params?.converter?.wordIdAllocator || null,
122+
partPath: resolveExportPartPath(params),
123+
}
124+
: null;
36125
let pPr = wPPrNodeTranslator.decode({ node: { ...node, attrs: { paragraphProperties } } });
126+
if (!params?.isFinalDoc && paragraphSplitTrackFormatMark) {
127+
const insertionElement = createParagraphSplitInsertionElement(
128+
paragraphSplitTrackFormatMark,
129+
paragraphSplitWordIdOptions,
130+
);
131+
if (insertionElement) {
132+
pPr = prependParagraphSplitInsertion(ensureParagraphPropertiesNode(pPr), insertionElement);
133+
}
134+
}
37135
const sectPr = node.attrs?.paragraphProperties?.sectPr;
38136
if (sectPr) {
39137
if (!pPr) {

0 commit comments

Comments
 (0)