diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index df697952dc..2f4c85918d 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -1718,11 +1718,30 @@ export class Editor extends EventEmitter { const clampedPos = Math.max(0, Math.min(pos, maxPos)); try { - const { node } = this.view.domAtPos(clampedPos); - if (node && node.nodeType === 1) { - return node as HTMLElement; + // ProseMirror's domAtPos returns either: + // - { node: , offset: }, or + // - { node: , offset: } when the position is + // between block children. + // The previous version returned the parent in the second case, which + // for the editor root means the entire document — scrolling that into + // view always lands at the top. Resolve to the actual child element + // when the returned node is an element parent. + const { node, offset } = this.view.domAtPos(clampedPos); + if (!node) return null; + + if (node.nodeType === 1) { + const parent = node as Element; + if (parent.childNodes?.length) { + const idx = Math.min(Math.max(0, offset), parent.childNodes.length - 1); + const child = parent.childNodes[idx]; + if (child) { + if (child.nodeType === 1) return child as HTMLElement; + if (child.nodeType === 3) return (child as Node).parentElement; + } + } + return parent as HTMLElement; } - if (node && node.nodeType === 3) { + if (node.nodeType === 3) { return node.parentElement; } return node?.parentElement ?? null; 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 72ff565c6d..a6b66e62e9 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 @@ -3712,7 +3712,20 @@ export class PresentationEditor extends EventEmitter { */ async scrollToPositionAsync( pos: number, - options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {}, + options: { + block?: 'start' | 'center' | 'end' | 'nearest'; + behavior?: ScrollBehavior; + /** + * Maximum time (ms) to wait for the painter to mount the target + * virtualised page before giving up. Defaults to + * `ANCHOR_NAV_TIMEOUT_MS` (2000 ms). Callers navigating across + * long documents — where the painter may need longer than 2 s to + * settle when jumping far from the current viewport — can extend + * this. The function still returns `false` on timeout, but the + * extension reduces false-negative anchor navigations. + */ + timeoutMs?: number; + } = {}, ): Promise { // Fast path: try sync scroll first (works if page already mounted) if (this.scrollToPosition(pos, options)) { @@ -3747,11 +3760,15 @@ export class PresentationEditor extends EventEmitter { this.#scrollPageIntoView(pageIndex); // Wait for page to mount in the DOM - const mounted = await this.#waitForPageMount(pageIndex, { - timeout: PresentationEditor.ANCHOR_NAV_TIMEOUT_MS, - }); + const timeout = + Number.isFinite(options.timeoutMs) && options.timeoutMs! > 0 + ? options.timeoutMs! + : PresentationEditor.ANCHOR_NAV_TIMEOUT_MS; + const mounted = await this.#waitForPageMount(pageIndex, { timeout }); if (!mounted) { - console.warn(`[PresentationEditor] scrollToPositionAsync: Page ${pageIndex} failed to mount within timeout`); + console.warn( + `[PresentationEditor] scrollToPositionAsync: Page ${pageIndex} failed to mount within ${timeout} ms`, + ); return false; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts index 76c3e7b0f3..1a3b9df88c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts @@ -667,7 +667,34 @@ describe('PresentationEditor - scrollToPosition', () => { // The page never mounted, so it should fail expect(result).toBe(false); - expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('failed to mount within timeout')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('failed to mount within')); + + consoleWarnSpy.mockRestore(); + }); + + it('honours a caller-supplied timeoutMs and surfaces the value in the warn message', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + editor = new PresentationEditor({ + element: container, + documentId: 'test-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // 100 ms ceiling — the page never mounts, so it should bail + // much faster than the 2 s default. + const t0 = Date.now(); + const result = await editor.scrollToPositionAsync(150, { timeoutMs: 100 }); + const elapsed = Date.now() - t0; + + expect(result).toBe(false); + // The warning should mention the supplied timeout (100 ms), not + // the static default. + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('100 ms')); + // And we should have bailed well under the 2 s default. + expect(elapsed).toBeLessThan(1500); consoleWarnSpy.mockRestore(); }); diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 59d9a729d1..c9f7af0227 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -1323,27 +1323,29 @@ export class SuperDoc extends EventEmitter { /** * Scroll the document to a given comment by id. * + * Delegates to {@link scrollToElement} so it works in both flow and + * paginated layouts. The previous implementation looked up the highlight + * span via `[data-comment-ids*=...]` and called `scrollIntoView()` on it + * directly — that fails in paginated/print mode (the painter virtualises + * pages so the highlight may not be in the DOM) and also fails for marks + * SuperDoc didn't emit a visible highlight for (e.g. two marks sharing a + * single position). The unified path walks the ProseMirror doc for the + * mark and dispatches to the presentation editor where available, + * falling back to the body editor in flow mode. + * * @param {string} commentId The comment id * @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options] - * @returns {boolean} Whether a matching element was found + * @returns {Promise} Whether the comment was found and scrolled to */ - scrollToComment(commentId, options = {}) { + async scrollToComment(commentId, options = {}) { const commentsConfig = this.config?.modules?.comments; - // `commentsConfig` can be `false | object | undefined`; `!commentsConfig` - // already covers both `false` and `undefined`, so the secondary - // `=== false` check below is redundant. if (!commentsConfig) return false; if (!commentId || typeof commentId !== 'string') return false; - const root = this.element || document; - const escaped = globalThis.CSS?.escape ? globalThis.CSS.escape(commentId) : commentId.replace(/"/g, '\\"'); - const element = root.querySelector(`[data-comment-ids*="${escaped}"]`); - if (!element) return false; - - const { behavior = 'smooth', block = 'start' } = options ?? {}; - element.scrollIntoView({ behavior, block }); + // Activate the thread in the side panel for visual continuity even if + // the scroll path subsequently bails. this.commentsStore?.setActiveComment?.(this, commentId); - return true; + return this.scrollToElement(commentId, options); } /** @@ -1371,7 +1373,13 @@ export class SuperDoc extends EventEmitter { * change entityId. The method resolves the element type automatically * and scrolls to it. * + * In paginated (presentation) layouts this delegates to the + * presentation editor's `scrollToElement`. In flow / web layouts the + * presentation editor doesn't apply, so we fall back to walking the + * ProseMirror doc directly and scrolling the body editor's view. + * * @param {string} elementId - The element's stable ID. + * @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options] * @returns {Promise} Whether the element was found and scrolled to. * * @example @@ -1381,13 +1389,207 @@ export class SuperDoc extends EventEmitter { * // Navigate to a comment by its entityId * await superdoc.scrollToElement('imported-25def254'); */ - async scrollToElement(elementId) { + async scrollToElement(elementId, options = {}) { + if (!elementId) return false; /** @type {RuntimeDocument[] | undefined} */ const storeDocs = this.superdocStore?.documents; if (!storeDocs?.length) return false; + const presentationEditor = storeDocs[0].getPresentationEditor?.(); - if (!presentationEditor?.scrollToElement) return false; - return presentationEditor.scrollToElement(elementId); + if (presentationEditor?.scrollToElement) { + const ok = await presentationEditor.scrollToElement(elementId); + if (ok) return true; + // Otherwise: presentationEditor may have returned false because layout + // state isn't active (flow/web mode masquerading as presentation). Fall + // through to the body-editor path. + } + + return this._scrollToElementInBodyEditor(elementId, options); + } + + /** + * Flow-layout fallback for {@link scrollToElement}. + * + * The body editor IS the visible view in flow mode, so we walk PM for the + * target position and use the editor's own DOM-by-position helper, then + * scroll the resulting element into view. Tries comment / tracked-change + * marks (via the existing `setCursorById` command) first, then falls back + * to block-level node IDs (paragraphs, headings, tables) by attribute + * matching. + * + * @param {string} elementId + * @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options] + * @returns {Promise} + * @private + */ + async _scrollToElementInBodyEditor(elementId, options = {}) { + const editor = this.activeEditor; + if (!editor?.state?.doc) return false; + + let pos = null; + + // 1. Try the comments-plugin command — handles commentMark, tracked + // change marks, and commentRangeStart/End nodes uniformly. + const setCursorById = editor.commands?.setCursorById; + if (typeof setCursorById === 'function') { + if (setCursorById(elementId, { preferredActiveThreadId: elementId })) { + pos = editor.state.selection?.from; + } + } + + // 2. Fall back to a single PM walk looking for matching block-level + // id attributes. Block nodes can carry multiple ID-shaped attrs + // at once — e.g. paragraphs from a `.docx` carry both `paraId` + // (the OOXML `w14:paraId`) and `sdBlockId` (minted by SuperDoc + // on import). We must compare against each independently rather + // than picking the first non-null and comparing, because the + // caller may have a handle on any one of them and consumers + // shouldn't have to know which ID type a given block carries. + if (pos == null || !Number.isFinite(pos)) { + editor.state.doc.descendants((node, p) => { + if (pos != null) return false; + const a = node.attrs || {}; + if (a.nodeId === elementId || a.sdBlockId === elementId || a.id === elementId || a.paraId === elementId) { + pos = p; + return false; + } + }); + } + + if (pos == null || !Number.isFinite(pos)) return false; + + const targetEl = typeof editor.getElementAtPos === 'function' ? editor.getElementAtPos(pos) : null; + if (!targetEl?.scrollIntoView) return false; + + const { behavior = 'smooth', block = 'center' } = options; + try { + targetEl.scrollIntoView({ behavior, block, inline: 'nearest' }); + } catch { + // Ignore scroll failures in environments with incomplete DOM APIs. + return false; + } + return true; + } + + /** + * Scroll to the Nth heading of a given level (1..6). + * + * In OOXML headings are paragraphs whose `paragraphProperties.styleId` is + * `Heading1`..`Heading6` (the schema also accepts a `heading` node type + * with a `level` attr for editor-native callers). This walks the doc in + * order, counts headings whose level matches, and scrolls to the + * 1-based `ordinal`-th one. + * + * @param {number} level 1..6 + * @param {number} [ordinal=1] 1-based index among headings of that level + * @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition, timeoutMs?: number }} [options] + * Pass `timeoutMs` to override the default 2 s page-mount wait + * in paginated layout. Useful when jumping far from the current + * viewport on long docs where the painter takes longer to + * mount the target page. + * @returns {Promise} Whether a matching heading was found and scrolled to + * + * @example + * // Scroll to the third top-level heading (a.k.a. chapter 3) + * await superdoc.scrollToHeading(1, 3); + */ + async scrollToHeading(level, ordinal = 1, options = {}) { + if (!Number.isInteger(level) || level < 1 || level > 6) return false; + if (!Number.isInteger(ordinal) || ordinal < 1) return false; + + const editor = this.activeEditor; + if (!editor?.state?.doc) return false; + + let count = 0; + let foundPos = null; + let foundNode = null; + editor.state.doc.descendants((node, p) => { + if (foundPos !== null) return false; + const t = node.type?.name; + let nodeLevel = null; + if (t === 'heading' && node.attrs?.level) { + nodeLevel = Number(node.attrs.level); + } else if (t === 'paragraph') { + const styleId = node.attrs?.paragraphProperties?.styleId ?? node.attrs?.styleId ?? null; + if (typeof styleId === 'string') { + const m = styleId.match(/^Heading(\d)$/); + if (m) nodeLevel = Number(m[1]); + } + } + if (nodeLevel === level) { + count += 1; + if (count === ordinal) { + foundPos = p; + foundNode = node; + return false; + } + } + }); + + if (foundPos === null) return false; + + // The position from descendants() is the doc-level boundary just + // BEFORE the heading paragraph. The presentation editor's + // layout-fragment index only covers positions INSIDE text content, + // so a doc-boundary position misses every fragment. Walk into the + // paragraph to find the first descendant that has actual text + // content (skipping bookmark markers, comment-range markers, etc.) + // and target that position instead. + let resolved = null; + if (foundNode && foundNode.content?.size > 0) { + foundNode.descendants((child, offset) => { + if (resolved !== null) return false; + if (child.isText && child.text && child.text.length > 0) { + // Position inside the paragraph = paragraph-start (foundPos+1) + // + descendant offset. + resolved = foundPos + 1 + offset; + return false; + } + }); + } + if (resolved == null) { + // The heading itself carries no text content (truly-empty + // paragraph, or content limited to structural markers like + // bookmarkStart). Walk forward in the doc for the next text-bearing + // position so the viewport at least lands near where the heading + // lives instead of returning false. + editor.state.doc.descendants((child, p) => { + if (resolved !== null) return false; + if (p <= foundPos) return undefined; + if (child.isText && child.text && child.text.length > 0) { + resolved = p; + return false; + } + }); + } + if (resolved != null) foundPos = resolved; + + // Same dispatch as scrollToElement: presentation if available, else + // body-editor + DOM scrollIntoView. + const storeDocs = this.superdocStore?.documents; + const presentationEditor = storeDocs?.[0]?.getPresentationEditor?.(); + if (typeof presentationEditor?.scrollToPositionAsync === 'function') { + const ok = await presentationEditor.scrollToPositionAsync(foundPos, { + behavior: options.behavior ?? 'auto', + block: options.block ?? 'center', + // Pass-through so callers can extend the page-mount wait on + // long docs without reaching into PresentationEditor directly. + ...(Number.isFinite(options.timeoutMs) ? { timeoutMs: options.timeoutMs } : {}), + }); + if (ok) return true; + // Fall through to body-editor path on layout-state miss. + } + + const targetEl = typeof editor.getElementAtPos === 'function' ? editor.getElementAtPos(foundPos) : null; + if (!targetEl?.scrollIntoView) return false; + + const { behavior = 'smooth', block = 'center' } = options; + try { + targetEl.scrollIntoView({ behavior, block, inline: 'nearest' }); + } catch { + return false; + } + return true; } /** diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.js index 70ab774045..e359c48a97 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.js @@ -276,8 +276,15 @@ describe('SuperDoc core', () => { expect(instance.user).toEqual(expect.objectContaining({ name: 'Default SuperDoc user', email: null })); }); - it('scrolls to a comment and sets it active', async () => { - const { commentsStore } = createAppHarness(); + it('delegates scrollToComment to scrollToElement and marks the thread active', async () => { + const { superdocStore, commentsStore } = createAppHarness(); + const scrollToElement = vi.fn(async () => true); + superdocStore.documents = [ + { + getPresentationEditor: vi.fn(() => ({ scrollToElement })), + }, + ]; + const instance = new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', @@ -289,19 +296,21 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - const target = document.createElement('div'); - target.setAttribute('data-comment-ids', 'comment-1'); - target.scrollIntoView = vi.fn(); - document.querySelector('#host').appendChild(target); - - const result = instance.scrollToComment('comment-1'); - expect(result).toBe(true); - expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + await expect(instance.scrollToComment('comment-1')).resolves.toBe(true); + expect(scrollToElement).toHaveBeenCalledWith('comment-1'); expect(commentsStore.setActiveComment).toHaveBeenCalledWith(instance, 'comment-1'); }); - it('returns false when comment element is not found', async () => { - createAppHarness(); + it('scrollToComment resolves false when neither presentation nor body editor can locate the id', async () => { + const { superdocStore } = createAppHarness(); + superdocStore.documents = [ + { + // Presentation editor present but returns false (e.g. mark on an unmounted page, + // or no such id in the doc). + getPresentationEditor: vi.fn(() => ({ scrollToElement: vi.fn(async () => false) })), + }, + ]; + const instance = new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', @@ -311,7 +320,23 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.scrollToComment('nonexistent-id')).toBe(false); + // No activeEditor on the instance → body-editor fallback also returns false. + await expect(instance.scrollToComment('nonexistent-id')).resolves.toBe(false); + }); + + it('scrollToComment returns false when comments module is disabled', async () => { + createAppHarness(); + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + // no `modules.comments` + modules: { toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + await expect(instance.scrollToComment('any-id')).resolves.toBe(false); }); it('forwards navigateTo to the first presentation editor', async () => { @@ -407,6 +432,221 @@ describe('SuperDoc core', () => { await expect(instance.scrollToElement('element-1')).resolves.toBe(false); }); + it('scrollToElement falls back to body editor when presentation returns false', async () => { + const { superdocStore } = createAppHarness(); + superdocStore.documents = [ + { + // Presentation editor is present but cannot scroll (e.g. flow layout). + getPresentationEditor: vi.fn(() => ({ scrollToElement: vi.fn(async () => false) })), + }, + ]; + + const scrollIntoView = vi.fn(); + const targetEl = { scrollIntoView }; + const setCursorById = vi.fn(() => true); + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + // Inject a minimal activeEditor stub. The body-editor fallback uses + // `setCursorById` to resolve the id and `getElementAtPos` to find the DOM. + Object.defineProperty(instance, 'activeEditor', { + configurable: true, + get: () => ({ + state: { + doc: { content: { size: 100 }, descendants: vi.fn() }, + selection: { from: 42 }, + }, + commands: { setCursorById }, + getElementAtPos: vi.fn(() => targetEl), + }), + }); + + await expect(instance.scrollToElement('comment-1')).resolves.toBe(true); + expect(setCursorById).toHaveBeenCalledWith('comment-1', { preferredActiveThreadId: 'comment-1' }); + expect(scrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ block: expect.any(String), inline: 'nearest' }), + ); + }); + + it('scrollToElement matches paraId even when sdBlockId is also present', async () => { + const { superdocStore } = createAppHarness(); + superdocStore.documents = [{ getPresentationEditor: vi.fn(() => ({ scrollToElement: vi.fn(async () => false) })) }]; + + const scrollIntoView = vi.fn(); + const targetEl = { scrollIntoView }; + const setCursorById = vi.fn(() => false); + + // A paragraph carrying BOTH a long sdBlockId and a short paraId. + // The walk must match against each attr independently — picking + // the first non-null and comparing would let sdBlockId mask paraId. + const node = { + attrs: { + sdBlockId: '3496bf7f-b408-489d-9d1d-7a6854c09e70', + paraId: '00000001', + }, + }; + const descendants = (cb) => { + cb(node, 7); + }; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + Object.defineProperty(instance, 'activeEditor', { + configurable: true, + get: () => ({ + state: { doc: { descendants, content: { size: 100 } }, selection: { from: null } }, + commands: { setCursorById }, + getElementAtPos: vi.fn(() => targetEl), + }), + }); + + await expect(instance.scrollToElement('00000001')).resolves.toBe(true); + expect(scrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ block: expect.any(String), inline: 'nearest' }), + ); + }); + + it('scrollToHeading walks for the Nth heading at the given level and scrolls', async () => { + const { superdocStore } = createAppHarness(); + // Mock doc with three Heading1 paragraphs at known positions. + const headings = [ + { pos: 10, text: 'first', styleId: 'Heading1' }, + { pos: 50, text: 'second', styleId: 'Heading1' }, + { pos: 200, text: 'third', styleId: 'Heading1' }, + ]; + const makeNode = (h) => ({ + type: { name: 'paragraph' }, + attrs: { paragraphProperties: { styleId: h.styleId } }, + content: { size: 5 }, + descendants: (cb) => cb({ isText: true, text: h.text }, 0), + }); + const descendants = (cb) => { + for (const h of headings) { + if (cb(makeNode(h), h.pos) === false) return; + } + }; + + const scrollToPositionAsync = vi.fn(async () => true); + superdocStore.documents = [{ getPresentationEditor: vi.fn(() => ({ scrollToPositionAsync })) }]; + + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + Object.defineProperty(instance, 'activeEditor', { + configurable: true, + get: () => ({ state: { doc: { descendants, content: { size: 1000 } } } }), + }); + + await expect(instance.scrollToHeading(1, 2)).resolves.toBe(true); + // The 2nd Heading1 starts at pos=50; the text-inside-content fix should + // shift the target one position into the paragraph's content. + expect(scrollToPositionAsync).toHaveBeenCalledWith(51, expect.any(Object)); + }); + + it('scrollToHeading forwards an explicit timeoutMs to scrollToPositionAsync', async () => { + const { superdocStore } = createAppHarness(); + const headings = [{ pos: 10, text: 'only', styleId: 'Heading1' }]; + const makeNode = (h) => ({ + type: { name: 'paragraph' }, + attrs: { paragraphProperties: { styleId: h.styleId } }, + content: { size: 5 }, + descendants: (cb) => cb({ isText: true, text: h.text }, 0), + }); + const descendants = (cb) => { + for (const h of headings) { + if (cb(makeNode(h), h.pos) === false) return; + } + }; + const scrollToPositionAsync = vi.fn(async () => true); + superdocStore.documents = [{ getPresentationEditor: vi.fn(() => ({ scrollToPositionAsync })) }]; + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + Object.defineProperty(instance, 'activeEditor', { + configurable: true, + get: () => ({ state: { doc: { descendants, content: { size: 1000 } } } }), + }); + + await expect(instance.scrollToHeading(1, 1, { timeoutMs: 10000 })).resolves.toBe(true); + expect(scrollToPositionAsync).toHaveBeenCalledWith(11, expect.objectContaining({ timeoutMs: 10000 })); + }); + + it('scrollToHeading omits timeoutMs when not given so the default applies', async () => { + const { superdocStore } = createAppHarness(); + const headings = [{ pos: 10, text: 'only', styleId: 'Heading1' }]; + const makeNode = (h) => ({ + type: { name: 'paragraph' }, + attrs: { paragraphProperties: { styleId: h.styleId } }, + content: { size: 5 }, + descendants: (cb) => cb({ isText: true, text: h.text }, 0), + }); + const descendants = (cb) => { + for (const h of headings) { + if (cb(makeNode(h), h.pos) === false) return; + } + }; + const scrollToPositionAsync = vi.fn(async () => true); + superdocStore.documents = [{ getPresentationEditor: vi.fn(() => ({ scrollToPositionAsync })) }]; + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + Object.defineProperty(instance, 'activeEditor', { + configurable: true, + get: () => ({ state: { doc: { descendants, content: { size: 1000 } } } }), + }); + + await expect(instance.scrollToHeading(1, 1)).resolves.toBe(true); + const opts = scrollToPositionAsync.mock.calls[0][1]; + expect(opts.timeoutMs).toBeUndefined(); + }); + + it('scrollToHeading rejects out-of-range levels and non-positive ordinals', async () => { + createAppHarness(); + const instance = new SuperDoc({ + selector: '#host', + document: 'https://example.com/doc.docx', + documents: [], + modules: { comments: {}, toolbar: {} }, + onException: vi.fn(), + }); + await flushMicrotasks(); + + await expect(instance.scrollToHeading(0, 1)).resolves.toBe(false); + await expect(instance.scrollToHeading(7, 1)).resolves.toBe(false); + await expect(instance.scrollToHeading(1, 0)).resolves.toBe(false); + await expect(instance.scrollToHeading(1.5, 1)).resolves.toBe(false); + }); + it('warns when both document object and documents list provided', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createAppHarness();