diff --git a/libs/yggdrasil-core/src/index.ts b/libs/yggdrasil-core/src/index.ts index 3ca4f80..0ff7c6d 100644 --- a/libs/yggdrasil-core/src/index.ts +++ b/libs/yggdrasil-core/src/index.ts @@ -1,15 +1,15 @@ +import type { ThemeName } from '@yggdrasil/shared'; import { initAnchorClick } from './anchor-click'; import { scrollToHash } from './hash-scroll'; import { initMermaid } from './mermaid'; import { initPostContent } from './post-content'; import { applyResolvedTheme, startThemeTransition } from './theme-transition'; -import type { ThemeName } from '@yggdrasil/shared'; import './style.css'; declare global { interface Window { __initPostContent: (selector: string) => void; - __initMermaid: (selector: string, theme: ThemeName) => void; + __initMermaid: (selector: string, theme: ThemeName) => Promise; __initAnchorClick: () => void; __scrollToHash: () => void; __startThemeTransition: (x: number, y: number) => void; diff --git a/libs/yggdrasil-core/src/mermaid.ts b/libs/yggdrasil-core/src/mermaid.ts index e5e1f19..84438d2 100644 --- a/libs/yggdrasil-core/src/mermaid.ts +++ b/libs/yggdrasil-core/src/mermaid.ts @@ -20,6 +20,10 @@ */ import type { ThemeName } from '@yggdrasil/shared'; +import { onThemeChange } from './theme-transition'; + +/** 文章正文容器选择器(与 post_content.rs 的 __initMermaid 调用一致)。 */ +const POST_CONTENT_SELECTOR = '.post-content'; type MermaidApi = { initialize: (config: Record) => void; @@ -146,7 +150,7 @@ function observeBlock(pre: HTMLPreElement, render: () => Promise): void { * @param selector 文章正文容器选择器(如 '.post-content') * @param theme 当前生效主题,传给 mermaid 适配暗色 */ -export function initMermaid(selector: string, theme: ThemeName): void { +export function initMermaid(selector: string, theme: ThemeName): Promise { const root = document.querySelector(selector); if (!root) return; @@ -172,8 +176,9 @@ export function initMermaid(selector: string, theme: ThemeName): void { }); // 路径 2:已渲染的块( 已被 SVG 替换,按 dataset 回找)。无条件执行, - // 覆盖「页面上未渲染块与已渲染块并存」的场景。 - rerenderExistingBlocks(root, theme); + // 覆盖「页面上未渲染块与已渲染块并存」的场景。主题切换时由 onThemeChange 订阅 + // 返回其 Promise,供 VT callback await(让新主题流程图进入 NEW 快照)。 + return rerenderExistingBlocks(root, theme); } /** @@ -181,16 +186,40 @@ export function initMermaid(selector: string, theme: ThemeName): void { * * 主题切换重跑 initMermaid 时,已渲染的 pre 里 已被 SVG 替换, * `pre > code.language-mermaid` 选择器不再命中。这里用 dataset 标记回找。 + * + * 返回聚合 Promise:所有需要重渲染的块完成后 resolve。onThemeChange 订阅返回它, + * VT callback await 它以等 mermaid.render 异步完成后再拍 NEW 快照。单块失败不中断 + * 聚合(catch 吞错 + 加 mermaid-error class)。 */ -function rerenderExistingBlocks(root: Element, theme: ThemeName): void { +function rerenderExistingBlocks(root: Element, theme: ThemeName): Promise { const rendered = root.querySelectorAll('pre[data-mermaid-rendered]'); + const tasks: Promise[] = []; rendered.forEach((pre) => { if (pre.dataset.mermaidTheme === theme) return; const source = pre.dataset.mermaidSource; if (!source) return; // 无缓存源码无法重渲染,保守跳过 - void renderBlock(pre, source, theme).catch((err) => { - console.error('mermaid re-render failed:', err); - pre.classList.add('mermaid-error'); - }); + tasks.push( + renderBlock(pre, source, theme).catch((err) => { + 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'); +}); diff --git a/libs/yggdrasil-core/src/theme-transition.test.ts b/libs/yggdrasil-core/src/theme-transition.test.ts index 3ef7463..76dabb2 100644 --- a/libs/yggdrasil-core/src/theme-transition.test.ts +++ b/libs/yggdrasil-core/src/theme-transition.test.ts @@ -11,7 +11,7 @@ * 且事件在 applyDarkClass 之前触发(编辑器换肤先于 class 翻转,同一 reflow 捕获)。 */ 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'; describe('startThemeTransition', () => { @@ -44,10 +44,10 @@ describe('startThemeTransition', () => { }); it('主路径:有 startViewTransition 时调用它,注入变量,callback 切换 dark class', async () => { - const cbRef: { cb: (() => void) | null } = { cb: null }; + const cbRef: { cb: (() => Promise) | null } = { cb: null }; const readyP = Promise.resolve(); const finishedP = Promise.resolve(); - const startVT = vi.fn((cb: () => void) => { + const startVT = vi.fn((cb: () => Promise) => { cbRef.cb = cb; return { ready: readyP, finished: finishedP, skipTransition: () => {} }; }); @@ -69,8 +69,8 @@ describe('startThemeTransition', () => { // is-theme-transitioning 应在 VT 之前添加 expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true); - // callback 里根据 DOM 现状(无 dark)切到 dark - cbRef.cb?.(); + // callback 里根据 DOM 现状(无 dark)切到 dark(callback 现为 async,需 await) + await cbRef.cb?.(); expect(document.documentElement.classList.contains('dark')).toBe(true); // finished 后移除 is-theme-transitioning 和 CSS 变量 @@ -117,10 +117,10 @@ describe('startThemeTransition', () => { delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; }); - it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', () => { - const cbRef: { cb: (() => void) | null } = { cb: null }; + it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', async () => { + const cbRef: { cb: (() => Promise) | null } = { cb: null }; Object.defineProperty(document, 'startViewTransition', { - value: (cb: () => void) => { + value: (cb: () => Promise) => { cbRef.cb = cb; return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} }; }, @@ -141,7 +141,7 @@ describe('startThemeTransition', () => { // 亮→暗:无 dark class,isDark=true window.__startThemeTransition(0, 0); - cbRef.cb?.(); + await cbRef.cb?.(); expect(eventSnapshots).toHaveLength(1); expect(eventSnapshots[0].isDark).toBe(true); @@ -198,4 +198,62 @@ describe('startThemeTransition', () => { window.removeEventListener(THEME_CHANGE_EVENT, listener); }); + + it('onThemeChange:VT callback 等待 registry 注册的异步回调 Promise', async () => { + const cbRef: { cb: (() => Promise) | null } = { cb: null }; + Object.defineProperty(document, 'startViewTransition', { + value: (cb: () => Promise) => { + 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((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(() => {}); // 永不 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(); + }); }); diff --git a/libs/yggdrasil-core/src/theme-transition.ts b/libs/yggdrasil-core/src/theme-transition.ts index 811594a..345d9a2 100644 --- a/libs/yggdrasil-core/src/theme-transition.ts +++ b/libs/yggdrasil-core/src/theme-transition.ts @@ -53,16 +53,47 @@ function applyDarkClass(isDark: boolean): void { } /** - * 同步通知命令式换肤的组件(CodeMirror / xterm)切换主题。 + * 主题切换 registry:命令式 / 异步换肤组件注册的回调。 * - * CustomEvent 的 dispatch 是同步的:listener 在本函数返回前执行完毕, - * 故编辑器的 setTheme(reconfigure / options.theme =) 在调用方继续前已完成。 - * 这对 VT 至关重要——必须在 NEW 快照捕获前完成换肤,否则快照里仍是旧色。 - * - * 幂等:与 Dioxus use_effect 驱动的 set_theme 并存,重复设置相同主题是 no-op。 + * 与 THEME_CHANGE_EVENT 事件并存: + * - 事件(CustomEvent):同步 dispatch,供 CodeMirror / xterm 等同步换肤组件用 + * (它们在 listener 里同步 setTheme,被同一 reflow 捕获进 NEW 快照)。事件拿不到 + * listener 返回值,fire-and-forget。 + * - 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>(); + +/** 注册主题切换回调,返回取消注册函数。回调可返回 Promise,调用方会等待它。 */ +export function onThemeChange(cb: (isDark: boolean) => Promise | 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 { window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: { isDark } })); + const promises: Promise[] = []; + 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) { // 降级路径:无 VT 动画,同步换肤 + 翻 class(瞬切)。 - // 同样 dispatch 事件,保持与主路径对称(编辑器不依赖动画存在与否)。 - notifyThemeChange(isDark); + // 同样通知换肤(不 await,保持瞬切语义;mermaid 等异步组件后台重渲染)。 + void notifyThemeChange(isDark); applyDarkClass(isDark); return; } @@ -106,16 +137,19 @@ export function startThemeTransition(x: number, y: number): void { // 禁用所有 CSS transition,确保 VT 截图是最终颜色 html.classList.add('is-theme-transitioning'); - const vt = document.startViewTransition(() => { - // ★ 关键:先 dispatch 事件让编辑器同步换肤,再翻 .dark class。 - // 顺序不能反——编辑器换肤 + class 翻转必须被同一个 getComputedStyle - // reflow 捕获进 NEW 快照。若先翻 class 后换肤,reflow 可能漏掉编辑器。 - notifyThemeChange(isDark); + const vt = document.startViewTransition(async () => { + // ★ 关键:先通知换肤(同步 dispatch 事件让编辑器同步换肤 + 收集 registry 的 + // 异步 Promise),再翻 .dark class。顺序不能反——编辑器换肤 + class 翻转必须被 + // 同一个 getComputedStyle reflow 捕获进 NEW 快照。 + const asyncWork = notifyThemeChange(isDark); applyDarkClass(isDark); // 强制同步样式重算:确保 body 的 background-color 解析为目标值, // 同时 flush 编辑器的同步换肤(CodeMirror