yggdrasil/libs/yggdrasil-core/src/anchor-click.ts
xfy 067075df8a fix(post): 直接访问 #hash 时标题被 sticky header 遮住
点击锚点正常,但直接访问带 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 场景的兜底。
2026-07-13 18:31:31 +08:00

77 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 拦截文章正文/目录里的 hash 锚点点击,阻止 Dioxus 接管导致整页刷新。
*
* 背景Dioxus 0.7 的 NativeInterpreter 通过事件委托在根节点上监听 click。
* 只要 app 任意位置用了 onclick本项目 post_nav_links.rs 有Dioxus 就启用
* 全局 click 委托。点击 dangerous_inner_html 注入的原生 <a href="#id"> 时:
* 1. click 冒泡到根 → handleEvent 同步 POST /__events
* 2. 目标 <a> 没有 Dioxus onclick → response 不含 preventDefault
* 3. 兜底分支 handleClickNavigate: event.preventDefault() + browser_open IPC
* 4. browser_open 走 History::external → window.location.set_href("#id") → 整页刷新
*
* 结果:原生 fragment-scroll 被 preventDefault 拦掉IPC 又把 hash 当外部 URL
* 整页加载表现为「点击锚点刷新文章页且不滚动」。WASM 未下载完时是纯 SSR HTML
* 走浏览器原生 fragment-scroll 正常——所以问题只在 hydration 后出现。
*
* 修复:在 capture 阶段(早于 Dioxus 的 bubble 委托监听器)拦截指向同页 hash 的
* <a> 点击stopPropagation 阻止事件到达 Dioxus 的委托监听器,自行滚动到标题
* scrollToHeading 手动扣除 header 高度)并用 history.replaceState 更新 URL hash
* (不触发 popstate/hashchange避免抖动
*/
import { scrollToHeading } from './scroll-to-heading';
let installed = false;
/**
* 安装全局 hash 锚点点击拦截器。幂等:重复调用安全。
*
* 用 capture 阶段 + 在 window 上注册,保证在 Dioxus 的根监听器之前执行;
* stopPropagation 阻止事件继续传播Dioxus 的 handleEvent/handleClickNavigate
* 不会收到这个事件browser_open IPC 不触发,整页刷新被避免。
*/
export function initAnchorClick(): void {
if (installed) return;
installed = true;
window.addEventListener(
'click',
(event) => {
// 只处理无修饰键的左键点击(中键/ctrl/cmd 点击交给浏览器新窗口行为)。
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) return;
// 从点击点向上找最近的 <a>(点击可能落在 <a> 内的子元素上)。
const anchor = target.closest('a');
if (!anchor) return;
const href = anchor.getAttribute('href');
// 只接管同页 hash 锚点href 以 # 开头且非空(排除纯 "#" 和 "#top" 之外的真锚点)。
// 外链、路径链接、空 hash 都交给浏览器/Dioxus 原行为。
if (!href?.startsWith('#') || href === '#') return;
const id = decodeURIComponent(href.slice(1));
const el = document.getElementById(id);
if (!el) return; // 目标不存在:放行,交还原生行为(不滚动但不刷新)
// 阻止事件继续传播到 Dioxus 的委托监听器 → 避免 handleClickNavigate。
event.stopPropagation();
event.preventDefault();
// 平滑滚动到目标标题,手动扣除 sticky header 高度(见 scroll-to-heading.ts
scrollToHeading(el, true);
// 更新 URL hash 但不产生历史记录抖动replaceState 不触发 popstate/hashchange。
// 这样地址栏显示当前章节,刷新页面也能 scrollToHash 回到原位。
if (window.location.hash !== href) {
history.replaceState(null, '', href);
}
},
true, // capture在 Dioxus 的 bubble 监听器之前执行
);
}