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 { initPostContent } from './post-content';
|
||||||
|
import { startThemeTransition } from './theme-transition';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__initPostContent: (selector: string) => void;
|
__initPostContent: (selector: string) => void;
|
||||||
|
__startThemeTransition: (x: number, y: number, isDark: boolean) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__initPostContent = initPostContent;
|
window.__initPostContent = initPostContent;
|
||||||
|
window.__startThemeTransition = startThemeTransition;
|
||||||
|
|
||||||
export {};
|
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。
|
//! `prefers-color-scheme` 媒体查询;切换时同步更新 DOM class 与 localStorage。
|
||||||
|
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
// InteractionLocation 提供 client_coordinates(),用于读取鼠标点击的视口坐标。
|
||||||
|
// 该 trait 不在 dioxus::prelude(后者只 re-export events::*),需单独从 dioxus::html 引入。
|
||||||
|
use dioxus::html::InteractionLocation;
|
||||||
|
|
||||||
/// localStorage 中存储主题值的键名。
|
/// localStorage 中存储主题值的键名。
|
||||||
#[cfg(any(target_arch = "wasm32", test))]
|
#[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",
|
class: "theme-toggle p-2 rounded-full cursor-pointer hover:text-paper-accent transition-colors duration-200 text-paper-secondary",
|
||||||
r#type: "button",
|
r#type: "button",
|
||||||
aria_label: "切换深色/浅色主题",
|
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 {
|
if theme() == Theme::Dark {
|
||||||
svg {
|
svg {
|
||||||
xmlns: "http://www.w3.org/2000/svg",
|
xmlns: "http://www.w3.org/2000/svg",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user