Skip to content

Commit e941e60

Browse files
fix: ensure document-wide bookmark uniqueness and align anchor navigation tests
1 parent 3d91865 commit e941e60

6 files changed

Lines changed: 346 additions & 16 deletions

File tree

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ vi.mock('../../Editor', () => {
176176
focus: vi.fn(),
177177
dispatch: vi.fn(),
178178
},
179+
commands: {
180+
setTextSelection: vi.fn(),
181+
setCursorById: vi.fn(),
182+
},
179183
options: {
180184
documentId: 'test-doc',
181185
element: document.createElement('div'),
@@ -641,7 +645,6 @@ describe('PresentationEditor - goToAnchor', () => {
641645

642646
const bodyEditor = editor.getActiveEditor();
643647
bodyEditor.commands.setCursorById = vi.fn(() => true);
644-
editor.getActiveEditor = vi.fn(() => mockActiveEditor as never);
645648

646649
const result = await editor.navigateTo({
647650
kind: 'entity',
@@ -654,7 +657,6 @@ describe('PresentationEditor - goToAnchor', () => {
654657
preferredActiveThreadId: 'comment-1',
655658
activeCommentId: 'comment-1',
656659
});
657-
expect(mockActiveEditor.commands.setCursorById).toBeUndefined();
658660
});
659661

660662
it('routes tracked change navigation through the raw tracked-change id when given a canonical id', async () => {
@@ -665,7 +667,6 @@ describe('PresentationEditor - goToAnchor', () => {
665667

666668
const bodyEditor = editor.getActiveEditor();
667669
bodyEditor.commands.setCursorById = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
668-
editor.getActiveEditor = vi.fn(() => mockActiveEditor as never);
669670
mockResolveTrackedChange.mockReturnValueOnce({
670671
id: 'canonical-tc-id',
671672
rawId: 'raw-tc-id',
@@ -691,7 +692,6 @@ describe('PresentationEditor - goToAnchor', () => {
691692
expect(bodyEditor.commands.setCursorById).toHaveBeenNthCalledWith(2, 'raw-tc-id', {
692693
preferredActiveThreadId: 'raw-tc-id',
693694
});
694-
expect(mockActiveEditor.commands.setCursorById).toBeUndefined();
695695
});
696696

697697
it('falls back to scroll + setTextSelection when both setCursorById attempts fail for tracked changes', async () => {
@@ -702,7 +702,6 @@ describe('PresentationEditor - goToAnchor', () => {
702702

703703
const bodyEditor = editor.getActiveEditor();
704704
bodyEditor.commands.setCursorById = vi.fn().mockReturnValue(false);
705-
editor.getActiveEditor = vi.fn(() => mockActiveEditor as never);
706705
editor.scrollToPositionAsync = vi.fn().mockResolvedValue(undefined);
707706

708707
mockResolveTrackedChange.mockReturnValueOnce({

packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,15 @@ vi.mock('prosemirror-model', async (importOriginal) => {
328328
const refResolverMocks = vi.hoisted(() => ({
329329
// Bookmark
330330
findAllBookmarks: vi.fn(() => []),
331+
findAllBookmarksInDocument: vi.fn(() => []),
331332
resolveBookmarkTarget: vi.fn(),
332333
extractBookmarkInfo: vi.fn(),
333334
buildBookmarkDiscoveryItem: vi.fn(),
335+
buildBookmarkAddress: vi.fn((name: string, story?: { storyType?: string }) => {
336+
const normalizedStory = story && story.storyType !== 'body' ? story : undefined;
337+
const base = { kind: 'entity', entityType: 'bookmark', name };
338+
return normalizedStory ? { ...base, story: normalizedStory } : base;
339+
}),
334340
// Link
335341
findAllLinks: vi.fn(() => []),
336342
resolveLinkTarget: vi.fn(),
@@ -392,9 +398,11 @@ const refResolverMocks = vi.hoisted(() => ({
392398

393399
vi.mock('../helpers/bookmark-resolver.js', () => ({
394400
findAllBookmarks: refResolverMocks.findAllBookmarks,
401+
findAllBookmarksInDocument: refResolverMocks.findAllBookmarksInDocument,
395402
resolveBookmarkTarget: refResolverMocks.resolveBookmarkTarget,
396403
extractBookmarkInfo: refResolverMocks.extractBookmarkInfo,
397404
buildBookmarkDiscoveryItem: refResolverMocks.buildBookmarkDiscoveryItem,
405+
buildBookmarkAddress: refResolverMocks.buildBookmarkAddress,
398406
}));
399407

400408
vi.mock('../helpers/footnote-resolver.js', () => ({
@@ -10929,6 +10937,12 @@ const resetMocks = () => {
1092910937
}
1093010938
// Restore list-returning defaults
1093110939
refResolverMocks.findAllBookmarks.mockImplementation(() => []);
10940+
refResolverMocks.findAllBookmarksInDocument.mockImplementation(() => []);
10941+
refResolverMocks.buildBookmarkAddress.mockImplementation((name: string, story?: { storyType?: string }) => {
10942+
const normalizedStory = story && story.storyType !== 'body' ? story : undefined;
10943+
const base = { kind: 'entity', entityType: 'bookmark', name };
10944+
return normalizedStory ? { ...base, story: normalizedStory } : base;
10945+
});
1093210946
refResolverMocks.findAllLinks.mockImplementation(() => []);
1093310947
refResolverMocks.findAllFootnotes.mockImplementation(() => []);
1093410948
refResolverMocks.findAllCrossRefs.mockImplementation(() => []);
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Editor } from '../../core/Editor.js';
3+
import { findAllBookmarksInDocument } from './bookmark-resolver.js';
4+
5+
type BookmarkSeed = {
6+
name: string;
7+
id: string;
8+
};
9+
10+
function makeDoc(bookmarks: BookmarkSeed[]) {
11+
return {
12+
descendants: (
13+
cb: (node: { type: { name: string }; attrs: Record<string, unknown> }, pos: number) => boolean | void,
14+
) => {
15+
for (const [index, bookmark] of bookmarks.entries()) {
16+
cb(
17+
{
18+
type: { name: 'bookmarkStart' },
19+
attrs: { name: bookmark.name, id: bookmark.id },
20+
},
21+
index + 1,
22+
);
23+
}
24+
return true;
25+
},
26+
};
27+
}
28+
29+
function makeEditor(bookmarks: BookmarkSeed[], converter: Record<string, unknown> = {}): Editor {
30+
return {
31+
state: {
32+
doc: makeDoc(bookmarks),
33+
},
34+
converter,
35+
} as unknown as Editor;
36+
}
37+
38+
describe('findAllBookmarksInDocument', () => {
39+
it('collects bookmarks from the body, concrete header/footer parts, and notes', () => {
40+
const editor = makeEditor([{ name: 'body-bm', id: '1' }], {
41+
headers: {
42+
rIdHeader: {
43+
type: 'doc',
44+
content: [{ type: 'bookmarkStart', attrs: { name: 'header-bm', id: '2' } }],
45+
},
46+
},
47+
footers: {
48+
rIdFooter: {
49+
type: 'doc',
50+
content: [{ type: 'bookmarkStart', attrs: { name: 'footer-bm', id: '3' } }],
51+
},
52+
},
53+
footnotes: [{ id: 'fn-1', content: [{ type: 'bookmarkStart', attrs: { name: 'footnote-bm', id: '4' } }] }],
54+
endnotes: [{ id: 'en-1', content: [{ type: 'bookmarkStart', attrs: { name: 'endnote-bm', id: '5' } }] }],
55+
});
56+
57+
expect(findAllBookmarksInDocument(editor)).toEqual(
58+
expect.arrayContaining([
59+
{ name: 'body-bm', bookmarkId: '1', storyKey: 'body' },
60+
{ name: 'header-bm', bookmarkId: '2', storyKey: 'hf:part:rIdHeader' },
61+
{ name: 'footer-bm', bookmarkId: '3', storyKey: 'hf:part:rIdFooter' },
62+
{ name: 'footnote-bm', bookmarkId: '4', storyKey: 'fn:fn-1' },
63+
{ name: 'endnote-bm', bookmarkId: '5', storyKey: 'en:en-1' },
64+
]),
65+
);
66+
});
67+
68+
it('prefers a live header/footer editor over cached PM JSON for the same part', () => {
69+
const liveHeaderEditor = makeEditor([{ name: 'live-header-bm', id: '10' }]);
70+
const editor = makeEditor([], {
71+
headerEditors: [{ id: 'rIdHeader', editor: liveHeaderEditor }],
72+
headers: {
73+
rIdHeader: {
74+
type: 'doc',
75+
content: [{ type: 'bookmarkStart', attrs: { name: 'stale-header-bm', id: '11' } }],
76+
},
77+
},
78+
});
79+
80+
const bookmarks = findAllBookmarksInDocument(editor).filter(
81+
(bookmark) => bookmark.storyKey === 'hf:part:rIdHeader',
82+
);
83+
84+
expect(bookmarks).toEqual([{ name: 'live-header-bm', bookmarkId: '10', storyKey: 'hf:part:rIdHeader' }]);
85+
});
86+
87+
it('does not double-count the same concrete header part referenced by multiple slots', () => {
88+
const editor = makeEditor([], {
89+
headers: {
90+
rIdShared: {
91+
type: 'doc',
92+
content: [{ type: 'bookmarkStart', attrs: { name: 'shared-header-bm', id: '20' } }],
93+
},
94+
},
95+
});
96+
97+
const bookmarks = findAllBookmarksInDocument(editor).filter(
98+
(bookmark) => bookmark.storyKey === 'hf:part:rIdShared',
99+
);
100+
101+
expect(bookmarks).toHaveLength(1);
102+
expect(bookmarks[0]).toEqual({ name: 'shared-header-bm', bookmarkId: '20', storyKey: 'hf:part:rIdShared' });
103+
});
104+
});

packages/super-editor/src/editors/v1/document-api-adapters/helpers/bookmark-resolver.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Bookmark node resolver — finds, resolves, and extracts info from bookmarkStart nodes.
33
*/
44

5+
import type { Editor } from '../../core/Editor.js';
56
import type { Node as ProseMirrorNode } from 'prosemirror-model';
67
import type {
78
BookmarkAddress,
@@ -13,6 +14,7 @@ import type {
1314
} from '@superdoc/document-api';
1415
import { buildDiscoveryItem, buildResolvedHandle } from '@superdoc/document-api';
1516
import { DocumentApiAdapterError } from '../errors.js';
17+
import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js';
1618

1719
// ---------------------------------------------------------------------------
1820
// Types
@@ -26,6 +28,33 @@ export interface ResolvedBookmark {
2628
endPos: number | null;
2729
}
2830

31+
export interface DocumentBookmarkEntry {
32+
name: string;
33+
bookmarkId: string;
34+
storyKey: string;
35+
}
36+
37+
type StoryEditorEntry = {
38+
id?: unknown;
39+
editor?: Editor;
40+
};
41+
42+
type NoteEntry = {
43+
id?: unknown;
44+
content?: unknown[];
45+
doc?: Record<string, unknown>;
46+
type?: unknown;
47+
};
48+
49+
type ConverterWithStories = {
50+
headers?: Record<string, unknown>;
51+
footers?: Record<string, unknown>;
52+
headerEditors?: StoryEditorEntry[];
53+
footerEditors?: StoryEditorEntry[];
54+
footnotes?: NoteEntry[];
55+
endnotes?: NoteEntry[];
56+
};
57+
2958
export function normalizeStory(locator?: StoryLocator): StoryLocator | undefined {
3059
if (!locator || locator.storyType === 'body') return undefined;
3160
return locator;
@@ -38,6 +67,24 @@ export function buildBookmarkAddress(name: string, story?: StoryLocator): Bookma
3867
: { kind: 'entity', entityType: 'bookmark', name };
3968
}
4069

70+
export function findAllBookmarksInDocument(editor: Editor): DocumentBookmarkEntry[] {
71+
const results: DocumentBookmarkEntry[] = [];
72+
const seenStoryKeys = new Set<string>();
73+
const converter = (editor as unknown as { converter?: ConverterWithStories }).converter;
74+
75+
seenStoryKeys.add(BODY_STORY_KEY);
76+
collectBookmarksFromDoc(editor.state.doc, BODY_STORY_KEY, results);
77+
78+
collectBookmarksFromHeaderFooterEditors(converter?.headerEditors, results, seenStoryKeys);
79+
collectBookmarksFromHeaderFooterEditors(converter?.footerEditors, results, seenStoryKeys);
80+
collectBookmarksFromHeaderFooterCache(converter?.headers, results, seenStoryKeys);
81+
collectBookmarksFromHeaderFooterCache(converter?.footers, results, seenStoryKeys);
82+
collectBookmarksFromNotes(converter?.footnotes, 'footnote', results, seenStoryKeys);
83+
collectBookmarksFromNotes(converter?.endnotes, 'endnote', results, seenStoryKeys);
84+
85+
return results;
86+
}
87+
4188
// ---------------------------------------------------------------------------
4289
// Node resolution
4390
// ---------------------------------------------------------------------------
@@ -77,6 +124,122 @@ function collectBookmarkEndPositions(doc: ProseMirrorNode): Map<string, number>
77124
return map;
78125
}
79126

127+
function collectBookmarksFromDoc(doc: ProseMirrorNode, storyKey: string, results: DocumentBookmarkEntry[]): void {
128+
doc.descendants((node) => {
129+
if (node.type.name === 'bookmarkStart') {
130+
results.push({
131+
name: (node.attrs?.name as string) ?? '',
132+
bookmarkId: (node.attrs?.id as string) ?? '',
133+
storyKey,
134+
});
135+
}
136+
return true;
137+
});
138+
}
139+
140+
function collectBookmarksFromHeaderFooterEditors(
141+
editors: StoryEditorEntry[] | undefined,
142+
results: DocumentBookmarkEntry[],
143+
seenStoryKeys: Set<string>,
144+
): void {
145+
if (!Array.isArray(editors)) return;
146+
147+
for (const entry of editors) {
148+
const refId = typeof entry?.id === 'string' && entry.id.length > 0 ? entry.id : null;
149+
const storyEditor = entry?.editor;
150+
if (!refId || !storyEditor?.state?.doc) continue;
151+
152+
const storyKey = buildStoryKey({ kind: 'story', storyType: 'headerFooterPart', refId });
153+
if (seenStoryKeys.has(storyKey)) continue;
154+
seenStoryKeys.add(storyKey);
155+
collectBookmarksFromDoc(storyEditor.state.doc, storyKey, results);
156+
}
157+
}
158+
159+
function collectBookmarksFromHeaderFooterCache(
160+
collection: Record<string, unknown> | undefined,
161+
results: DocumentBookmarkEntry[],
162+
seenStoryKeys: Set<string>,
163+
): void {
164+
if (!collection || typeof collection !== 'object') return;
165+
166+
for (const [refId, pmJson] of Object.entries(collection)) {
167+
if (typeof refId !== 'string' || refId.length === 0) continue;
168+
169+
const storyKey = buildStoryKey({ kind: 'story', storyType: 'headerFooterPart', refId });
170+
if (seenStoryKeys.has(storyKey)) continue;
171+
seenStoryKeys.add(storyKey);
172+
collectBookmarksFromPmJson(pmJson, storyKey, results);
173+
}
174+
}
175+
176+
function collectBookmarksFromNotes(
177+
notes: NoteEntry[] | undefined,
178+
storyType: 'footnote' | 'endnote',
179+
results: DocumentBookmarkEntry[],
180+
seenStoryKeys: Set<string>,
181+
): void {
182+
if (!Array.isArray(notes)) return;
183+
184+
for (const note of notes) {
185+
const noteId = note?.id != null ? String(note.id) : '';
186+
if (!noteId) continue;
187+
188+
const storyKey = buildStoryKey({ kind: 'story', storyType, noteId });
189+
if (seenStoryKeys.has(storyKey)) continue;
190+
seenStoryKeys.add(storyKey);
191+
192+
const pmJson = getNotePmJson(note);
193+
if (!pmJson) continue;
194+
collectBookmarksFromPmJson(pmJson, storyKey, results);
195+
}
196+
}
197+
198+
function getNotePmJson(note: NoteEntry): Record<string, unknown> | null {
199+
if (Array.isArray(note.content)) {
200+
return {
201+
type: 'doc',
202+
content: note.content.length > 0 ? note.content : [{ type: 'paragraph' }],
203+
};
204+
}
205+
206+
if (note.doc && typeof note.doc === 'object') {
207+
return note.doc;
208+
}
209+
210+
return null;
211+
}
212+
213+
function collectBookmarksFromPmJson(pmJson: unknown, storyKey: string, results: DocumentBookmarkEntry[]): void {
214+
if (!isObjectRecord(pmJson)) return;
215+
216+
visitPmJson(pmJson, (node) => {
217+
if (node.type !== 'bookmarkStart') return;
218+
219+
const attrs = isObjectRecord(node.attrs) ? node.attrs : undefined;
220+
const name = typeof attrs?.name === 'string' ? attrs.name : '';
221+
const bookmarkId = attrs?.id != null ? String(attrs.id) : '';
222+
results.push({ name, bookmarkId, storyKey });
223+
});
224+
}
225+
226+
function visitPmJson(node: Record<string, unknown>, visitor: (node: Record<string, unknown>) => void): void {
227+
visitor(node);
228+
229+
const content = node.content;
230+
if (!Array.isArray(content)) return;
231+
232+
for (const child of content) {
233+
if (isObjectRecord(child)) {
234+
visitPmJson(child, visitor);
235+
}
236+
}
237+
}
238+
239+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
240+
return typeof value === 'object' && value !== null;
241+
}
242+
80243
/**
81244
* Resolves a BookmarkAddress to its ProseMirror node and position.
82245
* @throws DocumentApiAdapterError with code TARGET_NOT_FOUND if not found.

0 commit comments

Comments
 (0)