feat(theme): 圆形展开主题切换动画(View Transitions API)

点击主题按钮时,新主题从按钮位置以圆形向外展开覆盖全屏。
JS 同步 toggle dark class 拍快照 + CSS clip-path circle keyframes;
Rust theme.set 事后对齐状态(use_effect 幂等)。
reduced-motion 与不支持 VT 的浏览器自动降级为瞬切。
This commit is contained in:
xfy 2026-06-26 14:01:01 +08:00
parent a0067c73d6
commit 6979bd1010
5 changed files with 223 additions and 1 deletions

View File

@ -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 {};

View File

@ -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));
}
}

View File

@ -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;
});
});

View File

@ -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;
});
}

View File

@ -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",