Skip to content

Commit 1fcf864

Browse files
bjohasclaude
andcommitted
feat(super-editor): timeoutMs option on scrollToPositionAsync
`scrollToPositionAsync` had a hard-coded 2 second timeout for waiting on the painter to mount the target virtualised page. Callers navigating long documents — where the painter may need longer than 2 s to settle when jumping far from the current viewport — had no way to extend it short of editing the static `ANCHOR_NAV_TIMEOUT_MS` constant. Adds an opt-in `timeoutMs` to the options bag. Backwards- compatible: defaults stay at 2000 ms when unset. The warn message now also includes the actual timeout that elapsed, which is useful when diagnosing whether a particular long-doc click was fighting the timeout or some other issue. `SuperDoc.scrollToHeading` passes the option through to `scrollToPositionAsync` when given, so external apps can extend the wait at the call site without reaching into the presentation editor directly: await superdoc.scrollToHeading(2, 18, { timeoutMs: 8000 }); `scrollToElement` / `navigateTo` deliberately don't yet thread the option — those traverse `#navigateToBlock` / `#navigateToComment` / `#navigateToTrackedChange` before reaching `scrollToPositionAsync`, which is a wider surface to touch. Can be added in a follow-up once the call sites that need it surface. Existing scrollToPosition test updated to match the new warn message; a new test exercises the option (short timeout fires faster than the default and the warn text includes the supplied value). 86/86 SuperDoc.test.js + 14/14 scrollToPosition.test.ts pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4ded2ae commit 1fcf864

4 files changed

Lines changed: 125 additions & 7 deletions

File tree

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3712,7 +3712,20 @@ export class PresentationEditor extends EventEmitter {
37123712
*/
37133713
async scrollToPositionAsync(
37143714
pos: number,
3715-
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
3715+
options: {
3716+
block?: 'start' | 'center' | 'end' | 'nearest';
3717+
behavior?: ScrollBehavior;
3718+
/**
3719+
* Maximum time (ms) to wait for the painter to mount the target
3720+
* virtualised page before giving up. Defaults to
3721+
* `ANCHOR_NAV_TIMEOUT_MS` (2000 ms). Callers navigating across
3722+
* long documents — where the painter may need longer than 2 s to
3723+
* settle when jumping far from the current viewport — can extend
3724+
* this. The function still returns `false` on timeout, but the
3725+
* extension reduces false-negative anchor navigations.
3726+
*/
3727+
timeoutMs?: number;
3728+
} = {},
37163729
): Promise<boolean> {
37173730
// Fast path: try sync scroll first (works if page already mounted)
37183731
if (this.scrollToPosition(pos, options)) {
@@ -3747,11 +3760,15 @@ export class PresentationEditor extends EventEmitter {
37473760
this.#scrollPageIntoView(pageIndex);
37483761

37493762
// Wait for page to mount in the DOM
3750-
const mounted = await this.#waitForPageMount(pageIndex, {
3751-
timeout: PresentationEditor.ANCHOR_NAV_TIMEOUT_MS,
3752-
});
3763+
const timeout =
3764+
Number.isFinite(options.timeoutMs) && options.timeoutMs! > 0
3765+
? options.timeoutMs!
3766+
: PresentationEditor.ANCHOR_NAV_TIMEOUT_MS;
3767+
const mounted = await this.#waitForPageMount(pageIndex, { timeout });
37533768
if (!mounted) {
3754-
console.warn(`[PresentationEditor] scrollToPositionAsync: Page ${pageIndex} failed to mount within timeout`);
3769+
console.warn(
3770+
`[PresentationEditor] scrollToPositionAsync: Page ${pageIndex} failed to mount within ${timeout} ms`,
3771+
);
37553772
return false;
37563773
}
37573774

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,34 @@ describe('PresentationEditor - scrollToPosition', () => {
667667

668668
// The page never mounted, so it should fail
669669
expect(result).toBe(false);
670-
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('failed to mount within timeout'));
670+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('failed to mount within'));
671+
672+
consoleWarnSpy.mockRestore();
673+
});
674+
675+
it('honours a caller-supplied timeoutMs and surfaces the value in the warn message', async () => {
676+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
677+
678+
editor = new PresentationEditor({
679+
element: container,
680+
documentId: 'test-doc',
681+
});
682+
683+
await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled());
684+
await new Promise((resolve) => setTimeout(resolve, 50));
685+
686+
// 100 ms ceiling — the page never mounts, so it should bail
687+
// much faster than the 2 s default.
688+
const t0 = Date.now();
689+
const result = await editor.scrollToPositionAsync(150, { timeoutMs: 100 });
690+
const elapsed = Date.now() - t0;
691+
692+
expect(result).toBe(false);
693+
// The warning should mention the supplied timeout (100 ms), not
694+
// the static default.
695+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('100 ms'));
696+
// And we should have bailed well under the 2 s default.
697+
expect(elapsed).toBeLessThan(1500);
671698

672699
consoleWarnSpy.mockRestore();
673700
});

packages/superdoc/src/core/SuperDoc.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1477,7 +1477,11 @@ export class SuperDoc extends EventEmitter {
14771477
*
14781478
* @param {number} level 1..6
14791479
* @param {number} [ordinal=1] 1-based index among headings of that level
1480-
* @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition }} [options]
1480+
* @param {{ behavior?: ScrollBehavior, block?: ScrollLogicalPosition, timeoutMs?: number }} [options]
1481+
* Pass `timeoutMs` to override the default 2 s page-mount wait
1482+
* in paginated layout. Useful when jumping far from the current
1483+
* viewport on long docs where the painter takes longer to
1484+
* mount the target page.
14811485
* @returns {Promise<boolean>} Whether a matching heading was found and scrolled to
14821486
*
14831487
* @example
@@ -1563,6 +1567,9 @@ export class SuperDoc extends EventEmitter {
15631567
const ok = await presentationEditor.scrollToPositionAsync(foundPos, {
15641568
behavior: options.behavior ?? 'auto',
15651569
block: options.block ?? 'center',
1570+
// Pass-through so callers can extend the page-mount wait on
1571+
// long docs without reaching into PresentationEditor directly.
1572+
...(Number.isFinite(options.timeoutMs) ? { timeoutMs: options.timeoutMs } : {}),
15661573
});
15671574
if (ok) return true;
15681575
// Fall through to body-editor path on layout-state miss.

packages/superdoc/src/core/SuperDoc.test.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,73 @@ describe('SuperDoc core', () => {
518518
expect(scrollToPositionAsync).toHaveBeenCalledWith(51, expect.any(Object));
519519
});
520520

521+
it('scrollToHeading forwards an explicit timeoutMs to scrollToPositionAsync', async () => {
522+
const { superdocStore } = createAppHarness();
523+
const headings = [{ pos: 10, text: 'only', styleId: 'Heading1' }];
524+
const makeNode = (h) => ({
525+
type: { name: 'paragraph' },
526+
attrs: { paragraphProperties: { styleId: h.styleId } },
527+
content: { size: 5 },
528+
descendants: (cb) => cb({ isText: true, text: h.text }, 0),
529+
});
530+
const descendants = (cb) => {
531+
for (const h of headings) {
532+
if (cb(makeNode(h), h.pos) === false) return;
533+
}
534+
};
535+
const scrollToPositionAsync = vi.fn(async () => true);
536+
superdocStore.documents = [{ getPresentationEditor: vi.fn(() => ({ scrollToPositionAsync })) }];
537+
const instance = new SuperDoc({
538+
selector: '#host',
539+
document: 'https://example.com/doc.docx',
540+
documents: [],
541+
modules: { comments: {}, toolbar: {} },
542+
onException: vi.fn(),
543+
});
544+
await flushMicrotasks();
545+
Object.defineProperty(instance, 'activeEditor', {
546+
configurable: true,
547+
get: () => ({ state: { doc: { descendants, content: { size: 1000 } } } }),
548+
});
549+
550+
await expect(instance.scrollToHeading(1, 1, { timeoutMs: 10000 })).resolves.toBe(true);
551+
expect(scrollToPositionAsync).toHaveBeenCalledWith(11, expect.objectContaining({ timeoutMs: 10000 }));
552+
});
553+
554+
it('scrollToHeading omits timeoutMs when not given so the default applies', async () => {
555+
const { superdocStore } = createAppHarness();
556+
const headings = [{ pos: 10, text: 'only', styleId: 'Heading1' }];
557+
const makeNode = (h) => ({
558+
type: { name: 'paragraph' },
559+
attrs: { paragraphProperties: { styleId: h.styleId } },
560+
content: { size: 5 },
561+
descendants: (cb) => cb({ isText: true, text: h.text }, 0),
562+
});
563+
const descendants = (cb) => {
564+
for (const h of headings) {
565+
if (cb(makeNode(h), h.pos) === false) return;
566+
}
567+
};
568+
const scrollToPositionAsync = vi.fn(async () => true);
569+
superdocStore.documents = [{ getPresentationEditor: vi.fn(() => ({ scrollToPositionAsync })) }];
570+
const instance = new SuperDoc({
571+
selector: '#host',
572+
document: 'https://example.com/doc.docx',
573+
documents: [],
574+
modules: { comments: {}, toolbar: {} },
575+
onException: vi.fn(),
576+
});
577+
await flushMicrotasks();
578+
Object.defineProperty(instance, 'activeEditor', {
579+
configurable: true,
580+
get: () => ({ state: { doc: { descendants, content: { size: 1000 } } } }),
581+
});
582+
583+
await expect(instance.scrollToHeading(1, 1)).resolves.toBe(true);
584+
const opts = scrollToPositionAsync.mock.calls[0][1];
585+
expect(opts.timeoutMs).toBeUndefined();
586+
});
587+
521588
it('scrollToHeading rejects out-of-range levels and non-positive ordinals', async () => {
522589
createAppHarness();
523590
const instance = new SuperDoc({

0 commit comments

Comments
 (0)