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 场景的兜底。
This commit is contained in:
parent
5ad6ce60cd
commit
067075df8a
@ -40,11 +40,10 @@ describe('initAnchorClick', () => {
|
|||||||
anchor.setAttribute('href', '#ru-men-zhi-nan');
|
anchor.setAttribute('href', '#ru-men-zhi-nan');
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
|
|
||||||
const spy = vi.spyOn(heading, 'scrollIntoView');
|
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
anchor.click();
|
anchor.click();
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledOnce();
|
expect(spy).toHaveBeenCalled();
|
||||||
expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
|
|
||||||
// 关键:Dioxus 委托监听器未被触发 → handleClickNavigate/browser_open 不会执行。
|
// 关键:Dioxus 委托监听器未被触发 → handleClickNavigate/browser_open 不会执行。
|
||||||
expect(dioxusInterceptorCalled).toBe(false);
|
expect(dioxusInterceptorCalled).toBe(false);
|
||||||
});
|
});
|
||||||
@ -93,7 +92,7 @@ describe('initAnchorClick', () => {
|
|||||||
anchor.setAttribute('href', '#target');
|
anchor.setAttribute('href', '#target');
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
|
|
||||||
const spy = vi.spyOn(heading, 'scrollIntoView');
|
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
|
||||||
// 模拟 ctrl+click(新标签页):dispatchEvent 携带 ctrlKey。
|
// 模拟 ctrl+click(新标签页):dispatchEvent 携带 ctrlKey。
|
||||||
const event = new MouseEvent('click', { bubbles: true, ctrlKey: true });
|
const event = new MouseEvent('click', { bubbles: true, ctrlKey: true });
|
||||||
@ -116,10 +115,10 @@ describe('initAnchorClick', () => {
|
|||||||
window.__initAnchorClick();
|
window.__initAnchorClick();
|
||||||
window.__initAnchorClick();
|
window.__initAnchorClick();
|
||||||
|
|
||||||
const spy = vi.spyOn(heading, 'scrollIntoView');
|
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
anchor.click();
|
anchor.click();
|
||||||
|
|
||||||
// 即使重复调用,scrollIntoView 仍只触发一次(监听器只注册一份)。
|
// 即使重复调用,scrollTo 仍只触发一次(监听器只注册一份)。
|
||||||
expect(spy).toHaveBeenCalledOnce();
|
expect(spy).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,10 +14,13 @@
|
|||||||
* 走浏览器原生 fragment-scroll 正常——所以问题只在 hydration 后出现。
|
* 走浏览器原生 fragment-scroll 正常——所以问题只在 hydration 后出现。
|
||||||
*
|
*
|
||||||
* 修复:在 capture 阶段(早于 Dioxus 的 bubble 委托监听器)拦截指向同页 hash 的
|
* 修复:在 capture 阶段(早于 Dioxus 的 bubble 委托监听器)拦截指向同页 hash 的
|
||||||
* <a> 点击,stopPropagation 阻止事件到达 Dioxus 的委托监听器,自行 scrollIntoView
|
* <a> 点击,stopPropagation 阻止事件到达 Dioxus 的委托监听器,自行滚动到标题
|
||||||
* 并用 history.replaceState 更新 URL hash(不触发 popstate/hashchange,避免抖动)。
|
* (scrollToHeading 手动扣除 header 高度)并用 history.replaceState 更新 URL hash
|
||||||
|
* (不触发 popstate/hashchange,避免抖动)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { scrollToHeading } from './scroll-to-heading';
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -59,8 +62,8 @@ export function initAnchorClick(): void {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// 平滑滚动到目标标题(scroll-margin-top 在 input.css 里设为 6rem 避开 sticky header)。
|
// 平滑滚动到目标标题,手动扣除 sticky header 高度(见 scroll-to-heading.ts)。
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
scrollToHeading(el, true);
|
||||||
|
|
||||||
// 更新 URL hash 但不产生历史记录抖动:replaceState 不触发 popstate/hashchange。
|
// 更新 URL hash 但不产生历史记录抖动:replaceState 不触发 popstate/hashchange。
|
||||||
// 这样地址栏显示当前章节,刷新页面也能 scrollToHash 回到原位。
|
// 这样地址栏显示当前章节,刷新页面也能 scrollToHash 回到原位。
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* hash-scroll 测试:钉住锚点跳转的解码与 scrollIntoView 行为。
|
* hash-scroll 测试:钉住锚点跳转的解码与手动偏移滚动行为。
|
||||||
* 黑盒驱动:只通过 window.__scrollToHash 入口。
|
* 黑盒驱动:只通过 window.__scrollToHash 入口。
|
||||||
|
*
|
||||||
|
* 现在用 window.scrollTo 手动扣除 sticky header 高度(不再依赖 scrollIntoView +
|
||||||
|
* scroll-margin-top,因为浏览器原生 fragment-scroll 对后者的应用时序不稳定)。
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import './index';
|
import './index';
|
||||||
@ -17,18 +20,16 @@ describe('scrollToHash', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('hash 为 CJK 百分号编码时解码后命中并滚动', () => {
|
it('hash 为 CJK 百分号编码时解码后命中并滚动', () => {
|
||||||
// 标题 id 是原始 CJK 字符,URL hash 是百分号编码形式。
|
|
||||||
const heading = document.createElement('h2');
|
const heading = document.createElement('h2');
|
||||||
heading.id = '三-五-零法则';
|
heading.id = '三-五-零法则';
|
||||||
document.body.appendChild(heading);
|
document.body.appendChild(heading);
|
||||||
|
|
||||||
const spy = vi.spyOn(heading, 'scrollIntoView');
|
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
history.replaceState(null, '', `#${encodeURIComponent(heading.id)}`);
|
history.replaceState(null, '', `#${encodeURIComponent(heading.id)}`);
|
||||||
|
|
||||||
window.__scrollToHash();
|
window.__scrollToHash();
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledOnce();
|
expect(spy).toHaveBeenCalled();
|
||||||
expect(spy).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hash 为纯 ASCII 时直接命中', () => {
|
it('hash 为纯 ASCII 时直接命中', () => {
|
||||||
@ -36,21 +37,75 @@ describe('scrollToHash', () => {
|
|||||||
heading.id = 'getting-started';
|
heading.id = 'getting-started';
|
||||||
document.body.appendChild(heading);
|
document.body.appendChild(heading);
|
||||||
|
|
||||||
const spy = vi.spyOn(heading, 'scrollIntoView');
|
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
history.replaceState(null, '', '#getting-started');
|
history.replaceState(null, '', '#getting-started');
|
||||||
|
|
||||||
window.__scrollToHash();
|
window.__scrollToHash();
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledOnce();
|
expect(spy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('滚动落点扣除 sticky header 高度', () => {
|
||||||
|
// 模拟 sticky header:getBoundingClientRect 返回 height 80。
|
||||||
|
const header = document.createElement('header');
|
||||||
|
header.className = 'sticky';
|
||||||
|
header.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 80,
|
||||||
|
width: 1000,
|
||||||
|
height: 80,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON() {},
|
||||||
|
}) as DOMRect;
|
||||||
|
document.body.appendChild(header);
|
||||||
|
|
||||||
|
// 目标标题:放在 scrollY=1000 处(getBoundingClientRect.top 反映相对视口位置)。
|
||||||
|
const heading = document.createElement('h2');
|
||||||
|
heading.id = 'mid';
|
||||||
|
heading.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
top: 1000,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 1050,
|
||||||
|
width: 800,
|
||||||
|
height: 50,
|
||||||
|
x: 0,
|
||||||
|
y: 1000,
|
||||||
|
toJSON() {},
|
||||||
|
}) as DOMRect;
|
||||||
|
document.body.appendChild(heading);
|
||||||
|
|
||||||
|
// scrollY 参与 scrollTo 目标计算:top = rect.top + scrollY - headerH - offset。
|
||||||
|
Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true });
|
||||||
|
|
||||||
|
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
|
history.replaceState(null, '', '#mid');
|
||||||
|
|
||||||
|
window.__scrollToHash();
|
||||||
|
|
||||||
|
// 第一帧调用(smooth):top = 1000 + 500 - 80 - 16 = 1404
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
const firstCall = spy.mock.calls[0][0] as ScrollToOptions;
|
||||||
|
expect(firstCall.top).toBe(1000 + 500 - 80 - 16);
|
||||||
|
expect(firstCall.behavior).toBe('smooth');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hash 为空时不滚动也不报错', () => {
|
it('hash 为空时不滚动也不报错', () => {
|
||||||
|
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
history.replaceState(null, '', '#');
|
history.replaceState(null, '', '#');
|
||||||
expect(() => window.__scrollToHash()).not.toThrow();
|
expect(() => window.__scrollToHash()).not.toThrow();
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('目标元素不存在时不报错', () => {
|
it('目标元素不存在时不报错', () => {
|
||||||
|
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
history.replaceState(null, '', '#no-such-heading');
|
history.replaceState(null, '', '#no-such-heading');
|
||||||
expect(() => window.__scrollToHash()).not.toThrow();
|
expect(() => window.__scrollToHash()).not.toThrow();
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,11 +5,18 @@
|
|||||||
* 标题 DOM 尚不存在,浏览器原生 fragment-scroll 找不到目标留在顶部。本函数在
|
* 标题 DOM 尚不存在,浏览器原生 fragment-scroll 找不到目标留在顶部。本函数在
|
||||||
* 内容挂载后调用,弥补原生 scroll 丢失的窗口。
|
* 内容挂载后调用,弥补原生 scroll 丢失的窗口。
|
||||||
*
|
*
|
||||||
|
* 落点遮挡问题:直接访问 #hash 链接时,浏览器原生 fragment-scroll 会在我们
|
||||||
|
* scrollToHash 之后触发,它不读 scroll-margin-top,把标题顶到 sticky header 下面。
|
||||||
|
* 解法:用 requestAnimationFrame 延后两帧再做最终校正,确保我们的落点在原生
|
||||||
|
* scroll 之后生效(原生 scroll 通常在下一帧完成)。
|
||||||
|
*
|
||||||
* CJK 编码要点:location.hash 返回百分号编码形式(%E4%B8%89-...),而标题 id
|
* CJK 编码要点:location.hash 返回百分号编码形式(%E4%B8%89-...),而标题 id
|
||||||
* 属性是原始字符(id="三-五-零法则")。必须先 decodeURIComponent 还原,getElementById
|
* 属性是原始字符(id="三-五-零法则")。必须先 decodeURIComponent 还原,getElementById
|
||||||
* 才能匹配。双 fallback(先解码、后原始)兼容两种情况。
|
* 才能匹配。双 fallback(先解码、后原始)兼容两种情况。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { scrollToHeading } from './scroll-to-heading';
|
||||||
|
|
||||||
export function scrollToHash(): void {
|
export function scrollToHash(): void {
|
||||||
const hash = window.location.hash.slice(1); // 去掉前导 #
|
const hash = window.location.hash.slice(1); // 去掉前导 #
|
||||||
if (!hash) return;
|
if (!hash) return;
|
||||||
@ -18,5 +25,13 @@ export function scrollToHash(): void {
|
|||||||
const el = document.getElementById(id) ?? document.getElementById(hash);
|
const el = document.getElementById(id) ?? document.getElementById(hash);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
// 立即先滚一次(覆盖骨架屏阶段原生 scroll 失败的窗口)。
|
||||||
|
scrollToHeading(el, true);
|
||||||
|
|
||||||
|
// 延后两帧再校正:直接访问 #hash 时浏览器原生 fragment-scroll 会在此期间触发,
|
||||||
|
// 把标题顶到 header 下面(它不读 scroll-margin-top)。两帧后原生 scroll 已完成,
|
||||||
|
// 用即时滚动(非 smooth,避免与刚才的 smooth 动画叠加抖动)把落点拉回正确位置。
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => scrollToHeading(el, false));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
39
libs/yggdrasil-core/src/scroll-to-heading.ts
Normal file
39
libs/yggdrasil-core/src/scroll-to-heading.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 滚动到标题锚点,手动扣除 sticky header 高度。
|
||||||
|
*
|
||||||
|
* 为什么不用 scrollIntoView + CSS scroll-margin-top:
|
||||||
|
* 浏览器原生 fragment-scroll(直接访问 #hash 链接时)对 scroll-margin-top 的
|
||||||
|
* 应用时序不稳定——常在标题 DOM 就绪后延迟触发,覆盖我们 scrollToHash 的落点,
|
||||||
|
* 导致标题被 sticky header 遮住。改用手动 getBoundingClientRect 计算绝对滚动
|
||||||
|
* 位置并 window.scrollTo,两个场景(直接访问 / 点击)行为确定一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 呼吸空间:标题顶部与 header 下沿之间留出的空白。 */
|
||||||
|
const HEADING_OFFSET_PX = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态测量 sticky header 高度。读 .md-content 最近的 sticky 祖先 header,
|
||||||
|
* 避免硬编码 80px(响应式/主题变化时 header 高度可能变)。
|
||||||
|
*/
|
||||||
|
function measureStickyHeaderHeight(): number {
|
||||||
|
const header = document.querySelector('header.sticky');
|
||||||
|
return header ? header.getBoundingClientRect().height : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平滑滚动到指定元素,落点上移 header 高度 + 固定呼吸空间。
|
||||||
|
* 元素不存在时安全跳过。
|
||||||
|
*/
|
||||||
|
export function scrollToHeading(el: Element, smooth = true): void {
|
||||||
|
// scrollIntoView 配合 scroll-margin-top 在点击场景能工作,但直接访问 #hash 时
|
||||||
|
// 浏览器原生 fragment-scroll 会争抢落点。统一用手动计算绕开时序竞态。
|
||||||
|
const top =
|
||||||
|
el.getBoundingClientRect().top +
|
||||||
|
window.scrollY -
|
||||||
|
measureStickyHeaderHeight() -
|
||||||
|
HEADING_OFFSET_PX;
|
||||||
|
window.scrollTo({
|
||||||
|
top: Math.max(top, 0),
|
||||||
|
behavior: smooth ? 'smooth' : 'auto',
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user