diff --git a/libs/yggdrasil-core/src/anchor-click.test.ts b/libs/yggdrasil-core/src/anchor-click.test.ts new file mode 100644 index 0000000..11db598 --- /dev/null +++ b/libs/yggdrasil-core/src/anchor-click.test.ts @@ -0,0 +1,125 @@ +/** + * anchor-click 测试:钉住 hash 锚点点击的拦截、滚动与 URL 更新。 + * 黑盒驱动:只通过 window.__initAnchorClick 入口 + 真实 DOM 事件派发。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import './index'; + +// 模拟 Dioxus 委托监听器:挂在 document 的 bubble 阶段 click,模拟 handleClickNavigate +// 会被触发时的副作用(preventDefault + 整页跳转)。测试用它验证拦截器确实阻止了冒泡。 +let dioxusInterceptorCalled = false; + +describe('initAnchorClick', () => { + beforeEach(() => { + document.body.innerHTML = ''; + history.replaceState(null, '', '#'); + vi.restoreAllMocks(); + dioxusInterceptorCalled = false; + // 模拟 Dioxus 在 document bubble 阶段的 click 委托监听器。 + // 拦截器在 window capture 阶段 stopPropagation 后,这个监听器不应被触发。 + document.addEventListener( + 'click', + () => { + dioxusInterceptorCalled = true; + }, + false, + ); + window.__initAnchorClick(); + }); + afterEach(() => { + document.body.innerHTML = ''; + history.replaceState(null, '', '#'); + }); + + it('点击 hash 锚点滚动到目标并阻止 Dioxus 委托监听器', () => { + const heading = document.createElement('h2'); + heading.id = 'ru-men-zhi-nan'; + document.body.appendChild(heading); + + const anchor = document.createElement('a'); + anchor.setAttribute('href', '#ru-men-zhi-nan'); + document.body.appendChild(anchor); + + const spy = vi.spyOn(heading, 'scrollIntoView'); + anchor.click(); + + expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + // 关键:Dioxus 委托监听器未被触发 → handleClickNavigate/browser_open 不会执行。 + expect(dioxusInterceptorCalled).toBe(false); + }); + + it('点击后 URL hash 更新为锚点 id(不触发整页刷新)', () => { + const heading = document.createElement('h2'); + heading.id = 'section-2'; + document.body.appendChild(heading); + + const anchor = document.createElement('a'); + anchor.setAttribute('href', '#section-2'); + document.body.appendChild(anchor); + + anchor.click(); + + expect(window.location.hash).toBe('#section-2'); + }); + + it('目标元素不存在时放行,不阻止 Dioxus 原行为', () => { + const anchor = document.createElement('a'); + anchor.setAttribute('href', '#no-such-heading'); + document.body.appendChild(anchor); + + anchor.click(); + + // 目标不存在:拦截器不接管,事件继续冒泡到 Dioxus 监听器。 + expect(dioxusInterceptorCalled).toBe(true); + }); + + it('非 hash 链接不拦截(外链/路径链接交给原行为)', () => { + const anchor = document.createElement('a'); + anchor.setAttribute('href', '/post/other'); + document.body.appendChild(anchor); + + anchor.click(); + + expect(dioxusInterceptorCalled).toBe(true); + }); + + it('带修饰键的点击不拦截(新窗口等交给浏览器)', () => { + const heading = document.createElement('h2'); + heading.id = 'target'; + document.body.appendChild(heading); + + const anchor = document.createElement('a'); + anchor.setAttribute('href', '#target'); + document.body.appendChild(anchor); + + const spy = vi.spyOn(heading, 'scrollIntoView'); + + // 模拟 ctrl+click(新标签页):dispatchEvent 携带 ctrlKey。 + const event = new MouseEvent('click', { bubbles: true, ctrlKey: true }); + anchor.dispatchEvent(event); + + expect(spy).not.toHaveBeenCalled(); + expect(dioxusInterceptorCalled).toBe(true); + }); + + it('重复调用 initAnchorClick 幂等,不会重复注册监听器', () => { + const heading = document.createElement('h2'); + heading.id = 'dup-target'; + document.body.appendChild(heading); + + const anchor = document.createElement('a'); + anchor.setAttribute('href', '#dup-target'); + document.body.appendChild(anchor); + + // 再次调用(模拟 PostContent 多次挂载)。 + window.__initAnchorClick(); + window.__initAnchorClick(); + + const spy = vi.spyOn(heading, 'scrollIntoView'); + anchor.click(); + + // 即使重复调用,scrollIntoView 仍只触发一次(监听器只注册一份)。 + expect(spy).toHaveBeenCalledOnce(); + }); +}); diff --git a/libs/yggdrasil-core/src/anchor-click.ts b/libs/yggdrasil-core/src/anchor-click.ts new file mode 100644 index 0000000..4780984 --- /dev/null +++ b/libs/yggdrasil-core/src/anchor-click.ts @@ -0,0 +1,73 @@ +/** + * 拦截文章正文/目录里的 hash 锚点点击,阻止 Dioxus 接管导致整页刷新。 + * + * 背景:Dioxus 0.7 的 NativeInterpreter 通过事件委托在根节点上监听 click。 + * 只要 app 任意位置用了 onclick(本项目 post_nav_links.rs 有),Dioxus 就启用 + * 全局 click 委托。点击 dangerous_inner_html 注入的原生 时: + * 1. click 冒泡到根 → handleEvent 同步 POST /__events + * 2. 目标 没有 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 的 + * 点击,stopPropagation 阻止事件到达 Dioxus 的委托监听器,自行 scrollIntoView + * 并用 history.replaceState 更新 URL hash(不触发 popstate/hashchange,避免抖动)。 + */ + +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; + + // 从点击点向上找最近的 (点击可能落在 内的子元素上)。 + 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(); + + // 平滑滚动到目标标题(scroll-margin-top 在 input.css 里设为 6rem 避开 sticky header)。 + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + + // 更新 URL hash 但不产生历史记录抖动:replaceState 不触发 popstate/hashchange。 + // 这样地址栏显示当前章节,刷新页面也能 scrollToHash 回到原位。 + if (window.location.hash !== href) { + history.replaceState(null, '', href); + } + }, + true, // capture:在 Dioxus 的 bubble 监听器之前执行 + ); +} diff --git a/libs/yggdrasil-core/src/index.ts b/libs/yggdrasil-core/src/index.ts index 27bd4ee..0ffc3f1 100644 --- a/libs/yggdrasil-core/src/index.ts +++ b/libs/yggdrasil-core/src/index.ts @@ -1,3 +1,4 @@ +import { initAnchorClick } from './anchor-click'; import { scrollToHash } from './hash-scroll'; import { initPostContent } from './post-content'; import { applyResolvedTheme, startThemeTransition } from './theme-transition'; @@ -6,6 +7,7 @@ import './style.css'; declare global { interface Window { __initPostContent: (selector: string) => void; + __initAnchorClick: () => void; __scrollToHash: () => void; __startThemeTransition: (x: number, y: number) => void; __applyResolvedTheme: (isDark: boolean) => void; @@ -13,6 +15,7 @@ declare global { } window.__initPostContent = initPostContent; +window.__initAnchorClick = initAnchorClick; 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 1e7390a..73079f9 100644 --- a/src/components/post/post_content.rs +++ b/src/components/post/post_content.rs @@ -154,6 +154,12 @@ pub fn PostContent(content_html: String) -> Element { let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val); invoke_optional_global(&window, "__initLightbox", &[selectors_val]); + // 安装 hash 锚点点击拦截器(幂等)。 + // Dioxus hydration 后其事件委托会接管所有 click(见 handleClickNavigate), + // 把 hash 锚点当外部 URL 整页刷新。拦截器在 capture 阶段阻止事件到达 Dioxus, + // 自行 scrollIntoView。initAnchorClick 内部幂等,PostContent 多次挂载也安全。 + invoke_optional_global(&window, "__initAnchorClick", &[]); + // 内容挂载后若 URL 带 hash,滚动到对应标题。 // 解决骨架屏阶段标题 DOM 缺失导致浏览器原生 fragment-scroll 失效的问题: // PostDetail 用 use_server_future 异步取数,首屏渲染骨架屏,此时标题 DOM