Skip to content

Commit e925ef9

Browse files
fix(track-changes): remove ghost TrackFormat on multi-node format cancel (#2233)
* fix: remove persisting yellow underline after canceling track-format suggestion Two bugs caused the gold/yellow TrackFormat underline to persist after reversing a tracked format change: 1. addMarkStep applied TrackFormat using the full step range instead of clamped per-node boundaries, leaking wrong before/after arrays onto adjacent text nodes. 2. removeMarkStep didn't clean up fully-reversed format changes — when a tracked addition was toggled back (after=[] but before had items), the TrackFormat mark persisted instead of being removed. * fix: only remove TrackFormat when both before and after are empty Narrowed the early-exit condition so TrackFormat is only removed when both arrays are empty (true no-op). Keeps the mark when before has legitimate tracked removals (e.g. before=[italic], after=[]). * fix: share TrackFormat ID across nodes in the same addMarkStep Hoist a sharedWid variable so all inline nodes touched by a single addMarkStep reuse the same TrackFormat ID. With per-node clamped ranges, getLiveInlineMarksInRange no longer sees the previous node's TrackFormat, causing each node to generate a separate UUID. This broke ID-based reject/accept flows for multi-node selections. * fix: share TrackFormat ID across nodes in removeMarkStep Mirror the sharedWid pattern from addMarkStep. When an existing formatChangeMark is present, reuse its ID to preserve change grouping. When creating new TrackFormat marks across multiple nodes, share a single generated ID so reject/accept treats them as one change. * fix(track-changes): clean up TrackFormat when cancel is a no-op on nodes with pre-existing marks When bold is toggled on then off across multi-node selections where some nodes have pre-existing marks (e.g., italic), the removeMarkStep cancel path only cleaned up when both `before` and `after` arrays were empty. This missed the case where `before` had pre-existing marks that were never actually removed — leaving a ghost TrackFormat mark showing "Format: removed italic" even though italic was untouched. The fix checks whether all marks in `before` still exist on the node. If they do, the tracked change is a no-op and the TrackFormat is removed. * fix(track-changes): use attribute-aware comparison in isNoop check The isNoop check compared mark snapshots by type name only, ignoring attributes. For textStyle/highlight marks with differing attrs, this could incorrectly treat a real change as a no-op. Use the existing markSnapshotMatchesStepMark helper for full type+attrs comparison. Also simplifies multi-node test to use the addThenRemoveMark helper. * test(track-changes): add multi-node format cancel tests - Unit tests for sharedWid across runs and range clamping - Behavior test for toggling bold on/off across mixed-mark nodes --------- Co-authored-by: G Pardhiv Varma <gpardhivvarma@gmail.com>
1 parent d326f3a commit e925ef9

4 files changed

Lines changed: 315 additions & 7 deletions

File tree

packages/super-editor/src/extensions/track-changes/trackChangesHelpers/addMarkStep.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js';
1818
*/
1919
export const addMarkStep = ({ state, step, newTr, doc, user, date }) => {
2020
const meta = {};
21+
let sharedWid = null;
2122

2223
doc.nodesBetween(step.from, step.to, (node, pos) => {
2324
if (!node.isInline || node.type.name === 'run') {
@@ -30,6 +31,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => {
3031

3132
const rangeFrom = Math.max(step.from, pos);
3233
const rangeTo = Math.min(step.to, pos + node.nodeSize);
34+
3335
const liveMarks = getLiveInlineMarksInRange({
3436
doc: newTr.doc,
3537
from: rangeFrom,
@@ -38,7 +40,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => {
3840
const existingChangeMark = liveMarks.find((mark) =>
3941
[TrackDeleteMarkName, TrackFormatMarkName].includes(mark.type.name),
4042
);
41-
const wid = existingChangeMark ? existingChangeMark.attrs.id : uuidv4();
43+
const wid = existingChangeMark ? existingChangeMark.attrs.id : (sharedWid ?? (sharedWid = uuidv4()));
4244
newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), step.mark);
4345

4446
const allowedMarks = ['bold', 'italic', 'strike', 'underline', 'textStyle', 'highlight'];
@@ -93,11 +95,7 @@ export const addMarkStep = ({ state, step, newTr, doc, user, date }) => {
9395
before,
9496
after,
9597
});
96-
newTr.addMark(
97-
step.from, // Math.max(step.from, pos)
98-
step.to, // Math.min(step.to, pos + node.nodeSize),
99-
newFormatMark,
100-
);
98+
newTr.addMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), newFormatMark);
10199

102100
meta.formatMark = newFormatMark;
103101
meta.step = step;

packages/super-editor/src/extensions/track-changes/trackChangesHelpers/removeMarkStep.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getLiveInlineMarksInRange } from './getLiveInlineMarksInRange.js';
1616
*/
1717
export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => {
1818
const meta = {};
19+
let sharedWid = null;
1920

2021
doc.nodesBetween(step.from, step.to, (node, pos) => {
2122
if (!node.isInline || node.type.name === 'run') {
@@ -52,6 +53,23 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => {
5253
after = [
5354
...formatChangeMark.attrs.after.filter((mark) => !markSnapshotMatchesStepMark(mark, step.mark, true)),
5455
];
56+
if (after.length === 0) {
57+
// All additions were canceled. Check if any marks in `before` were
58+
// actually removed from the node. If they all still exist, the
59+
// tracked change is a no-op — clean it up.
60+
const remainingFormatMarks = liveMarksBeforeRemove.filter(
61+
(m) =>
62+
![TrackDeleteMarkName, TrackFormatMarkName].includes(m.type.name) &&
63+
m.type.name !== step.mark.type.name,
64+
);
65+
const isNoop = formatChangeMark.attrs.before.every((snapshot) =>
66+
remainingFormatMarks.some((m) => markSnapshotMatchesStepMark(snapshot, m, true)),
67+
);
68+
if (isNoop) {
69+
newTr.removeMark(Math.max(step.from, pos), Math.min(step.to, pos + node.nodeSize), formatChangeMark);
70+
return;
71+
}
72+
}
5573
before = [...formatChangeMark.attrs.before];
5674
} else {
5775
after = [...formatChangeMark.attrs.after];
@@ -77,7 +95,7 @@ export const removeMarkStep = ({ state, step, newTr, doc, user, date }) => {
7795

7896
if (after.length || before.length) {
7997
const newFormatMark = state.schema.marks[TrackFormatMarkName].create({
80-
id: uuidv4(),
98+
id: formatChangeMark ? formatChangeMark.attrs.id : (sharedWid ?? (sharedWid = uuidv4())),
8199
author: user.name,
82100
authorEmail: user.email,
83101
authorImage: user.image,
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
2+
import { EditorState } from 'prosemirror-state';
3+
import { AddMarkStep, RemoveMarkStep } from 'prosemirror-transform';
4+
import { TrackFormatMarkName } from '../constants.js';
5+
import { TrackChangesBasePluginKey } from '../plugins/trackChangesBasePlugin.js';
6+
import { addMarkStep } from './addMarkStep.js';
7+
import { removeMarkStep } from './removeMarkStep.js';
8+
import { initTestEditor } from '@tests/helpers/helpers.js';
9+
10+
describe('removeMarkStep cancel logic', () => {
11+
let editor;
12+
let schema;
13+
let basePlugins;
14+
15+
const user = { name: 'Track Tester', email: 'track@example.com' };
16+
const date = '2024-01-01T00:00:00.000Z';
17+
18+
beforeEach(() => {
19+
({ editor } = initTestEditor({ mode: 'text', content: '<p></p>' }));
20+
schema = editor.schema;
21+
basePlugins = editor.state.plugins;
22+
});
23+
24+
afterEach(() => {
25+
vi.restoreAllMocks();
26+
editor?.destroy();
27+
editor = null;
28+
});
29+
30+
const createDocWithRuns = (runs) => {
31+
const runNodes = runs.map(({ text, marks = [] }) => schema.nodes.run.create({}, schema.text(text, marks)));
32+
return schema.nodes.doc.create({}, schema.nodes.paragraph.create({}, runNodes));
33+
};
34+
35+
const createState = (doc) =>
36+
EditorState.create({
37+
schema,
38+
doc,
39+
plugins: basePlugins,
40+
});
41+
42+
/**
43+
* Simulate add-then-remove of a mark and return the resulting doc.
44+
* Applies addMarkStep to get the TrackFormat mark, then removeMarkStep.
45+
*/
46+
const addThenRemoveMark = ({ state, mark, from, to }) => {
47+
const addStep = new AddMarkStep(from, to, mark);
48+
const addTr = state.tr;
49+
addMarkStep({ state, step: addStep, newTr: addTr, doc: state.doc, user, date });
50+
const stateAfterAdd = state.apply(addTr);
51+
52+
const removeStep = new RemoveMarkStep(from, to, mark);
53+
const removeTr = stateAfterAdd.tr;
54+
removeMarkStep({ state: stateAfterAdd, step: removeStep, newTr: removeTr, doc: stateAfterAdd.doc, user, date });
55+
return { stateAfterRemove: stateAfterAdd.apply(removeTr), removeTr };
56+
};
57+
58+
const hasTrackFormatMark = (doc) => {
59+
let found = false;
60+
doc.descendants((node) => {
61+
if (node.marks?.some((m) => m.type.name === TrackFormatMarkName)) {
62+
found = true;
63+
}
64+
});
65+
return found;
66+
};
67+
68+
it('removes TrackFormat when bold is toggled on then off (no pre-existing marks)', () => {
69+
const doc = createDocWithRuns([{ text: 'Hello' }]);
70+
const state = createState(doc);
71+
72+
const { stateAfterRemove } = addThenRemoveMark({
73+
state,
74+
mark: schema.marks.bold.create(),
75+
from: 2,
76+
to: 7,
77+
});
78+
79+
expect(hasTrackFormatMark(stateAfterRemove.doc)).toBe(false);
80+
});
81+
82+
it('removes TrackFormat when bold is toggled on then off (node has pre-existing italic)', () => {
83+
const italic = schema.marks.italic.create();
84+
const doc = createDocWithRuns([{ text: 'world', marks: [italic] }]);
85+
const state = createState(doc);
86+
87+
const { stateAfterRemove } = addThenRemoveMark({
88+
state,
89+
mark: schema.marks.bold.create(),
90+
from: 2,
91+
to: 7,
92+
});
93+
94+
// The bug: before this fix, a ghost TrackFormat with before=[italic], after=[]
95+
// would persist, showing "Format: removed italic" even though italic was never touched.
96+
expect(hasTrackFormatMark(stateAfterRemove.doc)).toBe(false);
97+
});
98+
99+
it('keeps TrackFormat when before marks were actually removed from the node', () => {
100+
// Setup: "world" has italic + bold
101+
const italic = schema.marks.italic.create();
102+
const bold = schema.marks.bold.create();
103+
const doc = createDocWithRuns([{ text: 'world', marks: [italic, bold] }]);
104+
const state = createState(doc);
105+
106+
// Step 1: Remove italic (creates TrackFormat with before=[italic], after=[])
107+
const removeItalicStep = new RemoveMarkStep(2, 7, italic);
108+
const removeItalicTr = state.tr;
109+
removeMarkStep({ state, step: removeItalicStep, newTr: removeItalicTr, doc: state.doc, user, date });
110+
const stateAfterRemoveItalic = state.apply(removeItalicTr);
111+
112+
// Step 2: Add bold (bold already exists, so this is a no-op for addMarkStep's
113+
// hasMatchingMark check — bold is already live). Instead, simulate adding underline
114+
// then removing it to test the cancel logic with a pre-existing removal.
115+
const addUnderlineStep = new AddMarkStep(2, 7, schema.marks.underline.create());
116+
const addUnderlineTr = stateAfterRemoveItalic.tr;
117+
addMarkStep({
118+
state: stateAfterRemoveItalic,
119+
step: addUnderlineStep,
120+
newTr: addUnderlineTr,
121+
doc: stateAfterRemoveItalic.doc,
122+
user,
123+
date,
124+
});
125+
const stateAfterAddUnderline = stateAfterRemoveItalic.apply(addUnderlineTr);
126+
127+
// Step 3: Remove underline (cancel the addition). The TrackFormat should remain
128+
// because italic was genuinely removed and is no longer on the node.
129+
const removeUnderlineStep = new RemoveMarkStep(2, 7, schema.marks.underline.create());
130+
const removeUnderlineTr = stateAfterAddUnderline.tr;
131+
removeMarkStep({
132+
state: stateAfterAddUnderline,
133+
step: removeUnderlineStep,
134+
newTr: removeUnderlineTr,
135+
doc: stateAfterAddUnderline.doc,
136+
user,
137+
date,
138+
});
139+
const stateAfterRemoveUnderline = stateAfterAddUnderline.apply(removeUnderlineTr);
140+
141+
// TrackFormat should persist because italic was actually removed
142+
expect(hasTrackFormatMark(stateAfterRemoveUnderline.doc)).toBe(true);
143+
144+
// Verify the remaining TrackFormat records the italic removal
145+
let trackFormatAttrs = null;
146+
stateAfterRemoveUnderline.doc.descendants((node) => {
147+
const mark = node.marks?.find((m) => m.type.name === TrackFormatMarkName);
148+
if (mark) trackFormatAttrs = mark.attrs;
149+
});
150+
expect(trackFormatAttrs.before).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'italic' })]));
151+
});
152+
153+
it('shares TrackFormat ID across nodes when addMarkStep spans multiple runs', () => {
154+
const italic = schema.marks.italic.create();
155+
const doc = createDocWithRuns([{ text: 'Hello ' }, { text: 'world', marks: [italic] }]);
156+
const state = createState(doc);
157+
158+
const bold = schema.marks.bold.create();
159+
const step = new AddMarkStep(2, 13, bold);
160+
const tr = state.tr;
161+
addMarkStep({ state, step, newTr: tr, doc: state.doc, user, date });
162+
const result = state.apply(tr);
163+
164+
// Collect TrackFormat mark IDs from all inline nodes
165+
const ids = [];
166+
result.doc.descendants((node) => {
167+
const mark = node.marks?.find((m) => m.type.name === TrackFormatMarkName);
168+
if (mark) ids.push(mark.attrs.id);
169+
});
170+
171+
expect(ids.length).toBe(2);
172+
expect(ids[0]).toBe(ids[1]);
173+
});
174+
175+
it('clamps TrackFormat mark to node boundaries, not step range', () => {
176+
// doc > paragraph > run_1("Hello " at pos 2-7) + run_2("world" at pos 10-14)
177+
// Run open/close tags add +1 offset each, so run_2 content starts at pos 10.
178+
const doc = createDocWithRuns([{ text: 'Hello ' }, { text: 'world' }]);
179+
const state = createState(doc);
180+
181+
// Apply bold from pos 5-11. Clamped per-node:
182+
// run_1 text [2,8): [max(5,2), min(11,8)] = [5,8] → "lo " (3 chars)
183+
// run_2 text [10,15): [max(5,10), min(11,15)] = [10,11] → "w" (1 char)
184+
const bold = schema.marks.bold.create();
185+
const step = new AddMarkStep(5, 11, bold);
186+
const tr = state.tr;
187+
addMarkStep({ state, step, newTr: tr, doc: state.doc, user, date });
188+
const result = state.apply(tr);
189+
190+
// Collect TrackFormat marks — each should be scoped to its text portion
191+
const trackMarks = [];
192+
result.doc.descendants((node, pos) => {
193+
const mark = node.marks?.find((m) => m.type.name === TrackFormatMarkName);
194+
if (mark) trackMarks.push({ pos, size: node.nodeSize, text: node.text, id: mark.attrs.id });
195+
});
196+
197+
// Two TrackFormat marks: one on "lo " (from first run) and one on "w" (from second run)
198+
expect(trackMarks.length).toBe(2);
199+
expect(trackMarks[0].text).toBe('lo ');
200+
expect(trackMarks[1].text).toBe('w');
201+
// Both should share the same ID (sharedWid)
202+
expect(trackMarks[0].id).toBe(trackMarks[1].id);
203+
// "Hel" and "orld" should NOT have TrackFormat (they were outside the step range)
204+
const nonTrackedTexts = [];
205+
result.doc.descendants((node) => {
206+
if (node.isText && !node.marks?.some((m) => m.type.name === TrackFormatMarkName)) {
207+
nonTrackedTexts.push(node.text);
208+
}
209+
});
210+
expect(nonTrackedTexts).toContain('Hel');
211+
expect(nonTrackedTexts).toContain('orld');
212+
});
213+
214+
it('removes TrackFormat for multi-node bold toggle (Hello plain + world italic)', () => {
215+
const italic = schema.marks.italic.create();
216+
const doc = createDocWithRuns([{ text: 'Hello ' }, { text: 'world', marks: [italic] }]);
217+
const state = createState(doc);
218+
219+
const { stateAfterRemove } = addThenRemoveMark({
220+
state,
221+
mark: schema.marks.bold.create(),
222+
from: 2,
223+
to: 13,
224+
});
225+
226+
// Neither node should have a TrackFormat mark
227+
expect(hasTrackFormatMark(stateAfterRemove.doc)).toBe(false);
228+
});
229+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { test, expect } from '../../fixtures/superdoc.js';
2+
3+
test.use({ config: { toolbar: 'full', comments: 'panel', trackChanges: true } });
4+
5+
/**
6+
* Regression test for the multi-node cancel scenario:
7+
* When bold is toggled on then off across nodes with different pre-existing marks,
8+
* no ghost TrackFormat marks should remain.
9+
*/
10+
test('no ghost TrackFormat after toggling bold on then off across mixed-mark nodes', async ({ superdoc }) => {
11+
// Type two words — we'll make only the second one italic to create mixed marks
12+
await superdoc.type('Hello world');
13+
await superdoc.waitForStable();
14+
15+
// Select "world" and make it italic in editing mode
16+
await superdoc.page.evaluate(() => {
17+
const editor = (window as any).editor;
18+
const { doc } = editor.state;
19+
// Find "world" position (after "Hello ")
20+
let worldFrom = 0;
21+
let worldTo = 0;
22+
doc.descendants((node: any, pos: number) => {
23+
if (node.isText && node.text?.includes('world')) {
24+
const offset = node.text.indexOf('world');
25+
worldFrom = pos + offset;
26+
worldTo = worldFrom + 'world'.length;
27+
}
28+
});
29+
editor.commands.setTextSelection({ from: worldFrom, to: worldTo });
30+
editor.commands.toggleItalic();
31+
});
32+
await superdoc.waitForStable();
33+
34+
// Switch to suggesting mode
35+
await superdoc.setDocumentMode('suggesting');
36+
await superdoc.waitForStable();
37+
38+
// Select all and toggle bold ON
39+
await superdoc.selectAll();
40+
await superdoc.page.evaluate(() => (window as any).editor.commands.toggleBold());
41+
await superdoc.waitForStable();
42+
43+
// Verify a format tracked change was created
44+
await superdoc.assertTrackedChangeExists('format');
45+
46+
// Toggle bold OFF (cancel the suggestion)
47+
await superdoc.selectAll();
48+
await superdoc.page.evaluate(() => (window as any).editor.commands.toggleBold());
49+
await superdoc.waitForStable();
50+
51+
// No track-format decorations should remain — the cancel was a no-op
52+
await expect(superdoc.page.locator('.track-format-dec')).toHaveCount(0);
53+
54+
// Text should be unchanged
55+
await superdoc.assertTextContent('Hello world');
56+
57+
// "world" should still have italic (it was never touched)
58+
await superdoc.assertTextHasMarks('world', ['italic']);
59+
60+
// Neither word should have bold
61+
await superdoc.assertTextLacksMarks('Hello', ['bold']);
62+
await superdoc.assertTextLacksMarks('world', ['bold']);
63+
});

0 commit comments

Comments
 (0)