11import { isMobile , mobileBreakpoint } from '../util/env.js' ;
2+ import { noop } from '../util/core.js' ;
23import * as dom from '../util/dom.js' ;
34import { stripUrlExceptId } from '../router/util.js' ;
45
@@ -12,6 +13,7 @@ export function Events(Base) {
1213 return class Events extends Base {
1314 #intersectionObserver = new IntersectionObserver ( ( ) => { } ) ;
1415 #isScrolling = false ;
16+ #cancelAnchorScroll = noop ;
1517 #title = dom . $ . title ;
1618
1719 // Initialization
@@ -374,11 +376,7 @@ export function Events(Base) {
374376 ) ;
375377
376378 if ( headingElm ) {
377- this . #watchNextScroll( ) ;
378- headingElm . scrollIntoView ( {
379- behavior : 'smooth' ,
380- block : 'start' ,
381- } ) ;
379+ this . #scrollToHeading( headingElm ) ;
382380 }
383381 }
384382 // User click/tap
@@ -606,6 +604,79 @@ export function Events(Base) {
606604 }
607605 }
608606
607+ /**
608+ * Scroll an anchor target into view and keep it aligned while late-loading
609+ * content above the target changes the page height.
610+ *
611+ * @param {Element } headingElm Heading element to scroll to
612+ * @void
613+ */
614+ #scrollToHeading( headingElm ) {
615+ this . #cancelAnchorScroll( ) ;
616+
617+ const contentElm = dom . find ( '.markdown-section' ) ;
618+ const userEvents = [ 'keydown' , 'mousedown' , 'touchstart' , 'wheel' ] ;
619+ /** @type {{ max?: ReturnType<typeof setTimeout>, settle?: ReturnType<typeof setTimeout> } } */
620+ const timers = { } ;
621+ let cancel = noop ;
622+
623+ const removeUserListeners = ( ) => {
624+ userEvents . forEach ( eventName => {
625+ window . removeEventListener ( eventName , cancel ) ;
626+ } ) ;
627+ } ;
628+
629+ /** @param {ScrollBehavior } [behavior] */
630+ const scrollToHeading = ( behavior = 'smooth' ) => {
631+ if ( ! document . contains ( headingElm ) ) {
632+ cancel ( ) ;
633+ return ;
634+ }
635+
636+ this . #watchNextScroll( ) ;
637+ headingElm . scrollIntoView ( {
638+ behavior,
639+ block : 'start' ,
640+ } ) ;
641+ } ;
642+
643+ const resync = ( ) => {
644+ scrollToHeading ( 'instant' ) ;
645+ clearTimeout ( timers . settle ) ;
646+ timers . settle = setTimeout ( cancel , 500 ) ;
647+ } ;
648+
649+ scrollToHeading ( ) ;
650+
651+ if ( ! contentElm || ! ( 'ResizeObserver' in window ) ) {
652+ return ;
653+ }
654+
655+ const resizeObserver = new ResizeObserver ( resync ) ;
656+
657+ cancel = ( ) => {
658+ resizeObserver . disconnect ( ) ;
659+ clearTimeout ( timers . settle ) ;
660+ clearTimeout ( timers . max ) ;
661+ removeUserListeners ( ) ;
662+ window . removeEventListener ( 'load' , resync ) ;
663+ this . #cancelAnchorScroll = noop ;
664+ } ;
665+
666+ resizeObserver . observe ( contentElm ) ;
667+ userEvents . forEach ( eventName => {
668+ window . addEventListener ( eventName , cancel , {
669+ once : true ,
670+ passive : true ,
671+ } ) ;
672+ } ) ;
673+ window . addEventListener ( 'load' , resync , { once : true } ) ;
674+ timers . max = setTimeout ( cancel , 3000 ) ;
675+ requestAnimationFrame ( ( ) => requestAnimationFrame ( resync ) ) ;
676+
677+ this . #cancelAnchorScroll = cancel ;
678+ }
679+
609680 /**
610681 * Monitor next scroll start/end and set #isScrolling to true/false
611682 * accordingly. Listeners are removed after the start/end events are fired.
0 commit comments