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:
parent
3baa0a87ba
commit
81682eb70e
@ -313,6 +313,12 @@
|
|||||||
color: var(--color-paper-primary);
|
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) {
|
.md-content a:not(.anchor) {
|
||||||
text-underline-offset: 0.3rem;
|
text-underline-offset: 0.3rem;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
56
libs/yggdrasil-core/src/hash-scroll.test.ts
Normal file
56
libs/yggdrasil-core/src/hash-scroll.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
22
libs/yggdrasil-core/src/hash-scroll.ts
Normal file
22
libs/yggdrasil-core/src/hash-scroll.ts
Normal 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' });
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { scrollToHash } from './hash-scroll';
|
||||||
import { initPostContent } from './post-content';
|
import { initPostContent } from './post-content';
|
||||||
import { applyResolvedTheme, startThemeTransition } from './theme-transition';
|
import { applyResolvedTheme, startThemeTransition } from './theme-transition';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
@ -5,11 +6,13 @@ import './style.css';
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__initPostContent: (selector: string) => void;
|
__initPostContent: (selector: string) => void;
|
||||||
|
__scrollToHash: () => void;
|
||||||
__startThemeTransition: (x: number, y: number) => void;
|
__startThemeTransition: (x: number, y: number) => void;
|
||||||
__applyResolvedTheme: (isDark: boolean) => void;
|
__applyResolvedTheme: (isDark: boolean) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__initPostContent = initPostContent;
|
window.__initPostContent = initPostContent;
|
||||||
|
window.__scrollToHash = scrollToHash;
|
||||||
window.__startThemeTransition = startThemeTransition;
|
window.__startThemeTransition = startThemeTransition;
|
||||||
window.__applyResolvedTheme = applyResolvedTheme;
|
window.__applyResolvedTheme = applyResolvedTheme;
|
||||||
|
|||||||
@ -153,6 +153,12 @@ pub fn PostContent(content_html: String) -> Element {
|
|||||||
let selectors_val = js_sys::Object::from(selectors).into();
|
let selectors_val = js_sys::Object::from(selectors).into();
|
||||||
let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
|
let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
|
||||||
invoke_optional_global(&window, "__initLightbox", &[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! {
|
rsx! {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user