点击锚点正常,但直接访问带 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 场景的兜底。
77 lines
3.5 KiB
TypeScript
77 lines
3.5 KiB
TypeScript
/**
|
||
* 拦截文章正文/目录里的 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 监听器之前执行
|
||
);
|
||
}
|