From 067075df8a8ded3ec80b9e4a877d637bacd9b7ac Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 13 Jul 2026 18:31:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(post):=20=E7=9B=B4=E6=8E=A5=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=20#hash=20=E6=97=B6=E6=A0=87=E9=A2=98=E8=A2=AB=20stic?= =?UTF-8?q?ky=20header=20=E9=81=AE=E4=BD=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点击锚点正常,但直接访问带 hashtag 的链接时滚动后标题被 80px sticky header 遮住。根因:浏览器原生 fragment-scroll(直接访问触发)不读 CSS scroll-margin-top,在我们 scrollToHash 的平滑滚动之后触发,把标题顶到 视口最顶端(header 下方)。 抽出共享 scrollToHeading:用 getBoundingClientRect 手动计算绝对滚动位置, 减去动态测量的 header 高度(+16px 呼吸空间)后 window.scrollTo,不再依赖 scroll-margin-top。hash-scroll(直接访问)与 anchor-click(点击)统一调用它。 scrollToHash 额外用双 requestAnimationFrame 延后做一次即时校正,覆盖原生 fragment-scroll 的触发窗口,确保落点在原生 scroll 之后生效。 scroll-margin-top: 6rem 保留在 input.css 作其它原生 scroll 场景的兜底。 --- libs/yggdrasil-core/src/anchor-click.test.ts | 11 ++-- libs/yggdrasil-core/src/anchor-click.ts | 11 ++-- libs/yggdrasil-core/src/hash-scroll.test.ts | 69 ++++++++++++++++++-- libs/yggdrasil-core/src/hash-scroll.ts | 17 ++++- libs/yggdrasil-core/src/scroll-to-heading.ts | 39 +++++++++++ 5 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 libs/yggdrasil-core/src/scroll-to-heading.ts diff --git a/libs/yggdrasil-core/src/anchor-click.test.ts b/libs/yggdrasil-core/src/anchor-click.test.ts index 11db598..e153e6d 100644 --- a/libs/yggdrasil-core/src/anchor-click.test.ts +++ b/libs/yggdrasil-core/src/anchor-click.test.ts @@ -40,11 +40,10 @@ describe('initAnchorClick', () => { anchor.setAttribute('href', '#ru-men-zhi-nan'); document.body.appendChild(anchor); - const spy = vi.spyOn(heading, 'scrollIntoView'); + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); anchor.click(); - expect(spy).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + expect(spy).toHaveBeenCalled(); // 关键:Dioxus 委托监听器未被触发 → handleClickNavigate/browser_open 不会执行。 expect(dioxusInterceptorCalled).toBe(false); }); @@ -93,7 +92,7 @@ describe('initAnchorClick', () => { anchor.setAttribute('href', '#target'); document.body.appendChild(anchor); - const spy = vi.spyOn(heading, 'scrollIntoView'); + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); // 模拟 ctrl+click(新标签页):dispatchEvent 携带 ctrlKey。 const event = new MouseEvent('click', { bubbles: true, ctrlKey: true }); @@ -116,10 +115,10 @@ describe('initAnchorClick', () => { window.__initAnchorClick(); window.__initAnchorClick(); - const spy = vi.spyOn(heading, 'scrollIntoView'); + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); anchor.click(); - // 即使重复调用,scrollIntoView 仍只触发一次(监听器只注册一份)。 + // 即使重复调用,scrollTo 仍只触发一次(监听器只注册一份)。 expect(spy).toHaveBeenCalledOnce(); }); }); diff --git a/libs/yggdrasil-core/src/anchor-click.ts b/libs/yggdrasil-core/src/anchor-click.ts index 4780984..1b4551e 100644 --- a/libs/yggdrasil-core/src/anchor-click.ts +++ b/libs/yggdrasil-core/src/anchor-click.ts @@ -14,10 +14,13 @@ * 走浏览器原生 fragment-scroll 正常——所以问题只在 hydration 后出现。 * * 修复:在 capture 阶段(早于 Dioxus 的 bubble 委托监听器)拦截指向同页 hash 的 - * 点击,stopPropagation 阻止事件到达 Dioxus 的委托监听器,自行 scrollIntoView - * 并用 history.replaceState 更新 URL hash(不触发 popstate/hashchange,避免抖动)。 + * 点击,stopPropagation 阻止事件到达 Dioxus 的委托监听器,自行滚动到标题 + * (scrollToHeading 手动扣除 header 高度)并用 history.replaceState 更新 URL hash + * (不触发 popstate/hashchange,避免抖动)。 */ +import { scrollToHeading } from './scroll-to-heading'; + let installed = false; /** @@ -59,8 +62,8 @@ export function initAnchorClick(): void { event.stopPropagation(); event.preventDefault(); - // 平滑滚动到目标标题(scroll-margin-top 在 input.css 里设为 6rem 避开 sticky header)。 - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // 平滑滚动到目标标题,手动扣除 sticky header 高度(见 scroll-to-heading.ts)。 + scrollToHeading(el, true); // 更新 URL hash 但不产生历史记录抖动:replaceState 不触发 popstate/hashchange。 // 这样地址栏显示当前章节,刷新页面也能 scrollToHash 回到原位。 diff --git a/libs/yggdrasil-core/src/hash-scroll.test.ts b/libs/yggdrasil-core/src/hash-scroll.test.ts index 3341873..872aa37 100644 --- a/libs/yggdrasil-core/src/hash-scroll.test.ts +++ b/libs/yggdrasil-core/src/hash-scroll.test.ts @@ -1,6 +1,9 @@ /** - * hash-scroll 测试:钉住锚点跳转的解码与 scrollIntoView 行为。 + * hash-scroll 测试:钉住锚点跳转的解码与手动偏移滚动行为。 * 黑盒驱动:只通过 window.__scrollToHash 入口。 + * + * 现在用 window.scrollTo 手动扣除 sticky header 高度(不再依赖 scrollIntoView + + * scroll-margin-top,因为浏览器原生 fragment-scroll 对后者的应用时序不稳定)。 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import './index'; @@ -17,18 +20,16 @@ describe('scrollToHash', () => { }); it('hash 为 CJK 百分号编码时解码后命中并滚动', () => { - // 标题 id 是原始 CJK 字符,URL hash 是百分号编码形式。 const heading = document.createElement('h2'); heading.id = '三-五-零法则'; document.body.appendChild(heading); - const spy = vi.spyOn(heading, 'scrollIntoView'); + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); history.replaceState(null, '', `#${encodeURIComponent(heading.id)}`); window.__scrollToHash(); - expect(spy).toHaveBeenCalledOnce(); - expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + expect(spy).toHaveBeenCalled(); }); it('hash 为纯 ASCII 时直接命中', () => { @@ -36,21 +37,75 @@ describe('scrollToHash', () => { heading.id = 'getting-started'; document.body.appendChild(heading); - const spy = vi.spyOn(heading, 'scrollIntoView'); + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); history.replaceState(null, '', '#getting-started'); window.__scrollToHash(); - expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalled(); + }); + + it('滚动落点扣除 sticky header 高度', () => { + // 模拟 sticky header:getBoundingClientRect 返回 height 80。 + const header = document.createElement('header'); + header.className = 'sticky'; + header.getBoundingClientRect = () => + ({ + top: 0, + left: 0, + right: 0, + bottom: 80, + width: 1000, + height: 80, + x: 0, + y: 0, + toJSON() {}, + }) as DOMRect; + document.body.appendChild(header); + + // 目标标题:放在 scrollY=1000 处(getBoundingClientRect.top 反映相对视口位置)。 + const heading = document.createElement('h2'); + heading.id = 'mid'; + heading.getBoundingClientRect = () => + ({ + top: 1000, + left: 0, + right: 0, + bottom: 1050, + width: 800, + height: 50, + x: 0, + y: 1000, + toJSON() {}, + }) as DOMRect; + document.body.appendChild(heading); + + // scrollY 参与 scrollTo 目标计算:top = rect.top + scrollY - headerH - offset。 + Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true }); + + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + history.replaceState(null, '', '#mid'); + + window.__scrollToHash(); + + // 第一帧调用(smooth):top = 1000 + 500 - 80 - 16 = 1404 + expect(spy).toHaveBeenCalled(); + const firstCall = spy.mock.calls[0][0] as ScrollToOptions; + expect(firstCall.top).toBe(1000 + 500 - 80 - 16); + expect(firstCall.behavior).toBe('smooth'); }); it('hash 为空时不滚动也不报错', () => { + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); history.replaceState(null, '', '#'); expect(() => window.__scrollToHash()).not.toThrow(); + expect(spy).not.toHaveBeenCalled(); }); it('目标元素不存在时不报错', () => { + const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); history.replaceState(null, '', '#no-such-heading'); expect(() => window.__scrollToHash()).not.toThrow(); + expect(spy).not.toHaveBeenCalled(); }); }); diff --git a/libs/yggdrasil-core/src/hash-scroll.ts b/libs/yggdrasil-core/src/hash-scroll.ts index 9ec6fa1..5a98856 100644 --- a/libs/yggdrasil-core/src/hash-scroll.ts +++ b/libs/yggdrasil-core/src/hash-scroll.ts @@ -5,11 +5,18 @@ * 标题 DOM 尚不存在,浏览器原生 fragment-scroll 找不到目标留在顶部。本函数在 * 内容挂载后调用,弥补原生 scroll 丢失的窗口。 * + * 落点遮挡问题:直接访问 #hash 链接时,浏览器原生 fragment-scroll 会在我们 + * scrollToHash 之后触发,它不读 scroll-margin-top,把标题顶到 sticky header 下面。 + * 解法:用 requestAnimationFrame 延后两帧再做最终校正,确保我们的落点在原生 + * scroll 之后生效(原生 scroll 通常在下一帧完成)。 + * * CJK 编码要点:location.hash 返回百分号编码形式(%E4%B8%89-...),而标题 id * 属性是原始字符(id="三-五-零法则")。必须先 decodeURIComponent 还原,getElementById * 才能匹配。双 fallback(先解码、后原始)兼容两种情况。 */ +import { scrollToHeading } from './scroll-to-heading'; + export function scrollToHash(): void { const hash = window.location.hash.slice(1); // 去掉前导 # if (!hash) return; @@ -18,5 +25,13 @@ export function scrollToHash(): void { const el = document.getElementById(id) ?? document.getElementById(hash); if (!el) return; - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // 立即先滚一次(覆盖骨架屏阶段原生 scroll 失败的窗口)。 + scrollToHeading(el, true); + + // 延后两帧再校正:直接访问 #hash 时浏览器原生 fragment-scroll 会在此期间触发, + // 把标题顶到 header 下面(它不读 scroll-margin-top)。两帧后原生 scroll 已完成, + // 用即时滚动(非 smooth,避免与刚才的 smooth 动画叠加抖动)把落点拉回正确位置。 + requestAnimationFrame(() => { + requestAnimationFrame(() => scrollToHeading(el, false)); + }); } diff --git a/libs/yggdrasil-core/src/scroll-to-heading.ts b/libs/yggdrasil-core/src/scroll-to-heading.ts new file mode 100644 index 0000000..b2facc2 --- /dev/null +++ b/libs/yggdrasil-core/src/scroll-to-heading.ts @@ -0,0 +1,39 @@ +/** + * 滚动到标题锚点,手动扣除 sticky header 高度。 + * + * 为什么不用 scrollIntoView + CSS scroll-margin-top: + * 浏览器原生 fragment-scroll(直接访问 #hash 链接时)对 scroll-margin-top 的 + * 应用时序不稳定——常在标题 DOM 就绪后延迟触发,覆盖我们 scrollToHash 的落点, + * 导致标题被 sticky header 遮住。改用手动 getBoundingClientRect 计算绝对滚动 + * 位置并 window.scrollTo,两个场景(直接访问 / 点击)行为确定一致。 + */ + +/** 呼吸空间:标题顶部与 header 下沿之间留出的空白。 */ +const HEADING_OFFSET_PX = 16; + +/** + * 动态测量 sticky header 高度。读 .md-content 最近的 sticky 祖先 header, + * 避免硬编码 80px(响应式/主题变化时 header 高度可能变)。 + */ +function measureStickyHeaderHeight(): number { + const header = document.querySelector('header.sticky'); + return header ? header.getBoundingClientRect().height : 0; +} + +/** + * 平滑滚动到指定元素,落点上移 header 高度 + 固定呼吸空间。 + * 元素不存在时安全跳过。 + */ +export function scrollToHeading(el: Element, smooth = true): void { + // scrollIntoView 配合 scroll-margin-top 在点击场景能工作,但直接访问 #hash 时 + // 浏览器原生 fragment-scroll 会争抢落点。统一用手动计算绕开时序竞态。 + const top = + el.getBoundingClientRect().top + + window.scrollY - + measureStickyHeaderHeight() - + HEADING_OFFSET_PX; + window.scrollTo({ + top: Math.max(top, 0), + behavior: smooth ? 'smooth' : 'auto', + }); +}