Compare commits

..

No commits in common. "f6b7ab4333037e03a776912453a0b55eb52c3bcc" and "ac04b23b597598ea42ad0479689c4fe2aea02daf" have entirely different histories.

6 changed files with 17 additions and 296 deletions

View File

@ -405,36 +405,12 @@
}
.md-content li > input[type="checkbox"] {
/* appearance:none 全自定义告别浏览器原生方框形状/边框差异
对勾用内联 SVG background<input> 是替换元素吃不到 ::after
颜色按主题烘焙亮色中绿底配白对勾暗色浅绿底配深对勾对比最强
文章内 checkbox disabled 的纯展示态故不加 focus/cursor 交互 */
appearance: none;
-webkit-appearance: none;
flex-shrink: 0;
width: 1.15rem;
height: 1.15rem;
width: 1rem;
height: 1rem;
margin: 0;
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;
accent-color: var(--color-paper-accent);
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 {
@ -553,7 +529,7 @@
border-radius: 0;
overflow-x: auto;
white-space: pre;
font-size: 1rem;
font-size: 0.85em;
line-height: 1.6;
}

View File

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

View File

@ -4,38 +4,15 @@
*
* window.scrollTo sticky header scrollIntoView +
* scroll-margin-top fragment-scroll
*
* ResizeObservermermaid SVG
* /
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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', () => {
beforeEach(() => {
document.body.innerHTML = '';
history.replaceState(null, '', '#');
vi.restoreAllMocks();
resizeCallback = null;
roDisconnect.mockClear();
});
afterEach(() => {
document.body.innerHTML = '';
@ -131,93 +108,4 @@ describe('scrollToHash', () => {
expect(() => window.__scrollToHash()).not.toThrow();
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,11 +10,6 @@
* requestAnimationFrame
* scroll scroll
*
* mermaid
* SVG/
* 访 hash URL ResizeObserver
* stabilizeScrollOnResize
*
* CJK location.hash %E4%B8%89-... id
* id="三-五-零法则" decodeURIComponent getElementById
* fallback
@ -22,75 +17,6 @@
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 {
const hash = window.location.hash.slice(1); // 去掉前导 #
if (!hash) return;
@ -108,8 +34,4 @@ export function scrollToHash(): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => scrollToHeading(el, false));
});
// 布局稳定期覆盖两帧之后的异步位移mermaid 懒加载、图片/字体加载等),
// 位移后重新校正。用户主动滚动或超时后自动停止。
stabilizeScrollOnResize(el);
}

View File

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

View File

@ -183,21 +183,6 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
code_buffer.push_str(&text);
}
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 对代码块进行服务端语法高亮。
let highlighted =
crate::highlight::server::highlight_code(&code_buffer, code_lang.as_deref());
@ -922,24 +907,4 @@ console.log(1)
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"));
}
}