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',
+ });
+}