From ab96d74a8d71668f1d5b15f2f71f1856db6e3ca1 Mon Sep 17 00:00:00 2001 From: Luffy Date: Wed, 3 Jun 2026 10:28:37 +0800 Subject: [PATCH 1/2] feat: improve indexing and embedded search (#2734) --- src/plugins/search/search.js | 172 +++++++++++++++++++++++++++++---- test/e2e/search.test.js | 180 ++++++++++++++++++++++++++++++++++- 2 files changed, 333 insertions(+), 19 deletions(-) diff --git a/src/plugins/search/search.js b/src/plugins/search/search.js index 38c074b02d..eb6f4d6186 100644 --- a/src/plugins/search/search.js +++ b/src/plugins/search/search.js @@ -4,6 +4,11 @@ import { removeAtag, escapeHtml, } from '../../core/render/utils.js'; +import { + getPath, + getParentPath, + isAbsolutePath, +} from '../../core/router/util.js'; import { markdownToTxt } from './markdown-to-txt.js'; import Dexie from 'dexie'; @@ -16,10 +21,29 @@ db.version(1).stores({ }); async function saveData(maxAge, expireKey) { - INDEXES = Object.values(INDEXES).flatMap(innerData => - Object.values(innerData), - ); - await /** @type {any} */ (db).search.bulkPut(INDEXES); + const records = []; + + Object.values(INDEXES).forEach(entry => { + if (!entry || typeof entry !== 'object') { + return; + } + + // Entry may already be a flat record read from IndexedDB. + if ('slug' in entry) { + records.push(entry); + return; + } + + // Entry may be a per-path map of slug -> record produced by genIndex(). + Object.values(entry).forEach(item => { + if (item && typeof item === 'object' && 'slug' in item) { + records.push(item); + } + }); + }); + + INDEXES = records; + await /** @type {any} */ (db).search.bulkPut(records); await /** @type {any} */ (db).expires.put({ key: expireKey, value: Date.now() + maxAge, @@ -96,6 +120,111 @@ function getListData(token) { return token.text; } +function extractFragmentContent(text, fragment, fullLine) { + if (!fragment) { + return text; + } + + let fragmentRegex = `(?:###|\\/\\/\\/)\\s*\\[${fragment}\\]`; + if (fullLine) { + fragmentRegex = `.*${fragmentRegex}.*\n`; + } + + const pattern = new RegExp( + `(?:${fragmentRegex})([\\s\\S]*?)(?:${fragmentRegex})`, + ); + const match = text.match(pattern); + return ((match || [])[1] || '').trim(); +} + +function collectEmbedRequests(raw = '', path, vm) { + const tokens = window.marked.lexer(raw); + const requests = []; + + const maybePushEmbed = inlineToken => { + if ( + !inlineToken || + (inlineToken.type !== 'link' && inlineToken.type !== 'image') + ) { + return; + } + + const { config } = getAndRemoveConfig(inlineToken.title || ''); + if (!config.include || !inlineToken.href) { + return; + } + + const href = isAbsolutePath(inlineToken.href) + ? inlineToken.href + : getPath(vm.router.getBasePath(), getParentPath(path), inlineToken.href); + + let type = 'code'; + if (/\.(md|markdown)/.test(href)) { + type = 'markdown'; + } else if (/\.mmd/.test(href)) { + type = 'mermaid'; + } + + requests.push({ + url: href, + type, + fragment: config.fragment, + omitFragmentLine: config.omitFragmentLine, + }); + }; + + tokens.forEach(token => { + if (token.type === 'paragraph') { + (token.tokens || []).forEach(maybePushEmbed); + } else if (token.type === 'table') { + (token.header || []).forEach(cell => { + (cell.tokens || []).forEach(maybePushEmbed); + }); + (token.rows || []).forEach(row => { + row.forEach(cell => { + (cell.tokens || []).forEach(maybePushEmbed); + }); + }); + } + }); + + return requests; +} + +async function getEmbeddedContent(raw = '', path, vm) { + const requests = collectEmbedRequests(raw, path, vm); + if (!requests.length) { + return ''; + } + + const results = await Promise.all( + requests.map( + request => + new Promise(resolve => { + Docsify.get(request.url, false, vm.config.requestHeaders).then( + text => { + let content = text || ''; + if (request.fragment) { + content = extractFragmentContent( + content, + request.fragment, + request.omitFragmentLine, + ); + } + + resolve( + request.type === 'markdown' ? content : markdownToTxt(content), + ); + }, + () => resolve(''), + ); + }), + ), + ); + + return results.filter(Boolean).join('\n'); +} + export function genIndex(path, content = '', router, depth, indexKey) { const tokens = window.marked.lexer(content); const slugify = window.Docsify.slugify; @@ -205,8 +334,6 @@ export function search(query) { ), 'gi', ); - let indexTitle = -1; - let indexContent = -1; handlePostTitle = postTitle ? escapeHtml(ignoreDiacriticalMarks(postTitle)) : postTitle; @@ -214,8 +341,8 @@ export function search(query) { ? escapeHtml(ignoreDiacriticalMarks(postContent)) : postContent; - indexTitle = postTitle ? handlePostTitle.search(regEx) : -1; - indexContent = postContent ? handlePostContent.search(regEx) : -1; + const indexTitle = postTitle ? handlePostTitle.search(regEx) : -1; + let indexContent = postContent ? handlePostContent.search(regEx) : -1; if (indexTitle >= 0 || indexContent >= 0) { matchesScore += indexTitle >= 0 ? 3 : indexContent >= 0 ? 2 : 0; @@ -223,11 +350,8 @@ export function search(query) { indexContent = 0; } - let start = 0; - let end = 0; - - start = indexContent < 11 ? 0 : indexContent - 10; - end = start === 0 ? 100 : indexContent + keyword.length + 90; + const start = indexContent < 11 ? 0 : indexContent - 10; + let end = start === 0 ? 100 : indexContent + keyword.length + 90; if (handlePostContent && end > handlePostContent.length) { end = handlePostContent.length; @@ -306,26 +430,38 @@ export async function init(config, vm) { const len = paths.length; let count = 0; + const markComplete = async () => { + if (len === ++count) { + await saveData(config.maxAge, expireKey); + } + }; + paths.forEach(path => { const pathExists = Array.isArray(INDEXES) ? INDEXES.some(obj => obj.path === path) : false; if (pathExists) { - return count++; + void markComplete(); + return; } Docsify.get(vm.router.getFile(path), false, vm.config.requestHeaders).then( async result => { + const embeddedContent = await getEmbeddedContent(result, path, vm); + const contentToIndex = embeddedContent + ? `${result}\n${embeddedContent}` + : result; INDEXES[path] = genIndex( path, - result, + contentToIndex, vm.router, config.depth, indexKey, ); - if (len === ++count) { - await saveData(config.maxAge, expireKey); - } + return markComplete(); + }, + () => { + return markComplete(); }, ); }); diff --git a/test/e2e/search.test.js b/test/e2e/search.test.js index a99e0121a2..c36c0a1812 100644 --- a/test/e2e/search.test.js +++ b/test/e2e/search.test.js @@ -198,6 +198,130 @@ test.describe('Search Plugin Tests', () => { await expect(resultsHeadingElm).toHaveText('EmptyContent'); }); + test('keeps saving index when one auto path request fails with cached records', async ({ + page, + }) => { + const indexKey = 'docsify.search.index'; + const expireKey = 'docsify.search.expires'; + + const pageErrors = []; + page.on('pageerror', error => pageErrors.push(error.message)); + + await page.evaluate( + ({ indexKey, expireKey }) => { + return new Promise((resolve, reject) => { + const request = indexedDB.open('docsify', 1); + + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains('search')) { + db.createObjectStore('search', { keyPath: 'slug' }); + } + + if (!db.objectStoreNames.contains('expires')) { + db.createObjectStore('expires', { keyPath: 'key' }); + } + }; + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction(['search', 'expires'], 'readwrite'); + + tx.objectStore('search').put({ + slug: '/cached', + title: 'Cached Page', + body: 'cached record', + path: '/cached', + indexKey, + }); + tx.objectStore('expires').put({ + key: expireKey, + value: Date.now() + 60 * 1000, + }); + + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + }); + }, + { indexKey, expireKey }, + ); + + await docsifyInit({ + markdown: { + homepage: '# Home', + sidebar: ` + - [Cached](cached) + - [Success](success) + - [Fail](fail) + `, + }, + routes: { + '/success.md': '# Success\n\nregressionKeyword', + '/fail.md': { + status: 404, + body: 'Not Found', + contentType: 'text/markdown', + }, + }, + scriptURLs: ['/dist/plugins/search.js'], + }); + + await expect + .poll(async () => { + return await page.evaluate(indexKey => { + return new Promise((resolve, reject) => { + const request = indexedDB.open('docsify'); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction(['search', 'expires'], 'readonly'); + const searchStore = tx.objectStore('search'); + const expiresStore = tx.objectStore('expires'); + const searchReq = searchStore.getAll(); + const expiresReq = expiresStore.get('docsify.search.expires'); + + tx.onerror = () => reject(tx.error); + tx.oncomplete = () => { + const records = Array.isArray(searchReq.result) + ? searchReq.result + : []; + const hasSuccessRecord = records.some( + record => + record && + record.indexKey === indexKey && + record.path === '/success', + ); + const hasInvalidRecord = records.some( + record => !record || typeof record.slug !== 'string', + ); + const hasExpireRecord = Boolean(expiresReq.result?.value); + + db.close(); + resolve( + hasSuccessRecord && hasExpireRecord && !hasInvalidRecord, + ); + }; + }; + }); + }, indexKey); + }) + .toBe(true); + + const searchFieldElm = page.locator('input[type=search]'); + const resultsHeadingElm = page.locator('.results-panel .title'); + + await searchFieldElm.fill('regressionKeyword'); + await expect(resultsHeadingElm).toHaveText('Success'); + expect(pageErrors).toEqual([]); + }); + test('handles default focusSearch binding', async ({ page }) => { const docsifyInitConfig = { scriptURLs: ['/dist/plugins/search.js'], @@ -277,10 +401,64 @@ console.log('Hello World'); await docsifyInit(docsifyInitConfig); await searchFieldElm.fill('filename'); expect(await resultsHeadingElm.textContent()).toContain( - '...filename _media/example.js :include :type=code :fragment=demo...', + 'filename _media/example.js :include :type=code :fragment=demo', ); }); + test('search should index embedded include content', async ({ page }) => { + const docsifyInitConfig = { + markdown: { + homepage: ` +# Include Search + +![snippet](snippet.js ':include :type=code') + `, + }, + routes: { + '/snippet.js': ` +const embeddedSearchKeyword = 'ok'; + `, + }, + scriptURLs: ['/dist/plugins/search.js'], + }; + + const searchFieldElm = page.locator('input[type=search]'); + const resultsHeadingElm = page.locator('.results-panel .title'); + + await docsifyInit(docsifyInitConfig); + await searchFieldElm.fill('embeddedSearchKeyword'); + await expect(resultsHeadingElm).toHaveText('Include Search'); + }); + + test('search should index embedded include content from relative path', async ({ + page, + }) => { + const docsifyInitConfig = { + markdown: { + homepage: '# Home', + sidebar: '- [Guide Intro](guide/intro)', + }, + routes: { + '/guide/intro.md': ` +# Relative Include Search + +![snippet](./snippets/demo.js ':include :type=code') + `, + '/guide/snippets/demo.js': ` +const embeddedRelativeKeyword = 'ok'; + `, + }, + scriptURLs: ['/dist/plugins/search.js'], + }; + + const searchFieldElm = page.locator('input[type=search]'); + const resultsHeadingElm = page.locator('.results-panel .title'); + + await docsifyInit(docsifyInitConfig); + await searchFieldElm.fill('embeddedRelativeKeyword'); + await expect(resultsHeadingElm).toHaveText('Relative Include Search'); + }); + test('search result should remove checkbox markdown and keep related values', async ({ page, }) => { From ab54d53c31e48e95a8db24f4f10f1b3ab47057a8 Mon Sep 17 00:00:00 2001 From: apple <123molang@gmail.com> Date: Wed, 3 Jun 2026 10:33:43 +0800 Subject: [PATCH 2/2] fix: keep anchor links aligned after layout changes (#2731) --- src/core/event/index.js | 246 ++++++++++- test/config/playwright.setup.js | 2 +- test/config/server.js | 2 +- test/e2e/anchor-scroll.test.js | 722 ++++++++++++++++++++++++++++++++ 4 files changed, 965 insertions(+), 7 deletions(-) create mode 100644 test/e2e/anchor-scroll.test.js diff --git a/src/core/event/index.js b/src/core/event/index.js index e9c446cee0..8395761430 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,243 @@ 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 {{ wait?: ReturnType }} */ + const timers = {}; + /** @type {number} */ + let animationFrame = 0; + /** @type {number} */ + let correctionFrame = 0; + let cancelled = false; + let cancel = noop; + let hasScrolled = false; + let scrollScheduled = false; + let remainingImages = 0; + /** @type {() => void} */ + let cleanup = () => {}; + /** @type {{ image: HTMLImageElement, eventName: "load" | "error", listener: () => void }[]} */ + const imageListeners = []; + /** @type {{ image: HTMLImageElement, previousHeight: number }[]} */ + const pendingImageCorrections = []; + + const removeUserListeners = () => { + userEvents.forEach(eventName => { + window.removeEventListener(eventName, cancel); + }); + }; + + const removeImageListeners = () => { + imageListeners.forEach(({ image, eventName, listener }) => { + image.removeEventListener(eventName, listener); + }); + imageListeners.length = 0; + }; + + const scrollToHeading = () => { + if (cancelled) { + return; + } + + if (!document.contains(headingElm)) { + cancel(); + return; + } + + hasScrolled = true; + this.#watchNextScroll(); + headingElm.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + + if (remainingImages === 0) { + cleanup(); + } + }; + + const scheduleScroll = () => { + if (hasScrolled || scrollScheduled) { + return; + } + + scrollScheduled = true; + clearTimeout(timers.wait); + animationFrame = requestAnimationFrame(scrollToHeading); + }; + + /** + * Keep the heading visually anchored when late images above it resize + * after the fallback scroll has already started. + * + * @param {HTMLImageElement} image Image that changed height + * @param {number} previousHeight Height before the image settled + * @void + */ + const scheduleCorrection = (image, previousHeight) => { + if (cancelled || !hasScrolled) { + return; + } + + pendingImageCorrections.push({ image, previousHeight }); + + if (correctionFrame) { + return; + } + + correctionFrame = requestAnimationFrame(() => { + correctionFrame = 0; + + if (cancelled) { + return; + } + + if (!document.contains(headingElm)) { + cleanup(); + return; + } + + let heightChange = 0; + + for (const { image, previousHeight } of pendingImageCorrections) { + const isBeforeHeading = + image.compareDocumentPosition(headingElm) & + Node.DOCUMENT_POSITION_FOLLOWING; + const currentHeight = image.getBoundingClientRect().height; + + if (isBeforeHeading) { + heightChange += currentHeight - previousHeight; + } + } + pendingImageCorrections.length = 0; + + if (Math.abs(heightChange) < 1) { + if (remainingImages === 0) { + cleanup(); + } + + return; + } + + const scrollingElm = document.scrollingElement; + + if (!scrollingElm) { + cleanup(); + return; + } + + const scrollPaddingTop = + parseFloat(getComputedStyle(scrollingElm).scrollPaddingTop) || 0; + const headingTop = headingElm.getBoundingClientRect().top; + const scrollAdjustment = headingTop - scrollPaddingTop; + + if (Math.abs(scrollAdjustment) < 1) { + if (remainingImages === 0) { + cleanup(); + } + + return; + } + + this.#watchNextScroll(); + scrollingElm.scrollTop += scrollAdjustment; + + if (remainingImages === 0) { + cleanup(); + } + }); + }; + + cleanup = () => { + if (cancelled) { + return; + } + + cancelled = true; + cancelAnimationFrame(animationFrame); + cancelAnimationFrame(correctionFrame); + clearTimeout(timers.wait); + removeImageListeners(); + removeUserListeners(); + this.#cancelAnchorScroll = noop; + }; + cancel = cleanup; + + const waitForImages = () => { + const images = /** @type {HTMLImageElement[]} */ ( + contentElm ? Array.from(contentElm.querySelectorAll('img')) : [] + ).filter(image => { + return ( + !image.complete && + image.compareDocumentPosition(headingElm) & + Node.DOCUMENT_POSITION_FOLLOWING + ); + }); + + if (!images.length) { + scheduleScroll(); + return; + } + + remainingImages = images.length; + const onImageSettled = (image, previousHeight) => { + remainingImages -= 1; + + if (hasScrolled) { + scheduleCorrection(image, previousHeight); + } else if (remainingImages === 0) { + scheduleScroll(); + } + + if (remainingImages === 0 && hasScrolled && !correctionFrame) { + cleanup(); + } + }; + + images.forEach(image => { + let settled = false; + const previousHeight = image.getBoundingClientRect().height; + const listener = () => { + if (settled) { + return; + } + + settled = true; + onImageSettled(image, previousHeight); + }; + + image.addEventListener('load', listener, { once: true }); + image.addEventListener('error', listener, { once: true }); + imageListeners.push( + { image, eventName: 'load', listener }, + { image, eventName: 'error', listener }, + ); + }); + + timers.wait = setTimeout(scheduleScroll, 300); + }; + + userEvents.forEach(eventName => { + window.addEventListener(eventName, cancel, { + once: true, + passive: true, + }); + }); + waitForImages(); + + 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. @@ -641,6 +876,7 @@ export function Events(Base) { }; document.addEventListener('scroll', callback, false); + callback(); } }, { once: true }, diff --git a/test/config/playwright.setup.js b/test/config/playwright.setup.js index 3b0b918af0..e0fe04a790 100644 --- a/test/config/playwright.setup.js +++ b/test/config/playwright.setup.js @@ -1,5 +1,5 @@ import { startServer } from './server.js'; export default async config => { - startServer(); + await startServer(); }; diff --git a/test/config/server.js b/test/config/server.js index 5875be3a26..a376286360 100644 --- a/test/config/server.js +++ b/test/config/server.js @@ -19,7 +19,7 @@ export async function startServer() { console.log( `\nPort ${settings.port} not available. Exiting process.\n`, ); - process.exit(0); + process.exit(1); } resolve(bsServer); diff --git a/test/e2e/anchor-scroll.test.js b/test/e2e/anchor-scroll.test.js new file mode 100644 index 0000000000..b81ebd91bd --- /dev/null +++ b/test/e2e/anchor-scroll.test.js @@ -0,0 +1,722 @@ +import docsifyInit from '../helpers/docsify-init.js'; +import { test, expect } from './fixtures/docsify-init-fixture.js'; + +async function recordScrollIntoViewCalls(page) { + await page.addInitScript(() => { + const originalScrollIntoView = Element.prototype.scrollIntoView; + const originalScrollTo = window.scrollTo; + + window.__scrollIntoViewCalls = []; + window.__scrollToCalls = []; + window.__imageLoadEvents = []; + document.addEventListener( + 'load', + event => { + const image = event.target; + + if (image instanceof HTMLImageElement) { + window.__imageLoadEvents.push({ + alt: image.alt, + time: performance.now(), + }); + } + }, + true, + ); + Element.prototype.scrollIntoView = function (options) { + window.__scrollIntoViewCalls.push({ + id: this.id, + block: options?.block, + behavior: options?.behavior, + time: performance.now(), + }); + + return originalScrollIntoView.call(this, options); + }; + window.scrollTo = function (...args) { + const options = args[0]; + const call = { + time: performance.now(), + }; + + if (typeof options === 'object' && options !== null) { + call.behavior = options.behavior; + call.left = options.left; + call.top = options.top; + } else { + call.left = options; + call.top = args[1]; + } + + window.__scrollToCalls.push(call); + + return originalScrollTo.apply(this, args); + }; + }); +} + +async function routeDelayedImage(page, url, imageReleased, height) { + await page.route(`**/${url}`, async route => { + await imageReleased; + await route.fulfill({ + contentType: 'image/svg+xml', + body: ` + + + + `, + }); + }); +} + +async function routeTimedImage(page, url, delay, height) { + await page.route(`**/${url}`, async route => { + await new Promise(resolve => setTimeout(resolve, delay)); + await route.fulfill({ + contentType: 'image/svg+xml', + body: ` + + + + `, + }); + }); +} + +test.describe('Anchor scrolling', () => { + test('keeps smooth scrolling for same-page anchor clicks', async ({ + page, + }) => { + await page.addInitScript(() => { + const originalScrollIntoView = Element.prototype.scrollIntoView; + + window.__scrollendEvents = []; + window.__scrollEvents = []; + window.__scrollIntoViewCalls = []; + document.addEventListener('scroll', () => { + window.__scrollEvents.push({ time: performance.now() }); + }); + document.addEventListener('scrollend', () => { + window.__scrollendEvents.push({ time: performance.now() }); + }); + Element.prototype.scrollIntoView = function (options) { + window.__scrollIntoViewCalls.push({ + id: this.id, + behavior: options?.behavior, + time: performance.now(), + }); + + return originalScrollIntoView.call(this, options); + }; + }); + + await docsifyInit({ + markdown: { + homepage: ` + # Anchor Scroll + + [Jump to target](#target-section) + + ## Middle Section + + This section keeps the target below the fold. + + ## Target Section + + This is the linked section. + `, + }, + style: ` + .markdown-section { + padding-bottom: 1200px; + } + + #middle-section { + margin-top: 900px; + } + `, + styleURLs: ['/dist/themes/core.css'], + }); + + await page.getByRole('link', { name: 'Jump to target' }).click(); + await page.waitForFunction(() => { + return window.__scrollIntoViewCalls?.some( + call => call.id === 'target-section' && call.behavior === 'smooth', + ); + }); + await page.evaluate(() => { + return new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + }); + await page.waitForFunction(() => { + const scrollendTime = window.__scrollendEvents[0]?.time; + const lastScrollTime = window.__scrollEvents.at(-1)?.time; + const readyTime = + scrollendTime ?? + (lastScrollTime === undefined ? undefined : lastScrollTime + 700); + + return readyTime !== undefined && performance.now() > readyTime; + }); + + const { readyTime, targetCalls } = await page.evaluate(() => { + const scrollendTime = window.__scrollendEvents[0]?.time; + const lastScrollTime = window.__scrollEvents.at(-1)?.time; + + return { + readyTime: + scrollendTime ?? + (lastScrollTime === undefined ? undefined : lastScrollTime + 700), + targetCalls: (window.__scrollIntoViewCalls ?? []).filter( + call => call.id === 'target-section', + ), + }; + }); + const earlyInstantCalls = targetCalls.filter(call => { + return ( + call.behavior === 'instant' && + (readyTime === undefined || call.time < readyTime) + ); + }); + + expect(targetCalls).toHaveLength(1); + expect(targetCalls[0]).toMatchObject({ behavior: 'smooth' }); + expect(earlyInstantCalls).toEqual([]); + }); + + test('waits for images above a direct anchor before smooth scrolling', async ({ + page, + }) => { + await recordScrollIntoViewCalls(page); + + await routeTimedImage(page, 'slow-anchor-image-1.svg', 80, 900); + await routeTimedImage(page, 'slow-anchor-image-2.svg', 120, 700); + + const initPromise = docsifyInit({ + testURL: '/docsify-init.html#/?id=target-section', + markdown: { + homepage: ` + # Anchor Scroll + + ![Slow image 1](/slow-anchor-image-1.svg) + + ![Slow image 2](/slow-anchor-image-2.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 { + overflow-anchor: none; + padding-bottom: 1200px; + } + + .markdown-section img { + display: block; + width: 100%; + height: auto; + } + `, + styleURLs: ['/dist/themes/core.css'], + }); + + await initPromise; + await page.waitForFunction(() => { + const firstImage = document.querySelector('img[alt="Slow image 1"]'); + const secondImage = document.querySelector('img[alt="Slow image 2"]'); + const firstImageLoadTime = window.__imageLoadEvents.find(event => { + return event.alt === 'Slow image 1'; + })?.time; + const secondImageLoadTime = window.__imageLoadEvents.find(event => { + return event.alt === 'Slow image 2'; + })?.time; + + return ( + firstImage instanceof HTMLImageElement && + secondImage instanceof HTMLImageElement && + firstImage.complete && + secondImage.complete && + firstImage.naturalHeight > 0 && + secondImage.naturalHeight > 0 && + firstImageLoadTime !== undefined && + secondImageLoadTime !== undefined + ); + }); + await page.waitForFunction(() => { + const target = document.querySelector('#target-section'); + const targetCalls = (window.__scrollIntoViewCalls ?? []).filter( + call => call.id === 'target-section', + ); + const targetTop = target?.getBoundingClientRect().top; + + return targetCalls.length === 1 && targetTop >= -1 && targetTop < 80; + }); + + const { firstImageLoadTime, secondImageLoadTime, targetCalls, targetTop } = + await page.evaluate(() => { + const target = document.querySelector('#target-section'); + const firstImageLoadTime = window.__imageLoadEvents.find(event => { + return event.alt === 'Slow image 1'; + })?.time; + const secondImageLoadTime = window.__imageLoadEvents.find(event => { + return event.alt === 'Slow image 2'; + })?.time; + + return { + firstImageLoadTime, + secondImageLoadTime, + targetCalls: (window.__scrollIntoViewCalls ?? []).filter( + call => call.id === 'target-section', + ), + targetTop: target.getBoundingClientRect().top, + }; + }); + + expect(firstImageLoadTime).not.toBeUndefined(); + expect(secondImageLoadTime).not.toBeUndefined(); + + expect(targetCalls).toHaveLength(1); + expect(targetCalls[0]).toMatchObject({ + behavior: 'smooth', + block: 'start', + }); + expect(targetCalls[0].time).toBeGreaterThanOrEqual(firstImageLoadTime); + expect(targetCalls[0].time).toBeGreaterThanOrEqual(secondImageLoadTime); + expect(targetTop).toBeGreaterThanOrEqual(-1); + expect(targetTop).toBeLessThan(80); + }); + + test('keeps a direct anchor aligned when images load after the fallback scroll', async ({ + page, + }) => { + await recordScrollIntoViewCalls(page); + + let releaseImage = () => {}; + const imageReleased = new Promise(resolve => { + releaseImage = resolve; + }); + + await routeDelayedImage( + page, + 'very-slow-anchor-image.svg', + imageReleased, + 1200, + ); + + const initPromise = docsifyInit({ + config: { + topMargin: 90, + }, + testURL: '/docsify-init.html#/?id=target-section', + markdown: { + homepage: ` + # Anchor Scroll + + ![Very slow image](/very-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 { + overflow-anchor: none; + padding-bottom: 2200px; + } + + .markdown-section img { + display: block; + width: 100%; + height: auto; + } + `, + styleURLs: ['/dist/themes/core.css'], + }); + + await page.locator('#target-section').waitFor(); + await page.locator('img[alt="Very slow image"]').waitFor({ + state: 'attached', + }); + await page.waitForFunction(() => { + return (window.__scrollIntoViewCalls ?? []).some( + call => call.id === 'target-section', + ); + }); + + const targetCallsBeforeImage = await page.evaluate(() => { + return (window.__scrollIntoViewCalls ?? []).filter( + call => call.id === 'target-section', + ); + }); + + expect(targetCallsBeforeImage).toHaveLength(1); + expect(targetCallsBeforeImage[0]).toMatchObject({ + behavior: 'smooth', + block: 'start', + }); + + releaseImage(); + await initPromise; + await page.waitForFunction(() => { + const image = document.querySelector('img[alt="Very slow image"]'); + + return ( + image instanceof HTMLImageElement && + image.complete && + image.naturalHeight > 0 + ); + }); + await page.waitForFunction(() => { + const target = document.querySelector('#target-section'); + const targetTop = target?.getBoundingClientRect().top; + + return targetTop >= 70 && targetTop < 120; + }); + + const { imageLoadTime, targetCalls, targetTop } = await page.evaluate( + () => { + const target = document.querySelector('#target-section'); + + return { + imageLoadTime: window.__imageLoadEvents.find(event => { + return event.alt === 'Very slow image'; + })?.time, + targetCalls: (window.__scrollIntoViewCalls ?? []).filter( + call => call.id === 'target-section', + ), + targetTop: target.getBoundingClientRect().top, + }; + }, + ); + + expect(imageLoadTime).toBeGreaterThanOrEqual(targetCalls[0].time); + expect(targetCalls).toHaveLength(1); + expect(targetCalls[0]).toMatchObject({ + behavior: 'smooth', + block: 'start', + }); + expect(targetTop).toBeGreaterThanOrEqual(70); + expect(targetTop).toBeLessThan(120); + }); + + test('cancels a pending direct anchor scroll after user input', async ({ + page, + }) => { + await recordScrollIntoViewCalls(page); + await page.addInitScript(() => { + window.__wheelTime = undefined; + window.__anchorWheelListenerAttachedTime = undefined; + const originalAddEventListener = window.addEventListener; + + window.addEventListener = function (eventName, listener, options) { + if ( + eventName === 'wheel' && + options?.once === true && + options?.passive === true + ) { + window.__anchorWheelListenerAttachedTime = performance.now(); + } + + return originalAddEventListener.call( + this, + eventName, + listener, + options, + ); + }; + + window.addEventListener( + 'wheel', + () => { + window.__wheelTime = performance.now(); + }, + { capture: true }, + ); + }); + + let releaseImage = () => {}; + const imageReleased = new Promise(resolve => { + releaseImage = resolve; + }); + + await routeDelayedImage(page, 'slow-anchor-image.svg', imageReleased, 900); + + const initPromise = 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 { + overflow-anchor: none; + padding-bottom: 2200px; + } + + .markdown-section img { + display: block; + width: 100%; + height: auto; + } + `, + styleURLs: ['/dist/themes/core.css'], + }); + + await page.locator('#target-section').waitFor(); + await page.locator('img[alt="Slow image"]').waitFor({ + state: 'attached', + }); + await page.waitForFunction(() => { + return window.__anchorWheelListenerAttachedTime !== undefined; + }); + + await page.mouse.wheel(0, 900); + const wheelTimeHandle = await page.waitForFunction( + () => window.__wheelTime, + ); + const wheelTime = await wheelTimeHandle.jsonValue(); + releaseImage(); + await initPromise; + await page.waitForFunction(() => { + const image = document.querySelector('img[alt="Slow image"]'); + + return ( + image instanceof HTMLImageElement && + image.complete && + image.naturalHeight > 0 + ); + }); + await page.waitForFunction(wheelTime => { + return performance.now() - wheelTime > 700; + }, wheelTime); + + const { scrollStopsAfterWheel, targetCalls, targetCallsAfterWheel } = + await page.evaluate(wheelTime => { + return { + scrollStopsAfterWheel: (window.__scrollToCalls ?? []).filter(call => { + return call.time >= wheelTime; + }), + targetCalls: (window.__scrollIntoViewCalls ?? []).filter( + call => call.id === 'target-section', + ), + targetCallsAfterWheel: (window.__scrollIntoViewCalls ?? []).filter( + call => call.id === 'target-section' && call.time >= wheelTime, + ), + }; + }, wheelTime); + + expect(targetCalls).toEqual([]); + expect(targetCallsAfterWheel).toEqual([]); + expect(scrollStopsAfterWheel).toEqual([]); + }); + + test('does not pull back after user input following a fallback scroll', async ({ + page, + }) => { + await recordScrollIntoViewCalls(page); + await page.addInitScript(() => { + window.__wheelTime = undefined; + + window.addEventListener( + 'wheel', + () => { + window.__wheelTime = performance.now(); + }, + { capture: true }, + ); + }); + + let releaseImage = () => {}; + const imageReleased = new Promise(resolve => { + releaseImage = resolve; + }); + + await routeDelayedImage( + page, + 'post-scroll-slow-image.svg', + imageReleased, + 1200, + ); + + const initPromise = docsifyInit({ + testURL: '/docsify-init.html#/?id=target-section', + markdown: { + homepage: ` + # Anchor Scroll + + ![Post-scroll slow image](/post-scroll-slow-image.svg) + + ## Middle Section + + This section should not pull the user back after wheel input. + + ## Target Section + + This is the linked section. + + Trailing content keeps the target scrollable. + `, + }, + routes: { + '/docsify-init.html': ` + + + + + + +
+ + + `, + }, + style: ` + .markdown-section { + overflow-anchor: none; + padding-bottom: 3200px; + } + + .markdown-section img { + display: block; + width: 100%; + height: auto; + } + `, + styleURLs: ['/dist/themes/core.css'], + }); + + await page.locator('#target-section').waitFor(); + await page.locator('img[alt="Post-scroll slow image"]').waitFor({ + state: 'attached', + }); + await page.waitForFunction(() => { + return (window.__scrollIntoViewCalls ?? []).some( + call => call.id === 'target-section', + ); + }); + + const scrollYBeforeWheel = await page.evaluate(() => scrollY); + await page.mouse.wheel(0, 900); + const wheelTimeHandle = await page.waitForFunction( + () => window.__wheelTime, + ); + const wheelTime = await wheelTimeHandle.jsonValue(); + await page.waitForFunction( + scrollYBeforeWheel => Math.abs(scrollY - scrollYBeforeWheel) > 100, + scrollYBeforeWheel, + ); + const scrollYAfterWheel = await page.evaluate(() => scrollY); + await page.waitForFunction(wheelTime => { + return performance.now() - wheelTime > 200; + }, wheelTime); + const scrollYBeforeImage = await page.evaluate(() => scrollY); + + releaseImage(); + await initPromise; + await page.waitForFunction(() => { + const image = document.querySelector('img[alt="Post-scroll slow image"]'); + + return ( + image instanceof HTMLImageElement && + image.complete && + image.naturalHeight > 0 + ); + }); + await page.waitForFunction(() => { + const imageLoadTime = window.__imageLoadEvents.find(event => { + return event.alt === 'Post-scroll slow image'; + })?.time; + + return ( + imageLoadTime !== undefined && performance.now() - imageLoadTime > 300 + ); + }); + + const { scrollStopsAfterWheel, scrollYAfterImage, targetCallsAfterWheel } = + await page.evaluate(wheelTime => { + return { + scrollStopsAfterWheel: (window.__scrollToCalls ?? []).filter(call => { + return call.time >= wheelTime; + }), + scrollYAfterImage: scrollY, + targetCallsAfterWheel: (window.__scrollIntoViewCalls ?? []).filter( + call => call.id === 'target-section' && call.time >= wheelTime, + ), + }; + }, wheelTime); + + expect(Math.abs(scrollYAfterWheel - scrollYBeforeWheel)).toBeGreaterThan( + 100, + ); + expect(targetCallsAfterWheel).toEqual([]); + expect(scrollStopsAfterWheel).toEqual([]); + expect(Math.abs(scrollYAfterImage - scrollYBeforeImage)).toBeLessThan(200); + }); +});