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,243 @@ 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 {{ wait?: ReturnType<typeof setTimeout> } } */
620+ const timers = { } ;
621+ /** @type {number } */
622+ let animationFrame = 0 ;
623+ /** @type {number } */
624+ let correctionFrame = 0 ;
625+ let cancelled = false ;
626+ let cancel = noop ;
627+ let hasScrolled = false ;
628+ let scrollScheduled = false ;
629+ let remainingImages = 0 ;
630+ /** @type {() => void } */
631+ let cleanup = ( ) => { } ;
632+ /** @type {{ image: HTMLImageElement, eventName: "load" | "error", listener: () => void }[] } */
633+ const imageListeners = [ ] ;
634+ /** @type {{ image: HTMLImageElement, previousHeight: number }[] } */
635+ const pendingImageCorrections = [ ] ;
636+
637+ const removeUserListeners = ( ) => {
638+ userEvents . forEach ( eventName => {
639+ window . removeEventListener ( eventName , cancel ) ;
640+ } ) ;
641+ } ;
642+
643+ const removeImageListeners = ( ) => {
644+ imageListeners . forEach ( ( { image, eventName, listener } ) => {
645+ image . removeEventListener ( eventName , listener ) ;
646+ } ) ;
647+ imageListeners . length = 0 ;
648+ } ;
649+
650+ const scrollToHeading = ( ) => {
651+ if ( cancelled ) {
652+ return ;
653+ }
654+
655+ if ( ! document . contains ( headingElm ) ) {
656+ cancel ( ) ;
657+ return ;
658+ }
659+
660+ hasScrolled = true ;
661+ this . #watchNextScroll( ) ;
662+ headingElm . scrollIntoView ( {
663+ behavior : 'smooth' ,
664+ block : 'start' ,
665+ } ) ;
666+
667+ if ( remainingImages === 0 ) {
668+ cleanup ( ) ;
669+ }
670+ } ;
671+
672+ const scheduleScroll = ( ) => {
673+ if ( hasScrolled || scrollScheduled ) {
674+ return ;
675+ }
676+
677+ scrollScheduled = true ;
678+ clearTimeout ( timers . wait ) ;
679+ animationFrame = requestAnimationFrame ( scrollToHeading ) ;
680+ } ;
681+
682+ /**
683+ * Keep the heading visually anchored when late images above it resize
684+ * after the fallback scroll has already started.
685+ *
686+ * @param {HTMLImageElement } image Image that changed height
687+ * @param {number } previousHeight Height before the image settled
688+ * @void
689+ */
690+ const scheduleCorrection = ( image , previousHeight ) => {
691+ if ( cancelled || ! hasScrolled ) {
692+ return ;
693+ }
694+
695+ pendingImageCorrections . push ( { image, previousHeight } ) ;
696+
697+ if ( correctionFrame ) {
698+ return ;
699+ }
700+
701+ correctionFrame = requestAnimationFrame ( ( ) => {
702+ correctionFrame = 0 ;
703+
704+ if ( cancelled ) {
705+ return ;
706+ }
707+
708+ if ( ! document . contains ( headingElm ) ) {
709+ cleanup ( ) ;
710+ return ;
711+ }
712+
713+ let heightChange = 0 ;
714+
715+ for ( const { image, previousHeight } of pendingImageCorrections ) {
716+ const isBeforeHeading =
717+ image . compareDocumentPosition ( headingElm ) &
718+ Node . DOCUMENT_POSITION_FOLLOWING ;
719+ const currentHeight = image . getBoundingClientRect ( ) . height ;
720+
721+ if ( isBeforeHeading ) {
722+ heightChange += currentHeight - previousHeight ;
723+ }
724+ }
725+ pendingImageCorrections . length = 0 ;
726+
727+ if ( Math . abs ( heightChange ) < 1 ) {
728+ if ( remainingImages === 0 ) {
729+ cleanup ( ) ;
730+ }
731+
732+ return ;
733+ }
734+
735+ const scrollingElm = document . scrollingElement ;
736+
737+ if ( ! scrollingElm ) {
738+ cleanup ( ) ;
739+ return ;
740+ }
741+
742+ const scrollPaddingTop =
743+ parseFloat ( getComputedStyle ( scrollingElm ) . scrollPaddingTop ) || 0 ;
744+ const headingTop = headingElm . getBoundingClientRect ( ) . top ;
745+ const scrollAdjustment = headingTop - scrollPaddingTop ;
746+
747+ if ( Math . abs ( scrollAdjustment ) < 1 ) {
748+ if ( remainingImages === 0 ) {
749+ cleanup ( ) ;
750+ }
751+
752+ return ;
753+ }
754+
755+ this . #watchNextScroll( ) ;
756+ scrollingElm . scrollTop += scrollAdjustment ;
757+
758+ if ( remainingImages === 0 ) {
759+ cleanup ( ) ;
760+ }
761+ } ) ;
762+ } ;
763+
764+ cleanup = ( ) => {
765+ if ( cancelled ) {
766+ return ;
767+ }
768+
769+ cancelled = true ;
770+ cancelAnimationFrame ( animationFrame ) ;
771+ cancelAnimationFrame ( correctionFrame ) ;
772+ clearTimeout ( timers . wait ) ;
773+ removeImageListeners ( ) ;
774+ removeUserListeners ( ) ;
775+ this . #cancelAnchorScroll = noop ;
776+ } ;
777+ cancel = cleanup ;
778+
779+ const waitForImages = ( ) => {
780+ const images = /** @type {HTMLImageElement[] } */ (
781+ contentElm ? Array . from ( contentElm . querySelectorAll ( 'img' ) ) : [ ]
782+ ) . filter ( image => {
783+ return (
784+ ! image . complete &&
785+ image . compareDocumentPosition ( headingElm ) &
786+ Node . DOCUMENT_POSITION_FOLLOWING
787+ ) ;
788+ } ) ;
789+
790+ if ( ! images . length ) {
791+ scheduleScroll ( ) ;
792+ return ;
793+ }
794+
795+ remainingImages = images . length ;
796+ const onImageSettled = ( image , previousHeight ) => {
797+ remainingImages -= 1 ;
798+
799+ if ( hasScrolled ) {
800+ scheduleCorrection ( image , previousHeight ) ;
801+ } else if ( remainingImages === 0 ) {
802+ scheduleScroll ( ) ;
803+ }
804+
805+ if ( remainingImages === 0 && hasScrolled && ! correctionFrame ) {
806+ cleanup ( ) ;
807+ }
808+ } ;
809+
810+ images . forEach ( image => {
811+ let settled = false ;
812+ const previousHeight = image . getBoundingClientRect ( ) . height ;
813+ const listener = ( ) => {
814+ if ( settled ) {
815+ return ;
816+ }
817+
818+ settled = true ;
819+ onImageSettled ( image , previousHeight ) ;
820+ } ;
821+
822+ image . addEventListener ( 'load' , listener , { once : true } ) ;
823+ image . addEventListener ( 'error' , listener , { once : true } ) ;
824+ imageListeners . push (
825+ { image, eventName : 'load' , listener } ,
826+ { image, eventName : 'error' , listener } ,
827+ ) ;
828+ } ) ;
829+
830+ timers . wait = setTimeout ( scheduleScroll , 300 ) ;
831+ } ;
832+
833+ userEvents . forEach ( eventName => {
834+ window . addEventListener ( eventName , cancel , {
835+ once : true ,
836+ passive : true ,
837+ } ) ;
838+ } ) ;
839+ waitForImages ( ) ;
840+
841+ this . #cancelAnchorScroll = cancel ;
842+ }
843+
609844 /**
610845 * Monitor next scroll start/end and set #isScrolling to true/false
611846 * accordingly. Listeners are removed after the start/end events are fired.
@@ -641,6 +876,7 @@ export function Events(Base) {
641876 } ;
642877
643878 document . addEventListener ( 'scroll' , callback , false ) ;
879+ callback ( ) ;
644880 }
645881 } ,
646882 { once : true } ,
0 commit comments