Skip to content

Commit ab54d53

Browse files
authored
fix: keep anchor links aligned after layout changes (docsifyjs#2731)
1 parent ab96d74 commit ab54d53

4 files changed

Lines changed: 965 additions & 7 deletions

File tree

src/core/event/index.js

Lines changed: 241 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isMobile, mobileBreakpoint } from '../util/env.js';
2+
import { noop } from '../util/core.js';
23
import * as dom from '../util/dom.js';
34
import { 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 },

test/config/playwright.setup.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { startServer } from './server.js';
22

33
export default async config => {
4-
startServer();
4+
await startServer();
55
};

test/config/server.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export async function startServer() {
1919
console.log(
2020
`\nPort ${settings.port} not available. Exiting process.\n`,
2121
);
22-
process.exit(0);
22+
process.exit(1);
2323
}
2424

2525
resolve(bsServer);

0 commit comments

Comments
 (0)