fix(post): 修复 hash 锚点跳转失效

内容异步加载(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。
This commit is contained in:
xfy 2026-07-10 00:21:08 +08:00
parent 3baa0a87ba
commit 81682eb70e
5 changed files with 93 additions and 0 deletions

View File

@ -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;

View File

@ -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();
});
});

View File

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

View File

@ -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;

View File

@ -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! {