From 81682eb70eedc3db36e517ee27a59ba79f00747d Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 10 Jul 2026 00:21:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(post):=20=E4=BF=AE=E5=A4=8D=20hash=20?= =?UTF-8?q?=E9=94=9A=E7=82=B9=E8=B7=B3=E8=BD=AC=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内容异步加载(use_server_future)导致骨架屏阶段标题 DOM 缺失,浏览器原生 fragment-scroll 找不到目标留在顶部;刷新页面也因 SSR 出的是骨架屏而失效。 - 新增 __scrollToHash 全局入口(yggdrasil-core):内容挂载后解码 location.hash(CJK 百分号编码 → 原始字符)并 scrollIntoView - PostContent use_effect 末尾复用 invoke_optional_global 调用,标题 DOM 已就绪时补一次滚动 - 标题加 scroll-margin-top: 6rem,避开 80px sticky header 点击 TOC/标题 hashtag 由原生 anchor + scroll-margin-top 覆盖,无需 JS。 --- input.css | 6 +++ libs/yggdrasil-core/src/hash-scroll.test.ts | 56 +++++++++++++++++++++ libs/yggdrasil-core/src/hash-scroll.ts | 22 ++++++++ libs/yggdrasil-core/src/index.ts | 3 ++ src/components/post/post_content.rs | 6 +++ 5 files changed, 93 insertions(+) create mode 100644 libs/yggdrasil-core/src/hash-scroll.test.ts create mode 100644 libs/yggdrasil-core/src/hash-scroll.ts diff --git a/input.css b/input.css index f2e3dbb..c9be2fd 100644 --- a/input.css +++ b/input.css @@ -313,6 +313,12 @@ color: var(--color-paper-primary); } + /* 锚点目标偏移:scrollIntoView / 原生 fragment-scroll 落点避开 80px sticky header。 + [id] 限定只作用于带锚点 id 的标题;6rem = 80px header + 16px 呼吸空间。 */ + .md-content :is(h1, h2, h3, h4, h5, h6)[id] { + scroll-margin-top: 6rem; + } + .md-content a:not(.anchor) { text-underline-offset: 0.3rem; text-decoration: underline; diff --git a/libs/yggdrasil-core/src/hash-scroll.test.ts b/libs/yggdrasil-core/src/hash-scroll.test.ts new file mode 100644 index 0000000..3341873 --- /dev/null +++ b/libs/yggdrasil-core/src/hash-scroll.test.ts @@ -0,0 +1,56 @@ +/** + * hash-scroll 测试:钉住锚点跳转的解码与 scrollIntoView 行为。 + * 黑盒驱动:只通过 window.__scrollToHash 入口。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import './index'; + +describe('scrollToHash', () => { + beforeEach(() => { + document.body.innerHTML = ''; + history.replaceState(null, '', '#'); + vi.restoreAllMocks(); + }); + afterEach(() => { + document.body.innerHTML = ''; + history.replaceState(null, '', '#'); + }); + + it('hash 为 CJK 百分号编码时解码后命中并滚动', () => { + // 标题 id 是原始 CJK 字符,URL hash 是百分号编码形式。 + const heading = document.createElement('h2'); + heading.id = '三-五-零法则'; + document.body.appendChild(heading); + + const spy = vi.spyOn(heading, 'scrollIntoView'); + history.replaceState(null, '', `#${encodeURIComponent(heading.id)}`); + + window.__scrollToHash(); + + expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + }); + + it('hash 为纯 ASCII 时直接命中', () => { + const heading = document.createElement('h2'); + heading.id = 'getting-started'; + document.body.appendChild(heading); + + const spy = vi.spyOn(heading, 'scrollIntoView'); + history.replaceState(null, '', '#getting-started'); + + window.__scrollToHash(); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('hash 为空时不滚动也不报错', () => { + history.replaceState(null, '', '#'); + expect(() => window.__scrollToHash()).not.toThrow(); + }); + + it('目标元素不存在时不报错', () => { + history.replaceState(null, '', '#no-such-heading'); + expect(() => window.__scrollToHash()).not.toThrow(); + }); +}); diff --git a/libs/yggdrasil-core/src/hash-scroll.ts b/libs/yggdrasil-core/src/hash-scroll.ts new file mode 100644 index 0000000..9ec6fa1 --- /dev/null +++ b/libs/yggdrasil-core/src/hash-scroll.ts @@ -0,0 +1,22 @@ +/** + * 锚点跳转:根据 URL hash 滚动到对应标题。 + * + * 解决 SPA 异步取数时序问题:文章正文经 use_server_future 异步加载,骨架屏阶段 + * 标题 DOM 尚不存在,浏览器原生 fragment-scroll 找不到目标留在顶部。本函数在 + * 内容挂载后调用,弥补原生 scroll 丢失的窗口。 + * + * CJK 编码要点:location.hash 返回百分号编码形式(%E4%B8%89-...),而标题 id + * 属性是原始字符(id="三-五-零法则")。必须先 decodeURIComponent 还原,getElementById + * 才能匹配。双 fallback(先解码、后原始)兼容两种情况。 + */ + +export function scrollToHash(): void { + const hash = window.location.hash.slice(1); // 去掉前导 # + if (!hash) return; + + const id = decodeURIComponent(hash); + const el = document.getElementById(id) ?? document.getElementById(hash); + if (!el) return; + + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} diff --git a/libs/yggdrasil-core/src/index.ts b/libs/yggdrasil-core/src/index.ts index 055cbad..27bd4ee 100644 --- a/libs/yggdrasil-core/src/index.ts +++ b/libs/yggdrasil-core/src/index.ts @@ -1,3 +1,4 @@ +import { scrollToHash } from './hash-scroll'; import { initPostContent } from './post-content'; import { applyResolvedTheme, startThemeTransition } from './theme-transition'; import './style.css'; @@ -5,11 +6,13 @@ import './style.css'; declare global { interface Window { __initPostContent: (selector: string) => void; + __scrollToHash: () => void; __startThemeTransition: (x: number, y: number) => void; __applyResolvedTheme: (isDark: boolean) => void; } } window.__initPostContent = initPostContent; +window.__scrollToHash = scrollToHash; window.__startThemeTransition = startThemeTransition; window.__applyResolvedTheme = applyResolvedTheme; diff --git a/src/components/post/post_content.rs b/src/components/post/post_content.rs index 4f8835e..1e7390a 100644 --- a/src/components/post/post_content.rs +++ b/src/components/post/post_content.rs @@ -153,6 +153,12 @@ pub fn PostContent(content_html: String) -> Element { let selectors_val = js_sys::Object::from(selectors).into(); let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val); invoke_optional_global(&window, "__initLightbox", &[selectors_val]); + + // 内容挂载后若 URL 带 hash,滚动到对应标题。 + // 解决骨架屏阶段标题 DOM 缺失导致浏览器原生 fragment-scroll 失效的问题: + // PostDetail 用 use_server_future 异步取数,首屏渲染骨架屏,此时标题 DOM + // 不存在;浏览器尝试滚动到 #hash 找不到目标留在顶部。此处标题已就绪,补一次。 + invoke_optional_global(&window, "__scrollToHash", &[]); }); rsx! {