From dff8b1e389b549e52fd7464546f551f776e36913 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Mon, 27 Apr 2026 18:26:52 +0200 Subject: [PATCH 1/8] fix toggle scroll, mostly correct reload scroll --- .vitepress/lib/restoreCodeGroupPreferences.js | 81 ++++++++++++++++++- .vitepress/lib/useCodeGroupSync.ts | 47 +++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/.vitepress/lib/restoreCodeGroupPreferences.js b/.vitepress/lib/restoreCodeGroupPreferences.js index f3202783e8..d25075057c 100644 --- a/.vitepress/lib/restoreCodeGroupPreferences.js +++ b/.vitepress/lib/restoreCodeGroupPreferences.js @@ -154,14 +154,64 @@ }) } + // Function to calculate scroll offset (matches VitePress's getScrollOffset) + const getScrollOffset = () => { + // Check for nav element (VitePress's default header) + const nav = document.querySelector('.VPNav') + if (nav) { + return nav.offsetHeight + 24 // nav height + padding + } + // Fallback to checking for any fixed header + const header = document.querySelector('header') + if (header && window.getComputedStyle(header).position === 'fixed') { + return header.offsetHeight + 24 + } + return 90 // Default offset if no header found + } + + // Function to scroll to hash (matches VitePress's scrollTo logic) + const scrollToHash = (hash) => { + const target = document.querySelector(hash) + if (target) { + const targetPadding = parseInt(window.getComputedStyle(target).paddingTop, 10) + const targetTop = window.scrollY + + target.getBoundingClientRect().top - + getScrollOffset() + + targetPadding + + window.scrollTo(0, targetTop) + } + } + const applyToAllCodeGroups = () => { const codeGroups = document.querySelectorAll('.vp-code-group') codeGroups.forEach(applyToCodeGroup) } + // Track if we need to restore hash scroll + const initialHash = window.location.hash + let hashScrollPending = false + + if (initialHash) { + // Clear hash to prevent browser's auto-scroll + history.replaceState(null, '', window.location.pathname + window.location.search) + hashScrollPending = true + } + // Apply immediately to any existing code groups (runs synchronously) applyToAllCodeGroups() + // If we have code groups and a hash, restore scroll now + if (hashScrollPending && document.querySelectorAll('.vp-code-group').length > 0) { + // Restore hash and scroll immediately + history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) + // Scroll on next frame to let layout settle + requestAnimationFrame(() => { + scrollToHash(initialHash) + hashScrollPending = false + }) + } + // Watch for code groups being added dynamically (SPA navigation, HMR in dev mode) const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { @@ -169,9 +219,27 @@ if (node instanceof HTMLElement) { if (node.classList?.contains('vp-code-group')) { applyToCodeGroup(node) + + // If we have a pending hash scroll and this might be the last code group, try to scroll + if (hashScrollPending) { + history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) + requestAnimationFrame(() => { + scrollToHash(initialHash) + hashScrollPending = false + }) + } } else if (node.querySelector) { const codeGroups = node.querySelectorAll('.vp-code-group') codeGroups.forEach(applyToCodeGroup) + + // If we have a pending hash scroll, try to scroll after processing all code groups + if (hashScrollPending && codeGroups.length > 0) { + history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) + requestAnimationFrame(() => { + scrollToHash(initialHash) + hashScrollPending = false + }) + } } } } @@ -188,6 +256,17 @@ // Apply again on DOMContentLoaded as safety net if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', applyToAllCodeGroups) + document.addEventListener('DOMContentLoaded', () => { + applyToAllCodeGroups() + + // Final attempt to restore hash scroll if still pending + if (hashScrollPending && initialHash) { + history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) + requestAnimationFrame(() => { + scrollToHash(initialHash) + hashScrollPending = false + }) + } + }) } })() diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts index 7192172e3b..bb7bf39b5a 100644 --- a/.vitepress/lib/useCodeGroupSync.ts +++ b/.vitepress/lib/useCodeGroupSync.ts @@ -179,11 +179,41 @@ function findCodeGroups(): CodeGroupInfo[] { function applyPreference(codeGroup: CodeGroupInfo): void { const { element, tabs } = codeGroup const selectedTab = getBestTab(tabs) + const selectedIndex = tabs.indexOf(selectedTab) + + if (selectedIndex === -1) return // Find and check the corresponding radio button and activate content const labels = element.querySelectorAll('.tabs label') const blocks = element.querySelectorAll('div[class*="language-"], .vp-block') + // Check if ALL tabs and blocks are already in the correct state + // This prevents any DOM changes that could affect scroll position + let alreadyCorrect = true + + labels.forEach((label, index) => { + const input = element.querySelector(`.tabs input:nth-of-type(${index + 1})`) as HTMLInputElement + const block = blocks[index] as HTMLElement + + if (index === selectedIndex) { + // This tab should be active + if (!input?.checked || !block?.classList.contains('active')) { + alreadyCorrect = false + } + } else { + // This tab should be inactive + if (input?.checked || block?.classList.contains('active')) { + alreadyCorrect = false + } + } + }) + + // If everything is already correct, don't touch the DOM at all + if (alreadyCorrect) { + return + } + + // Apply the preference labels.forEach((label, index) => { const tabLabel = (label.textContent || '').trim() const input = element.querySelector(`.tabs input:nth-of-type(${index + 1})`) as HTMLInputElement @@ -267,8 +297,25 @@ function setupEventListeners(): void { const tabLabel = (label.textContent || '').trim() if (!tabLabel) return + // Capture the scroll position of the clicked tab before syncing + const clickedRect = label.getBoundingClientRect() + const scrollTop = window.pageYOffset || document.documentElement.scrollTop + // Sync all code groups with fuzzy matching syncTabs(tabLabel) + + // Restore scroll position to keep the clicked tab in view + requestAnimationFrame(() => { + const newRect = label.getBoundingClientRect() + const scrollDelta = newRect.top - clickedRect.top + + if (scrollDelta !== 0) { + window.scrollTo({ + top: scrollTop + scrollDelta, + behavior: 'instant' + }) + } + }) }) } From 325f76358c98052d9d6877f03e95a31acacdda72 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Tue, 28 Apr 2026 18:57:33 +0200 Subject: [PATCH 2/8] working scrolling --- .vitepress/lib/restoreCodeGroupPreferences.js | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/.vitepress/lib/restoreCodeGroupPreferences.js b/.vitepress/lib/restoreCodeGroupPreferences.js index d25075057c..a9836dea20 100644 --- a/.vitepress/lib/restoreCodeGroupPreferences.js +++ b/.vitepress/lib/restoreCodeGroupPreferences.js @@ -154,33 +154,25 @@ }) } - // Function to calculate scroll offset (matches VitePress's getScrollOffset) - const getScrollOffset = () => { - // Check for nav element (VitePress's default header) - const nav = document.querySelector('.VPNav') - if (nav) { - return nav.offsetHeight + 24 // nav height + padding - } - // Fallback to checking for any fixed header - const header = document.querySelector('header') - if (header && window.getComputedStyle(header).position === 'fixed') { - return header.offsetHeight + 24 - } - return 90 // Default offset if no header found - } + // VitePress's default scrollOffset (134) accounts for the fixed header + // and padding. This must match VitePress's getScrollOffset() to ensure + // consistent scroll positions between hash-link clicks and page reloads. + const getScrollOffset = () => 134 // Function to scroll to hash (matches VitePress's scrollTo logic) const scrollToHash = (hash) => { - const target = document.querySelector(hash) - if (target) { - const targetPadding = parseInt(window.getComputedStyle(target).paddingTop, 10) - const targetTop = window.scrollY + - target.getBoundingClientRect().top - - getScrollOffset() + - targetPadding - - window.scrollTo(0, targetTop) - } + try { + const target = document.getElementById(decodeURIComponent(hash).slice(1)) + if (target) { + const targetPadding = parseInt(window.getComputedStyle(target).paddingTop, 10) + const targetTop = window.scrollY + + target.getBoundingClientRect().top - + getScrollOffset() + + targetPadding + + window.scrollTo(0, targetTop) + } + } catch (e) { /* ignore invalid hash */ } } const applyToAllCodeGroups = () => { From 06c70907f3705fa31ca4b5c1b31d39f98b11d133 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Thu, 30 Apr 2026 15:55:13 +0200 Subject: [PATCH 3/8] fix for small viewports --- .vitepress/lib/useCodeGroupSync.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts index bb7bf39b5a..5a99135481 100644 --- a/.vitepress/lib/useCodeGroupSync.ts +++ b/.vitepress/lib/useCodeGroupSync.ts @@ -297,9 +297,8 @@ function setupEventListeners(): void { const tabLabel = (label.textContent || '').trim() if (!tabLabel) return - // Capture the scroll position of the clicked tab before syncing + // Capture the viewport position of the clicked tab before syncing const clickedRect = label.getBoundingClientRect() - const scrollTop = window.pageYOffset || document.documentElement.scrollTop // Sync all code groups with fuzzy matching syncTabs(tabLabel) @@ -311,7 +310,7 @@ function setupEventListeners(): void { if (scrollDelta !== 0) { window.scrollTo({ - top: scrollTop + scrollDelta, + top: (window.pageYOffset || document.documentElement.scrollTop) + scrollDelta, behavior: 'instant' }) } From 762bfba04435bf6a4c5e4e6c5fd1b95eccf6a8d1 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Thu, 30 Apr 2026 16:10:24 +0200 Subject: [PATCH 4/8] refactor --- .vitepress/lib/restoreCodeGroupPreferences.js | 50 +++++++------------ 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/.vitepress/lib/restoreCodeGroupPreferences.js b/.vitepress/lib/restoreCodeGroupPreferences.js index a9836dea20..ebc25f9d5b 100644 --- a/.vitepress/lib/restoreCodeGroupPreferences.js +++ b/.vitepress/lib/restoreCodeGroupPreferences.js @@ -190,19 +190,23 @@ hashScrollPending = true } + const restoreHashScroll = () => { + if (hashScrollPending) { + // Restore hash and scroll immediately + history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) + // Scroll on next frame to let layout settle + requestAnimationFrame(() => { + scrollToHash(initialHash) + hashScrollPending = false + }) + } + } + // Apply immediately to any existing code groups (runs synchronously) applyToAllCodeGroups() // If we have code groups and a hash, restore scroll now - if (hashScrollPending && document.querySelectorAll('.vp-code-group').length > 0) { - // Restore hash and scroll immediately - history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) - // Scroll on next frame to let layout settle - requestAnimationFrame(() => { - scrollToHash(initialHash) - hashScrollPending = false - }) - } + if (document.querySelectorAll('.vp-code-group').length > 0) restoreHashScroll() // Watch for code groups being added dynamically (SPA navigation, HMR in dev mode) const observer = new MutationObserver((mutations) => { @@ -212,25 +216,15 @@ if (node.classList?.contains('vp-code-group')) { applyToCodeGroup(node) - // If we have a pending hash scroll and this might be the last code group, try to scroll - if (hashScrollPending) { - history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) - requestAnimationFrame(() => { - scrollToHash(initialHash) - hashScrollPending = false - }) - } + // This might be the last code group, try to scroll + restoreHashScroll() } else if (node.querySelector) { const codeGroups = node.querySelectorAll('.vp-code-group') codeGroups.forEach(applyToCodeGroup) - // If we have a pending hash scroll, try to scroll after processing all code groups - if (hashScrollPending && codeGroups.length > 0) { - history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) - requestAnimationFrame(() => { - scrollToHash(initialHash) - hashScrollPending = false - }) + // Try to scroll after processing all code groups + if (codeGroups.length > 0) { + restoreHashScroll() } } } @@ -252,13 +246,7 @@ applyToAllCodeGroups() // Final attempt to restore hash scroll if still pending - if (hashScrollPending && initialHash) { - history.replaceState(null, '', window.location.pathname + window.location.search + initialHash) - requestAnimationFrame(() => { - scrollToHash(initialHash) - hashScrollPending = false - }) - } + restoreHashScroll() }) } })() From 2caed7a469ebe58d7c28f69bcf136a41172d5a97 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Thu, 30 Apr 2026 16:20:40 +0200 Subject: [PATCH 5/8] refactor --- .vitepress/lib/useCodeGroupSync.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts index 5a99135481..ac877674ef 100644 --- a/.vitepress/lib/useCodeGroupSync.ts +++ b/.vitepress/lib/useCodeGroupSync.ts @@ -187,24 +187,15 @@ function applyPreference(codeGroup: CodeGroupInfo): void { const labels = element.querySelectorAll('.tabs label') const blocks = element.querySelectorAll('div[class*="language-"], .vp-block') - // Check if ALL tabs and blocks are already in the correct state - // This prevents any DOM changes that could affect scroll position - let alreadyCorrect = true - - labels.forEach((label, index) => { + const alreadyCorrect = labels.keys().some(index => { const input = element.querySelector(`.tabs input:nth-of-type(${index + 1})`) as HTMLInputElement const block = blocks[index] as HTMLElement - if (index === selectedIndex) { - // This tab should be active - if (!input?.checked || !block?.classList.contains('active')) { - alreadyCorrect = false - } - } else { - // This tab should be inactive - if (input?.checked || block?.classList.contains('active')) { - alreadyCorrect = false - } + if (index === selectedIndex) { // tab active + return input?.checked && block?.classList.contains('active') + } + else { // tab inactive + return !input?.checked && !block?.classList.contains('active') } }) From ded472a333eb6f23719c1822094be35520b61059 Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Thu, 30 Apr 2026 16:21:18 +0200 Subject: [PATCH 6/8] remove unnecessary code --- .vitepress/lib/useCodeGroupSync.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts index ac877674ef..ec2ccae092 100644 --- a/.vitepress/lib/useCodeGroupSync.ts +++ b/.vitepress/lib/useCodeGroupSync.ts @@ -187,23 +187,6 @@ function applyPreference(codeGroup: CodeGroupInfo): void { const labels = element.querySelectorAll('.tabs label') const blocks = element.querySelectorAll('div[class*="language-"], .vp-block') - const alreadyCorrect = labels.keys().some(index => { - const input = element.querySelector(`.tabs input:nth-of-type(${index + 1})`) as HTMLInputElement - const block = blocks[index] as HTMLElement - - if (index === selectedIndex) { // tab active - return input?.checked && block?.classList.contains('active') - } - else { // tab inactive - return !input?.checked && !block?.classList.contains('active') - } - }) - - // If everything is already correct, don't touch the DOM at all - if (alreadyCorrect) { - return - } - // Apply the preference labels.forEach((label, index) => { const tabLabel = (label.textContent || '').trim() From fe2a6a5fb8e718ba74d5008cb17ffa1abdc6842c Mon Sep 17 00:00:00 2001 From: Johannes Vogt Date: Thu, 30 Apr 2026 16:39:46 +0200 Subject: [PATCH 7/8] cleanup --- .vitepress/lib/useCodeGroupSync.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.vitepress/lib/useCodeGroupSync.ts b/.vitepress/lib/useCodeGroupSync.ts index ec2ccae092..5f2154db9e 100644 --- a/.vitepress/lib/useCodeGroupSync.ts +++ b/.vitepress/lib/useCodeGroupSync.ts @@ -179,15 +179,11 @@ function findCodeGroups(): CodeGroupInfo[] { function applyPreference(codeGroup: CodeGroupInfo): void { const { element, tabs } = codeGroup const selectedTab = getBestTab(tabs) - const selectedIndex = tabs.indexOf(selectedTab) - - if (selectedIndex === -1) return // Find and check the corresponding radio button and activate content const labels = element.querySelectorAll('.tabs label') const blocks = element.querySelectorAll('div[class*="language-"], .vp-block') - // Apply the preference labels.forEach((label, index) => { const tabLabel = (label.textContent || '').trim() const input = element.querySelector(`.tabs input:nth-of-type(${index + 1})`) as HTMLInputElement From 1853c953ac762c6c3b9e20ae0097da0177310d37 Mon Sep 17 00:00:00 2001 From: Christian Georgi Date: Tue, 5 May 2026 15:33:59 +0200 Subject: [PATCH 8/8] Lint fixes --- .vitepress/lib/restoreCodeGroupPreferences.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.vitepress/lib/restoreCodeGroupPreferences.js b/.vitepress/lib/restoreCodeGroupPreferences.js index ebc25f9d5b..d189c47610 100644 --- a/.vitepress/lib/restoreCodeGroupPreferences.js +++ b/.vitepress/lib/restoreCodeGroupPreferences.js @@ -60,7 +60,7 @@ } return typeof parsed === 'object' ? parsed : {} } - } catch (e) { + } catch { // localStorage might not be available or JSON parse failed } return {} @@ -77,7 +77,7 @@ } } keysToRemove.forEach(key => localStorage.removeItem(key)) - } catch (e) { + } catch { // localStorage might not be available } } @@ -172,7 +172,7 @@ window.scrollTo(0, targetTop) } - } catch (e) { /* ignore invalid hash */ } + } catch { /* ignore invalid hash */ } } const applyToAllCodeGroups = () => {