diff --git a/libs/codemirror-editor/src/index.ts b/libs/codemirror-editor/src/index.ts index 445b3c9..a5da10f 100644 --- a/libs/codemirror-editor/src/index.ts +++ b/libs/codemirror-editor/src/index.ts @@ -1,4 +1,16 @@ import { CodeMirrorInstance, EditorOptions } from './editor'; +import type { ThemeName } from './themes'; + +/** + * 主题切换事件名——与 yggdrasil-core 的 THEME_CHANGE_EVENT 保持一致。 + * + * 本包是独立 IIFE,不 import yggdrasil-core,故用同名 string literal 订阅。 + * yggdrasil-core 在 VT 回调内(NEW 快照捕获前)同步 dispatch 此事件, + * 让 CodeMirror 同步 reconfigure 主题——否则圆形展开扫过编辑器区域时 + * OLD/NEW 快照同色(背景由 catppuccin Extension 注入,不随 .dark 翻转), + * 看不到变化,动画结束后才瞬切。 + */ +const THEME_CHANGE_EVENT = 'yggdrasil:theme-change'; /** * 模块入口:暴露对象字面量 { create } 作为默认导出。 @@ -28,4 +40,26 @@ const CodeMirrorEditor = { }, }; +/** + * 订阅主题切换事件:VT 回调内同步 dispatch 时,遍历所有存活实例调 setTheme。 + * + * 必须在模块加载时注册一次(IIFE 顶层),确保任何时刻 dispatch 都能命中。 + * 单实例异常用 try/catch 隔离,避免一个实例失败中断其他实例换肤。 + * 与 Dioxus use_effect 驱动的 set_theme 幂等共存(reconfigure 相同主题是 no-op)。 + */ +if (typeof window !== 'undefined') { + window.addEventListener(THEME_CHANGE_EVENT, (event) => { + const detail = (event as CustomEvent).detail as { isDark: boolean } | undefined; + if (!detail) return; + const theme: ThemeName = detail.isDark ? 'dark' : 'light'; + CodeMirrorEditor._instances.forEach((instance) => { + try { + instance.setTheme(theme); + } catch (e) { + console.error('[CodeMirrorEditor] setTheme failed during theme change:', e); + } + }); + }); +} + export default CodeMirrorEditor; diff --git a/libs/xterm-terminal/src/index.ts b/libs/xterm-terminal/src/index.ts index 9f611ad..fb2c426 100644 --- a/libs/xterm-terminal/src/index.ts +++ b/libs/xterm-terminal/src/index.ts @@ -1,4 +1,16 @@ import { TerminalInstance, XtermOptions } from './terminal'; +import type { ThemeName } from './themes'; + +/** + * 主题切换事件名——与 yggdrasil-core 的 THEME_CHANGE_EVENT 保持一致。 + * + * 本包是独立 IIFE,不 import yggdrasil-core,故用同名 string literal 订阅。 + * yggdrasil-core 在 VT 回调内(NEW 快照捕获前)同步 dispatch 此事件, + * 让 xterm 同步 setTheme——否则圆形展开扫过终端区域时 OLD/NEW 快照同色 + * (背景由 .xterm-scrollable-element 的 inline background-color 注入,不随 + * .dark 翻转),看不到变化,动画结束后才瞬切。 + */ +const THEME_CHANGE_EVENT = 'yggdrasil:theme-change'; /** * 模块入口:暴露对象字面量 { create } 作为默认导出。 @@ -25,4 +37,26 @@ const XtermTerminal = { }, }; +/** + * 订阅主题切换事件:VT 回调内同步 dispatch 时,遍历所有存活实例调 setTheme。 + * + * 必须在模块加载时注册一次(IIFE 顶层),确保任何时刻 dispatch 都能命中。 + * 单实例异常用 try/catch 隔离,避免一个实例失败中断其他实例换肤。 + * 与 Dioxus use_effect 驱动的 set_theme 幂等共存(setTheme 相同主题是 no-op)。 + */ +if (typeof window !== 'undefined') { + window.addEventListener(THEME_CHANGE_EVENT, (event) => { + const detail = (event as CustomEvent).detail as { isDark: boolean } | undefined; + if (!detail) return; + const theme: ThemeName = detail.isDark ? 'dark' : 'light'; + XtermTerminal._instances.forEach((instance) => { + try { + instance.setTheme(theme); + } catch (e) { + console.error('[XtermTerminal] setTheme failed during theme change:', e); + } + }); + }); +} + export default XtermTerminal; diff --git a/libs/yggdrasil-core/src/theme-transition.test.ts b/libs/yggdrasil-core/src/theme-transition.test.ts index bcbb2fd..3ef7463 100644 --- a/libs/yggdrasil-core/src/theme-transition.test.ts +++ b/libs/yggdrasil-core/src/theme-transition.test.ts @@ -6,8 +6,12 @@ * * 注意:startThemeTransition 只接收 (x, y),目标主题(亮/暗)从 DOM 的 dark class * 现状推导(取反),不依赖外部传入——避免与调用方状态不同步。 + * + * 主题变更事件(THEME_CHANGE_EVENT):验证 VT 回调 + 降级路径都同步 dispatch, + * 且事件在 applyDarkClass 之前触发(编辑器换肤先于 class 翻转,同一 reflow 捕获)。 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { THEME_CHANGE_EVENT } from './theme-transition'; import './index'; describe('startThemeTransition', () => { @@ -112,4 +116,86 @@ describe('startThemeTransition', () => { delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; }); + + it('主路径:VT callback 内 dispatch 主题变更事件,且先于 dark class 翻转', () => { + const cbRef: { cb: (() => void) | null } = { cb: null }; + Object.defineProperty(document, 'startViewTransition', { + value: (cb: () => void) => { + cbRef.cb = cb; + return { ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} }; + }, + configurable: true, + writable: true, + }); + + // 记录事件触发时刻的 dark class 状态——验证事件在 applyDarkClass 之前 dispatch + const eventSnapshots: { isDark: boolean; darkClassAtDispatch: boolean }[] = []; + const listener = (e: Event) => { + const detail = (e as CustomEvent).detail as { isDark: boolean }; + eventSnapshots.push({ + isDark: detail.isDark, + darkClassAtDispatch: document.documentElement.classList.contains('dark'), + }); + }; + window.addEventListener(THEME_CHANGE_EVENT, listener); + + // 亮→暗:无 dark class,isDark=true + window.__startThemeTransition(0, 0); + cbRef.cb?.(); + + expect(eventSnapshots).toHaveLength(1); + expect(eventSnapshots[0].isDark).toBe(true); + // 事件触发时 dark class 尚未翻转(仍是 light)——证明事件先于 applyDarkClass + expect(eventSnapshots[0].darkClassAtDispatch).toBe(false); + // callback 执行完后 dark class 已翻转 + expect(document.documentElement.classList.contains('dark')).toBe(true); + + window.removeEventListener(THEME_CHANGE_EVENT, listener); + delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; + }); + + it('降级路径:无 VT 时也 dispatch 主题变更事件(亮→暗)', () => { + const calls: boolean[] = []; + const listener = (e: Event) => { + calls.push((e as CustomEvent).detail.isDark); + }; + window.addEventListener(THEME_CHANGE_EVENT, listener); + + // 亮→暗 + window.__startThemeTransition(0, 0); + expect(calls).toEqual([true]); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + // 暗→亮 + window.__startThemeTransition(0, 0); + expect(calls).toEqual([true, false]); + expect(document.documentElement.classList.contains('dark')).toBe(false); + + window.removeEventListener(THEME_CHANGE_EVENT, listener); + }); + + it('applyResolvedTheme:同步 dispatch 主题变更事件 + 翻 dark class', () => { + const calls: { isDark: boolean; darkClassAtDispatch: boolean }[] = []; + const listener = (e: Event) => { + const detail = (e as CustomEvent).detail as { isDark: boolean }; + calls.push({ + isDark: detail.isDark, + darkClassAtDispatch: document.documentElement.classList.contains('dark'), + }); + }; + window.addEventListener(THEME_CHANGE_EVENT, listener); + + window.__applyResolvedTheme(true); + expect(calls).toEqual([{ isDark: true, darkClassAtDispatch: false }]); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + window.__applyResolvedTheme(false); + expect(calls).toEqual([ + { isDark: true, darkClassAtDispatch: false }, + { isDark: false, darkClassAtDispatch: true }, + ]); + expect(document.documentElement.classList.contains('dark')).toBe(false); + + window.removeEventListener(THEME_CHANGE_EVENT, listener); + }); }); diff --git a/libs/yggdrasil-core/src/theme-transition.ts b/libs/yggdrasil-core/src/theme-transition.ts index 61c9e92..d39938f 100644 --- a/libs/yggdrasil-core/src/theme-transition.ts +++ b/libs/yggdrasil-core/src/theme-transition.ts @@ -10,6 +10,20 @@ * API 优先级 bug,是目前最稳定的 VT 主题切换方案。 */ +/** + * 主题切换自定义事件名。 + * + * 在 VT 回调内(NEW 快照捕获前)同步 dispatch,通知 CodeMirror / xterm 等 + * 命令式换肤的组件同步调 setTheme——它们的背景色不随 .dark class 翻转, + * 必须在快照前显式换肤,否则圆形展开扫过时看不到变化(OLD/NEW 同色)。 + * + * 事件 detail: `{ isDark: boolean }`。 + * + * 各编辑器包(codemirror-editor / xterm-terminal)是独立 IIFE,不 import 本包, + * 故各自用同名 string literal 订阅;本常量仅用于本包内部 + 测试断言一致性。 + */ +export const THEME_CHANGE_EVENT = 'yggdrasil:theme-change'; + function prefersReducedMotion(): boolean { return !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; } @@ -40,6 +54,19 @@ function applyDarkClass(isDark: boolean): void { } } +/** + * 同步通知命令式换肤的组件(CodeMirror / xterm)切换主题。 + * + * CustomEvent 的 dispatch 是同步的:listener 在本函数返回前执行完毕, + * 故编辑器的 setTheme(reconfigure / options.theme =) 在调用方继续前已完成。 + * 这对 VT 至关重要——必须在 NEW 快照捕获前完成换肤,否则快照里仍是旧色。 + * + * 幂等:与 Dioxus use_effect 驱动的 set_theme 并存,重复设置相同主题是 no-op。 + */ +function notifyThemeChange(isDark: boolean): void { + window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: { isDark } })); +} + /** * 直接设置 的 dark class(设置语义,非翻转)。 * @@ -47,8 +74,12 @@ function applyDarkClass(isDark: boolean): void { * 在此上下文下动画不可靠(实测圆形展开不显示,仅瞬切),故跟随系统场景不走 * startThemeTransition 的 VT 路径,改用此函数直接同步 class,做无动画的瞬切。 * 手动点击主题按钮仍走 startThemeTransition,保留圆形展开动画。 + * + * 同步 dispatch 主题变更事件,让命令式换肤的编辑器跟随系统偏好瞬切 + * (与 Dioxus use_effect 幂等共存,后者作兜底)。 */ export function applyResolvedTheme(isDark: boolean): void { + notifyThemeChange(isDark); applyDarkClass(isDark); } @@ -60,6 +91,9 @@ export function startThemeTransition(x: number, y: number): void { const reduced = prefersReducedMotion(); if (!hasVT || reduced) { + // 降级路径:无 VT 动画,同步换肤 + 翻 class(瞬切)。 + // 同样 dispatch 事件,保持与主路径对称(编辑器不依赖动画存在与否)。 + notifyThemeChange(isDark); applyDarkClass(isDark); return; } @@ -75,8 +109,13 @@ export function startThemeTransition(x: number, y: number): void { html.classList.add('is-theme-transitioning'); const vt = document.startViewTransition(() => { + // ★ 关键:先 dispatch 事件让编辑器同步换肤,再翻 .dark class。 + // 顺序不能反——编辑器换肤 + class 翻转必须被同一个 getComputedStyle + // reflow 捕获进 NEW 快照。若先翻 class 后换肤,reflow 可能漏掉编辑器。 + notifyThemeChange(isDark); applyDarkClass(isDark); - // 强制同步样式重算:确保 body 的 background-color 解析为目标值 + // 强制同步样式重算:确保 body 的 background-color 解析为目标值, + // 同时 flush 编辑器的同步换肤(CodeMirror + +
+ +