Skip to content

Commit dce311a

Browse files
committed
Fix: Reduce bouncing when navigating between pages
1 parent 8d4695c commit dce311a

File tree

1 file changed

+74
-3
lines changed

1 file changed

+74
-3
lines changed

client/content_manager.ts

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const autoSaveInterval = 1000;
3737
export class ContentManager {
3838
documentEditor: DocumentEditor | null = null;
3939
saveTimeout?: ReturnType<typeof setTimeout>;
40+
private scrollRestoreCleanup?: () => void;
4041
debouncedUpdateEvent = throttle(() => {
4142
this.client.eventHook
4243
.dispatchEvent("editor:updated")
@@ -541,9 +542,7 @@ export class ContentManager {
541542

542543
// Was a particular scroll position persisted?
543544
if (pageState.scrollTop && pageState.scrollTop > 0) {
544-
setTimeout(() => {
545-
this.client.editorView.scrollDOM.scrollTop = pageState.scrollTop!;
546-
});
545+
this.restoreScrollPosition(pageState.scrollTop);
547546
adjustedPosition = true;
548547
}
549548

@@ -556,6 +555,11 @@ export class ContentManager {
556555
}
557556

558557
// If not: just put the cursor at the top of the page, right after the frontmatter
558+
if (!adjustedPosition && this.scrollRestoreCleanup) {
559+
// No scroll position to restore, cancel any pending restoration
560+
this.scrollRestoreCleanup();
561+
this.scrollRestoreCleanup = undefined;
562+
}
559563
if (!adjustedPosition) {
560564
// Somewhat ad-hoc way to determine if the document contains frontmatter and if so, putting the cursor _after it_.
561565
const pageText = this.client.editorView.state.sliceDoc();
@@ -576,4 +580,71 @@ export class ContentManager {
576580
});
577581
}
578582
}
583+
584+
/**
585+
* Restores scroll position after page navigation, accounting for async widget
586+
* rendering that may change the page layout. Uses a MutationObserver to
587+
* re-apply the scroll position whenever the DOM changes (e.g. widgets finish
588+
* rendering), with a timeout to stop after the layout has stabilized.
589+
*/
590+
private restoreScrollPosition(scrollTop: number) {
591+
// Cancel any previous scroll restoration
592+
if (this.scrollRestoreCleanup) {
593+
this.scrollRestoreCleanup();
594+
}
595+
596+
const scrollDOM = this.client.editorView.scrollDOM;
597+
let settled = false;
598+
599+
const applyScroll = () => {
600+
if (!settled) {
601+
scrollDOM.scrollTop = scrollTop;
602+
}
603+
};
604+
605+
// Apply immediately on the next tick (as before)
606+
setTimeout(applyScroll);
607+
608+
// Watch for DOM mutations (widget rendering) and re-apply scroll position
609+
const observer = new MutationObserver(() => {
610+
applyScroll();
611+
});
612+
613+
observer.observe(scrollDOM, {
614+
childList: true,
615+
subtree: true,
616+
attributes: true,
617+
// Watch for style changes (widget height changes)
618+
attributeFilter: ["style", "class"],
619+
});
620+
621+
// Also handle user scroll: if the user manually scrolls, stop restoring
622+
const onUserScroll = () => {
623+
cleanup();
624+
};
625+
// Delay attaching the scroll listener so our own scroll assignments don't
626+
// trigger it
627+
const scrollListenerTimer = setTimeout(() => {
628+
scrollDOM.addEventListener("scroll", onUserScroll, { once: true });
629+
}, 100);
630+
631+
// Stop restoring after a reasonable timeout (widgets should be done by then)
632+
const timeout = setTimeout(() => {
633+
cleanup();
634+
}, 2000);
635+
636+
const cleanup = () => {
637+
if (settled) return;
638+
settled = true;
639+
observer.disconnect();
640+
clearTimeout(timeout);
641+
clearTimeout(scrollListenerTimer);
642+
scrollDOM.removeEventListener("scroll", onUserScroll);
643+
if (this.scrollRestoreCleanup === cleanup) {
644+
this.scrollRestoreCleanup = undefined;
645+
}
646+
};
647+
648+
this.scrollRestoreCleanup = cleanup;
649+
}
579650
}

0 commit comments

Comments
 (0)