Skip to content

Commit 1371d49

Browse files
authored
fix: nested replacement tracked-change decisions (#3557)
1 parent 4c51e5c commit 1371d49

23 files changed

Lines changed: 827 additions & 170 deletions

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

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
33

44
/**
55
* @typedef {'paired' | 'independent'} TrackChangesReplacements
6-
* @typedef {{ type: string, author: string, date: string, internalId: string }} TrackedChangeEntry
7-
* @typedef {{ lastTrackedChange: TrackedChangeEntry | null, replacements: TrackChangesReplacements }} WalkContext
6+
* @typedef {{ type: string, author: string, date: string, internalId?: string }} TrackedChangeEntry
7+
* @typedef {{ beforeLastTrackedChange: TrackedChangeEntry | null, lastTrackedChange: TrackedChangeEntry | null, replacements: TrackChangesReplacements }} WalkContext
88
*/
99

1010
const TRACKED_CHANGE_NAMES = new Set(['w:ins', 'w:del']);
@@ -44,6 +44,66 @@ function isReplacementPair(previous, current) {
4444
return previous.type !== current.type && previous.author === current.author && previous.date === current.date;
4545
}
4646

47+
/**
48+
* @param {object} element
49+
* @returns {TrackedChangeEntry}
50+
*/
51+
function trackedChangeEntryFromElement(element) {
52+
return {
53+
type: element.name,
54+
author: element.attributes?.['w:author'] ?? '',
55+
date: element.attributes?.['w:date'] ?? '',
56+
};
57+
}
58+
59+
/**
60+
* Returns the next sibling tracked-change element, skipping only non-content
61+
* markers. Content-bearing elements terminate the sibling check because they
62+
* break Word replacement adjacency.
63+
*
64+
* @param {Array} elements
65+
* @param {number} startIndex
66+
* @returns {TrackedChangeEntry | null}
67+
*/
68+
function findNextSiblingTrackedChange(elements, startIndex) {
69+
if (!Array.isArray(elements)) return null;
70+
71+
for (let i = startIndex; i < elements.length; i += 1) {
72+
const element = elements[i];
73+
if (TRACKED_CHANGE_NAMES.has(element?.name)) {
74+
return trackedChangeEntryFromElement(element);
75+
}
76+
if (!PAIRING_TRANSPARENT_NAMES.has(element?.name)) {
77+
return null;
78+
}
79+
}
80+
81+
return null;
82+
}
83+
84+
/**
85+
* Word serializes a replacement selected inside another author's deletion as
86+
* child insertion/deletion sides surrounded by the parent deletion fragments.
87+
* In paired mode the generic adjacent-replacement heuristic would otherwise
88+
* collapse the child sides into one replacement. Keep them independent when
89+
* either side of the candidate pair touches a different-author deletion.
90+
*
91+
* @param {TrackedChangeEntry | null} beforePrevious
92+
* @param {TrackedChangeEntry} previous
93+
* @param {TrackedChangeEntry} current
94+
* @param {TrackedChangeEntry | null} next
95+
* @returns {boolean}
96+
*/
97+
function isChildReplacementInsideDeletion(beforePrevious, previous, current, next) {
98+
if (!isReplacementPair(previous, current)) return false;
99+
100+
const touchesDifferentAuthorDeletionBefore =
101+
beforePrevious?.type === 'w:del' && beforePrevious.author !== previous.author;
102+
const touchesDifferentAuthorDeletionAfter = next?.type === 'w:del' && next.author !== previous.author;
103+
104+
return touchesDifferentAuthorDeletionBefore || touchesDifferentAuthorDeletionAfter;
105+
}
106+
47107
/**
48108
* Assigns an internal UUID to a tracked change element. In paired mode,
49109
* adjacent replacement halves (w:del + w:ins with matching author/date)
@@ -53,8 +113,9 @@ function isReplacementPair(previous, current) {
53113
* @param {Map<string, string>} idMap Accumulates Word ID → internal UUID
54114
* @param {WalkContext} context Mutable walk state for replacement pairing
55115
* @param {boolean} insideTrackedChange Whether this element is nested in another tracked change
116+
* @param {TrackedChangeEntry | null} nextTrackedChange
56117
*/
57-
function assignInternalId(element, idMap, context, insideTrackedChange) {
118+
function assignInternalId(element, idMap, context, insideTrackedChange, nextTrackedChange = null) {
58119
const wordId = String(element.attributes?.['w:id'] ?? '');
59120
if (!wordId) return;
60121

@@ -66,29 +127,40 @@ function assignInternalId(element, idMap, context, insideTrackedChange) {
66127
return;
67128
}
68129

69-
const current = {
70-
type: element.name,
71-
author: element.attributes?.['w:author'] ?? '',
72-
date: element.attributes?.['w:date'] ?? '',
73-
};
130+
const current = trackedChangeEntryFromElement(element);
74131

75132
const shouldPair = context.replacements === 'paired';
133+
const shouldKeepChildSides =
134+
context.lastTrackedChange &&
135+
isChildReplacementInsideDeletion(
136+
context.beforeLastTrackedChange,
137+
context.lastTrackedChange,
138+
current,
139+
nextTrackedChange,
140+
);
76141

77-
if (shouldPair && context.lastTrackedChange && isReplacementPair(context.lastTrackedChange, current)) {
142+
if (
143+
shouldPair &&
144+
context.lastTrackedChange &&
145+
!shouldKeepChildSides &&
146+
isReplacementPair(context.lastTrackedChange, current)
147+
) {
78148
// Second half of a replacement — share the first half's UUID, but only
79149
// if this w:id hasn't already been mapped. A reused id that was already
80150
// part of an earlier pair must keep its original mapping.
81151
if (!idMap.has(wordId)) {
82152
idMap.set(wordId, context.lastTrackedChange.internalId);
83153
}
84154
context.lastTrackedChange = null;
155+
context.beforeLastTrackedChange = null;
85156
} else {
86157
// Reuse an existing mapping when the same w:id appears more than once
87158
// (Word reuses tracked-change ids across the document). Minting a fresh
88159
// UUID here would overwrite the earlier entry and break any replacement
89160
// pair that was already recorded for this id.
90161
const internalId = idMap.get(wordId) ?? uuidv4();
91162
idMap.set(wordId, internalId);
163+
context.beforeLastTrackedChange = context.lastTrackedChange;
92164
context.lastTrackedChange = { ...current, internalId };
93165
}
94166
}
@@ -105,9 +177,11 @@ function assignInternalId(element, idMap, context, insideTrackedChange) {
105177
function walkElements(elements, idMap, context, insideTrackedChange = false) {
106178
if (!Array.isArray(elements)) return;
107179

108-
for (const element of elements) {
180+
for (let index = 0; index < elements.length; index += 1) {
181+
const element = elements[index];
109182
if (TRACKED_CHANGE_NAMES.has(element.name)) {
110-
assignInternalId(element, idMap, context, insideTrackedChange);
183+
const nextTrackedChange = findNextSiblingTrackedChange(elements, index + 1);
184+
assignInternalId(element, idMap, context, insideTrackedChange, nextTrackedChange);
111185

112186
if (element.elements) {
113187
// Descend with an isolated context so content inside a tracked change
@@ -116,7 +190,7 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) {
116190
walkElements(
117191
element.elements,
118192
idMap,
119-
{ lastTrackedChange: null, replacements: context.replacements },
193+
{ beforeLastTrackedChange: null, lastTrackedChange: null, replacements: context.replacements },
120194
/* insideTrackedChange */ true,
121195
);
122196
}
@@ -125,6 +199,7 @@ function walkElements(elements, idMap, context, insideTrackedChange = false) {
125199
// markers (comment/bookmark/permission ranges) are transparent.
126200
if (!PAIRING_TRANSPARENT_NAMES.has(element.name)) {
127201
context.lastTrackedChange = null;
202+
context.beforeLastTrackedChange = null;
128203
}
129204

130205
if (element.elements) {
@@ -150,7 +225,7 @@ function buildTrackedChangeIdMapForPart(part, options = {}) {
150225

151226
const replacements = options.replacements === 'independent' ? 'independent' : 'paired';
152227
const idMap = new Map();
153-
walkElements(root.elements, idMap, { lastTrackedChange: null, replacements });
228+
walkElements(root.elements, idMap, { beforeLastTrackedChange: null, lastTrackedChange: null, replacements });
154229
return idMap;
155230
}
156231

packages/super-editor/src/editors/v1/document-api-adapters/find-adapter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,12 +249,12 @@ function projectMatchToSDNodeResult(
249249
const found = blockIndex.candidates.find((c) => c.nodeType === address.nodeType && c.nodeId === address.nodeId);
250250
if (!found) return null;
251251
return {
252-
node: projectContentNode(found.node),
252+
node: projectContentNode(found.node, { textModel: 'visible' }),
253253
address,
254254
};
255255
}
256256
return {
257-
node: projectContentNode(candidate.node),
257+
node: projectContentNode(candidate.node, { textModel: 'visible' }),
258258
address,
259259
};
260260
}

packages/super-editor/src/editors/v1/document-api-adapters/find/common.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
UnknownNodeDiagnostic,
99
} from '@superdoc/document-api';
1010
import { toId } from '../helpers/value-utils.js';
11-
import { getInlineIndex } from '../helpers/index-cache.js';
11+
import { getBlockIndex, getInlineIndex } from '../helpers/index-cache.js';
1212
import {
1313
findBlockById,
1414
toBlockAddress,
@@ -17,6 +17,7 @@ import {
1717
} from '../helpers/node-address-resolver.js';
1818
import { findInlineByAnchor, isInlineQueryType } from '../helpers/inline-address-resolver.js';
1919
import { findCandidateByPos } from '../helpers/adapter-utils.js';
20+
import { pmPositionToTextOffset, textContentInBlock, type TextOffsetOptions } from '../helpers/text-offset-resolver.js';
2021

2122
/** Characters of document text to include before and after a match in snippet context. */
2223
const SNIPPET_PADDING = 30;
@@ -49,6 +50,13 @@ const KNOWN_INLINE_PM_NODE_TYPES = new Set<string>([
4950
'commentRangeEnd',
5051
]);
5152

53+
function getCandidateText(editor: Editor, candidate: BlockCandidate, options?: TextOffsetOptions): string {
54+
if (candidate.node.childCount > 0) {
55+
return textContentInBlock(candidate.node, options);
56+
}
57+
return editor.state.doc.textBetween(candidate.pos + 1, candidate.end - 1, '\n', '\ufffc');
58+
}
59+
5260
function resolveUnknownBlockId(attrs: Record<string, unknown> | undefined): string | undefined {
5361
if (!attrs) return undefined;
5462
return toId(attrs.paraId) ?? toId(attrs.sdBlockId) ?? toId(attrs.blockId) ?? toId(attrs.id) ?? toId(attrs.uuid);
@@ -85,7 +93,46 @@ export function buildTextContext(
8593
matchFrom: number,
8694
matchTo: number,
8795
textRanges?: TextAddress[],
96+
options?: TextOffsetOptions,
8897
): MatchContext {
98+
if (textRanges?.length) {
99+
const index = getBlockIndex(editor);
100+
const firstRange = textRanges[0];
101+
const lastRange = textRanges[textRanges.length - 1];
102+
const firstBlock = index.candidates.find((candidate) => candidate.nodeId === firstRange.blockId);
103+
const lastBlock = index.candidates.find((candidate) => candidate.nodeId === lastRange.blockId);
104+
105+
if (firstBlock && lastBlock) {
106+
const matchText = textRanges
107+
.map((range) => {
108+
const block = index.candidates.find((candidate) => candidate.nodeId === range.blockId);
109+
if (!block) return '';
110+
return getCandidateText(editor, block, options).slice(range.range.start, range.range.end);
111+
})
112+
.join('\n');
113+
const firstText = getCandidateText(editor, firstBlock, options);
114+
const lastText = getCandidateText(editor, lastBlock, options);
115+
const leftContext = firstText.slice(
116+
Math.max(0, firstRange.range.start - SNIPPET_PADDING),
117+
firstRange.range.start,
118+
);
119+
const rightContext = lastText.slice(lastRange.range.end, lastRange.range.end + SNIPPET_PADDING);
120+
const snippet = `${leftContext}${matchText}${rightContext}`.replace(/ {2,}/g, ' ');
121+
const prefix = leftContext.replace(/ {2,}/g, ' ');
122+
const normalizedMatch = matchText.replace(/ {2,}/g, ' ');
123+
124+
return {
125+
address,
126+
snippet,
127+
highlightRange: {
128+
start: prefix.length,
129+
end: prefix.length + normalizedMatch.length,
130+
},
131+
textRanges,
132+
};
133+
}
134+
}
135+
89136
const docSize = editor.state.doc.content.size;
90137
const snippetFrom = Math.max(0, matchFrom - SNIPPET_PADDING);
91138
const snippetTo = Math.min(docSize, matchTo + SNIPPET_PADDING);
@@ -120,13 +167,20 @@ export function toTextAddress(
120167
editor: Editor,
121168
block: BlockCandidate,
122169
range: { from: number; to: number },
170+
options?: TextOffsetOptions,
123171
): TextAddress | undefined {
124172
const blockStart = block.pos + 1;
125173
const blockEnd = block.end - 1;
126174
if (range.from < blockStart || range.to > blockEnd) return undefined;
127175

128-
const start = editor.state.doc.textBetween(blockStart, range.from, '\n', '\ufffc').length;
129-
const end = editor.state.doc.textBetween(blockStart, range.to, '\n', '\ufffc').length;
176+
const start =
177+
block.node.childCount > 0
178+
? pmPositionToTextOffset(block.node, block.pos, range.from, options)
179+
: editor.state.doc.textBetween(blockStart, range.from, '\n', '\ufffc').length;
180+
const end =
181+
block.node.childCount > 0
182+
? pmPositionToTextOffset(block.node, block.pos, range.to, options)
183+
: editor.state.doc.textBetween(blockStart, range.to, '\n', '\ufffc').length;
130184

131185
return {
132186
kind: 'text',

packages/super-editor/src/editors/v1/document-api-adapters/find/text-strategy.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { addDiagnostic, findCandidateByPos, paginate, resolveWithinScope } from
1919
import { buildTextContext, toTextAddress } from './common.js';
2020
import { DocumentApiAdapterError } from '../errors.js';
2121
import { requireEditorCommand } from '../helpers/mutation-helpers.js';
22+
import type { TextOffsetModel } from '../helpers/text-offset-resolver.js';
2223

2324
/** Shape returned by `editor.commands.search`. */
2425
type SearchMatch = {
@@ -30,6 +31,10 @@ type SearchMatch = {
3031

3132
/** Maximum allowed pattern length to guard against ReDoS and excessive memory usage. */
3233
const MAX_PATTERN_LENGTH = 1024;
34+
export type TextSelectorSearchModel = Extract<TextOffsetModel, 'raw' | 'visible'>;
35+
export type ExecuteTextSelectorOptions = {
36+
searchModel?: TextSelectorSearchModel;
37+
};
3338

3439
function compileRegex(selector: TextSelector, diagnostics: UnknownNodeDiagnostic[]): RegExp | null {
3540
if (selector.pattern.length > MAX_PATTERN_LENGTH) {
@@ -81,6 +86,7 @@ export function executeTextSelector(
8186
index: BlockIndex,
8287
query: Query,
8388
diagnostics: UnknownNodeDiagnostic[],
89+
options: ExecuteTextSelectorOptions = {},
8490
): QueryResult {
8591
if (query.select.type !== 'text') {
8692
addDiagnostic(diagnostics, `Text strategy received a non-text selector (type="${query.select.type}").`);
@@ -100,12 +106,15 @@ export function executeTextSelector(
100106
if (!pattern) return { matches: [], total: 0 };
101107

102108
const search = requireEditorCommand(editor.commands?.search, 'find (search)');
109+
const searchModel = options.searchModel ?? 'visible';
110+
const textOffsetOptions = { textModel: searchModel };
103111

112+
pattern.lastIndex = 0;
104113
const rawResult = search(pattern, {
105114
highlight: false,
106115
caseSensitive: selector.caseSensitive ?? false,
107116
maxMatches: Infinity,
108-
searchModel: 'visible',
117+
searchModel,
109118
});
110119

111120
if (!Array.isArray(rawResult)) {
@@ -114,9 +123,9 @@ export function executeTextSelector(
114123
'Editor search command returned an unexpected result format.',
115124
);
116125
}
117-
const allMatches = rawResult as SearchMatch[];
118126

119127
const scopeRange = scope.range;
128+
const allMatches = rawResult as SearchMatch[];
120129
const matches = scopeRange
121130
? allMatches.filter((m) => m.from >= scopeRange.start && m.to <= scopeRange.end)
122131
: allMatches;
@@ -133,7 +142,7 @@ export function executeTextSelector(
133142
const block = findCandidateByPos(textBlocks, range.from);
134143
if (!block) return undefined;
135144
if (!source) source = block;
136-
return toTextAddress(editor, block, range);
145+
return toTextAddress(editor, block, range, textOffsetOptions);
137146
})
138147
.filter((range): range is TextAddress => Boolean(range));
139148

@@ -144,7 +153,7 @@ export function executeTextSelector(
144153

145154
const address = toBlockAddress(source);
146155
addresses.push(address);
147-
contexts.push(buildTextContext(editor, address, match.from, match.to, textRanges));
156+
contexts.push(buildTextContext(editor, address, match.from, match.to, textRanges, textOffsetOptions));
148157
}
149158

150159
const paged = paginate(addresses, query.offset, query.limit);

packages/super-editor/src/editors/v1/document-api-adapters/get-text-adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ import { textBetweenWithTabs } from './helpers/text-with-tabs.js';
1616
export function getTextAdapter(editor: Editor, input: GetTextInput): string {
1717
const runtime = resolveStoryRuntime(editor, input.in);
1818
const doc = runtime.editor.state.doc;
19-
return textBetweenWithTabs(doc, 0, doc.content.size, '\n', '\n');
19+
return textBetweenWithTabs(doc, 0, doc.content.size, '\n', '\n', { textModel: 'visible' });
2020
}

packages/super-editor/src/editors/v1/document-api-adapters/helpers/adapter-utils.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function resolveTextTarget(editor: Editor, target: TextAddress): Resolved
7474
assertUnambiguous(matches, target.blockId);
7575
const block = matches[0];
7676
if (!block) return null;
77-
return resolveTextRangeInBlock(block.node, block.pos, target.range);
77+
return resolveTextRangeInBlock(block.node, block.pos, target.range, { textModel: 'visible' });
7878
}
7979

8080
/**
@@ -167,8 +167,13 @@ export function resolveDefaultInsertTarget(editor: Editor): DefaultInsertTarget
167167
for (let i = index.candidates.length - 1; i >= 0; i--) {
168168
const candidate = index.candidates[i];
169169
if (topLevelPositions.has(candidate.pos) && isTextBlockCandidate(candidate)) {
170-
const textLength = computeTextContentLength(candidate.node);
171-
const range = resolveTextRangeInBlock(candidate.node, candidate.pos, { start: textLength, end: textLength });
170+
const textLength = computeTextContentLength(candidate.node, { textModel: 'visible' });
171+
const range = resolveTextRangeInBlock(
172+
candidate.node,
173+
candidate.pos,
174+
{ start: textLength, end: textLength },
175+
{ textModel: 'visible' },
176+
);
172177
if (!range) continue;
173178

174179
return {

0 commit comments

Comments
 (0)