Skip to content

Commit 1110202

Browse files
committed
fix: keep anchor scroll aligned after layout changes
1 parent eefe3cc commit 1110202

2 files changed

Lines changed: 150 additions & 5 deletions

File tree

src/core/event/index.js

Lines changed: 76 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,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.

test/e2e/anchor-scroll.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import docsifyInit from '../helpers/docsify-init.js';
2+
import { test, expect } from './fixtures/docsify-init-fixture.js';
3+
4+
test.describe('Anchor scrolling', () => {
5+
test('keeps direct anchor targets aligned after images above them load', async ({
6+
page,
7+
}) => {
8+
await page.route('**/slow-anchor-image.svg', async route => {
9+
await new Promise(resolve => setTimeout(resolve, 250));
10+
await route.fulfill({
11+
contentType: 'image/svg+xml',
12+
body: `
13+
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="900">
14+
<rect width="640" height="900" fill="#ddd" />
15+
</svg>
16+
`,
17+
});
18+
});
19+
20+
await docsifyInit({
21+
testURL: '/docsify-init.html#/?id=target-section',
22+
markdown: {
23+
homepage: `
24+
# Anchor Scroll
25+
26+
![Slow image](/slow-anchor-image.svg)
27+
28+
## Middle Section
29+
30+
This section should not stay at the top after the image loads.
31+
32+
## Target Section
33+
34+
This is the linked section.
35+
36+
Trailing content keeps the target scrollable.
37+
`,
38+
},
39+
routes: {
40+
'/docsify-init.html': `
41+
<!DOCTYPE html>
42+
<html>
43+
<head>
44+
<meta charset="UTF-8" />
45+
</head>
46+
<body>
47+
<div id="app"></div>
48+
</body>
49+
</html>
50+
`,
51+
},
52+
style: `
53+
.markdown-section img {
54+
display: block;
55+
width: 100%;
56+
height: auto;
57+
}
58+
59+
.markdown-section {
60+
padding-bottom: 1200px;
61+
}
62+
`,
63+
styleURLs: ['/dist/themes/core.css'],
64+
});
65+
66+
await expect
67+
.poll(async () => {
68+
return page.locator('#target-section').evaluate(el => {
69+
return el.getBoundingClientRect().top;
70+
});
71+
})
72+
.toBeLessThan(80);
73+
});
74+
});

0 commit comments

Comments
 (0)