@@ -37,6 +37,7 @@ const autoSaveInterval = 1000;
3737export 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