Compare commits

..

4 Commits

Author SHA1 Message Date
xfy
f6b7ab4333 fix(post): scrollToHash 增加 ResizeObserver 布局稳定期,修正 mermaid 异步渲染导致的锚点落点偏移
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
直接访问 /post/slug#heading 或刷新该 URL 时,目标标题停在屏幕中间偏下,但点击目录
或标题跳转却正常。根因是时序竞争:

post_content 的 use_effect 同步依次调用 __initMermaid(注册 IntersectionObserver,
异步)→ __scrollToHash。scrollToHash 的"立即 + 两帧"校正只覆盖浏览器原生
fragment-scroll;而 mermaid 懒加载(加载 ~1MB bundle → render → pre.innerHTML=svg)
是数百毫秒的异步链,发生在两帧之后。mermaid 块从源码文本膨胀成 SVG 图时撑高内容,
把目标标题推离已校准的落点。mermaid.ts 渲染完成后无任何落点修正。点击目录正常是
因为那时 mermaid 已渲染、布局稳定。

解法:scrollToHash 末尾新增 stabilizeScrollOnResize——在 STABILIZE_WINDOW_MS(2s) 内
用 ResizeObserver 观察 .post-content 容器,检测到布局变化即用 rAF 合并重新校正一次
落点。与 mermaid 解耦,同时覆盖图片/字体等所有异步位移源。用户主动滚动(wheel/
touchmove/keydown)或超时后自动停止,不与交互打架。重入安全:主题切换重跑 use_effect
时先 dispose 上一次稳定器,避免泄漏/叠加。

测试(hash-scroll.test.ts)新增 3 例:resize 后再校正、用户 wheel 后停止、重入清理旧 RO。
2026-07-16 16:05:11 +08:00
xfy
1ea11b6434 style(checkbox): 文章页 task-list checkbox 改用 appearance:none 自绘圆角方框
原生 checkbox 形状随浏览器而异(Safari 圆角小框/Chrome 直角),
accent-color 只能上色无法统一形状。改为全自定义:
- appearance:none + 4px 圆角 + 1.15rem 方框
- 勾选时 accent 绿填充,对勾用内联 SVG background
  (<input> 是替换元素,吃不到伪元素)
- 亮色:中绿底 #40a02b + 白对勾(对比 ≥4.5:1)
- 暗色:浅绿底 #a6e3a1 + 深对勾 #1e1e2e(白对勾在浅绿上对比不足)
- 文章内 checkbox 是 disabled 展示态,故不加 focus/cursor 交互
2026-07-16 15:58:54 +08:00
xfy
e526da0571 style(code-block): 代码块字号从 13.6px 调整为 16px
.md-content pre code 的 font-size 由 0.85em 改为 1rem,
锚定根字号 16px 以提升代码块可读性。行间 code 保持 0.85em 不变。
2026-07-16 15:52:58 +08:00
xfy
445b92f407 fix(mermaid): 改用 script 标签加载 IIFE bundle,修正全局变量取值
根因:mermaid bundle 是 IIFE 格式(与项目其他前端库一致),挂全局变量
而非 ES module export。但 mermaid.ts 错用了 import() 取 .default——
IIFE 无 export,mod.default 是 undefined,导致 mermaid.initialize 调用
TypeError,流程图永远不渲染(数学公式不受影响,因为是服务端渲染)。

修复:
- mermaid.ts: loadMermaidBundle 改用动态注入 <script src=/mermaid/mermaid.js>
  标签,加载后从 window.MermaidRenderer 取(与 codemirror/lightbox 的
  Reflect::get(window,...) 模式一致)
- mermaid-renderer/index.ts: 显式 globalThis.MermaidRenderer = mermaid,
  避免 vite IIFE 把 export default 编译成 .default 多一层
- markdown.rs: mermaid 代码块跳过 syntect 高亮(无语法定义且会包 <span>
  污染 textContent),直接输出转义纯源码

前端 28 测试 + Rust 557 测试 + 双 target 编译通过。
2026-07-16 15:36:07 +08:00
6 changed files with 296 additions and 17 deletions

View File

@ -405,12 +405,36 @@
} }
.md-content li > input[type="checkbox"] { .md-content li > input[type="checkbox"] {
/* appearance:none 全自定义告别浏览器原生方框形状/边框差异
对勾用内联 SVG background<input> 是替换元素吃不到 ::after
颜色按主题烘焙亮色中绿底配白对勾暗色浅绿底配深对勾对比最强
文章内 checkbox disabled 的纯展示态故不加 focus/cursor 交互 */
appearance: none;
-webkit-appearance: none;
flex-shrink: 0; flex-shrink: 0;
width: 1rem; width: 1.15rem;
height: 1rem; height: 1.15rem;
margin: 0; margin: 0;
accent-color: var(--color-paper-accent); border: 1.5px solid var(--color-paper-border);
border-radius: 4px;
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: 0.75rem;
cursor: default; cursor: default;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.md-content li > input[type="checkbox"]:checked {
background-color: var(--color-paper-accent);
border-color: var(--color-paper-accent);
/* 亮色:中绿 #40a02b 底 + 白对勾(对比 ≥ 4.5:1 */
background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%20fill='none'%20stroke='%23ffffff'%20stroke-width='2.2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M3.5%208.5l3%203%206-6.5'/%3E%3C/svg%3E");
}
/* 暗色:浅绿 #a6e3a1 底上白对勾对比不足,改深色对勾(主题底色 #1e1e2e */
.dark .md-content li > input[type="checkbox"]:checked {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%20fill='none'%20stroke='%231e1e2e'%20stroke-width='2.2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpath%20d='M3.5%208.5l3%203%206-6.5'/%3E%3C/svg%3E");
} }
.md-content hr { .md-content hr {
@ -529,7 +553,7 @@
border-radius: 0; border-radius: 0;
overflow-x: auto; overflow-x: auto;
white-space: pre; white-space: pre;
font-size: 0.85em; font-size: 1rem;
line-height: 1.6; line-height: 1.6;
} }

View File

@ -1,5 +1,12 @@
// 把 mermaid 默认导出包成 IIFE 全局 bundle。 // 把 mermaid 包成 IIFE 全局 bundle。
// yggdrasil-core 的 mermaid.ts 通过动态 import('/mermaid/mermaid.js') 拿到 .default。 //
// 关键vite IIFE + `export default X` 会产出 `window.MermaidRenderer.default = X`
// (多一层 .default。为避免 mermaid.ts 取值时纠结这一层,这里把 mermaid 对象
// 直接赋给 window.MermaidRenderer 作为构建时副作用,使 window.MermaidRenderer
// 本身即 mermaid APIinitialize/render
import mermaid from 'mermaid'; import mermaid from 'mermaid';
// 构建时副作用:把 mermaid 挂到全局。ES module 的顶层赋值会被 vite 保留。
;(globalThis as unknown as { MermaidRenderer: typeof mermaid }).MermaidRenderer = mermaid;
export default mermaid; export default mermaid;

View File

@ -4,15 +4,38 @@
* *
* window.scrollTo sticky header scrollIntoView + * window.scrollTo sticky header scrollIntoView +
* scroll-margin-top fragment-scroll * scroll-margin-top fragment-scroll
*
* ResizeObservermermaid SVG
* /
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import './index'; import './index';
// --- ResizeObserver mockhappy-dom 不实现,手动提供回调钩子 ---
let resizeCallback:
| ((entries: { target: Element; contentRect: { width: number; height: number } }[]) => void)
| null = null;
const roDisconnect = vi.fn();
vi.stubGlobal(
'ResizeObserver',
class {
constructor(cb: typeof resizeCallback) {
resizeCallback = cb;
}
observe() {}
disconnect() {
roDisconnect();
}
},
);
describe('scrollToHash', () => { describe('scrollToHash', () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
history.replaceState(null, '', '#'); history.replaceState(null, '', '#');
vi.restoreAllMocks(); vi.restoreAllMocks();
resizeCallback = null;
roDisconnect.mockClear();
}); });
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
@ -108,4 +131,93 @@ describe('scrollToHash', () => {
expect(() => window.__scrollToHash()).not.toThrow(); expect(() => window.__scrollToHash()).not.toThrow();
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
// ---------- 布局稳定期ResizeObserver ----------
// 复现 bug 根因mermaid 懒加载等异步位移源发生在两帧校正之后,撑高内容把标题
// 推离落点。稳定期监听内容容器 resize位移后重新校正一次。
/** 构造一个带 .post-content 容器和带 id 标题的 DOM用于稳定期测试。 */
function setupPostWithHeading(headingId: string): { root: HTMLElement; heading: HTMLElement } {
const root = document.createElement('div');
root.className = 'post-content';
const heading = document.createElement('h2');
heading.id = headingId;
heading.getBoundingClientRect = () =>
({
top: 1000,
left: 0,
right: 0,
bottom: 1050,
width: 800,
height: 50,
x: 0,
y: 0,
toJSON() {},
}) as DOMRect;
root.appendChild(heading);
document.body.appendChild(root);
Object.defineProperty(window, 'scrollY', { value: 500, writable: true, configurable: true });
return { root, heading };
}
it('内容容器 resize 后再次校正落点(异步布局位移修正)', async () => {
vi.useFakeTimers();
const spy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
setupPostWithHeading('mermaid-tu-biao');
history.replaceState(null, '', '#mermaid-tu-biao');
window.__scrollToHash();
// 推进两帧校正的 rAFfake rAF 周期 ~16ms不触发 2s 超时。
await vi.advanceTimersByTimeAsync(40);
spy.mockClear();
expect(resizeCallback).not.toBeNull();
// 模拟 mermaid 注入 SVG 撑高内容:触发一次 resize。
resizeCallback!([
{
target: document.querySelector('.post-content')!,
contentRect: { width: 800, height: 2000 },
},
]);
// rAF 合并的校正在下一帧执行。
await vi.advanceTimersByTimeAsync(20);
expect(spy).toHaveBeenCalled();
// 推进超时,让稳定器在本测试内自清理,避免泄漏到后续测试。
await vi.advanceTimersByTimeAsync(2000);
vi.useRealTimers();
});
it('用户主动滚动wheel后停止校正避免与用户交互打架', () => {
vi.useFakeTimers();
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
setupPostWithHeading('h2');
history.replaceState(null, '', '#h2');
window.__scrollToHash();
expect(roDisconnect).not.toHaveBeenCalled();
// 用户主动滚动:派发 wheel应触发 disposeRO 断开、监听移除、超时清除)。
window.dispatchEvent(new WheelEvent('wheel'));
expect(roDisconnect).toHaveBeenCalled();
// dispose 后 resize 不再触发新的稳定器RO 已断开,回调不会再被调用。
expect(resizeCallback).not.toBeNull();
vi.useRealTimers();
});
it('重入安全:再次 scrollToHash 时旧稳定器被清理(不泄漏/不叠加)', () => {
vi.useFakeTimers();
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
setupPostWithHeading('h3');
history.replaceState(null, '', '#h3');
window.__scrollToHash();
expect(roDisconnect).not.toHaveBeenCalled();
// 第二次调用(模拟主题切换触发 use_effect 重跑)。
window.__scrollToHash();
expect(roDisconnect).toHaveBeenCalled();
vi.useRealTimers();
});
}); });

View File

@ -10,6 +10,11 @@
* requestAnimationFrame * requestAnimationFrame
* scroll scroll * scroll scroll
* *
* mermaid
* SVG/
* 访 hash URL ResizeObserver
* stabilizeScrollOnResize
*
* CJK location.hash %E4%B8%89-... id * CJK location.hash %E4%B8%89-... id
* id="三-五-零法则" decodeURIComponent getElementById * id="三-五-零法则" decodeURIComponent getElementById
* fallback * fallback
@ -17,6 +22,75 @@
import { scrollToHeading } from './scroll-to-heading'; import { scrollToHeading } from './scroll-to-heading';
/** 布局稳定期时长:覆盖 mermaid bundle 加载+渲染、图片/字体加载等异步位移源。 */
const STABILIZE_WINDOW_MS = 2000;
/** 用户主动滚动的输入事件:收到任一即认为用户接管,停止自动校正。 */
const USER_INPUT_EVENTS: Array<keyof WindowEventMap> = ['wheel', 'touchmove', 'keydown'];
/** 当前活跃的稳定器;重入时先 dispose 上一次,避免泄漏/叠加。 */
let activeStabilizer: (() => void) | null = null;
/**
* STABILIZE_WINDOW_MS rAF
*
*
* ResizeObserver
* mermaid/ .post-content
* mermaid//
*/
function stabilizeScrollOnResize(el: Element): void {
// 重入安全dispose 上一次活跃的稳定器。
activeStabilizer?.();
activeStabilizer = null;
if (typeof ResizeObserver === 'undefined') return;
const target = el.closest('.post-content') ?? document.body;
let rafId = 0;
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
ro.disconnect();
window.clearTimeout(timerId);
for (const evt of USER_INPUT_EVENTS) {
window.removeEventListener(evt, onUserInput, { capture: true } as EventListenerOptions);
}
if (rafId) window.cancelAnimationFrame(rafId);
if (activeStabilizer === dispose) activeStabilizer = null;
};
// rAF 合并:同帧多次 resize 只校正一次,避免抖动。
const scheduleCorrect = () => {
if (disposed) return;
if (rafId) return;
rafId = window.requestAnimationFrame(() => {
rafId = 0;
if (!disposed) scrollToHeading(el, false);
});
};
// 用户主动滚动即停止校正:不与用户交互打架。
// 用输入事件而非 scroll 事件——window.scrollTo 本身会触发 scroll无法区分。
const onUserInput = () => dispose();
for (const evt of USER_INPUT_EVENTS) {
window.addEventListener(evt, onUserInput, {
capture: true,
passive: true,
} as AddEventListenerOptions);
}
const ro = new ResizeObserver(scheduleCorrect);
ro.observe(target);
// 超时兜底:无论如何 STABILIZE_WINDOW_MS 后停止,防止长期占用。
const timerId = window.setTimeout(dispose, STABILIZE_WINDOW_MS);
activeStabilizer = dispose;
}
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;
@ -34,4 +108,8 @@ export function scrollToHash(): void {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToHeading(el, false)); requestAnimationFrame(() => scrollToHeading(el, false));
}); });
// 布局稳定期覆盖两帧之后的异步位移mermaid 懒加载、图片/字体加载等),
// 位移后重新校正。用户主动滚动或超时后自动停止。
stabilizeScrollOnResize(el);
} }

View File

@ -1,10 +1,14 @@
/** /**
* Mermaid * Mermaid
* *
* `pre > code.language-mermaid` import bundle * `pre > code.language-mermaid` IIFE bundle
* `public/mermaid/mermaid.js`~1MB * `public/mermaid/mermaid.js`~1MB
* mermaid SVG <pre> * mermaid SVG <pre>
* *
* bundle IIFE `window.MermaidRenderer`
* ES module `import().default`IIFE export
* `<script src="/mermaid/mermaid.js">` window MermaidRenderer
*
* post-content.tsquerySelectorAll + + DOM * post-content.tsquerySelectorAll + + DOM
* mermaid SSR markdown.rs * mermaid SSR markdown.rs
* `language-mermaid` class data * `language-mermaid` class data
@ -17,21 +21,40 @@ type MermaidApi = {
render: (id: string, text: string) => Promise<{ svg: string }>; render: (id: string, text: string) => Promise<{ svg: string }>;
}; };
declare global {
interface Window {
MermaidRenderer?: MermaidApi;
}
}
let mermaidPromise: Promise<MermaidApi> | null = null; let mermaidPromise: Promise<MermaidApi> | null = null;
/** /**
* mermaid bundle 便 * mermaid IIFE bundle 便
* *
* '/mermaid/mermaid.js' importVite * IIFE bundle `window.MermaidRenderer` ES module export
* @vite-ignore import * `<script>` onload window
* tsc TS2307bundle default export mermaid API * [`_resetMermaidLoader`] mock
* `loadMermaidBundle` mock
*/ */
export let loadMermaidBundle: () => Promise<MermaidApi> = async () => { export let loadMermaidBundle: () => Promise<MermaidApi> = () =>
const url = '/mermaid/mermaid.js'; new Promise((resolve, reject) => {
const mod = (await import(/* @vite-ignore */ url)) as { default: MermaidApi }; // 已加载(同页多次调用 / SPA 导航)直接复用。
return mod.default; if (window.MermaidRenderer) {
}; resolve(window.MermaidRenderer);
return;
}
const script = document.createElement('script');
script.src = '/mermaid/mermaid.js';
script.onload = () => {
if (window.MermaidRenderer) {
resolve(window.MermaidRenderer);
} else {
reject(new Error('mermaid bundle loaded but window.MermaidRenderer undefined'));
}
};
script.onerror = () => reject(new Error('failed to load /mermaid/mermaid.js'));
document.head.appendChild(script);
});
/** 重置加载函数(测试用,重新注入 mock 后必须重置 mermaidPromise 缓存)。 */ /** 重置加载函数(测试用,重新注入 mock 后必须重置 mermaidPromise 缓存)。 */
export function _resetMermaidLoader(loader?: () => Promise<MermaidApi>): void { export function _resetMermaidLoader(loader?: () => Promise<MermaidApi>): void {

View File

@ -183,6 +183,21 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
code_buffer.push_str(&text); code_buffer.push_str(&text);
} }
Event::End(TagEnd::CodeBlock) => { Event::End(TagEnd::CodeBlock) => {
// mermaid 代码块:前端 yggdrasil-core 扫描 language-mermaid 渲染成 SVG
// 源码不应被 syntect 高亮(无语法定义,且会包 <span> 污染 textContent 提取)。
// 直接输出转义后的纯源码,前端 textContent 无损拿到原始 mermaid 文本。
let is_mermaid = code_lang
.as_deref()
.map(|l| l.split_whitespace().next() == Some("mermaid"))
.unwrap_or(false);
if is_mermaid {
let escaped = crate::utils::html::escape_html(&code_buffer);
html.push_str(r#"<pre><code class="language-mermaid">"#);
html.push_str(&escaped);
html.push_str("</code></pre>");
in_codeblock = false;
continue;
}
// 使用 syntect 对代码块进行服务端语法高亮。 // 使用 syntect 对代码块进行服务端语法高亮。
let highlighted = let highlighted =
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref()); crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
@ -907,4 +922,24 @@ console.log(1)
result.html result.html
); );
} }
#[test]
fn render_markdown_mermaid_block_not_highlighted() {
// mermaid 块跳过 syntect 高亮:源码应是转义纯文本,不被 <span> 包裹。
// 前端 mermaid.ts 用 textContent 无损提取渲染成 SVG。
let result = render_markdown_enhanced("```mermaid\ngraph LR\n A --> B\n```");
assert!(
result.html.contains(r#"class="language-mermaid""#),
"应保留 language-mermaid class, got: {}",
result.html
);
// 不应被 syntect 高亮(无 text plain span
assert!(
!result.html.contains("text plain"),
"mermaid 源码不应被 syntect 高亮, got: {}",
result.html
);
// 源码内容保留HTML 转义后)。
assert!(result.html.contains("graph LR"));
}
} }