feat(mermaid): 流程图主题切换跟随 VT 圆形扩散动画

此前 mermaid.render() 异步,而 VT 回调同步,Dioxus effect 重跑发生在 VT 回调
之后的微任务,导致动画期间流程图不变、动画结束后才瞬切。

利用 startViewTransition 回调可返回 Promise 的特性(浏览器等其 resolve 才拍
NEW 快照、播动画),让 VT 回调等 mermaid 重渲染完成,新主题流程图进入快照,
圆形扩散覆盖整页含 mermaid 区域。

改动:
- theme-transition.ts: 新增 onThemeChange registry(回调可返回 Promise)。
  notifyThemeChange 遍历 registry 收集 Promise 返回聚合结果;THEME_CHANGE_EVENT
  事件保留(给 codemirror/xterm 同步换肤,向后兼容)。
  startThemeTransition 的 VT 回调改为 async,await notifyThemeChange 聚合 Promise,
  确保异步换肤组件(mermaid)完成后再拍 NEW 快照。
  applyResolvedTheme(跟随系统瞬切)不等 registry,保持瞬切语义。
- mermaid.ts: 顶层注册 onThemeChange 订阅,listener 调 rerenderExistingBlocks 并
  返回其 Promise,供 VT 回调等待。initMermaid/rerenderExistingBlocks 返回 Promise。
- post_content.rs: VT 期间(is-theme-transitioning)跳过 __initMermaid 调用,避免
  Dioxus effect 抢先重渲染穿透伪元素快照(照搬 code_runner/runner.rs 守卫);
  动画结束后 effect 重跑做幂等兜底。
- theme-transition.test.ts: VT callback 签名 void→Promise<void>,新增 registry
  等待测试 + 降级路径不等 registry 测试。
This commit is contained in:
xfy 2026-07-16 16:58:27 +08:00
parent 6eb7a743e5
commit bdfc7a19e9
5 changed files with 169 additions and 34 deletions

View File

@ -1,15 +1,15 @@
import type { ThemeName } from '@yggdrasil/shared';
import { initAnchorClick } from './anchor-click'; import { initAnchorClick } from './anchor-click';
import { scrollToHash } from './hash-scroll'; import { scrollToHash } from './hash-scroll';
import { initMermaid } from './mermaid'; import { initMermaid } from './mermaid';
import { initPostContent } from './post-content'; import { initPostContent } from './post-content';
import { applyResolvedTheme, startThemeTransition } from './theme-transition'; import { applyResolvedTheme, startThemeTransition } from './theme-transition';
import type { ThemeName } from '@yggdrasil/shared';
import './style.css'; import './style.css';
declare global { declare global {
interface Window { interface Window {
__initPostContent: (selector: string) => void; __initPostContent: (selector: string) => void;
__initMermaid: (selector: string, theme: ThemeName) => void; __initMermaid: (selector: string, theme: ThemeName) => Promise<void>;
__initAnchorClick: () => void; __initAnchorClick: () => void;
__scrollToHash: () => void; __scrollToHash: () => void;
__startThemeTransition: (x: number, y: number) => void; __startThemeTransition: (x: number, y: number) => void;

View File

@ -20,6 +20,10 @@
*/ */
import type { ThemeName } from '@yggdrasil/shared'; import type { ThemeName } from '@yggdrasil/shared';
import { onThemeChange } from './theme-transition';
/** 文章正文容器选择器(与 post_content.rs 的 __initMermaid 调用一致)。 */
const POST_CONTENT_SELECTOR = '.post-content';
type MermaidApi = { type MermaidApi = {
initialize: (config: Record<string, unknown>) => void; initialize: (config: Record<string, unknown>) => void;
@ -146,7 +150,7 @@ function observeBlock(pre: HTMLPreElement, render: () => Promise<void>): void {
* @param selector '.post-content' * @param selector '.post-content'
* @param theme mermaid * @param theme mermaid
*/ */
export function initMermaid(selector: string, theme: ThemeName): void { export function initMermaid(selector: string, theme: ThemeName): Promise<void> {
const root = document.querySelector(selector); const root = document.querySelector(selector);
if (!root) return; if (!root) return;
@ -172,8 +176,9 @@ export function initMermaid(selector: string, theme: ThemeName): void {
}); });
// 路径 2已渲染的块<code> 已被 SVG 替换,按 dataset 回找)。无条件执行, // 路径 2已渲染的块<code> 已被 SVG 替换,按 dataset 回找)。无条件执行,
// 覆盖「页面上未渲染块与已渲染块并存」的场景。 // 覆盖「页面上未渲染块与已渲染块并存」的场景。主题切换时由 onThemeChange 订阅
rerenderExistingBlocks(root, theme); // 返回其 Promise供 VT callback await让新主题流程图进入 NEW 快照)。
return rerenderExistingBlocks(root, theme);
} }
/** /**
@ -181,16 +186,40 @@ export function initMermaid(selector: string, theme: ThemeName): void {
* *
* initMermaid pre <code> SVG * initMermaid pre <code> SVG
* `pre > code.language-mermaid` dataset * `pre > code.language-mermaid` dataset
*
* Promise resolveonThemeChange
* VT callback await mermaid.render NEW
* catch + mermaid-error class
*/ */
function rerenderExistingBlocks(root: Element, theme: ThemeName): void { function rerenderExistingBlocks(root: Element, theme: ThemeName): Promise<void> {
const rendered = root.querySelectorAll<HTMLPreElement>('pre[data-mermaid-rendered]'); const rendered = root.querySelectorAll<HTMLPreElement>('pre[data-mermaid-rendered]');
const tasks: Promise<void>[] = [];
rendered.forEach((pre) => { rendered.forEach((pre) => {
if (pre.dataset.mermaidTheme === theme) return; if (pre.dataset.mermaidTheme === theme) return;
const source = pre.dataset.mermaidSource; const source = pre.dataset.mermaidSource;
if (!source) return; // 无缓存源码无法重渲染,保守跳过 if (!source) return; // 无缓存源码无法重渲染,保守跳过
void renderBlock(pre, source, theme).catch((err) => { tasks.push(
console.error('mermaid re-render failed:', err); renderBlock(pre, source, theme).catch((err) => {
pre.classList.add('mermaid-error'); console.error('mermaid re-render failed:', err);
}); pre.classList.add('mermaid-error');
}),
);
}); });
return Promise.all(tasks).then(() => {});
} }
/**
* 订阅主题切换:主题变化时重渲染已渲染的 mermaid , Promise
*
* VT 协调的关键: listener Promise notifyThemeChange VT callback
* await mermaid.render NEW
* VT( / ),mermaid
*
* (IIFE ),,
* rerenderExistingBlocks no-op
*/
onThemeChange((isDark) => {
const root = document.querySelector(POST_CONTENT_SELECTOR);
if (!root) return;
return rerenderExistingBlocks(root, isDark ? 'dark' : 'light');
});

View File

@ -11,7 +11,7 @@
* applyDarkClass ( class , reflow ) * applyDarkClass ( class , reflow )
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { THEME_CHANGE_EVENT } from './theme-transition'; import { onThemeChange, THEME_CHANGE_EVENT } from './theme-transition';
import './index'; import './index';
describe('startThemeTransition', () => { describe('startThemeTransition', () => {
@ -44,10 +44,10 @@ describe('startThemeTransition', () => {
}); });
it('主路径:有 startViewTransition 时调用它,注入变量,callback 切换 dark class', async () => { it('主路径:有 startViewTransition 时调用它,注入变量,callback 切换 dark class', async () => {
const cbRef: { cb: (() => void) | null } = { cb: null }; const cbRef: { cb: (() => Promise<void>) | null } = { cb: null };
const readyP = Promise.resolve(); const readyP = Promise.resolve();
const finishedP = Promise.resolve(); const finishedP = Promise.resolve();
const startVT = vi.fn((cb: () => void) => { const startVT = vi.fn((cb: () => Promise<void>) => {
cbRef.cb = cb; cbRef.cb = cb;
return { ready: readyP, finished: finishedP, skipTransition: () => {} }; return { ready: readyP, finished: finishedP, skipTransition: () => {} };
}); });
@ -69,8 +69,8 @@ describe('startThemeTransition', () => {
// is-theme-transitioning 应在 VT 之前添加 // is-theme-transitioning 应在 VT 之前添加
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true); expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
// callback 里根据 DOM 现状(无 dark)切到 dark // callback 里根据 DOM 现状(无 dark)切到 dark(callback 现为 async,需 await)
cbRef.cb?.(); await cbRef.cb?.();
expect(document.documentElement.classList.contains('dark')).toBe(true); expect(document.documentElement.classList.contains('dark')).toBe(true);
// finished 后移除 is-theme-transitioning 和 CSS 变量 // finished 后移除 is-theme-transitioning 和 CSS 变量
@ -117,10 +117,10 @@ describe('startThemeTransition', () => {
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
}); });
it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', () => { it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', async () => {
const cbRef: { cb: (() => void) | null } = { cb: null }; const cbRef: { cb: (() => Promise<void>) | null } = { cb: null };
Object.defineProperty(document, 'startViewTransition', { Object.defineProperty(document, 'startViewTransition', {
value: (cb: () => void) => { value: (cb: () => Promise<void>) => {
cbRef.cb = cb; cbRef.cb = cb;
return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} }; return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} };
}, },
@ -141,7 +141,7 @@ describe('startThemeTransition', () => {
// 亮→暗:无 dark class,isDark=true // 亮→暗:无 dark class,isDark=true
window.__startThemeTransition(0, 0); window.__startThemeTransition(0, 0);
cbRef.cb?.(); await cbRef.cb?.();
expect(eventSnapshots).toHaveLength(1); expect(eventSnapshots).toHaveLength(1);
expect(eventSnapshots[0].isDark).toBe(true); expect(eventSnapshots[0].isDark).toBe(true);
@ -198,4 +198,62 @@ describe('startThemeTransition', () => {
window.removeEventListener(THEME_CHANGE_EVENT, listener); window.removeEventListener(THEME_CHANGE_EVENT, listener);
}); });
it('onThemeChange:VT callback 等待 registry 注册的异步回调 Promise', async () => {
const cbRef: { cb: (() => Promise<void>) | null } = { cb: null };
Object.defineProperty(document, 'startViewTransition', {
value: (cb: () => Promise<void>) => {
cbRef.cb = cb;
return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} };
},
configurable: true,
writable: true,
});
// registry 回调返回一个可控的 Promise,记录它是否在 callback resolve 前完成
let resolveAsync: (() => void) | null = null;
const asyncDone = { value: false };
const off = onThemeChange((isDark) => {
void isDark;
return new Promise<void>((resolve) => {
resolveAsync = () => {
asyncDone.value = true;
resolve();
};
});
});
window.__startThemeTransition(0, 0);
// callback 尚未 resolve(async 任务未完成)——VT callback 的 Promise 仍 pending
const callbackPromise = cbRef.cb?.();
expect(asyncDone.value).toBe(false);
// 触发异步任务完成
resolveAsync?.();
await callbackPromise;
expect(asyncDone.value).toBe(true);
off();
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});
it('onThemeChange:降级路径(无 VT)不等 registry 异步回调', async () => {
// 降级路径不 await notifyThemeChange,applyDarkClass 同步完成,registry 后台跑
let registryCalled = false;
const off = onThemeChange(() => {
registryCalled = true;
return new Promise<void>(() => {}); // 永不 resolve
});
// 无 startViewTransition → 降级路径
window.__startThemeTransition(0, 0);
// dark class 应已同步翻转(不等 registry)
expect(document.documentElement.classList.contains('dark')).toBe(true);
// registry 回调被调用(同步触发),但 Promise 未被 await
expect(registryCalled).toBe(true);
off();
});
}); });

View File

@ -53,16 +53,47 @@ function applyDarkClass(isDark: boolean): void {
} }
/** /**
* (CodeMirror / xterm) * registry:命令式 /
* *
* CustomEvent dispatch 是同步的:listener , * THEME_CHANGE_EVENT :
* setTheme(reconfigure / options.theme =) * - (CustomEvent): dispatch, CodeMirror / xterm
* VT NEW , * ( listener setTheme, reflow NEW )
* * listener ,fire-and-forget
* 幂等: Dioxus use_effect set_theme , no-op * - registry:回调** Promise**,( VT callback) await ,
* ( mermaid render()) VT
* mermaid.render , VT NEW ,
*/ */
function notifyThemeChange(isDark: boolean): void { const themeChangeCallbacks = new Set<(isDark: boolean) => Promise<void> | void>();
/** 注册主题切换回调,返回取消注册函数。回调可返回 Promise,调用方会等待它。 */
export function onThemeChange(cb: (isDark: boolean) => Promise<void> | void): () => void {
themeChangeCallbacks.add(cb);
return () => themeChangeCallbacks.delete(cb);
}
/**
* (CodeMirror / xterm / mermaid)
*
* :
* 1. dispatch THEME_CHANGE_EVENT( CodeMirror / xterm, listener
* setTheme, reflow NEW )
* 2. registry cb, Promise, Promise(VT callback
* await mermaid ),
*
* Promise, await( VT await, await)
*/
function notifyThemeChange(isDark: boolean): Promise<void> {
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: { isDark } })); window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: { isDark } }));
const promises: Promise<void>[] = [];
themeChangeCallbacks.forEach((cb) => {
try {
const ret = cb(isDark);
if (ret) promises.push(ret.catch(() => {})); // 单个失败不中断聚合
} catch {
// 同步抛错的 cb 忽略,不中断其他回调
}
});
return Promise.all(promises).then(() => {});
} }
/** /**
@ -90,8 +121,8 @@ export function startThemeTransition(x: number, y: number): void {
if (!hasVT || reduced) { if (!hasVT || reduced) {
// 降级路径:无 VT 动画,同步换肤 + 翻 class(瞬切)。 // 降级路径:无 VT 动画,同步换肤 + 翻 class(瞬切)。
// 同样 dispatch 事件,保持与主路径对称(编辑器不依赖动画存在与否)。 // 同样通知换肤(不 await,保持瞬切语义;mermaid 等异步组件后台重渲染)。
notifyThemeChange(isDark); void notifyThemeChange(isDark);
applyDarkClass(isDark); applyDarkClass(isDark);
return; return;
} }
@ -106,16 +137,19 @@ export function startThemeTransition(x: number, y: number): void {
// 禁用所有 CSS transition,确保 VT 截图是最终颜色 // 禁用所有 CSS transition,确保 VT 截图是最终颜色
html.classList.add('is-theme-transitioning'); html.classList.add('is-theme-transitioning');
const vt = document.startViewTransition(() => { const vt = document.startViewTransition(async () => {
// ★ 关键:先 dispatch 事件让编辑器同步换肤,再翻 .dark class。 // ★ 关键:先通知换肤(同步 dispatch 事件让编辑器同步换肤 + 收集 registry 的
// 顺序不能反——编辑器换肤 + class 翻转必须被同一个 getComputedStyle // 异步 Promise),再翻 .dark class。顺序不能反——编辑器换肤 + class 翻转必须被
// reflow 捕获进 NEW 快照。若先翻 class 后换肤,reflow 可能漏掉编辑器 // 同一个 getComputedStyle reflow 捕获进 NEW 快照。
notifyThemeChange(isDark); const asyncWork = notifyThemeChange(isDark);
applyDarkClass(isDark); applyDarkClass(isDark);
// 强制同步样式重算:确保 body 的 background-color 解析为目标值, // 强制同步样式重算:确保 body 的 background-color 解析为目标值,
// 同时 flush 编辑器的同步换肤(CodeMirror <style> / xterm inline bg)。 // 同时 flush 编辑器的同步换肤(CodeMirror <style> / xterm inline bg)。
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
getComputedStyle(document.body).backgroundColor; getComputedStyle(document.body).backgroundColor;
// ★ 等 registry 里异步换肤组件(mermaid.render)完成。callback 返回 Promise 时,
// 浏览器等它 resolve 才拍 NEW 快照、播圆形扩散——这样快照里已是新主题流程图。
await asyncWork;
}); });
vt.ready.catch(() => {}); vt.ready.catch(() => {});

View File

@ -166,7 +166,21 @@ pub fn PostContent(content_html: String) -> Element {
} else { } else {
"light".into() "light".into()
}; };
invoke_optional_global(&window, "__initMermaid", &[".post-content".into(), theme_str.into()]); // VT 动画期间跳过:手动点击主题按钮时,__startThemeTransition 的 VT 回调内已通过
// onThemeChange registry 同步触发 mermaid 重渲染(被 VT 等待,出现在 NEW 快照里)。
// 但本 effect 在 theme.set(next) 后立即触发——早于 VT 回调的异步执行,会抢先改
// 实时 DOM。VT 动画播的是伪元素快照,实时 DOM 改动会穿透伪元素,表现为「圆形
// 还没展开到流程图,流程图就瞬切」。is-theme-transitioning 期间跳过,让 VT 回调
// 内的 registry 重渲染负责;动画结束后此 effect 因 resolved 信号变化重跑(此时
// is-theme-transitioning 已移除),做幂等兜底。照搬 code_runner/runner.rs 的守卫。
let transitioning = window
.document()
.and_then(|d| d.document_element())
.map(|el| el.class_list().contains("is-theme-transitioning"))
.unwrap_or(false);
if !transitioning {
invoke_optional_global(&window, "__initMermaid", &[".post-content".into(), theme_str.into()]);
}
// lightbox 改由 Dioxus.toml 全局 <script src> 加载(不再 include_str!)。 // lightbox 改由 Dioxus.toml 全局 <script src> 加载(不再 include_str!)。
// 双保险契约:先设配置,若 lightbox.js 已加载则立即调用; // 双保险契约:先设配置,若 lightbox.js 已加载则立即调用;