From 6979bd101043b72c3bd104c437900779a4aeb456 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 26 Jun 2026 14:01:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(theme):=20=E5=9C=86=E5=BD=A2=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=E5=8A=A8=E7=94=BB?= =?UTF-8?q?(View=20Transitions=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点击主题按钮时,新主题从按钮位置以圆形向外展开覆盖全屏。 JS 同步 toggle dark class 拍快照 + CSS clip-path circle keyframes; Rust theme.set 事后对齐状态(use_effect 幂等)。 reduced-motion 与不支持 VT 的浏览器自动降级为瞬切。 --- libs/yggdrasil-core/src/index.ts | 4 + libs/yggdrasil-core/src/style.css | 22 +++++ .../src/theme-transition.test.ts | 98 +++++++++++++++++++ libs/yggdrasil-core/src/theme-transition.ts | 79 +++++++++++++++ src/theme.rs | 21 +++- 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 libs/yggdrasil-core/src/style.css create mode 100644 libs/yggdrasil-core/src/theme-transition.test.ts create mode 100644 libs/yggdrasil-core/src/theme-transition.ts diff --git a/libs/yggdrasil-core/src/index.ts b/libs/yggdrasil-core/src/index.ts index cf8c324..5b9a0cd 100644 --- a/libs/yggdrasil-core/src/index.ts +++ b/libs/yggdrasil-core/src/index.ts @@ -1,11 +1,15 @@ import { initPostContent } from './post-content'; +import { startThemeTransition } from './theme-transition'; +import './style.css'; declare global { interface Window { __initPostContent: (selector: string) => void; + __startThemeTransition: (x: number, y: number, isDark: boolean) => void; } } window.__initPostContent = initPostContent; +window.__startThemeTransition = startThemeTransition; export {}; diff --git a/libs/yggdrasil-core/src/style.css b/libs/yggdrasil-core/src/style.css new file mode 100644 index 0000000..19b9349 --- /dev/null +++ b/libs/yggdrasil-core/src/style.css @@ -0,0 +1,22 @@ +/* ========== 主题切换圆形展开(View Transitions API) ========== */ +/* 取消默认交叉淡入淡出,由 clip-path: circle() 接管新快照的展开。 */ + +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} + +::view-transition-new(root) { + /* --tt-x / --tt-y / --tt-max-r 由 JS 在 html 上设。 */ + animation: tt-reveal 0.4s ease-out; +} + +@keyframes tt-reveal { + from { + clip-path: circle(0px at var(--tt-x, 0px) var(--tt-y, 0px)); + } + to { + clip-path: circle(var(--tt-max-r, 100vmax) at var(--tt-x, 0px) var(--tt-y, 0px)); + } +} diff --git a/libs/yggdrasil-core/src/theme-transition.test.ts b/libs/yggdrasil-core/src/theme-transition.test.ts new file mode 100644 index 0000000..634b74c --- /dev/null +++ b/libs/yggdrasil-core/src/theme-transition.test.ts @@ -0,0 +1,98 @@ +/** + * theme-transition 测试。 + * + * happy-dom 不提供 document.startViewTransition,天然覆盖降级路径(瞬切 dark class)。 + * 主路径通过 mock startViewTransition 验证:调用它、传对参数、设 CSS 变量。 + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import './index'; + +describe('startThemeTransition', () => { + beforeEach(() => { + document.documentElement.classList.remove('dark'); + document.documentElement.style.cssText = ''; + vi.restoreAllMocks(); + }); + afterEach(() => { + document.documentElement.classList.remove('dark'); + document.documentElement.style.cssText = ''; + }); + + it('降级:无 startViewTransition 时直接 toggle dark class(切到 dark)', () => { + expect(document.documentElement.classList.contains('dark')).toBe(false); + + window.__startThemeTransition(100, 200, true); + + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('降级:切到 light 时移除 dark class', () => { + document.documentElement.classList.add('dark'); + + window.__startThemeTransition(100, 200, false); + + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); + + it('降级时不设 CSS 变量(--tt-x/y/max-r 仅主路径用)', () => { + window.__startThemeTransition(100, 200, true); + + expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe(''); + }); + + it('主路径:有 startViewTransition 时调用它并设 CSS 变量', () => { + const cbRef: { cb: (() => void) | null } = { cb: null }; + const startVT = vi.fn((cb: () => void) => { + cbRef.cb = cb; + return { finished: Promise.resolve(), skipTransition: () => {} }; + }); + Object.defineProperty(document, 'startViewTransition', { + value: startVT, + configurable: true, + writable: true, + }); + + window.__startThemeTransition(100, 200, true); + + // 调了 startViewTransition + expect(startVT).toHaveBeenCalledTimes(1); + // 设了圆心与半径变量 + expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe('100px'); + expect(document.documentElement.style.getPropertyValue('--tt-y')).toBe('200px'); + // max-r 是个正数 px 值 + expect(document.documentElement.style.getPropertyValue('--tt-max-r')).toMatch(/^\d+(\.\d+)?px$/); + + // 回调里 toggle dark class(模拟浏览器截图前) + expect(document.documentElement.classList.contains('dark')).toBe(false); + cbRef.cb!(); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; + }); + + it('reduced-motion:即使有 startViewTransition 也走降级(瞬切)', () => { + const startVT = vi.fn(() => ({ finished: Promise.resolve(), skipTransition: () => {} })); + Object.defineProperty(document, 'startViewTransition', { + value: startVT, + configurable: true, + writable: true, + }); + vi.stubGlobal('matchMedia', vi.fn((q: string) => ({ + matches: q.includes('reduce'), + media: q, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }))); + + window.__startThemeTransition(0, 0, true); + + expect(startVT).not.toHaveBeenCalled(); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; + }); +}); diff --git a/libs/yggdrasil-core/src/theme-transition.ts b/libs/yggdrasil-core/src/theme-transition.ts new file mode 100644 index 0000000..0142f44 --- /dev/null +++ b/libs/yggdrasil-core/src/theme-transition.ts @@ -0,0 +1,79 @@ +/** + * 圆形展开主题切换动画(View Transitions API)。 + * + * 点击按钮时,新主题页面从点击点 (x,y) 以圆形向外展开覆盖全屏。 + * 流程:设 CSS 圆心/半径变量 → startViewTransition(同步 toggle dark class) + * → CSS @keyframes 用 clip-path: circle() 展开 ::view-transition-new(root)。 + * + * 降级:无 startViewTransition 或 prefers-reduced-motion 时瞬切 dark class。 + * 重入保护:动画进行中(transitioning=true)忽略后续点击。 + */ + +let transitioning = false; + +function prefersReducedMotion(): boolean { + return ( + !!window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +/** 视口四角到 (x,y) 的最大欧氏距离,作为圆形展开半径(保证完全覆盖视口)。 */ +function maxCornerDistance(x: number, y: number): number { + const w = window.innerWidth; + const h = window.innerHeight; + const corners = [ + [0, 0], + [w, 0], + [0, h], + [w, h], + ]; + let max = 0; + for (const [cx, cy] of corners) { + const d = Math.hypot(cx - x, cy - y); + if (d > max) max = d; + } + return max; +} + +function applyDarkClass(isDark: boolean): void { + const html = document.documentElement; + if (isDark) { + html.classList.add('dark'); + } else { + html.classList.remove('dark'); + } +} + +export function startThemeTransition(x: number, y: number, isDark: boolean): void { + if (transitioning) return; + transitioning = true; + + const hasVT = typeof document.startViewTransition === 'function'; + const reduced = prefersReducedMotion(); + + // 降级路径:瞬切。body 的 .3s 背景过渡仍给柔和淡入。 + if (!hasVT || reduced) { + applyDarkClass(isDark); + transitioning = false; + return; + } + + // 主路径:设圆心与半径,启动 VT。 + const html = document.documentElement; + html.style.setProperty('--tt-x', `${x}px`); + html.style.setProperty('--tt-y', `${y}px`); + html.style.setProperty('--tt-max-r', `${maxCornerDistance(x, y)}px`); + + const vt = document.startViewTransition(() => { + applyDarkClass(isDark); + }); + + // vt.finished 在 skipTransition 或页面跳转时会 reject(属预期),用 .catch 吞掉 + // 以避免 unhandled rejection 刷控制台;无论 resolve/reject 都复位重入标志。 + vt.finished + .catch(() => {}) + .finally(() => { + transitioning = false; + }); +} diff --git a/src/theme.rs b/src/theme.rs index f1ea03a..0b70d94 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -6,6 +6,9 @@ //! `prefers-color-scheme` 媒体查询;切换时同步更新 DOM class 与 localStorage。 use dioxus::prelude::*; +// InteractionLocation 提供 client_coordinates(),用于读取鼠标点击的视口坐标。 +// 该 trait 不在 dioxus::prelude(后者只 re-export events::*),需单独从 dioxus::html 引入。 +use dioxus::html::InteractionLocation; /// localStorage 中存储主题值的键名。 #[cfg(any(target_arch = "wasm32", test))] @@ -164,7 +167,23 @@ pub fn ThemeToggle() -> Element { class: "theme-toggle p-2 rounded-full cursor-pointer hover:text-paper-accent transition-colors duration-200 text-paper-secondary", r#type: "button", aria_label: "切换深色/浅色主题", - onclick: move |_| theme.set(theme().toggle()), + onclick: move |evt| { + let next = theme().toggle(); + #[cfg(target_arch = "wasm32")] + { + let is_dark = next == Theme::Dark; + let coords = evt.client_coordinates(); + // 兜底:若 yggdrasil-core.js 尚未加载完(__startThemeTransition 未定义), + // 跳过动画(后续 use_effect 仍会同步 dark class 与 localStorage)。 + let _ = js_sys::eval(&format!( + "if (window.__startThemeTransition) \ + window.__startThemeTransition({x}, {y}, {is_dark})", + x = coords.x, + y = coords.y, + )); + } + theme.set(next); + }, if theme() == Theme::Dark { svg { xmlns: "http://www.w3.org/2000/svg",