diff --git a/src/core/event/index.js b/src/core/event/index.js index e9c446cee0..f281de345a 100644 --- a/src/core/event/index.js +++ b/src/core/event/index.js @@ -1,4 +1,5 @@ import { isMobile, mobileBreakpoint } from '../util/env.js'; +import { noop } from '../util/core.js'; import * as dom from '../util/dom.js'; import { stripUrlExceptId } from '../router/util.js'; @@ -12,6 +13,7 @@ export function Events(Base) { return class Events extends Base { #intersectionObserver = new IntersectionObserver(() => {}); #isScrolling = false; + #cancelAnchorScroll = noop; #title = dom.$.title; // Initialization @@ -374,11 +376,7 @@ export function Events(Base) { ); if (headingElm) { - this.#watchNextScroll(); - headingElm.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); + this.#scrollToHeading(headingElm); } } // User click/tap @@ -606,6 +604,103 @@ export function Events(Base) { } } + /** + * Scroll an anchor target into view and keep it aligned while late-loading + * content above the target changes the page height. + * + * @param {Element} headingElm Heading element to scroll to + * @void + */ + #scrollToHeading(headingElm) { + this.#cancelAnchorScroll(); + + const contentElm = dom.find('.markdown-section'); + const userEvents = ['keydown', 'mousedown', 'touchstart', 'wheel']; + /** @type {{ max?: ReturnType, settle?: ReturnType }} */ + const timers = {}; + /** @type {number[]} */ + const animationFrames = []; + let cancelled = false; + let cancel = noop; + + const removeUserListeners = () => { + userEvents.forEach(eventName => { + window.removeEventListener(eventName, cancel); + }); + }; + + /** @param {ScrollBehavior} [behavior] */ + const scrollToHeading = (behavior = 'smooth') => { + if (cancelled) { + return; + } + + if (!document.contains(headingElm)) { + cancel(); + return; + } + + if (behavior === 'smooth') { + this.#watchNextScroll(); + } + + headingElm.scrollIntoView({ + behavior, + block: 'start', + }); + }; + + const resync = () => { + if (cancelled) { + return; + } + + scrollToHeading('instant'); + clearTimeout(timers.settle); + timers.settle = setTimeout(cancel, 500); + }; + + scrollToHeading(); + + if (!contentElm || !('ResizeObserver' in window)) { + return; + } + + const resizeObserver = new ResizeObserver(resync); + + cancel = () => { + if (cancelled) { + return; + } + + cancelled = true; + resizeObserver.disconnect(); + animationFrames.forEach(cancelAnimationFrame); + clearTimeout(timers.settle); + clearTimeout(timers.max); + removeUserListeners(); + window.removeEventListener('load', resync); + this.#cancelAnchorScroll = noop; + }; + + resizeObserver.observe(contentElm); + userEvents.forEach(eventName => { + window.addEventListener(eventName, cancel, { + once: true, + passive: true, + }); + }); + window.addEventListener('load', resync, { once: true }); + timers.max = setTimeout(cancel, 3000); + animationFrames.push( + requestAnimationFrame(() => { + animationFrames.push(requestAnimationFrame(resync)); + }), + ); + + this.#cancelAnchorScroll = cancel; + } + /** * Monitor next scroll start/end and set #isScrolling to true/false * accordingly. Listeners are removed after the start/end events are fired. diff --git a/test/e2e/anchor-scroll.test.js b/test/e2e/anchor-scroll.test.js new file mode 100644 index 0000000000..5d15eae8bf --- /dev/null +++ b/test/e2e/anchor-scroll.test.js @@ -0,0 +1,84 @@ +import docsifyInit from '../helpers/docsify-init.js'; +import { test, expect } from './fixtures/docsify-init-fixture.js'; + +test.describe('Anchor scrolling', () => { + test('keeps direct anchor targets aligned after images above them load', async ({ + page, + }) => { + await page.route('**/slow-anchor-image.svg', async route => { + await new Promise(resolve => setTimeout(resolve, 250)); + await route.fulfill({ + contentType: 'image/svg+xml', + body: ` + + + + `, + }); + }); + + await docsifyInit({ + testURL: '/docsify-init.html#/?id=target-section', + markdown: { + homepage: ` + # Anchor Scroll + + ![Slow image](/slow-anchor-image.svg) + + ## Middle Section + + This section should not stay at the top after the image loads. + + ## Target Section + + This is the linked section. + + Trailing content keeps the target scrollable. + `, + }, + routes: { + '/docsify-init.html': ` + + + + + + +
+ + + `, + }, + style: ` + .markdown-section img { + display: block; + width: 100%; + height: auto; + } + + .markdown-section { + padding-bottom: 1200px; + } + `, + styleURLs: ['/dist/themes/core.css'], + }); + + await page.locator('img[alt="Slow image"]').waitFor(); + await page.waitForFunction(() => { + const image = document.querySelector('img[alt="Slow image"]'); + const target = document.querySelector('#target-section'); + return ( + image instanceof HTMLImageElement && + image.complete && + image.naturalHeight > 0 && + target instanceof HTMLElement && + target.getBoundingClientRect().top < 80 + ); + }); + + const targetTop = await page.locator('#target-section').evaluate(el => { + return el.getBoundingClientRect().top; + }); + expect(targetTop).toBeLessThan(80); + }); +});