Skip to content

Commit 75f7999

Browse files
committed
fix(track-changes): honor independent replacements in story editors
1 parent b079aca commit 75f7999

4 files changed

Lines changed: 278 additions & 1 deletion

File tree

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4431,6 +4431,13 @@ export class PresentationEditor extends EventEmitter {
44314431
});
44324432
},
44334433
onSurfaceTransaction: ({ sourceEditor, surface, headerId, sectionType, transaction, duration }) => {
4434+
if (transaction?.docChanged && headerId) {
4435+
this.#invalidateTrackedChangesForStory({
4436+
kind: 'story',
4437+
storyType: 'headerFooterPart',
4438+
refId: headerId,
4439+
});
4440+
}
44344441
this.emit('headerFooterTransaction', {
44354442
editor: this.#editor,
44364443
sourceEditor,
@@ -4477,6 +4484,7 @@ export class PresentationEditor extends EventEmitter {
44774484
if (!transaction?.docChanged) {
44784485
return;
44794486
}
4487+
this.#invalidateTrackedChangesForStory(session.locator);
44804488
this.#flowBlockCache.setHasExternalChanges(true);
44814489
this.#pendingDocChange = true;
44824490
this.#selectionSync.onLayoutStart();
@@ -4510,6 +4518,14 @@ export class PresentationEditor extends EventEmitter {
45104518
session.editor.setOptions?.({ documentMode: this.#documentMode });
45114519
}
45124520

4521+
#invalidateTrackedChangesForStory(locator: StoryLocator): void {
4522+
try {
4523+
getTrackedChangeIndex(this.#editor).invalidate(locator);
4524+
} catch {
4525+
// Tracked-change sync is best-effort while a live story session is typing.
4526+
}
4527+
}
4528+
45134529
#ensureStorySessionManager(): StoryPresentationSessionManager {
45144530
if (this.#storySessionManager) {
45154531
return this.#storySessionManager;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import type { Editor } from './Editor.js';
3+
import { createStoryEditor } from './story-editor-factory.ts';
4+
import { initTestEditor } from '../tests/helpers/helpers.js';
5+
6+
const createdEditors: Editor[] = [];
7+
8+
function trackEditor(editor: Editor): Editor {
9+
createdEditors.push(editor);
10+
return editor;
11+
}
12+
13+
afterEach(() => {
14+
while (createdEditors.length > 0) {
15+
const editor = createdEditors.pop();
16+
try {
17+
editor?.destroy?.();
18+
} catch {
19+
// best-effort cleanup for test editors
20+
}
21+
}
22+
});
23+
24+
describe('createStoryEditor', () => {
25+
it('inherits tracked changes configuration from the parent editor', () => {
26+
const parent = trackEditor(
27+
initTestEditor({
28+
mode: 'text',
29+
content: '<p>Hello world</p>',
30+
trackedChanges: {
31+
visible: true,
32+
mode: 'review',
33+
enabled: true,
34+
replacements: 'independent',
35+
},
36+
}).editor as Editor,
37+
);
38+
39+
const child = trackEditor(
40+
createStoryEditor(
41+
parent,
42+
{
43+
type: 'doc',
44+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header text' }] }],
45+
},
46+
{
47+
documentId: 'hf:part:rId9',
48+
isHeaderOrFooter: true,
49+
headless: true,
50+
},
51+
),
52+
);
53+
54+
expect(child.options.trackedChanges).toEqual({
55+
visible: true,
56+
mode: 'review',
57+
enabled: true,
58+
replacements: 'independent',
59+
});
60+
61+
child.options.trackedChanges!.replacements = 'paired';
62+
expect(parent.options.trackedChanges?.replacements).toBe('independent');
63+
});
64+
});

packages/super-editor/src/editors/v1/core/story-editor-factory.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ export function createStoryEditor(
129129
const inheritedExtensions = parentEditor.options.extensions?.length
130130
? [...parentEditor.options.extensions]
131131
: undefined;
132+
const inheritedTrackedChanges = parentEditor.options.trackedChanges
133+
? { ...parentEditor.options.trackedChanges }
134+
: undefined;
132135
const StoryEditorClass = parentEditor.constructor as new (options: Partial<EditorOptions>) => Editor;
133136

134137
const storyEditor = new StoryEditorClass({
@@ -145,6 +148,7 @@ export function createStoryEditor(
145148
mediaFiles: media,
146149
fonts: parentEditor.options.fonts,
147150
user: parentEditor.options.user,
151+
trackedChanges: inheritedTrackedChanges,
148152
isHeaderOrFooter,
149153
isHeadless,
150154
pagination: false,
@@ -157,7 +161,9 @@ export function createStoryEditor(
157161
// Only set element when not headless
158162
...(isHeadless ? {} : { element }),
159163

160-
// Disable collaboration, comments, and tracked changes for story editors
164+
// Disable collaboration and comment threading for story editors.
165+
// Tracked-change configuration is inherited from the parent editor so
166+
// suggesting-mode story sessions honor the same replacement model.
161167
ydoc: null,
162168
collaborationProvider: null,
163169
isCommentsEnabled: false,
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
import { expect, test, type Locator, type Page } from '../../fixtures/superdoc.js';
5+
6+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
7+
const HEADER_DOC_PATH = path.resolve(__dirname, '../../test-data/pagination/longer-header.docx');
8+
const FOOTNOTE_DOC_PATH = path.resolve(
9+
__dirname,
10+
'../../../../packages/super-editor/src/editors/v1/tests/data/basic-footnotes.docx',
11+
);
12+
13+
test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Header/footer test document not available — run pnpm corpus:pull');
14+
test.skip(!fs.existsSync(FOOTNOTE_DOC_PATH), 'Footnote test document not available');
15+
16+
test.use({
17+
config: {
18+
comments: 'panel',
19+
trackChanges: true,
20+
replacements: 'independent',
21+
useHiddenHostForStoryParts: true,
22+
},
23+
});
24+
25+
async function activateHeader(page: Page) {
26+
const header = page.locator('.superdoc-page-header').first();
27+
await header.waitFor({ state: 'visible', timeout: 15_000 });
28+
const box = await header.boundingBox();
29+
expect(box).toBeTruthy();
30+
await page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2);
31+
}
32+
33+
async function activateFooter(page: Page) {
34+
const footer = page.locator('.superdoc-page-footer').first();
35+
await footer.scrollIntoViewIfNeeded();
36+
await footer.waitFor({ state: 'visible', timeout: 15_000 });
37+
const box = await footer.boundingBox();
38+
expect(box).toBeTruthy();
39+
await page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2);
40+
}
41+
42+
async function activateFootnote(page: Page) {
43+
const footnote = page.locator('[data-block-id^="footnote-1-"]').first();
44+
await footnote.scrollIntoViewIfNeeded();
45+
await footnote.waitFor({ state: 'visible', timeout: 15_000 });
46+
const box = await footnote.boundingBox();
47+
expect(box).toBeTruthy();
48+
await page.mouse.dblclick(box!.x + box!.width / 2, box!.y + box!.height / 2);
49+
}
50+
51+
async function replaceFirstTwoLettersInActiveStory(page: Page, replacementText: string) {
52+
return page.evaluate(
53+
({ replacement }) => {
54+
const presentation = (window as any).editor?.presentationEditor;
55+
const hostEditor = (window as any).editor;
56+
const activeEditor = presentation?.getActiveEditor?.();
57+
if (!activeEditor || activeEditor === hostEditor) {
58+
throw new Error('Expected an active story editor.');
59+
}
60+
61+
const storyText = activeEditor.state.doc.textBetween(0, activeEditor.state.doc.content.size, '\n', '\n') ?? '';
62+
const match = storyText.match(/[A-Za-z]{2,}/);
63+
if (!match || match.index == null) {
64+
throw new Error(`No replaceable word found in active story text: "${storyText}"`);
65+
}
66+
67+
const deletedText = storyText.slice(match.index, match.index + 2);
68+
const positions: number[] = [];
69+
activeEditor.state.doc.descendants((node: any, pos: number) => {
70+
if (!node?.isText || !node.text) return;
71+
for (let i = 0; i < node.text.length; i += 1) positions.push(pos + i);
72+
});
73+
74+
const from = positions[match.index];
75+
const to = positions[match.index + 1] + 1;
76+
const success = activeEditor.commands.insertTrackedChange({ from, to, text: replacement });
77+
78+
return {
79+
success,
80+
activeDocumentId: activeEditor.options.documentId,
81+
trackedChanges: activeEditor.options.trackedChanges ?? null,
82+
deletedText,
83+
replacement,
84+
};
85+
},
86+
{ replacement: replacementText },
87+
);
88+
}
89+
90+
async function expectIndependentStoryBubbles(page: Page, deletedText: string, insertedText: string) {
91+
await expect
92+
.poll(
93+
() =>
94+
page.evaluate(
95+
({ deleted, inserted }) => {
96+
const comments = (window as any).superdoc?.commentsStore?.commentsList ?? [];
97+
const trackedChangeComments = comments.filter((comment: any) => comment?.trackedChange);
98+
const matchingComments = trackedChangeComments.filter(
99+
(comment: any) => comment?.deletedText === deleted || comment?.trackedChangeText === inserted,
100+
);
101+
const floatingCount = (window as any).superdoc?.commentsStore?.getFloatingComments?.length ?? 0;
102+
const dialogTexts = Array.from(document.querySelectorAll('.comment-placeholder .comments-dialog'))
103+
.map((node) => node.textContent ?? '')
104+
.filter(Boolean);
105+
106+
return {
107+
matchingTypes: matchingComments.map((comment: any) => comment?.trackedChangeType).sort(),
108+
matchingDeletedTexts: matchingComments.map((comment: any) => comment?.deletedText).filter(Boolean),
109+
matchingInsertedTexts: matchingComments.map((comment: any) => comment?.trackedChangeText).filter(Boolean),
110+
floatingCount,
111+
dialogTexts,
112+
};
113+
},
114+
{ deleted: deletedText, inserted: insertedText },
115+
),
116+
{ timeout: 10_000 },
117+
)
118+
.toEqual(
119+
expect.objectContaining({
120+
matchingTypes: ['trackDelete', 'trackInsert'],
121+
matchingDeletedTexts: [deletedText],
122+
matchingInsertedTexts: [insertedText],
123+
}),
124+
);
125+
}
126+
127+
async function expectActiveStoryReplacementMode(page: Page) {
128+
await expect
129+
.poll(() =>
130+
page.evaluate(() => (window as any).editor?.presentationEditor?.getActiveEditor?.()?.options?.trackedChanges),
131+
)
132+
.toEqual(
133+
expect.objectContaining({
134+
replacements: 'independent',
135+
}),
136+
);
137+
}
138+
139+
test('header replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => {
140+
await superdoc.loadDocument(HEADER_DOC_PATH);
141+
await superdoc.waitForStable();
142+
await superdoc.setDocumentMode('suggesting');
143+
await superdoc.waitForStable();
144+
145+
await activateHeader(superdoc.page);
146+
await superdoc.waitForStable();
147+
await expectActiveStoryReplacementMode(superdoc.page);
148+
149+
const result = await replaceFirstTwoLettersInActiveStory(superdoc.page, 'x');
150+
expect(result.success).toBe(true);
151+
expect(result.activeDocumentId).not.toBe(
152+
(await superdoc.page.evaluate(() => (window as any).editor?.options?.documentId)) ?? null,
153+
);
154+
155+
await superdoc.waitForStable();
156+
await expectIndependentStoryBubbles(superdoc.page, result.deletedText, result.replacement);
157+
});
158+
159+
test('footer replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => {
160+
await superdoc.loadDocument(HEADER_DOC_PATH);
161+
await superdoc.waitForStable();
162+
await superdoc.setDocumentMode('suggesting');
163+
await superdoc.waitForStable();
164+
165+
await activateFooter(superdoc.page);
166+
await superdoc.waitForStable();
167+
await expectActiveStoryReplacementMode(superdoc.page);
168+
169+
const result = await replaceFirstTwoLettersInActiveStory(superdoc.page, 'x');
170+
expect(result.success).toBe(true);
171+
172+
await superdoc.waitForStable();
173+
await expectIndependentStoryBubbles(superdoc.page, result.deletedText, result.replacement);
174+
});
175+
176+
test('footnote replacement sidebar stays independent in suggesting mode', async ({ superdoc }) => {
177+
await superdoc.loadDocument(FOOTNOTE_DOC_PATH);
178+
await superdoc.waitForStable();
179+
await superdoc.setDocumentMode('suggesting');
180+
await superdoc.waitForStable();
181+
182+
await activateFootnote(superdoc.page);
183+
await superdoc.waitForStable();
184+
await expectActiveStoryReplacementMode(superdoc.page);
185+
186+
const result = await replaceFirstTwoLettersInActiveStory(superdoc.page, 'x');
187+
expect(result.success).toBe(true);
188+
189+
await superdoc.waitForStable();
190+
await expectIndependentStoryBubbles(superdoc.page, result.deletedText, result.replacement);
191+
});

0 commit comments

Comments
 (0)