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:
parent
a0067c73d6
commit
6979bd1010
@ -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 {};
|
||||
|
||||
22
libs/yggdrasil-core/src/style.css
Normal file
22
libs/yggdrasil-core/src/style.css
Normal 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));
|
||||
}
|
||||
}
|
||||
98
libs/yggdrasil-core/src/theme-transition.test.ts
Normal file
98
libs/yggdrasil-core/src/theme-transition.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
79
libs/yggdrasil-core/src/theme-transition.ts
Normal file
79
libs/yggdrasil-core/src/theme-transition.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
21
src/theme.rs
21
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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user