diff --git a/packages/document-api/src/bookmarks/bookmarks.ts b/packages/document-api/src/bookmarks/bookmarks.ts index c020bca39b..178f35a12a 100644 --- a/packages/document-api/src/bookmarks/bookmarks.ts +++ b/packages/document-api/src/bookmarks/bookmarks.ts @@ -2,6 +2,7 @@ import type { MutationOptions } from '../write/write.js'; import { normalizeMutationOptions } from '../write/write.js'; import { DocumentApiValidationError } from '../errors.js'; import { assertTargetPresent } from '../validation-primitives.js'; +import { validateStoryLocator } from '../validation/story-validator.js'; import type { BookmarkAddress, BookmarkGetInput, @@ -43,6 +44,9 @@ function validateBookmarkTarget(target: unknown, operationName: string): asserts { target }, ); } + if (t.story !== undefined) { + validateStoryLocator(t.story, `${operationName}.target.story`); + } } // --------------------------------------------------------------------------- @@ -50,6 +54,9 @@ function validateBookmarkTarget(target: unknown, operationName: string): asserts // --------------------------------------------------------------------------- export function executeBookmarksList(adapter: BookmarksAdapter, query?: BookmarkListInput): BookmarksListResult { + if (query?.in !== undefined) { + validateStoryLocator(query.in, 'bookmarks.list.in'); + } return adapter.list(query); } diff --git a/packages/document-api/src/bookmarks/bookmarks.types.ts b/packages/document-api/src/bookmarks/bookmarks.types.ts index a1dfea95c2..231abad8ce 100644 --- a/packages/document-api/src/bookmarks/bookmarks.types.ts +++ b/packages/document-api/src/bookmarks/bookmarks.types.ts @@ -1,5 +1,6 @@ import type { Position } from '../types/base.js'; import type { TextTarget } from '../types/address.js'; +import type { StoryLocator } from '../types/story.types.js'; import type { AdapterMutationFailure } from '../types/adapter-result.js'; import type { DiscoveryOutput } from '../types/discovery.js'; @@ -11,6 +12,14 @@ export interface BookmarkAddress { kind: 'entity'; entityType: 'bookmark'; name: string; + /** + * Story containing this bookmark. Omit for body (backward compatible). + * + * **Limitation:** `navigateTo()` currently supports bookmark navigation only + * in the body and header/footer stories. Footnote/endnote bookmark + * navigation currently returns `false`. + */ + story?: StoryLocator; } // --------------------------------------------------------------------------- @@ -20,6 +29,8 @@ export interface BookmarkAddress { export interface BookmarkListInput { limit?: number; offset?: number; + /** Restrict listing to a specific story. Omit for body (backward compatible). */ + in?: StoryLocator; } export interface BookmarkGetInput { diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 94812164fc..22fcae393c 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -382,6 +382,7 @@ const SHARED_DEFS: Record = { kind: { const: 'entity' }, entityType: { const: 'comment' }, entityId: { type: 'string' }, + story: ref('StoryLocator'), }, ['kind', 'entityType', 'entityId'], ), @@ -390,6 +391,7 @@ const SHARED_DEFS: Record = { kind: { const: 'entity' }, entityType: { const: 'trackedChange' }, entityId: { type: 'string' }, + story: ref('StoryLocator'), }, ['kind', 'entityType', 'entityId'], ), @@ -2494,10 +2496,12 @@ function buildContentControlSchemas(): Record; + +const refListQuerySchema = objectSchema(refListQueryProperties); const discoveryOutputSchema: JsonSchema = { type: 'object' }; @@ -2541,7 +2545,12 @@ function refConfigSchemas(): { output: JsonSchema; success: JsonSchema; failure: // --- Bookmark schemas --- const bookmarkAddressSchema: JsonSchema = objectSchema( - { kind: { const: 'entity' }, entityType: { const: 'bookmark' }, name: { type: 'string' } }, + { + kind: { const: 'entity' }, + entityType: { const: 'bookmark' }, + name: { type: 'string' }, + story: ref('StoryLocator'), + }, ['kind', 'entityType', 'name'], ); @@ -6698,7 +6707,10 @@ const operationSchemas: Record = { // Bookmarks // ------------------------------------------------------------------------- 'bookmarks.list': { - input: refListQuerySchema, + input: objectSchema({ + ...refListQueryProperties, + in: storyLocatorSchema, + }), output: discoveryOutputSchema, }, 'bookmarks.get': { diff --git a/packages/document-api/src/types/address.ts b/packages/document-api/src/types/address.ts index 8cae52dd5f..3b817b0b10 100644 --- a/packages/document-api/src/types/address.ts +++ b/packages/document-api/src/types/address.ts @@ -1,5 +1,6 @@ import type { BlockNodeType } from './base.js'; import type { StoryLocator } from './story.types.js'; +import type { BookmarkAddress } from '../bookmarks/bookmarks.types.js'; export type Range = { /** Inclusive start offset (0-based, UTF-16 code units). */ @@ -119,12 +120,35 @@ export type CommentAddress = { kind: 'entity'; entityType: 'comment'; entityId: string; + /** + * Story containing this comment. Omit for body (backward compatible). + * + * **Limitation:** Comments are only supported in the document body. + * Passing a non-body story locator will return `false` from `navigateTo`. + */ + story?: StoryLocator; }; export type TrackedChangeAddress = { kind: 'entity'; entityType: 'trackedChange'; entityId: string; + /** + * Story containing this tracked change. Omit for body (backward compatible). + * + * **Limitation:** Tracked changes are only supported in the document body. + * Passing a non-body story locator will return `false` from `navigateTo`. + */ + story?: StoryLocator; }; export type EntityAddress = CommentAddress | TrackedChangeAddress; + +/** + * Address for in-document navigation targets exposed by viewer/editor APIs. + * + * This is additive to the document-api mutation/read contracts and is used by + * higher-level navigation entrypoints that can target bookmarks, comments, or + * tracked changes through a single method. + */ +export type NavigableEntityAddress = BookmarkAddress | CommentAddress | TrackedChangeAddress; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 9a4a7ea4be..d239da12e7 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -115,6 +115,16 @@ import { ySyncPluginKey } from 'y-prosemirror'; import type * as Y from 'yjs'; import type { HeaderFooterDescriptor } from '../header-footer/HeaderFooterRegistry.js'; import { isHeaderFooterPartId } from '../parts/adapters/header-footer-part-descriptor.js'; +import type { + BookmarkAddress, + NavigableEntityAddress, + StoryLocator, + ResolveRangeOutput, + DocumentApi, +} from '@superdoc/document-api'; +import { resolveStoryRuntime } from '../../document-api-adapters/story-runtime/resolve-story-runtime.js'; +import { resolveBookmarkTarget } from '../../document-api-adapters/helpers/bookmark-resolver.js'; +import { resolveTrackedChange } from '../../document-api-adapters/helpers/tracked-change-resolver.js'; import type { PartChangedEvent } from '../parts/types.js'; import { isInRegisteredSurface } from './utils/uiSurfaceRegistry.js'; import { buildSemanticFootnoteBlocks } from './semantic-flow-footnotes.js'; @@ -130,7 +140,6 @@ import { ensureEditorFieldAnnotationInteractionStyles, } from './dom/EditorStyleInjector.js'; -import type { ResolveRangeOutput, DocumentApi } from '@superdoc/document-api'; import type { SelectionHandle } from '../selection-state.js'; const DOCUMENT_RELS_PART_ID = 'word/_rels/document.xml.rels'; @@ -5878,6 +5887,21 @@ export class PresentationEditor extends EventEmitter { blocks: this.#layoutState.blocks, measures: this.#layoutState.measures, bookmarks: this.#layoutState.bookmarks, + resolveAnchorPosition: (name) => { + const activeEditor = this.#editor; + if (!activeEditor?.state?.doc) return null; + + try { + const resolved = resolveBookmarkTarget(activeEditor.state.doc, { + kind: 'entity', + entityType: 'bookmark', + name, + }); + return resolved?.pos ?? null; + } catch { + return null; + } + }, pageGeometryHelper: this.#pageGeometryHelper ?? undefined, painterHost: this.#painterHost, scrollContainer: this.#scrollContainer ?? this.#visibleHost, @@ -5897,6 +5921,190 @@ export class PresentationEditor extends EventEmitter { } } + async navigateTo(target: NavigableEntityAddress): Promise { + if (!target || target.kind !== 'entity') return false; + + try { + if (target.entityType === 'bookmark') { + return await this.#navigateToBookmarkTarget(target); + } + + // Comments and tracked changes are only supported in the document body. + if ('story' in target && target.story && target.story.storyType !== 'body') { + console.warn( + `[PresentationEditor] navigateTo does not support non-body stories for ${target.entityType}. ` + + `Only bookmarks support cross-story navigation.`, + ); + return false; + } + + if (target.entityType === 'trackedChange') { + return await this.#navigateToTrackedChange(target.entityId); + } + + if (target.entityType === 'comment') { + const bodyEditor = this.#resolveBodyEditorForNavigation(); + const setCursorById = bodyEditor?.commands?.setCursorById; + if (typeof setCursorById !== 'function') return false; + + return Boolean( + setCursorById(target.entityId, { + preferredActiveThreadId: target.entityId, + activeCommentId: target.entityId, + }), + ); + } + + const _exhaustive: never = target; + return false; + } catch (error) { + console.error('[PresentationEditor] navigateTo failed:', error); + this.emit('error', { error, context: 'navigateTo' }); + return false; + } + } + + #resolveBodyEditorForNavigation(): Editor | null { + if (!this.#editor) return null; + + const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; + if (sessionMode !== 'body') { + this.#exitHeaderFooterMode(); + } + + return this.#editor; + } + + async #navigateToTrackedChange(id: string): Promise { + const bodyEditor = this.#resolveBodyEditorForNavigation(); + const setCursorById = bodyEditor?.commands?.setCursorById; + if (!bodyEditor || typeof setCursorById !== 'function') return false; + + if (setCursorById(id, { preferredActiveThreadId: id })) { + return true; + } + + const resolved = resolveTrackedChange(bodyEditor, id); + if (!resolved) return false; + + if (setCursorById(resolved.rawId, { preferredActiveThreadId: resolved.rawId })) { + return true; + } + + await this.scrollToPositionAsync(resolved.from, { + behavior: 'auto', + block: 'center', + }); + bodyEditor.commands?.setTextSelection?.({ from: resolved.from, to: resolved.from }); + bodyEditor.view?.focus?.(); + + return true; + } + + async #navigateToBookmarkTarget(target: BookmarkAddress): Promise { + const story = target.story; + if (!story || story.storyType === 'body') { + return this.goToAnchor(target.name); + } + + // Note stories are rendered into layout, but they do not currently expose + // a visible interactive editor surface that can receive selection/focus. + // Returning false is more accurate than reporting success after moving the + // selection in a detached headless note runtime. + if (story.storyType === 'footnote' || story.storyType === 'endnote') { + console.warn( + `[PresentationEditor] navigateTo does not yet support bookmark navigation in ${story.storyType} stories.`, + ); + return false; + } + + if (!this.#editor) return false; + + try { + if (story.storyType === 'headerFooterSlot' || story.storyType === 'headerFooterPart') { + const region = this.#findHeaderFooterRegionForStory(story); + if (!region) return false; + + this.#scrollPageIntoView(region.pageIndex); + await this.#waitForPageMount(region.pageIndex, { timeout: PresentationEditor.ANCHOR_NAV_TIMEOUT_MS }); + this.#activateHeaderFooterRegion(region); + + try { + const activeEditor = await this.#waitForHeaderFooterEditor(PresentationEditor.ANCHOR_NAV_TIMEOUT_MS); + if (!activeEditor?.commands?.setTextSelection) return false; + + // Resolve position in the live editor — the story runtime editor used + // before activation may be a different instance with different state. + const resolved = resolveBookmarkTarget(activeEditor.state.doc, target); + activeEditor.commands.setTextSelection({ from: resolved.pos, to: resolved.pos }); + activeEditor.view?.focus?.(); + return true; + } finally { + // If navigation did not succeed, exit header/footer mode so the + // editor is not left in a stale activation state. + if (!this.#headerFooterSession?.activeEditor) { + this.#exitHeaderFooterMode(); + } + } + } + + const runtime = resolveStoryRuntime(this.#editor, story); + const resolved = resolveBookmarkTarget(runtime.editor.state.doc, target); + if (typeof runtime.editor.commands?.setTextSelection !== 'function') return false; + runtime.editor.commands.setTextSelection({ from: resolved.pos, to: resolved.pos }); + runtime.editor.view?.focus?.(); + return true; + } catch (error) { + this.#exitHeaderFooterMode(); + console.error('[PresentationEditor] navigateTo bookmark failed:', error); + this.emit('error', { + error, + context: 'navigateTo', + }); + return false; + } + } + + #findHeaderFooterRegionForStory(story: StoryLocator) { + if (!this.#headerFooterSession) return null; + + if (story.storyType === 'headerFooterPart') { + const allRegions = [ + ...Array.from(this.#headerFooterSession.headerRegions.values()), + ...Array.from(this.#headerFooterSession.footerRegions.values()), + ]; + return allRegions.find((region) => region.headerFooterRefId === story.refId) ?? null; + } + + if (story.storyType !== 'headerFooterSlot') { + return null; + } + + const regions = + story.headerFooterKind === 'header' + ? this.#headerFooterSession.headerRegions.values() + : this.#headerFooterSession.footerRegions.values(); + + return ( + Array.from(regions).find( + (region) => region.sectionId === story.section.sectionId && region.sectionType === story.variant, + ) ?? null + ); + } + + async #waitForHeaderFooterEditor(timeoutMs: number): Promise { + const startTime = performance.now(); + + while (performance.now() - startTime < timeoutMs) { + const activeEditor = this.#headerFooterSession?.activeEditor ?? null; + if (activeEditor) return activeEditor; + + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + + return null; + } + /** * Waits for a page to be mounted in the DOM after scrolling. * diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts index cf12ec716b..79dbe654b3 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts @@ -22,6 +22,8 @@ const { mockOnHeaderFooterDataUpdate, mockUpdateYdocDocxData, mockEditorOverlayManager, + mockResolveBookmarkTarget, + mockResolveTrackedChange, } = vi.hoisted(() => { const createDefaultConverter = () => ({ headers: { @@ -134,6 +136,8 @@ const { getActiveEditorHost: vi.fn(() => null), destroy: vi.fn(), })), + mockResolveBookmarkTarget: vi.fn(), + mockResolveTrackedChange: vi.fn(), }; }); @@ -172,6 +176,10 @@ vi.mock('../../Editor', () => { focus: vi.fn(), dispatch: vi.fn(), }, + commands: { + setTextSelection: vi.fn(), + setCursorById: vi.fn(), + }, options: { documentId: 'test-doc', element: document.createElement('div'), @@ -260,12 +268,27 @@ vi.mock('@superdoc/layout-resolved', () => ({ resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), })); +vi.mock('../../../document-api-adapters/helpers/bookmark-resolver.js', () => ({ + resolveBookmarkTarget: mockResolveBookmarkTarget, +})); + +vi.mock('../../../document-api-adapters/helpers/tracked-change-resolver.js', () => ({ + resolveTrackedChange: mockResolveTrackedChange, +})); + describe('PresentationEditor - goToAnchor', () => { let container: HTMLElement; let editor: PresentationEditor; let mockActiveEditor: { + state?: { + doc?: unknown; + }; + view?: { + focus?: Mock; + }; commands: { setTextSelection: Mock; + setCursorById?: Mock; }; }; @@ -285,6 +308,12 @@ describe('PresentationEditor - goToAnchor', () => { // Create mock active editor with setTextSelection command mockActiveEditor = { + state: { + doc: {}, + }, + view: { + focus: vi.fn(), + }, commands: { setTextSelection: vi.fn(), }, @@ -589,4 +618,139 @@ describe('PresentationEditor - goToAnchor', () => { const result = await editor.goToAnchor('#'); expect(result).toBe(false); }); + + it('should route bookmark navigation through navigateTo', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'test-doc', + }); + + const goToAnchorSpy = vi.spyOn(editor, 'goToAnchor').mockResolvedValue(true); + + const result = await editor.navigateTo({ + kind: 'entity', + entityType: 'bookmark', + name: 'bookmark1', + }); + + expect(result).toBe(true); + expect(goToAnchorSpy).toHaveBeenCalledWith('bookmark1'); + }); + + it('should route comment navigation through setCursorById', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'test-doc', + }); + + const bodyEditor = editor.getActiveEditor(); + bodyEditor.commands.setCursorById = vi.fn(() => true); + + const result = await editor.navigateTo({ + kind: 'entity', + entityType: 'comment', + entityId: 'comment-1', + }); + + expect(result).toBe(true); + expect(bodyEditor.commands.setCursorById).toHaveBeenCalledWith('comment-1', { + preferredActiveThreadId: 'comment-1', + activeCommentId: 'comment-1', + }); + }); + + it('routes tracked change navigation through the raw tracked-change id when given a canonical id', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'test-doc', + }); + + const bodyEditor = editor.getActiveEditor(); + bodyEditor.commands.setCursorById = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + mockResolveTrackedChange.mockReturnValueOnce({ + id: 'canonical-tc-id', + rawId: 'raw-tc-id', + from: 88, + to: 96, + hasInsert: true, + hasDelete: false, + hasFormat: false, + attrs: {}, + }); + + const result = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'canonical-tc-id', + }); + + expect(result).toBe(true); + expect(bodyEditor.commands.setCursorById).toHaveBeenNthCalledWith(1, 'canonical-tc-id', { + preferredActiveThreadId: 'canonical-tc-id', + }); + expect(mockResolveTrackedChange).toHaveBeenCalledWith(bodyEditor, 'canonical-tc-id'); + expect(bodyEditor.commands.setCursorById).toHaveBeenNthCalledWith(2, 'raw-tc-id', { + preferredActiveThreadId: 'raw-tc-id', + }); + }); + + it('falls back to scroll + setTextSelection when both setCursorById attempts fail for tracked changes', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'test-doc', + }); + + const bodyEditor = editor.getActiveEditor(); + bodyEditor.commands.setCursorById = vi.fn().mockReturnValue(false); + editor.scrollToPositionAsync = vi.fn().mockResolvedValue(undefined); + + mockResolveTrackedChange.mockReturnValueOnce({ + id: 'canonical-tc-id', + rawId: 'raw-tc-id', + from: 88, + to: 96, + hasInsert: true, + hasDelete: false, + hasFormat: false, + attrs: {}, + }); + + const result = await editor.navigateTo({ + kind: 'entity', + entityType: 'trackedChange', + entityId: 'canonical-tc-id', + }); + + expect(result).toBe(true); + expect(bodyEditor.commands.setCursorById).toHaveBeenCalledTimes(2); + expect(editor.scrollToPositionAsync).toHaveBeenCalledWith(88, { + behavior: 'auto', + block: 'center', + }); + expect(bodyEditor.commands.setTextSelection).toHaveBeenCalledWith({ from: 88, to: 88 }); + expect(bodyEditor.view?.focus).toHaveBeenCalled(); + expect(mockResolveTrackedChange).toHaveBeenCalledWith(bodyEditor, 'canonical-tc-id'); + }); + + it('returns false for bookmark navigation in note stories', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'test-doc', + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await editor.navigateTo({ + kind: 'entity', + entityType: 'bookmark', + name: 'bookmark-in-footnote', + story: { kind: 'story', storyType: 'footnote', noteId: 'fn-1' }, + }); + + expect(result).toBe(false); + expect(mockResolveBookmarkTarget).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '[PresentationEditor] navigateTo does not yet support bookmark navigation in footnote stories.', + ); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/AnchorNavigation.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/AnchorNavigation.test.ts index 16fdeca137..452515cf41 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/AnchorNavigation.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/AnchorNavigation.test.ts @@ -183,6 +183,19 @@ describe('goToAnchor', () => { }); }); + it('uses resolveAnchorPosition when the layout bookmark map misses', async () => { + const resolveAnchorPosition = vi.fn(() => 150); + const deps = makeDeps({ + bookmarks: new Map(), + resolveAnchorPosition, + }); + + const result = await goToAnchor(deps); + + expect(result).toBe(true); + expect(resolveAnchorPosition).toHaveBeenCalledWith('heading1'); + }); + it('should not use rect.y for fragmentY (coordinate space mismatch)', async () => { // Even when selectionToRects returns a rect, we should NOT use rect.y // because it's document-absolute, not page-relative like fragment.y diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/AnchorNavigation.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/AnchorNavigation.ts index 60c17ca24f..3492c3719b 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/utils/AnchorNavigation.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/utils/AnchorNavigation.ts @@ -83,6 +83,7 @@ export type GoToAnchorDeps = { blocks: FlowBlock[]; measures: Measure[]; bookmarks: Map; + resolveAnchorPosition?: (anchor: string) => number | null; pageGeometryHelper?: PageGeometryHelper; painterHost: HTMLElement; scrollContainer: Element | Window; @@ -99,6 +100,7 @@ export async function goToAnchor({ blocks, measures, bookmarks, + resolveAnchorPosition, pageGeometryHelper, painterHost, scrollContainer, @@ -114,7 +116,7 @@ export async function goToAnchor({ const normalized = anchor.startsWith('#') ? anchor.slice(1) : anchor; if (!normalized) return false; - const pmPos = bookmarks.get(normalized); + const pmPos = bookmarks.get(normalized) ?? resolveAnchorPosition?.(normalized) ?? null; if (pmPos == null) return false; // Try to get exact position rect for precise scrolling diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts index d4b3c91f06..d450019d5a 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -328,9 +328,15 @@ vi.mock('prosemirror-model', async (importOriginal) => { const refResolverMocks = vi.hoisted(() => ({ // Bookmark findAllBookmarks: vi.fn(() => []), + findAllBookmarksInDocument: vi.fn(() => []), resolveBookmarkTarget: vi.fn(), extractBookmarkInfo: vi.fn(), buildBookmarkDiscoveryItem: vi.fn(), + buildBookmarkAddress: vi.fn((name: string, story?: { storyType?: string }) => { + const normalizedStory = story && story.storyType !== 'body' ? story : undefined; + const base = { kind: 'entity', entityType: 'bookmark', name }; + return normalizedStory ? { ...base, story: normalizedStory } : base; + }), // Link findAllLinks: vi.fn(() => []), resolveLinkTarget: vi.fn(), @@ -392,9 +398,11 @@ const refResolverMocks = vi.hoisted(() => ({ vi.mock('../helpers/bookmark-resolver.js', () => ({ findAllBookmarks: refResolverMocks.findAllBookmarks, + findAllBookmarksInDocument: refResolverMocks.findAllBookmarksInDocument, resolveBookmarkTarget: refResolverMocks.resolveBookmarkTarget, extractBookmarkInfo: refResolverMocks.extractBookmarkInfo, buildBookmarkDiscoveryItem: refResolverMocks.buildBookmarkDiscoveryItem, + buildBookmarkAddress: refResolverMocks.buildBookmarkAddress, })); vi.mock('../helpers/footnote-resolver.js', () => ({ @@ -10929,6 +10937,12 @@ const resetMocks = () => { } // Restore list-returning defaults refResolverMocks.findAllBookmarks.mockImplementation(() => []); + refResolverMocks.findAllBookmarksInDocument.mockImplementation(() => []); + refResolverMocks.buildBookmarkAddress.mockImplementation((name: string, story?: { storyType?: string }) => { + const normalizedStory = story && story.storyType !== 'body' ? story : undefined; + const base = { kind: 'entity', entityType: 'bookmark', name }; + return normalizedStory ? { ...base, story: normalizedStory } : base; + }); refResolverMocks.findAllLinks.mockImplementation(() => []); refResolverMocks.findAllFootnotes.mockImplementation(() => []); refResolverMocks.findAllCrossRefs.mockImplementation(() => []); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/bookmark-resolver.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/bookmark-resolver.test.ts new file mode 100644 index 0000000000..80fcef87d6 --- /dev/null +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/bookmark-resolver.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import { + findAllBookmarksInDocument, + findAllBookmarks, + resolveBookmarkTarget, + extractBookmarkInfo, + buildBookmarkAddress, + normalizeStory, +} from './bookmark-resolver.js'; + +type BookmarkSeed = { + name: string; + id: string; +}; + +function makeDoc(bookmarks: BookmarkSeed[]) { + return { + descendants: ( + cb: (node: { type: { name: string }; attrs: Record }, pos: number) => boolean | void, + ) => { + for (const [index, bookmark] of bookmarks.entries()) { + cb( + { + type: { name: 'bookmarkStart' }, + attrs: { name: bookmark.name, id: bookmark.id }, + }, + index + 1, + ); + } + return true; + }, + }; +} + +function makeEditor(bookmarks: BookmarkSeed[], converter: Record = {}): Editor { + return { + state: { + doc: makeDoc(bookmarks), + }, + converter, + } as unknown as Editor; +} + +describe('findAllBookmarksInDocument', () => { + it('collects bookmarks from the body, concrete header/footer parts, and notes', () => { + const editor = makeEditor([{ name: 'body-bm', id: '1' }], { + headers: { + rIdHeader: { + type: 'doc', + content: [{ type: 'bookmarkStart', attrs: { name: 'header-bm', id: '2' } }], + }, + }, + footers: { + rIdFooter: { + type: 'doc', + content: [{ type: 'bookmarkStart', attrs: { name: 'footer-bm', id: '3' } }], + }, + }, + footnotes: [{ id: 'fn-1', content: [{ type: 'bookmarkStart', attrs: { name: 'footnote-bm', id: '4' } }] }], + endnotes: [{ id: 'en-1', content: [{ type: 'bookmarkStart', attrs: { name: 'endnote-bm', id: '5' } }] }], + }); + + expect(findAllBookmarksInDocument(editor)).toEqual( + expect.arrayContaining([ + { name: 'body-bm', bookmarkId: '1', storyKey: 'body' }, + { name: 'header-bm', bookmarkId: '2', storyKey: 'hf:part:rIdHeader' }, + { name: 'footer-bm', bookmarkId: '3', storyKey: 'hf:part:rIdFooter' }, + { name: 'footnote-bm', bookmarkId: '4', storyKey: 'fn:fn-1' }, + { name: 'endnote-bm', bookmarkId: '5', storyKey: 'en:en-1' }, + ]), + ); + }); + + it('prefers a live header/footer editor over cached PM JSON for the same part', () => { + const liveHeaderEditor = makeEditor([{ name: 'live-header-bm', id: '10' }]); + const editor = makeEditor([], { + headerEditors: [{ id: 'rIdHeader', editor: liveHeaderEditor }], + headers: { + rIdHeader: { + type: 'doc', + content: [{ type: 'bookmarkStart', attrs: { name: 'stale-header-bm', id: '11' } }], + }, + }, + }); + + const bookmarks = findAllBookmarksInDocument(editor).filter( + (bookmark) => bookmark.storyKey === 'hf:part:rIdHeader', + ); + + expect(bookmarks).toEqual([{ name: 'live-header-bm', bookmarkId: '10', storyKey: 'hf:part:rIdHeader' }]); + }); + + it('does not double-count the same concrete header part referenced by multiple slots', () => { + const editor = makeEditor([], { + headers: { + rIdShared: { + type: 'doc', + content: [{ type: 'bookmarkStart', attrs: { name: 'shared-header-bm', id: '20' } }], + }, + }, + }); + + const bookmarks = findAllBookmarksInDocument(editor).filter( + (bookmark) => bookmark.storyKey === 'hf:part:rIdShared', + ); + + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0]).toEqual({ name: 'shared-header-bm', bookmarkId: '20', storyKey: 'hf:part:rIdShared' }); + }); +}); + +function makePmDoc(nodes: Array<{ type: string; attrs?: Record; nodeSize?: number }>) { + return { + descendants: (cb: (node: any, pos: number) => boolean | void) => { + let pos = 1; + for (const node of nodes) { + const result = cb({ type: { name: node.type }, attrs: node.attrs ?? {}, isInline: true }, pos); + if (result === false) return; + pos += node.nodeSize ?? 1; + } + }, + resolve: (position: number) => ({ + depth: 1, + node: (depth: number) => (depth === 1 ? { attrs: { sdBlockId: 'block-1' } } : { attrs: {} }), + start: () => 0, + }), + textBetween: () => '', + } as any; +} + +describe('findAllBookmarks', () => { + it('finds all bookmarkStart nodes with their paired ends', () => { + const doc = makePmDoc([ + { type: 'bookmarkStart', attrs: { name: 'bm1', id: '0' } }, + { type: 'paragraph' }, + { type: 'bookmarkEnd', attrs: { id: '0' } }, + { type: 'bookmarkStart', attrs: { name: 'bm2', id: '1' } }, + { type: 'bookmarkEnd', attrs: { id: '1' } }, + ]); + + const results = findAllBookmarks(doc); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ name: 'bm1', bookmarkId: '0', pos: 1 }); + expect(results[0].endPos).toBe(3); + expect(results[1]).toMatchObject({ name: 'bm2', bookmarkId: '1', pos: 4 }); + expect(results[1].endPos).toBe(5); + }); + + it('returns null endPos for orphaned bookmarkStart', () => { + const doc = makePmDoc([{ type: 'bookmarkStart', attrs: { name: 'orphan', id: '99' } }]); + + const results = findAllBookmarks(doc); + + expect(results).toHaveLength(1); + expect(results[0].endPos).toBeNull(); + }); +}); + +describe('resolveBookmarkTarget', () => { + it('resolves an existing bookmark by name', () => { + const doc = makePmDoc([ + { type: 'bookmarkStart', attrs: { name: 'target', id: '5' } }, + { type: 'bookmarkEnd', attrs: { id: '5' } }, + ]); + + const result = resolveBookmarkTarget(doc, { + kind: 'entity', + entityType: 'bookmark', + name: 'target', + }); + + expect(result.name).toBe('target'); + expect(result.bookmarkId).toBe('5'); + expect(result.pos).toBe(1); + }); + + it('throws TARGET_NOT_FOUND for a non-existent bookmark', () => { + const doc = makePmDoc([{ type: 'bookmarkStart', attrs: { name: 'exists', id: '0' } }]); + + expect(() => + resolveBookmarkTarget(doc, { + kind: 'entity', + entityType: 'bookmark', + name: 'does-not-exist', + }), + ).toThrowError( + expect.objectContaining({ + code: 'TARGET_NOT_FOUND', + }), + ); + }); +}); + +describe('extractBookmarkInfo', () => { + it('returns bookmark info with range positions', () => { + const doc = makePmDoc([ + { type: 'bookmarkStart', attrs: { name: 'bm1', id: '0' } }, + { type: 'bookmarkEnd', attrs: { id: '0' } }, + ]); + + const resolved = { + node: { type: { name: 'bookmarkStart' }, attrs: { name: 'bm1', id: '0' } }, + pos: 1, + name: 'bm1', + bookmarkId: '0', + endPos: 2, + } as any; + + const info = extractBookmarkInfo(doc, resolved); + + expect(info.name).toBe('bm1'); + expect(info.bookmarkId).toBe('0'); + expect(info.address).toEqual({ kind: 'entity', entityType: 'bookmark', name: 'bm1' }); + expect(info.range.from).toBeDefined(); + expect(info.range.to).toBeDefined(); + expect(info.tableColumn).toBeUndefined(); + }); + + it('includes tableColumn when colFirst and colLast are set', () => { + const doc = makePmDoc([]); + const resolved = { + node: { type: { name: 'bookmarkStart' }, attrs: { name: 'tbl-bm', id: '1', colFirst: 0, colLast: 2 } }, + pos: 1, + name: 'tbl-bm', + bookmarkId: '1', + endPos: 5, + } as any; + + const info = extractBookmarkInfo(doc, resolved); + + expect(info.tableColumn).toEqual({ colFirst: 0, colLast: 2 }); + }); + + it('includes story in address for non-body stories', () => { + const doc = makePmDoc([]); + const resolved = { + node: { type: { name: 'bookmarkStart' }, attrs: { name: 'hdr-bm', id: '3' } }, + pos: 1, + name: 'hdr-bm', + bookmarkId: '3', + endPos: 2, + } as any; + const story = { kind: 'story' as const, storyType: 'headerFooterPart' as const, refId: 'rId7' }; + + const info = extractBookmarkInfo(doc, resolved, story); + + expect(info.address.story).toEqual(story); + }); +}); + +describe('normalizeStory', () => { + it('returns undefined for body story', () => { + expect(normalizeStory({ kind: 'story', storyType: 'body' })).toBeUndefined(); + }); + + it('returns undefined for undefined input', () => { + expect(normalizeStory(undefined)).toBeUndefined(); + }); + + it('passes through non-body stories', () => { + const story = { kind: 'story' as const, storyType: 'footnote' as const, noteId: 'fn-1' }; + expect(normalizeStory(story)).toEqual(story); + }); +}); + +describe('buildBookmarkAddress', () => { + it('builds a plain address for body bookmarks', () => { + expect(buildBookmarkAddress('bm1')).toEqual({ + kind: 'entity', + entityType: 'bookmark', + name: 'bm1', + }); + }); + + it('omits story for body locator', () => { + const result = buildBookmarkAddress('bm1', { kind: 'story', storyType: 'body' }); + expect('story' in result).toBe(false); + }); + + it('includes story for non-body locator', () => { + const story = { kind: 'story' as const, storyType: 'footnote' as const, noteId: 'fn-1' }; + const result = buildBookmarkAddress('bm1', story); + expect(result.story).toEqual(story); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/bookmark-resolver.ts b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/bookmark-resolver.ts index e466fdb478..adacc2fa4b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/helpers/bookmark-resolver.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/helpers/bookmark-resolver.ts @@ -2,10 +2,19 @@ * Bookmark node resolver — finds, resolves, and extracts info from bookmarkStart nodes. */ +import type { Editor } from '../../core/Editor.js'; import type { Node as ProseMirrorNode } from 'prosemirror-model'; -import type { BookmarkAddress, BookmarkDomain, BookmarkInfo, DiscoveryItem, Position } from '@superdoc/document-api'; +import type { + BookmarkAddress, + BookmarkDomain, + BookmarkInfo, + DiscoveryItem, + Position, + StoryLocator, +} from '@superdoc/document-api'; import { buildDiscoveryItem, buildResolvedHandle } from '@superdoc/document-api'; import { DocumentApiAdapterError } from '../errors.js'; +import { BODY_STORY_KEY, buildStoryKey } from '../story-runtime/story-key.js'; // --------------------------------------------------------------------------- // Types @@ -19,6 +28,63 @@ export interface ResolvedBookmark { endPos: number | null; } +export interface DocumentBookmarkEntry { + name: string; + bookmarkId: string; + storyKey: string; +} + +type StoryEditorEntry = { + id?: unknown; + editor?: Editor; +}; + +type NoteEntry = { + id?: unknown; + content?: unknown[]; + doc?: Record; + type?: unknown; +}; + +type ConverterWithStories = { + headers?: Record; + footers?: Record; + headerEditors?: StoryEditorEntry[]; + footerEditors?: StoryEditorEntry[]; + footnotes?: NoteEntry[]; + endnotes?: NoteEntry[]; +}; + +export function normalizeStory(locator?: StoryLocator): StoryLocator | undefined { + if (!locator || locator.storyType === 'body') return undefined; + return locator; +} + +export function buildBookmarkAddress(name: string, story?: StoryLocator): BookmarkAddress { + const normalizedStory = normalizeStory(story); + return normalizedStory + ? { kind: 'entity', entityType: 'bookmark', name, story: normalizedStory } + : { kind: 'entity', entityType: 'bookmark', name }; +} + +export function findAllBookmarksInDocument(editor: Editor): DocumentBookmarkEntry[] { + const results: DocumentBookmarkEntry[] = []; + const seenStoryKeys = new Set(); + const converter = (editor as unknown as { converter?: ConverterWithStories }).converter; + + seenStoryKeys.add(BODY_STORY_KEY); + collectBookmarksFromDoc(editor.state.doc, BODY_STORY_KEY, results); + + collectBookmarksFromHeaderFooterEditors(converter?.headerEditors, results, seenStoryKeys); + collectBookmarksFromHeaderFooterEditors(converter?.footerEditors, results, seenStoryKeys); + collectBookmarksFromHeaderFooterCache(converter?.headers, results, seenStoryKeys); + collectBookmarksFromHeaderFooterCache(converter?.footers, results, seenStoryKeys); + collectBookmarksFromNotes(converter?.footnotes, 'footnote', results, seenStoryKeys); + collectBookmarksFromNotes(converter?.endnotes, 'endnote', results, seenStoryKeys); + + return results; +} + // --------------------------------------------------------------------------- // Node resolution // --------------------------------------------------------------------------- @@ -58,6 +124,122 @@ function collectBookmarkEndPositions(doc: ProseMirrorNode): Map return map; } +function collectBookmarksFromDoc(doc: ProseMirrorNode, storyKey: string, results: DocumentBookmarkEntry[]): void { + doc.descendants((node) => { + if (node.type.name === 'bookmarkStart') { + results.push({ + name: (node.attrs?.name as string) ?? '', + bookmarkId: (node.attrs?.id as string) ?? '', + storyKey, + }); + } + return true; + }); +} + +function collectBookmarksFromHeaderFooterEditors( + editors: StoryEditorEntry[] | undefined, + results: DocumentBookmarkEntry[], + seenStoryKeys: Set, +): void { + if (!Array.isArray(editors)) return; + + for (const entry of editors) { + const refId = typeof entry?.id === 'string' && entry.id.length > 0 ? entry.id : null; + const storyEditor = entry?.editor; + if (!refId || !storyEditor?.state?.doc) continue; + + const storyKey = buildStoryKey({ kind: 'story', storyType: 'headerFooterPart', refId }); + if (seenStoryKeys.has(storyKey)) continue; + seenStoryKeys.add(storyKey); + collectBookmarksFromDoc(storyEditor.state.doc, storyKey, results); + } +} + +function collectBookmarksFromHeaderFooterCache( + collection: Record | undefined, + results: DocumentBookmarkEntry[], + seenStoryKeys: Set, +): void { + if (!collection || typeof collection !== 'object') return; + + for (const [refId, pmJson] of Object.entries(collection)) { + if (typeof refId !== 'string' || refId.length === 0) continue; + + const storyKey = buildStoryKey({ kind: 'story', storyType: 'headerFooterPart', refId }); + if (seenStoryKeys.has(storyKey)) continue; + seenStoryKeys.add(storyKey); + collectBookmarksFromPmJson(pmJson, storyKey, results); + } +} + +function collectBookmarksFromNotes( + notes: NoteEntry[] | undefined, + storyType: 'footnote' | 'endnote', + results: DocumentBookmarkEntry[], + seenStoryKeys: Set, +): void { + if (!Array.isArray(notes)) return; + + for (const note of notes) { + const noteId = note?.id != null ? String(note.id) : ''; + if (!noteId) continue; + + const storyKey = buildStoryKey({ kind: 'story', storyType, noteId }); + if (seenStoryKeys.has(storyKey)) continue; + seenStoryKeys.add(storyKey); + + const pmJson = getNotePmJson(note); + if (!pmJson) continue; + collectBookmarksFromPmJson(pmJson, storyKey, results); + } +} + +function getNotePmJson(note: NoteEntry): Record | null { + if (Array.isArray(note.content)) { + return { + type: 'doc', + content: note.content.length > 0 ? note.content : [{ type: 'paragraph' }], + }; + } + + if (note.doc && typeof note.doc === 'object') { + return note.doc; + } + + return null; +} + +function collectBookmarksFromPmJson(pmJson: unknown, storyKey: string, results: DocumentBookmarkEntry[]): void { + if (!isObjectRecord(pmJson)) return; + + visitPmJson(pmJson, (node) => { + if (node.type !== 'bookmarkStart') return; + + const attrs = isObjectRecord(node.attrs) ? node.attrs : undefined; + const name = typeof attrs?.name === 'string' ? attrs.name : ''; + const bookmarkId = attrs?.id != null ? String(attrs.id) : ''; + results.push({ name, bookmarkId, storyKey }); + }); +} + +function visitPmJson(node: Record, visitor: (node: Record) => void): void { + visitor(node); + + const content = node.content; + if (!Array.isArray(content)) return; + + for (const child of content) { + if (isObjectRecord(child)) { + visitPmJson(child, visitor); + } + } +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + /** * Resolves a BookmarkAddress to its ProseMirror node and position. * @throws DocumentApiAdapterError with code TARGET_NOT_FOUND if not found. @@ -88,7 +270,11 @@ function nodePositionToPosition(doc: ProseMirrorNode, pos: number): Position { return { blockId: '', offset: pos }; } -export function extractBookmarkInfo(doc: ProseMirrorNode, resolved: ResolvedBookmark): BookmarkInfo { +export function extractBookmarkInfo( + doc: ProseMirrorNode, + resolved: ResolvedBookmark, + story?: StoryLocator, +): BookmarkInfo { const from = nodePositionToPosition(doc, resolved.pos); const to = resolved.endPos !== null ? nodePositionToPosition(doc, resolved.endPos) : from; @@ -96,7 +282,7 @@ export function extractBookmarkInfo(doc: ProseMirrorNode, resolved: ResolvedBook const colLast = resolved.node.attrs?.colLast as number | undefined; const info: BookmarkInfo = { - address: { kind: 'entity', entityType: 'bookmark', name: resolved.name }, + address: buildBookmarkAddress(resolved.name, story), name: resolved.name, bookmarkId: resolved.bookmarkId, range: { from, to }, @@ -117,6 +303,7 @@ export function buildBookmarkDiscoveryItem( doc: ProseMirrorNode, resolved: ResolvedBookmark, evaluatedRevision: string, + story?: StoryLocator, ): DiscoveryItem { const from = nodePositionToPosition(doc, resolved.pos); const to = resolved.endPos !== null ? nodePositionToPosition(doc, resolved.endPos) : from; @@ -125,7 +312,7 @@ export function buildBookmarkDiscoveryItem( const colLast = resolved.node.attrs?.colLast as number | undefined; const domain: BookmarkDomain = { - address: { kind: 'entity', entityType: 'bookmark', name: resolved.name }, + address: buildBookmarkAddress(resolved.name, story), name: resolved.name, bookmarkId: resolved.bookmarkId, range: { from, to }, diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/bookmark-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/bookmark-wrappers.test.ts index 487ae4ed17..3f7d8e1d3b 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/bookmark-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/bookmark-wrappers.test.ts @@ -6,6 +6,13 @@ vi.mock('./plan-wrappers.js', () => ({ executeDomainCommand: vi.fn((_editor: Editor, handler: () => boolean) => ({ steps: [{ effect: handler() ? 'changed' : 'noop' }], })), + resolveWriteStoryRuntime: vi.fn((editor: Editor) => ({ + locator: { kind: 'story', storyType: 'body' }, + storyKey: 'story:body', + editor, + kind: 'body', + })), + disposeEphemeralWriteRuntime: vi.fn(), })); vi.mock('./revision-tracker.js', () => ({ @@ -29,17 +36,46 @@ vi.mock('../helpers/index-cache.js', () => ({ clearIndexCache: vi.fn(), })); -vi.mock('../helpers/bookmark-resolver.js', () => ({ - findAllBookmarks: vi.fn(() => []), - resolveBookmarkTarget: vi.fn(), - extractBookmarkInfo: vi.fn(), - buildBookmarkDiscoveryItem: vi.fn(), +vi.mock('../helpers/bookmark-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findAllBookmarks: vi.fn(() => []), + findAllBookmarksInDocument: vi.fn(() => []), + resolveBookmarkTarget: vi.fn(), + extractBookmarkInfo: vi.fn(), + buildBookmarkDiscoveryItem: vi.fn(), + }; +}); + +vi.mock('../story-runtime/resolve-story-runtime.js', () => ({ + resolveStoryRuntime: vi.fn((editor: Editor) => ({ + locator: { kind: 'story', storyType: 'body' }, + storyKey: 'story:body', + editor, + kind: 'body', + })), })); -import { bookmarksInsertWrapper } from './bookmark-wrappers.js'; -import { resolveInlineInsertPosition } from '../helpers/adapter-utils.js'; +import { + bookmarksListWrapper, + bookmarksGetWrapper, + bookmarksInsertWrapper, + bookmarksRenameWrapper, + bookmarksRemoveWrapper, +} from './bookmark-wrappers.js'; +import { resolveInlineInsertPosition, paginate } from '../helpers/adapter-utils.js'; import { clearIndexCache } from '../helpers/index-cache.js'; -import { findAllBookmarks } from '../helpers/bookmark-resolver.js'; +import { + findAllBookmarks, + findAllBookmarksInDocument, + resolveBookmarkTarget, + extractBookmarkInfo, + buildBookmarkDiscoveryItem, +} from '../helpers/bookmark-resolver.js'; +import { resolveWriteStoryRuntime, disposeEphemeralWriteRuntime } from './plan-wrappers.js'; +import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; +import { getRevision } from './revision-tracker.js'; type BookmarkNode = { type: { name: string }; @@ -64,6 +100,8 @@ function makeEditor(existingNodes: BookmarkNode[] = []): { const tr = { insert: vi.fn((_pos: number, _node: unknown) => tr), delete: vi.fn((_from: number, _to: number) => tr), + setNodeMarkup: vi.fn(() => tr), + doc: { nodeAt: vi.fn(() => ({ nodeSize: 1 })) }, }; const startCreate = vi.fn((attrs: Record) => ({ type: 'bookmarkStart', attrs, nodeSize: 1 })); @@ -109,6 +147,13 @@ describe('bookmarksInsertWrapper', () => { { type: { name: 'bookmarkEnd' }, attrs: { id: '9' } }, { type: { name: 'bookmarkStart' }, attrs: { id: 'not-a-number' } }, ]); + const existingEntries = [ + { name: 'a', bookmarkId: '2', storyKey: 'body' }, + { name: 'b', bookmarkId: '9', storyKey: 'body' }, + { name: 'c', bookmarkId: 'not-a-number', storyKey: 'body' }, + ]; + // Called twice: once for bookmarkExistsAnywhere, once for allocateBookmarkId. + vi.mocked(findAllBookmarksInDocument).mockReturnValueOnce(existingEntries).mockReturnValueOnce(existingEntries); const result = bookmarksInsertWrapper(editor, makeInput()); @@ -148,9 +193,56 @@ describe('bookmarksInsertWrapper', () => { }); }); + it('returns a story-qualified bookmark address and commits non-body story inserts', () => { + const { editor } = makeEditor(); + const commit = vi.fn(); + vi.mocked(resolveWriteStoryRuntime).mockReturnValueOnce({ + locator: { kind: 'story', storyType: 'footnote', noteId: 'fn-1' }, + storyKey: 'story:footnote:fn-1', + editor, + kind: 'note', + commit, + }); + + const result = bookmarksInsertWrapper(editor, { + name: 'bm-footnote', + at: { + kind: 'text', + story: { kind: 'story', storyType: 'footnote', noteId: 'fn-1' }, + segments: [{ blockId: 'p1', range: { start: 0, end: 3 } }], + }, + }); + + expect(result).toEqual({ + success: true, + bookmark: { + kind: 'entity', + entityType: 'bookmark', + name: 'bm-footnote', + story: { kind: 'story', storyType: 'footnote', noteId: 'fn-1' }, + }, + }); + expect(commit).toHaveBeenCalledWith(editor); + }); + it('returns NO_OP when a bookmark with the same name already exists', () => { - vi.mocked(findAllBookmarks).mockReturnValueOnce([ - { name: 'bm1', pos: 1, bookmarkId: '0', endPos: 2, node: {} as never }, + vi.mocked(findAllBookmarksInDocument).mockReturnValueOnce([{ name: 'bm1', bookmarkId: '0', storyKey: 'body' }]); + const { editor, tr, dispatch } = makeEditor(); + + const result = bookmarksInsertWrapper(editor, makeInput('bm1')); + + expect(result).toEqual({ + success: false, + failure: { code: 'NO_OP', message: 'Bookmark with name "bm1" already exists.' }, + }); + expect(resolveInlineInsertPosition).not.toHaveBeenCalled(); + expect(tr.insert).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns NO_OP when the same bookmark name already exists in another story', () => { + vi.mocked(findAllBookmarksInDocument).mockReturnValueOnce([ + { name: 'bm1', bookmarkId: '55', storyKey: 'hf:part:rId7' }, ]); const { editor, tr, dispatch } = makeEditor(); @@ -177,3 +269,300 @@ describe('bookmarksInsertWrapper', () => { ); }); }); + +describe('bookmarksRenameWrapper', () => { + it('renames a body bookmark and returns a plain address without commit', () => { + const { editor, tr } = makeEditor(); + + vi.mocked(resolveBookmarkTarget).mockReturnValueOnce({ + pos: 5, + name: 'old-name', + bookmarkId: '1', + endPos: 8, + node: { attrs: { name: 'old-name', id: '1' } } as never, + }); + + vi.mocked(findAllBookmarksInDocument).mockReturnValueOnce([]); + + const result = bookmarksRenameWrapper(editor, { + target: { kind: 'entity', entityType: 'bookmark', name: 'old-name' }, + newName: 'new-name', + }); + + expect(result).toEqual({ + success: true, + bookmark: { kind: 'entity', entityType: 'bookmark', name: 'new-name' }, + }); + expect(result.success && !('story' in result.bookmark)).toBe(true); + expect(tr.setNodeMarkup).toHaveBeenCalledWith(5, undefined, { name: 'new-name', id: '1' }); + expect(disposeEphemeralWriteRuntime).toHaveBeenCalled(); + }); + + it('returns a story-qualified address and commits non-body story renames', () => { + const { editor } = makeEditor(); + const commit = vi.fn(); + const footnoteLocator = { kind: 'story' as const, storyType: 'footnote' as const, noteId: 'fn-1' }; + + vi.mocked(resolveWriteStoryRuntime).mockReturnValueOnce({ + locator: footnoteLocator, + storyKey: 'story:footnote:fn-1', + editor, + kind: 'note', + commit, + }); + + vi.mocked(resolveBookmarkTarget).mockReturnValueOnce({ + pos: 5, + name: 'old-name', + bookmarkId: '1', + endPos: 8, + node: { attrs: { name: 'old-name', id: '1' } } as never, + }); + + vi.mocked(findAllBookmarksInDocument).mockReturnValueOnce([]); + + const result = bookmarksRenameWrapper(editor, { + target: { kind: 'entity', entityType: 'bookmark', name: 'old-name', story: footnoteLocator }, + newName: 'new-name', + }); + + expect(result).toEqual({ + success: true, + bookmark: { + kind: 'entity', + entityType: 'bookmark', + name: 'new-name', + story: footnoteLocator, + }, + }); + expect(commit).toHaveBeenCalledWith(editor); + expect(disposeEphemeralWriteRuntime).toHaveBeenCalled(); + }); + + it('throws INVALID_INPUT when the new name exists in another story', () => { + const { editor, tr } = makeEditor(); + + vi.mocked(resolveBookmarkTarget).mockReturnValueOnce({ + pos: 5, + name: 'old-name', + bookmarkId: '1', + endPos: 8, + node: { attrs: { name: 'old-name', id: '1' } } as never, + }); + + vi.mocked(findAllBookmarksInDocument).mockReturnValueOnce([ + { name: 'taken-name', bookmarkId: '77', storyKey: 'hf:part:rId7' }, + ]); + + expect(() => + bookmarksRenameWrapper(editor, { + target: { kind: 'entity', entityType: 'bookmark', name: 'old-name' }, + newName: 'taken-name', + }), + ).toThrowError( + expect.objectContaining({ + name: 'DocumentApiAdapterError', + code: 'INVALID_INPUT', + }), + ); + expect(tr.setNodeMarkup).not.toHaveBeenCalled(); + }); +}); + +describe('bookmarksRemoveWrapper', () => { + it('removes a body bookmark and returns a plain address without commit', () => { + const { editor, tr } = makeEditor(); + + vi.mocked(resolveBookmarkTarget).mockReturnValueOnce({ + pos: 5, + name: 'bm-remove', + bookmarkId: '1', + endPos: 8, + node: { attrs: { name: 'bm-remove', id: '1' }, nodeSize: 1 } as never, + }); + + const result = bookmarksRemoveWrapper(editor, { + target: { kind: 'entity', entityType: 'bookmark', name: 'bm-remove' }, + }); + + expect(result).toEqual({ + success: true, + bookmark: { kind: 'entity', entityType: 'bookmark', name: 'bm-remove' }, + }); + expect(result.success && !('story' in result.bookmark)).toBe(true); + expect(tr.delete).toHaveBeenCalled(); + expect(disposeEphemeralWriteRuntime).toHaveBeenCalled(); + }); + + it('returns a story-qualified address and commits non-body story removals', () => { + const { editor } = makeEditor(); + const commit = vi.fn(); + const footnoteLocator = { kind: 'story' as const, storyType: 'footnote' as const, noteId: 'fn-1' }; + + vi.mocked(resolveWriteStoryRuntime).mockReturnValueOnce({ + locator: footnoteLocator, + storyKey: 'story:footnote:fn-1', + editor, + kind: 'note', + commit, + }); + + vi.mocked(resolveBookmarkTarget).mockReturnValueOnce({ + pos: 5, + name: 'bm-remove', + bookmarkId: '1', + endPos: 8, + node: { attrs: { name: 'bm-remove', id: '1' }, nodeSize: 1 } as never, + }); + + const result = bookmarksRemoveWrapper(editor, { + target: { kind: 'entity', entityType: 'bookmark', name: 'bm-remove', story: footnoteLocator }, + }); + + expect(result).toEqual({ + success: true, + bookmark: { + kind: 'entity', + entityType: 'bookmark', + name: 'bm-remove', + story: footnoteLocator, + }, + }); + expect(commit).toHaveBeenCalledWith(editor); + expect(disposeEphemeralWriteRuntime).toHaveBeenCalled(); + }); +}); + +describe('bookmarksListWrapper', () => { + it('lists all bookmarks in the body story', () => { + const { editor } = makeEditor(); + const mockBookmarks = [ + { node: {}, pos: 5, name: 'bm1', bookmarkId: '0', endPos: 10 }, + { node: {}, pos: 20, name: 'bm2', bookmarkId: '1', endPos: 25 }, + ]; + const mockDiscoveryItem = { id: 'mock', handle: {}, domain: {} }; + + vi.mocked(resolveStoryRuntime).mockReturnValueOnce({ + locator: { kind: 'story', storyType: 'body' }, + storyKey: 'story:body', + editor, + kind: 'body', + }); + vi.mocked(findAllBookmarks).mockReturnValueOnce(mockBookmarks as never); + vi.mocked(buildBookmarkDiscoveryItem).mockReturnValue(mockDiscoveryItem as never); + vi.mocked(getRevision).mockReturnValueOnce('rev-test'); + + const result = bookmarksListWrapper(editor); + + expect(findAllBookmarks).toHaveBeenCalledWith(editor.state.doc); + expect(buildBookmarkDiscoveryItem).toHaveBeenCalledTimes(2); + expect(buildBookmarkDiscoveryItem).toHaveBeenCalledWith(editor.state.doc, mockBookmarks[0], 'rev-test', { + kind: 'story', + storyType: 'body', + }); + expect(result.total).toBe(2); + }); + + it('resolves a non-body story runtime when query.in is provided', () => { + const { editor } = makeEditor(); + const headerLocator = { kind: 'story' as const, storyType: 'headerFooterPart' as const, refId: 'rId7' }; + + vi.mocked(resolveStoryRuntime).mockReturnValueOnce({ + locator: headerLocator, + storyKey: 'hf:part:rId7', + editor, + kind: 'headerFooter', + }); + vi.mocked(findAllBookmarks).mockReturnValueOnce([]); + vi.mocked(getRevision).mockReturnValueOnce('rev-2'); + + bookmarksListWrapper(editor, { in: headerLocator }); + + expect(resolveStoryRuntime).toHaveBeenCalledWith(editor, headerLocator); + }); + + it('applies pagination via offset and limit', () => { + const { editor } = makeEditor(); + const mockBookmarks = [ + { node: {}, pos: 5, name: 'bm1', bookmarkId: '0', endPos: 10 }, + { node: {}, pos: 20, name: 'bm2', bookmarkId: '1', endPos: 25 }, + { node: {}, pos: 40, name: 'bm3', bookmarkId: '2', endPos: 45 }, + ]; + + vi.mocked(resolveStoryRuntime).mockReturnValueOnce({ + locator: { kind: 'story', storyType: 'body' }, + storyKey: 'story:body', + editor, + kind: 'body', + }); + vi.mocked(findAllBookmarks).mockReturnValueOnce(mockBookmarks as never); + vi.mocked(buildBookmarkDiscoveryItem).mockReturnValue({ id: 'mock', handle: {}, domain: {} } as never); + vi.mocked(getRevision).mockReturnValueOnce('rev-3'); + + const result = bookmarksListWrapper(editor, { offset: 1, limit: 1 }); + + expect(paginate).toHaveBeenCalledWith(expect.any(Array), 1, 1); + expect(result.total).toBe(3); + }); +}); + +describe('bookmarksGetWrapper', () => { + it('resolves a bookmark by name and returns its info', () => { + const { editor } = makeEditor(); + const target = { kind: 'entity' as const, entityType: 'bookmark' as const, name: 'bm1' }; + const mockResolved = { node: {}, pos: 5, name: 'bm1', bookmarkId: '0', endPos: 10 }; + const mockInfo = { + address: { kind: 'entity', entityType: 'bookmark', name: 'bm1' }, + name: 'bm1', + bookmarkId: '0', + range: { from: { blockId: 'p1', offset: 5 }, to: { blockId: 'p1', offset: 10 } }, + }; + + vi.mocked(resolveStoryRuntime).mockReturnValueOnce({ + locator: { kind: 'story', storyType: 'body' }, + storyKey: 'story:body', + editor, + kind: 'body', + }); + vi.mocked(resolveBookmarkTarget).mockReturnValueOnce(mockResolved as never); + vi.mocked(extractBookmarkInfo).mockReturnValueOnce(mockInfo as never); + + const result = bookmarksGetWrapper(editor, { target }); + + expect(resolveStoryRuntime).toHaveBeenCalledWith(editor, undefined); + expect(resolveBookmarkTarget).toHaveBeenCalledWith(editor.state.doc, target); + expect(extractBookmarkInfo).toHaveBeenCalledWith(editor.state.doc, mockResolved, { + kind: 'story', + storyType: 'body', + }); + expect(result).toEqual(mockInfo); + }); + + it('resolves a story-qualified bookmark in a header', () => { + const { editor } = makeEditor(); + const headerLocator = { kind: 'story' as const, storyType: 'headerFooterPart' as const, refId: 'rId7' }; + const target = { kind: 'entity' as const, entityType: 'bookmark' as const, name: 'hdr-bm', story: headerLocator }; + const mockResolved = { node: {}, pos: 3, name: 'hdr-bm', bookmarkId: '5', endPos: 8 }; + const mockInfo = { + address: { kind: 'entity', entityType: 'bookmark', name: 'hdr-bm', story: headerLocator }, + name: 'hdr-bm', + bookmarkId: '5', + range: { from: { blockId: 'h1', offset: 3 }, to: { blockId: 'h1', offset: 8 } }, + }; + + vi.mocked(resolveStoryRuntime).mockReturnValueOnce({ + locator: headerLocator, + storyKey: 'hf:part:rId7', + editor, + kind: 'headerFooter', + }); + vi.mocked(resolveBookmarkTarget).mockReturnValueOnce(mockResolved as never); + vi.mocked(extractBookmarkInfo).mockReturnValueOnce(mockInfo as never); + + const result = bookmarksGetWrapper(editor, { target }); + + expect(resolveStoryRuntime).toHaveBeenCalledWith(editor, headerLocator); + expect(extractBookmarkInfo).toHaveBeenCalledWith(editor.state.doc, mockResolved, headerLocator); + expect(result).toEqual(mockInfo); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/bookmark-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/bookmark-wrappers.ts index 460797eb9a..929f3ffa3d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/bookmark-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/bookmark-wrappers.ts @@ -19,16 +19,19 @@ import type { import { buildDiscoveryResult } from '@superdoc/document-api'; import { findAllBookmarks, + findAllBookmarksInDocument, resolveBookmarkTarget, extractBookmarkInfo, buildBookmarkDiscoveryItem, + buildBookmarkAddress, } from '../helpers/bookmark-resolver.js'; import { paginate, resolveInlineInsertPosition } from '../helpers/adapter-utils.js'; import { getRevision } from './revision-tracker.js'; -import { executeDomainCommand } from './plan-wrappers.js'; +import { disposeEphemeralWriteRuntime, executeDomainCommand, resolveWriteStoryRuntime } from './plan-wrappers.js'; import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; import { clearIndexCache } from '../helpers/index-cache.js'; import { DocumentApiAdapterError } from '../errors.js'; +import { resolveStoryRuntime } from '../story-runtime/resolve-story-runtime.js'; // --------------------------------------------------------------------------- // Result helpers @@ -54,27 +57,35 @@ function parseBookmarkId(raw: unknown): number | null { return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; } -function allocateBookmarkId(doc: import('prosemirror-model').Node): string { +function allocateBookmarkId(editor: Editor): string { let maxId = -1; - doc.descendants((node) => { - if (node.type.name !== 'bookmarkStart' && node.type.name !== 'bookmarkEnd') return true; - const id = parseBookmarkId(node.attrs?.id); + for (const entry of findAllBookmarksInDocument(editor)) { + const id = parseBookmarkId(entry.bookmarkId); if (id !== null && id > maxId) maxId = id; - return true; - }); + } return String(maxId + 1); } +function bookmarkExistsAnywhere(editor: Editor, name: string, excludeBookmarkId?: string): boolean { + return findAllBookmarksInDocument(editor).some((bookmark) => { + if (bookmark.name !== name) return false; + if (!excludeBookmarkId) return true; + return bookmark.bookmarkId !== excludeBookmarkId; + }); +} + // --------------------------------------------------------------------------- // Read operations // --------------------------------------------------------------------------- export function bookmarksListWrapper(editor: Editor, query?: BookmarkListInput): BookmarksListResult { - const doc = editor.state.doc; - const revision = getRevision(editor); + const runtime = resolveStoryRuntime(editor, query?.in); + const storyEditor = runtime.editor; + const doc = storyEditor.state.doc; + const revision = getRevision(storyEditor); const bookmarks = findAllBookmarks(doc); - const allItems = bookmarks.map((b) => buildBookmarkDiscoveryItem(doc, b, revision)); + const allItems = bookmarks.map((b) => buildBookmarkDiscoveryItem(doc, b, revision, runtime.locator)); const { total, items: paged } = paginate(allItems, query?.offset, query?.limit); const effectiveLimit = query?.limit ?? total; @@ -87,8 +98,9 @@ export function bookmarksListWrapper(editor: Editor, query?: BookmarkListInput): } export function bookmarksGetWrapper(editor: Editor, input: BookmarkGetInput): BookmarkInfo { - const resolved = resolveBookmarkTarget(editor.state.doc, input.target); - return extractBookmarkInfo(editor.state.doc, resolved); + const runtime = resolveStoryRuntime(editor, input.target.story); + const resolved = resolveBookmarkTarget(runtime.editor.state.doc, input.target); + return extractBookmarkInfo(runtime.editor.state.doc, resolved, runtime.locator); } // --------------------------------------------------------------------------- @@ -101,62 +113,66 @@ export function bookmarksInsertWrapper( options?: MutationOptions, ): BookmarkMutationResult { rejectTrackedMode('bookmarks.insert', options); + const runtime = resolveWriteStoryRuntime(editor, input.at.story); + const storyEditor = runtime.editor; + const address: BookmarkAddress = buildBookmarkAddress(input.name, runtime.locator); + + try { + if (bookmarkExistsAnywhere(editor, input.name)) { + return bookmarkFailure('NO_OP', `Bookmark with name "${input.name}" already exists.`); + } + + if (options?.dryRun) { + return bookmarkSuccess(address); + } + + const bookmarkStartType = storyEditor.schema.nodes.bookmarkStart; + const bookmarkEndType = storyEditor.schema.nodes.bookmarkEnd; + if (!bookmarkStartType || !bookmarkEndType) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'bookmarks.insert requires bookmarkStart and bookmarkEnd node types in the schema.', + ); + } + + const resolved = resolveInlineInsertPosition(storyEditor, input.at, 'bookmarks.insert'); + + const receipt = executeDomainCommand( + storyEditor, + () => { + const bookmarkId = allocateBookmarkId(editor); + const startAttrs: Record = { + name: input.name, + id: bookmarkId, + }; + if (input.tableColumn) { + startAttrs.colFirst = input.tableColumn.colFirst; + startAttrs.colLast = input.tableColumn.colLast; + } - // Check for duplicate name - const existing = findAllBookmarks(editor.state.doc); - if (existing.some((b) => b.name === input.name)) { - return bookmarkFailure('NO_OP', `Bookmark with name "${input.name}" already exists.`); - } + const startNode = bookmarkStartType.create(startAttrs); + const endNode = bookmarkEndType.create({ id: bookmarkId }); + + // Insert end first so range bookmarks survive index shifts. + const { tr } = storyEditor.state; + tr.insert(resolved.to, endNode); + tr.insert(resolved.from, startNode); + storyEditor.dispatch(tr); + clearIndexCache(storyEditor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); - const address: BookmarkAddress = { kind: 'entity', entityType: 'bookmark', name: input.name }; + if (!receiptApplied(receipt)) { + return bookmarkFailure('NO_OP', 'Insert operation produced no change.'); + } - if (options?.dryRun) { + if (runtime.commit) runtime.commit(editor); return bookmarkSuccess(address); + } finally { + disposeEphemeralWriteRuntime(runtime); } - - const bookmarkStartType = editor.schema.nodes.bookmarkStart; - const bookmarkEndType = editor.schema.nodes.bookmarkEnd; - if (!bookmarkStartType || !bookmarkEndType) { - throw new DocumentApiAdapterError( - 'CAPABILITY_UNAVAILABLE', - 'bookmarks.insert requires bookmarkStart and bookmarkEnd node types in the schema.', - ); - } - - const resolved = resolveInlineInsertPosition(editor, input.at, 'bookmarks.insert'); - - const receipt = executeDomainCommand( - editor, - () => { - const bookmarkId = allocateBookmarkId(editor.state.doc); - const startAttrs: Record = { - name: input.name, - id: bookmarkId, - }; - if (input.tableColumn) { - startAttrs.colFirst = input.tableColumn.colFirst; - startAttrs.colLast = input.tableColumn.colLast; - } - - const startNode = bookmarkStartType.create(startAttrs); - const endNode = bookmarkEndType.create({ id: bookmarkId }); - - // Insert end first so range bookmarks survive index shifts. - const { tr } = editor.state; - tr.insert(resolved.to, endNode); - tr.insert(resolved.from, startNode); - editor.dispatch(tr); - clearIndexCache(editor); - return true; - }, - { expectedRevision: options?.expectedRevision }, - ); - - if (!receiptApplied(receipt)) { - return bookmarkFailure('NO_OP', 'Insert operation produced no change.'); - } - - return bookmarkSuccess(address); } export function bookmarksRenameWrapper( @@ -165,48 +181,53 @@ export function bookmarksRenameWrapper( options?: MutationOptions, ): BookmarkMutationResult { rejectTrackedMode('bookmarks.rename', options); - - const resolved = resolveBookmarkTarget(editor.state.doc, input.target); - - if (resolved.name === input.newName) { - return bookmarkFailure('NO_OP', 'New name is identical to current name.'); - } - - // Check that the new name is not already taken - const all = findAllBookmarks(editor.state.doc); - if (all.some((b) => b.name === input.newName)) { - throw new DocumentApiAdapterError( - 'INVALID_INPUT', - `bookmarks.rename: a bookmark with name "${input.newName}" already exists.`, + const runtime = resolveWriteStoryRuntime(editor, input.target.story); + const storyEditor = runtime.editor; + + try { + const resolved = resolveBookmarkTarget(storyEditor.state.doc, input.target); + + if (resolved.name === input.newName) { + return bookmarkFailure('NO_OP', 'New name is identical to current name.'); + } + + if (bookmarkExistsAnywhere(editor, input.newName, resolved.bookmarkId)) { + throw new DocumentApiAdapterError( + 'INVALID_INPUT', + `bookmarks.rename: a bookmark with name "${input.newName}" already exists.`, + ); + } + + const newAddress: BookmarkAddress = buildBookmarkAddress(input.newName, runtime.locator); + + if (options?.dryRun) { + return bookmarkSuccess(newAddress); + } + + const receipt = executeDomainCommand( + storyEditor, + () => { + const { tr } = storyEditor.state; + tr.setNodeMarkup(resolved.pos, undefined, { + ...resolved.node.attrs, + name: input.newName, + }); + storyEditor.dispatch(tr); + clearIndexCache(storyEditor); + return true; + }, + { expectedRevision: options?.expectedRevision }, ); - } - const newAddress: BookmarkAddress = { kind: 'entity', entityType: 'bookmark', name: input.newName }; + if (!receiptApplied(receipt)) { + return bookmarkFailure('NO_OP', 'Rename operation produced no change.'); + } - if (options?.dryRun) { + if (runtime.commit) runtime.commit(editor); return bookmarkSuccess(newAddress); + } finally { + disposeEphemeralWriteRuntime(runtime); } - - const receipt = executeDomainCommand( - editor, - () => { - const { tr } = editor.state; - tr.setNodeMarkup(resolved.pos, undefined, { - ...resolved.node.attrs, - name: input.newName, - }); - editor.dispatch(tr); - clearIndexCache(editor); - return true; - }, - { expectedRevision: options?.expectedRevision }, - ); - - if (!receiptApplied(receipt)) { - return bookmarkFailure('NO_OP', 'Rename operation produced no change.'); - } - - return bookmarkSuccess(newAddress); } export function bookmarksRemoveWrapper( @@ -215,43 +236,50 @@ export function bookmarksRemoveWrapper( options?: MutationOptions, ): BookmarkMutationResult { rejectTrackedMode('bookmarks.remove', options); + const runtime = resolveWriteStoryRuntime(editor, input.target.story); + const storyEditor = runtime.editor; + + try { + const resolved = resolveBookmarkTarget(storyEditor.state.doc, input.target); + const address: BookmarkAddress = buildBookmarkAddress(resolved.name, runtime.locator); + + if (options?.dryRun) { + return bookmarkSuccess(address); + } + + const receipt = executeDomainCommand( + storyEditor, + () => { + const { tr } = storyEditor.state; + + // Delete bookmarkEnd first (if it exists and is after start) to avoid position shifts + if (resolved.endPos !== null && resolved.endPos > resolved.pos) { + const endNode = tr.doc.nodeAt(resolved.endPos); + if (endNode) { + tr.delete(resolved.endPos, resolved.endPos + endNode.nodeSize); + } + } - const resolved = resolveBookmarkTarget(editor.state.doc, input.target); - const address: BookmarkAddress = { kind: 'entity', entityType: 'bookmark', name: resolved.name }; + // Delete bookmarkStart + const startNode = tr.doc.nodeAt(resolved.pos); + if (startNode) { + tr.delete(resolved.pos, resolved.pos + startNode.nodeSize); + } - if (options?.dryRun) { - return bookmarkSuccess(address); - } + storyEditor.dispatch(tr); + clearIndexCache(storyEditor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); - const receipt = executeDomainCommand( - editor, - () => { - const { tr } = editor.state; + if (!receiptApplied(receipt)) { + return bookmarkFailure('NO_OP', 'Remove operation produced no change.'); + } - // Delete bookmarkEnd first (if it exists and is after start) to avoid position shifts - if (resolved.endPos !== null && resolved.endPos > resolved.pos) { - const endNode = tr.doc.nodeAt(resolved.endPos); - if (endNode) { - tr.delete(resolved.endPos, resolved.endPos + endNode.nodeSize); - } - } - - // Delete bookmarkStart - const startNode = tr.doc.nodeAt(resolved.pos); - if (startNode) { - tr.delete(resolved.pos, resolved.pos + startNode.nodeSize); - } - - editor.dispatch(tr); - clearIndexCache(editor); - return true; - }, - { expectedRevision: options?.expectedRevision }, - ); - - if (!receiptApplied(receipt)) { - return bookmarkFailure('NO_OP', 'Remove operation produced no change.'); + if (runtime.commit) runtime.commit(editor); + return bookmarkSuccess(address); + } finally { + disposeEphemeralWriteRuntime(runtime); } - - return bookmarkSuccess(address); } diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 2b40f6914d..b1036573f2 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -65,6 +65,7 @@ const DEFAULT_AWARENESS_PALETTE = Object.freeze([ /** @typedef {import('./types').UpgradeToCollaborationOptions} UpgradeToCollaborationOptions */ /** @typedef {import('./types').SurfaceRequest} SurfaceRequest */ /** @typedef {import('./types').SurfaceHandle} SurfaceHandle */ +/** @typedef {import('./types').NavigableEntityAddress} NavigableEntityAddress */ /** * SuperDoc class @@ -801,6 +802,39 @@ export class SuperDoc extends EventEmitter { return target; } + /** + * Resolve the active PresentationEditor when a top-level SuperDoc API needs + * viewer/layout capabilities instead of raw editor commands. + * + * @returns {import('@superdoc/super-editor').PresentationEditor | null} + */ + #resolveActivePresentationEditor() { + if (typeof this.activeEditor?.navigateTo === 'function') { + return this.activeEditor; + } + + const storeDocs = this.superdocStore?.documents; + if (!Array.isArray(storeDocs) || storeDocs.length === 0) return null; + + const activeDocumentId = this.activeEditor?.options?.documentId; + if (activeDocumentId) { + const activeDoc = storeDocs.find((doc) => doc?.id === activeDocumentId); + const presentationEditor = activeDoc?.getPresentationEditor?.(); + if (typeof presentationEditor?.navigateTo === 'function') { + return presentationEditor; + } + } + + for (const doc of storeDocs) { + const presentationEditor = doc?.getPresentationEditor?.(); + if (typeof presentationEditor?.navigateTo === 'function') { + return presentationEditor; + } + } + + return null; + } + /** * Undo config/store/awareness mutations if `editor.attachCollaboration()` fails. * The editor itself is still in local mode (the throw happened before or during @@ -1192,6 +1226,11 @@ export class SuperDoc extends EventEmitter { /** * Scroll the document to a given comment by id. * + * This is a lightweight DOM-based scroll that does **not** move the editing + * caret or depend on the layout engine. Use {@link navigateTo} instead when + * you need to place the caret inside the comment range (e.g. for deep links + * or programmatic editing). + * * @param {string} commentId The comment id * @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options] * @returns {boolean} Whether a matching element was found @@ -1425,6 +1464,27 @@ export class SuperDoc extends EventEmitter { return this.activeEditor?.commands.goToSearchResult(match); } + /** + * Navigate to a bookmark, comment, or tracked change in the active document. + * Prefers the active document's PresentationEditor because navigation depends + * on rendered layout rather than raw editor commands. + * + * For comments this moves the caret into the comment range and activates the + * thread. If you only need to scroll a comment into view without moving the + * caret, use {@link scrollToComment} instead. + * + * Note: bookmark navigation currently supports the body and header/footer + * stories. Footnote/endnote bookmark navigation returns `false`. + * + * @param {NavigableEntityAddress} address Navigation target descriptor + * @returns {Promise} + */ + async navigateTo(address) { + const editor = this.#resolveActivePresentationEditor(); + if (!editor) return false; + return editor.navigateTo(address); + } + /** * Get the current zoom level as a percentage (e.g., 100 for 100%) * @returns {number} The current zoom level as a percentage diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 007f309d2c..775a1cb6b0 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -1398,6 +1398,61 @@ describe('SuperDoc core', () => { }); }); + describe('Navigation API', () => { + it('forwards navigateTo to the active document presentation editor', async () => { + const { superdocStore } = createAppHarness(); + const navigateTo = vi.fn().mockResolvedValue(true); + const activePresentationEditor = { navigateTo }; + const inactivePresentationEditor = { navigateTo: vi.fn() }; + + superdocStore.documents = [ + { + id: 'doc-1', + getPresentationEditor: vi.fn(() => inactivePresentationEditor), + }, + { + id: 'doc-2', + getPresentationEditor: vi.fn(() => activePresentationEditor), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + instance.setActiveEditor({ options: { documentId: 'doc-2' } }); + + const address = { kind: 'entity', entityType: 'bookmark', name: '_Paragraph_level_formatting' }; + await expect(instance.navigateTo(address)).resolves.toBe(true); + expect(navigateTo).toHaveBeenCalledWith(address); + expect(inactivePresentationEditor.navigateTo).not.toHaveBeenCalled(); + }); + + it('returns false when no presentation editor navigation surface is available', async () => { + const { superdocStore } = createAppHarness(); + superdocStore.documents = [ + { + id: 'doc-1', + getPresentationEditor: vi.fn(() => null), + }, + ]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + }); + await flushMicrotasks(); + + instance.setActiveEditor({ options: { documentId: 'doc-1' } }); + + await expect(instance.navigateTo({ kind: 'entity', entityType: 'comment', entityId: 'comment-1' })).resolves.toBe( + false, + ); + }); + }); + describe('Web layout mode configuration', () => { it('keeps PM fallback when web layout is enabled without semantic flow mode', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js index 75f7479003..5bf4f3a1b2 100644 --- a/packages/superdoc/src/core/types/index.js +++ b/packages/superdoc/src/core/types/index.js @@ -48,6 +48,82 @@ * @property {CollaborationProvider} provider The collaboration provider to use */ +/** + * @typedef {Object} SectionAddress + * @property {'section'} kind + * @property {string} sectionId + */ + +/** + * @typedef {Object} BodyStoryLocator + * @property {'story'} kind + * @property {'body'} storyType + */ + +/** + * @typedef {Object} HeaderFooterSlotStoryLocator + * @property {'story'} kind + * @property {'headerFooterSlot'} storyType + * @property {SectionAddress} section + * @property {'header' | 'footer'} headerFooterKind + * @property {'default' | 'first' | 'even'} variant + * @property {'effective' | 'explicit'} [resolution] + * @property {'materializeIfInherited' | 'editResolvedPart' | 'error'} [onWrite] + */ + +/** + * @typedef {Object} HeaderFooterPartStoryLocator + * @property {'story'} kind + * @property {'headerFooterPart'} storyType + * @property {string} refId + */ + +/** + * @typedef {Object} FootnoteStoryLocator + * @property {'story'} kind + * @property {'footnote'} storyType + * @property {string} noteId + */ + +/** + * @typedef {Object} EndnoteStoryLocator + * @property {'story'} kind + * @property {'endnote'} storyType + * @property {string} noteId + */ + +/** + * @typedef {BodyStoryLocator | HeaderFooterSlotStoryLocator | HeaderFooterPartStoryLocator | FootnoteStoryLocator | EndnoteStoryLocator} StoryLocator + */ + +/** + * @typedef {Object} BookmarkAddress + * @property {'entity'} kind + * @property {'bookmark'} entityType + * @property {string} name + * @property {StoryLocator} [story] + */ + +/** + * @typedef {Object} CommentAddress + * @property {'entity'} kind + * @property {'comment'} entityType + * @property {string} entityId + * @property {StoryLocator} [story] + */ + +/** + * @typedef {Object} TrackedChangeAddress + * @property {'entity'} kind + * @property {'trackedChange'} entityType + * @property {string} entityId + * @property {StoryLocator} [story] + */ + +/** + * @typedef {BookmarkAddress | CommentAddress | TrackedChangeAddress} NavigableEntityAddress + */ + /** * Context passed to a link popover resolver when a link is clicked. * @typedef {Object} LinkPopoverContext diff --git a/tests/behavior/helpers/document-api.ts b/tests/behavior/helpers/document-api.ts index 278f972128..c939ec2c40 100644 --- a/tests/behavior/helpers/document-api.ts +++ b/tests/behavior/helpers/document-api.ts @@ -3,6 +3,7 @@ import type { TextAddress, SelectionTarget, MatchContext, + NavigableEntityAddress, TrackChangeType, CommentsListResult, TrackChangesListResult, @@ -340,6 +341,17 @@ export async function listTrackChanges( }, query) as Promise; } +export async function navigateToEntity(page: Page, target: NavigableEntityAddress): Promise { + return page.evaluate((payload) => { + const navigationHost = (window as any).superdoc; + if (typeof navigationHost?.navigateTo !== 'function') { + throw new Error('Navigation API is unavailable: expected superdoc.navigateTo().'); + } + + return navigationHost.navigateTo(payload); + }, target); +} + export async function listItems(page: Page, query: ListsListQuery = {}): Promise { return page.evaluate((input) => (window as any).editor.doc.lists.list(input), query); } diff --git a/tests/behavior/tests/navigation/header-bookmark-crud.spec.ts b/tests/behavior/tests/navigation/header-bookmark-crud.spec.ts new file mode 100644 index 0000000000..065accf6a5 --- /dev/null +++ b/tests/behavior/tests/navigation/header-bookmark-crud.spec.ts @@ -0,0 +1,204 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Page } from '@playwright/test'; +import type { StoryLocator } from '@superdoc/document-api'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { assertDocumentApiReady } from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const HEADER_DOC_PATH = path.resolve( + __dirname, + '../../../../packages/super-editor/src/editors/v1/tests/data/longer-header-sign-area.docx', +); + +test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Test document not available'); + +/** + * Resolves a header story locator and a text range within it that can be used + * as an insertion target for bookmarks. + */ +async function resolveHeaderInsertionTarget(page: Page) { + return page.evaluate(() => { + const docApi = (window as any).editor?.doc; + if (!docApi?.headerFooters?.list || !docApi?.find || !docApi?.bookmarks) { + throw new Error('Required document APIs are unavailable.'); + } + + const headers = docApi.headerFooters.list({ kind: 'header' }); + const entry = headers?.items?.find((item: any) => item?.variant === 'default') ?? headers?.items?.[0]; + if (!entry?.section?.sectionId) { + throw new Error('Unable to resolve a header/footer slot for the test document.'); + } + + const story: StoryLocator = { + kind: 'story', + storyType: 'headerFooterSlot', + section: entry.section, + headerFooterKind: 'header', + variant: entry.variant ?? 'default', + } as any; + + const toRanges = (item: any) => { + const blocks = Array.isArray(item?.blocks) ? item.blocks : []; + return blocks + .map((block: any) => { + const blockId = block?.blockId; + const start = block?.range?.start; + const end = block?.range?.end; + if (typeof blockId !== 'string' || typeof start !== 'number' || typeof end !== 'number') return null; + return { kind: 'text' as const, blockId, range: { start, end } }; + }) + .filter(Boolean); + }; + + const queryMatch = docApi?.query?.match; + const queryResult = + typeof queryMatch === 'function' + ? queryMatch({ + select: { type: 'text', pattern: 'Generic content header', mode: 'contains' }, + require: 'any', + in: story, + }) + : null; + + const queryItem = Array.isArray(queryResult?.items) ? queryResult.items[0] : null; + const findResult = + queryItem == null + ? docApi.find({ + select: { type: 'text', pattern: 'Generic content header', mode: 'contains' }, + in: story, + limit: 1, + }) + : null; + const firstItem = queryItem ?? (Array.isArray(findResult?.items) ? findResult.items[0] : null); + const textRange = toRanges(firstItem)[0] ?? null; + + if (!textRange) { + throw new Error('Unable to resolve a header text range for bookmark insertion.'); + } + + return { story, textRange }; + }); +} + +test.describe('Header bookmark CRUD', () => { + test('@behavior insert, get, list, rename, and remove a bookmark in a header story', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(2000); + await assertDocumentApiReady(superdoc.page); + + const { story, textRange } = await resolveHeaderInsertionTarget(superdoc.page); + const bookmarkName = `hf-crud-${Date.now()}`; + + // --- Insert --- + const insertResult = await superdoc.page.evaluate( + ({ name, at, storyLocator }) => { + return (window as any).editor.doc.bookmarks.insert({ + name, + at: { kind: 'text', segments: [at], story: storyLocator }, + }); + }, + { name: bookmarkName, at: textRange, storyLocator: story }, + ); + + expect(insertResult.success).toBe(true); + expect(insertResult.bookmark).toEqual( + expect.objectContaining({ + kind: 'entity', + entityType: 'bookmark', + name: bookmarkName, + story: expect.objectContaining({ storyType: 'headerFooterSlot' }), + }), + ); + + // --- Get --- + const getResult = await superdoc.page.evaluate( + ({ name, storyLocator }) => { + return (window as any).editor.doc.bookmarks.get({ + target: { kind: 'entity', entityType: 'bookmark', name, story: storyLocator }, + }); + }, + { name: bookmarkName, storyLocator: story }, + ); + + expect(getResult).toEqual( + expect.objectContaining({ + name: bookmarkName, + address: expect.objectContaining({ + kind: 'entity', + entityType: 'bookmark', + name: bookmarkName, + }), + }), + ); + + // --- List (filtered to header story) --- + const listResult = await superdoc.page.evaluate( + ({ storyLocator }) => { + return (window as any).editor.doc.bookmarks.list({ in: storyLocator }); + }, + { storyLocator: story }, + ); + + const listedNames: string[] = (listResult?.items ?? []).map((item: any) => item?.name ?? item?.address?.name); + expect(listedNames).toContain(bookmarkName); + + // --- Rename --- + const renamedName = `${bookmarkName}-renamed`; + const renameResult = await superdoc.page.evaluate( + ({ oldName, newName, storyLocator }) => { + return (window as any).editor.doc.bookmarks.rename({ + target: { kind: 'entity', entityType: 'bookmark', name: oldName, story: storyLocator }, + newName, + }); + }, + { oldName: bookmarkName, newName: renamedName, storyLocator: story }, + ); + + expect(renameResult.success).toBe(true); + expect(renameResult.bookmark).toEqual( + expect.objectContaining({ + name: renamedName, + story: expect.objectContaining({ storyType: 'headerFooterSlot' }), + }), + ); + + // --- Get after rename --- + const getAfterRename = await superdoc.page.evaluate( + ({ name, storyLocator }) => { + return (window as any).editor.doc.bookmarks.get({ + target: { kind: 'entity', entityType: 'bookmark', name, story: storyLocator }, + }); + }, + { name: renamedName, storyLocator: story }, + ); + + expect(getAfterRename).toEqual(expect.objectContaining({ name: renamedName })); + + // --- Remove --- + const removeResult = await superdoc.page.evaluate( + ({ name, storyLocator }) => { + return (window as any).editor.doc.bookmarks.remove({ + target: { kind: 'entity', entityType: 'bookmark', name, story: storyLocator }, + }); + }, + { name: renamedName, storyLocator: story }, + ); + + expect(removeResult.success).toBe(true); + + // --- List after remove (bookmark should be gone) --- + const listAfterRemove = await superdoc.page.evaluate( + ({ storyLocator }) => { + return (window as any).editor.doc.bookmarks.list({ in: storyLocator }); + }, + { storyLocator: story }, + ); + + const remainingNames: string[] = (listAfterRemove?.items ?? []).map( + (item: any) => item?.name ?? item?.address?.name, + ); + expect(remainingNames).not.toContain(renamedName); + }); +}); diff --git a/tests/behavior/tests/navigation/navigate-to-entity.spec.ts b/tests/behavior/tests/navigation/navigate-to-entity.spec.ts new file mode 100644 index 0000000000..fa3c07b305 --- /dev/null +++ b/tests/behavior/tests/navigation/navigate-to-entity.spec.ts @@ -0,0 +1,382 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Page } from '@playwright/test'; +import type { StoryLocator } from '@superdoc/document-api'; +import { test, expect } from '../../fixtures/superdoc.js'; +import { + assertDocumentApiReady, + listComments, + listTrackChanges, + navigateToEntity, +} from '../../helpers/document-api.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC_PATH = path.resolve( + __dirname, + '../../../../packages/super-editor/src/editors/v1/tests/data/advanced-text.docx', +); +const HEADER_DOC_PATH = path.resolve( + __dirname, + '../../../../packages/super-editor/src/editors/v1/tests/data/longer-header-sign-area.docx', +); + +test.skip(!fs.existsSync(DOC_PATH), 'Test document not available'); +test.use({ config: { comments: 'on', trackChanges: true } }); + +type SelectionRange = { from: number; to: number }; + +async function resolveBookmarkPosition(page: Page, bookmarkName: string): Promise { + return page.evaluate((name) => { + const doc = (window as any).editor?.state?.doc; + if (!doc) throw new Error('Editor state is unavailable.'); + + let resolvedPos: number | null = null; + doc.descendants((node: any, pos: number) => { + const nodeName = node?.type?.name; + const attrs = node?.attrs ?? {}; + const candidateName = attrs.name ?? attrs['w:name']; + if (nodeName === 'bookmarkStart' && candidateName === name) { + resolvedPos = pos; + return false; + } + return undefined; + }); + + return resolvedPos; + }, bookmarkName); +} + +async function resolveMarkedRange(page: Page, markName: string, entityId: string): Promise { + return page.evaluate( + ({ name, id }) => { + const doc = (window as any).editor?.state?.doc; + if (!doc) throw new Error('Editor state is unavailable.'); + + let from: number | null = null; + let to: number | null = null; + + doc.descendants((node: any, pos: number) => { + if (!node?.isText || typeof node.text !== 'string') return undefined; + + const hasMark = Array.isArray(node.marks) + ? node.marks.some((mark: any) => { + if (mark?.type?.name !== name) return false; + const attrs = mark?.attrs ?? {}; + return attrs.id === id || attrs.commentId === id || attrs.importedId === id; + }) + : false; + + if (!hasMark) return undefined; + + const start = pos; + const end = pos + node.nodeSize; + from = from == null ? start : Math.min(from, start); + to = to == null ? end : Math.max(to, end); + return undefined; + }); + + if (from == null || to == null) return null; + return { from, to }; + }, + { name: markName, id: entityId }, + ); +} + +async function resolveFirstMarkedRange(page: Page, markName: string): Promise { + return page.evaluate((name) => { + const doc = (window as any).editor?.state?.doc; + if (!doc) throw new Error('Editor state is unavailable.'); + + let from: number | null = null; + let to: number | null = null; + + doc.descendants((node: any, pos: number) => { + if (!node?.isText || typeof node.text !== 'string') return undefined; + + const hasMark = Array.isArray(node.marks) ? node.marks.some((mark: any) => mark?.type?.name === name) : false; + + if (!hasMark) return undefined; + + const start = pos; + const end = pos + node.nodeSize; + from = from == null ? start : Math.min(from, start); + to = to == null ? end : Math.max(to, end); + return undefined; + }); + + if (from == null || to == null) return null; + return { from, to }; + }, markName); +} + +async function expectCursorWithinRange(page: Page, range: SelectionRange): Promise { + await expect + .poll(async () => { + const selection = await page.evaluate(() => { + const { from, to } = (window as any).editor.state.selection; + return { from, to }; + }); + + return selection.from === selection.to && selection.from >= range.from && selection.from <= range.to; + }) + .toBe(true); +} + +async function expectCursorNearExcerpt(page: Page, excerpt: string): Promise { + await expect + .poll(async () => { + return page.evaluate((expectedExcerpt) => { + const editor = (window as any).editor; + const state = editor?.state; + if (!state?.doc || !state?.selection) return false; + + const from = state.selection.from; + const to = state.selection.to; + if (from !== to || from <= 1) return false; + + const around = state.doc.textBetween( + Math.max(0, from - 80), + Math.min(state.doc.content.size, from + 120), + '\n', + '\n', + ); + + return typeof around === 'string' && around.includes(expectedExcerpt); + }, excerpt); + }) + .toBe(true); +} + +async function createHeaderBookmark(page: Page): Promise<{ story: StoryLocator; name: string }> { + return page.evaluate(() => { + const editor = (window as any).editor; + const docApi = editor?.doc; + if (!docApi?.headerFooters?.list || !docApi?.find || !docApi?.bookmarks?.insert || !docApi?.bookmarks?.get) { + throw new Error('Required document APIs are unavailable for header bookmark setup.'); + } + + const headers = docApi.headerFooters.list({ kind: 'header' }); + const entry = headers?.items?.find((item: any) => item?.variant === 'default') ?? headers?.items?.[0]; + if (!entry?.section?.sectionId) { + throw new Error('Unable to resolve a header/footer slot for the test document.'); + } + + const story = { + kind: 'story', + storyType: 'headerFooterSlot', + section: entry.section, + headerFooterKind: 'header', + variant: entry.variant ?? 'default', + } as const; + + const toRanges = (item: any): Array<{ kind: 'text'; blockId: string; range: { start: number; end: number } }> => { + const blocks = Array.isArray(item?.blocks) ? item.blocks : []; + const fromBlocks = blocks + .map((block: any) => { + const blockId = block?.blockId; + const start = block?.range?.start; + const end = block?.range?.end; + if (typeof blockId !== 'string' || typeof start !== 'number' || typeof end !== 'number') return null; + return { kind: 'text' as const, blockId, range: { start, end } }; + }) + .filter(Boolean); + + if (fromBlocks.length > 0) return fromBlocks; + + const legacyRanges = Array.isArray(item?.context?.textRanges) ? item.context.textRanges : []; + return legacyRanges.filter( + (range: any) => + range?.kind === 'text' && + typeof range?.blockId === 'string' && + typeof range?.range?.start === 'number' && + typeof range?.range?.end === 'number', + ); + }; + + const queryMatch = docApi?.query?.match; + const queryResult = + typeof queryMatch === 'function' + ? queryMatch({ + select: { type: 'text', pattern: 'Generic content header', mode: 'contains' }, + require: 'any', + in: story, + }) + : null; + + const queryItem = Array.isArray(queryResult?.items) ? queryResult.items[0] : null; + const findResult = + queryItem == null + ? docApi.find({ + select: { type: 'text', pattern: 'Generic content header', mode: 'contains' }, + in: story, + limit: 1, + }) + : null; + const firstItem = queryItem ?? (Array.isArray(findResult?.items) ? findResult.items[0] : null); + const textRange = toRanges(firstItem)[0] ?? null; + + if ( + !textRange || + textRange.kind !== 'text' || + typeof textRange.blockId !== 'string' || + typeof textRange.range?.start !== 'number' || + typeof textRange.range?.end !== 'number' + ) { + throw new Error('Unable to resolve a header text range for bookmark insertion.'); + } + + const name = `hf-nav-${Date.now()}`; + const insertResult = docApi.bookmarks.insert({ + name, + at: { + kind: 'text', + segments: [ + { + blockId: textRange.blockId, + range: textRange.range, + }, + ], + story, + }, + }); + + if (!insertResult?.success) { + throw new Error(`Bookmark insert failed: ${insertResult?.failure?.code ?? 'UNKNOWN'}`); + } + + return { story, name }; + }); +} + +test('@behavior navigateTo moves the caret to a bookmark in advanced-text.docx', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(2000); + await assertDocumentApiReady(superdoc.page); + + const bookmarkName = '_Paragraph_level_formatting'; + const bookmarkPos = await resolveBookmarkPosition(superdoc.page, bookmarkName); + expect(bookmarkPos).not.toBeNull(); + + await superdoc.setTextSelection(1); + const didNavigate = await navigateToEntity(superdoc.page, { + kind: 'entity', + entityType: 'bookmark', + name: bookmarkName, + }); + + expect(didNavigate).toBe(true); + await expect + .poll(() => superdoc.getSelection()) + .toEqual(expect.objectContaining({ from: bookmarkPos as number, to: bookmarkPos as number })); +}); + +test('@behavior navigateTo activates the anchored comment in advanced-text.docx', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(2000); + await assertDocumentApiReady(superdoc.page); + + const comments = (await listComments(superdoc.page, { includeResolved: true })) as any; + const comment = comments.matches[0]; + expect(comment?.commentId).toBeTruthy(); + + const commentRange = await resolveMarkedRange(superdoc.page, 'commentMark', comment.commentId); + expect(commentRange).not.toBeNull(); + + await superdoc.setTextSelection(1); + const didNavigate = await navigateToEntity(superdoc.page, { + kind: 'entity', + entityType: 'comment', + entityId: comment.commentId, + }); + + expect(didNavigate).toBe(true); + await expectCursorWithinRange(superdoc.page, commentRange as SelectionRange); + await expect( + superdoc.page.locator(`.comment-placeholder[data-comment-id="${comment.commentId}"] .comments-dialog.is-active`), + ).toBeVisible({ timeout: 10_000 }); +}); + +test('@behavior navigateTo moves the caret to a tracked change in advanced-text.docx', async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(2000); + await assertDocumentApiReady(superdoc.page); + + const trackedChanges = (await listTrackChanges(superdoc.page, { type: 'insert' })) as any; + const trackedChange = trackedChanges.changes[0]; + expect(trackedChange?.id).toBeTruthy(); + + const trackedChangeRange = + (await resolveMarkedRange(superdoc.page, 'trackInsert', trackedChange.id)) ?? + (await resolveFirstMarkedRange(superdoc.page, 'trackInsert')); + expect(trackedChangeRange).not.toBeNull(); + + await superdoc.setTextSelection(1); + const didNavigate = await navigateToEntity(superdoc.page, { + kind: 'entity', + entityType: 'trackedChange', + entityId: trackedChange.id, + }); + + expect(didNavigate).toBe(true); + await expectCursorNearExcerpt(superdoc.page, trackedChange.excerpt); +}); + +test('@behavior navigateTo activates a header bookmark when given a header story address', async ({ superdoc }) => { + await superdoc.loadDocument(HEADER_DOC_PATH); + await superdoc.waitForStable(2000); + await assertDocumentApiReady(superdoc.page); + + const headerBookmark = await createHeaderBookmark(superdoc.page); + + await superdoc.setTextSelection(1); + const didNavigate = await navigateToEntity(superdoc.page, { + kind: 'entity', + entityType: 'bookmark', + name: headerBookmark.name, + story: headerBookmark.story, + }); + + expect(didNavigate).toBe(true); + await expect(superdoc.page.locator('.superdoc-header-editor-host').first()).toBeVisible({ timeout: 10_000 }); + + await expect + .poll(async () => { + return superdoc.page.evaluate((bookmarkName) => { + const activeEditor = (window as any).editor?.presentationEditor?.getActiveEditor?.(); + const selection = activeEditor?.state?.selection; + if (!selection) return null; + + let bookmarkPos: number | null = null; + activeEditor.state.doc.descendants((node: any, pos: number) => { + const candidateName = node?.attrs?.name ?? node?.attrs?.['w:name']; + if (node?.type?.name === 'bookmarkStart' && candidateName === bookmarkName) { + bookmarkPos = pos; + return false; + } + return undefined; + }); + + const snippet = activeEditor.state.doc.textBetween( + Math.max(0, selection.from - 40), + Math.min(activeEditor.state.doc.content.size, selection.from + 60), + '\n', + '\n', + ); + + return { + from: selection.from, + to: selection.to, + snippet, + documentId: activeEditor.options?.documentId ?? null, + matchesExpectedPos: bookmarkPos != null && selection.from === bookmarkPos && selection.to === bookmarkPos, + }; + }, headerBookmark.name); + }) + .toEqual( + expect.objectContaining({ + matchesExpectedPos: true, + snippet: expect.stringContaining('Generic content header'), + }), + ); +});