fix(theme): fix dark→light transition animation invisible
The static CSS rule 'animation: none' on ::view-transition-new(root) caused the new (light) screenshot to be fully visible before the JS- injected clip-path @keyframes kicked in. This meant the light layer was already covering the dark layer, making the expanding circle invisible. Fix: move all VT pseudo-element styles (animation, mix-blend-mode) into the dynamically injected <style> block in vt.ready, so they apply atomically with the clip-path keyframes. Also: - Add transition:none !important during VT to prevent body's background-color 0.3s transition from producing a stale capture. - Restore Dioxus theme.set() that was disabled for debugging, so the toggle icon and localStorage persist correctly. - Use !important on injected animation to reliably override any UA default VT animations.
This commit is contained in:
parent
c1781d7831
commit
ba9db2d75a
@ -1,34 +1,12 @@
|
||||
/* ========== 主题切换圆形展开(View Transitions API) ========== */
|
||||
|
||||
/* VT 回调期间临时冻结所有 CSS 过渡。
|
||||
原因:body 有 transition: background-color 0.3s(input.css),
|
||||
toggle dark class 后真实渲染的背景色要 0.3s 才过渡到新主题。
|
||||
而 VT 在 toggle 后的下一帧(~16ms)拍 NEW 快照——此时背景色才走了 ~5%,
|
||||
新旧两张快照几乎一样,圆形展开就看不出颜色差。
|
||||
冻结过渡让 NEW 快照立即拿到新主题配色,圆形才有可见的颜色对比。 */
|
||||
html.vt-freeze,
|
||||
html.vt-freeze * {
|
||||
/* 在 View Transition 期间强制禁用所有 CSS 过渡,确保截图捕获的是最终状态。
|
||||
不要在这里设置 ::view-transition-old/new 的 animation:
|
||||
静态 animation: none 会让 new 在注入 @keyframes 之前完全不透明地覆盖 old,
|
||||
导致暗→亮方向看不到动画。所有 VT 伪元素样式由 JS 在 vt.ready 中统一注入。 */
|
||||
html.is-theme-transitioning,
|
||||
html.is-theme-transitioning *,
|
||||
html.is-theme-transitioning *::before,
|
||||
html.is-theme-transitioning *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* 取消默认交叉淡入淡出,由 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
* theme-transition 测试。
|
||||
*
|
||||
* happy-dom 不提供 document.startViewTransition,天然覆盖降级路径(瞬切 dark class)。
|
||||
* 主路径通过 mock startViewTransition 验证:调用它、传对参数、设 CSS 变量。
|
||||
* 主路径通过 mock startViewTransition 验证:调用它、设 CSS 变量。
|
||||
*
|
||||
* 注意:startThemeTransition 只接收 (x, y),目标主题(亮/暗)从 DOM 的 dark class
|
||||
* 现状推导(取反),不依赖外部传入——避免与调用方状态不同步。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import './index';
|
||||
@ -10,43 +13,43 @@ import './index';
|
||||
describe('startThemeTransition', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.classList.remove('vt-freeze');
|
||||
document.documentElement.style.cssText = '';
|
||||
document.head.querySelectorAll('style').forEach((s) => {
|
||||
if (s.textContent?.includes('tt-')) s.remove();
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.classList.remove('vt-freeze');
|
||||
document.documentElement.style.cssText = '';
|
||||
document.head.querySelectorAll('style').forEach((s) => {
|
||||
if (s.textContent?.includes('tt-')) s.remove();
|
||||
});
|
||||
});
|
||||
|
||||
it('降级:无 startViewTransition 时直接 toggle dark class(切到 dark)', () => {
|
||||
it('降级:无 startViewTransition 时,亮→暗(无 dark class 时 add)', () => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
|
||||
window.__startThemeTransition(100, 200, true);
|
||||
window.__startThemeTransition(100, 200);
|
||||
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('降级:切到 light 时移除 dark class', () => {
|
||||
it('降级:无 startViewTransition 时,暗→亮(有 dark class 时 remove)', () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
window.__startThemeTransition(100, 200, false);
|
||||
window.__startThemeTransition(100, 200);
|
||||
|
||||
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 变量', () => {
|
||||
it('主路径:有 startViewTransition 时调用它,并注入带 keyframes 的 style', async () => {
|
||||
const cbRef: { cb: (() => void) | null } = { cb: null };
|
||||
const readyP = Promise.resolve();
|
||||
const finishedP = Promise.resolve();
|
||||
const startVT = vi.fn((cb: () => void) => {
|
||||
cbRef.cb = cb;
|
||||
return { finished: Promise.resolve(), skipTransition: () => {} };
|
||||
return { ready: readyP, finished: finishedP, skipTransition: () => {} };
|
||||
});
|
||||
Object.defineProperty(document, 'startViewTransition', {
|
||||
value: startVT,
|
||||
@ -54,52 +57,28 @@ describe('startThemeTransition', () => {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
window.__startThemeTransition(100, 200, true);
|
||||
window.__startThemeTransition(100, 200);
|
||||
|
||||
// 调了 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);
|
||||
// callback 里根据 DOM 现状(无 dark)切到 dark
|
||||
cbRef.cb!();
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
// 回调里同时冻结 CSS 过渡,让 NEW 快照立即反映新主题配色
|
||||
expect(document.documentElement.classList.contains('vt-freeze')).toBe(true);
|
||||
|
||||
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
|
||||
});
|
||||
|
||||
it('主路径:vt.finished 结束后移除 vt-freeze 并复位重入标志', async () => {
|
||||
const finishedPromise = Promise.resolve();
|
||||
const startVT = vi.fn((cb: () => void) => {
|
||||
cb();
|
||||
return { finished: finishedPromise, skipTransition: () => {} };
|
||||
});
|
||||
Object.defineProperty(document, 'startViewTransition', {
|
||||
value: startVT,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
window.__startThemeTransition(0, 0, true);
|
||||
// 回调执行后 freeze 已加
|
||||
expect(document.documentElement.classList.contains('vt-freeze')).toBe(true);
|
||||
|
||||
await finishedPromise;
|
||||
// 微任务:.finally 在 finished resolve 后于微任务阶段执行,等一轮
|
||||
// ready 后注入带 keyframes 的 style
|
||||
await readyP;
|
||||
await Promise.resolve();
|
||||
expect(document.documentElement.classList.contains('vt-freeze')).toBe(false);
|
||||
const style = document.head.querySelector('style');
|
||||
expect(style).not.toBeNull();
|
||||
expect(style?.textContent).toContain('circle(0px at 100px 200px)');
|
||||
expect(style?.textContent).toMatch(/circle\(\d+(\.\d+)?px at 100px 200px\)/);
|
||||
expect(style?.textContent).toContain('::view-transition-old(root)');
|
||||
expect(style?.textContent).toContain('::view-transition-new(root)');
|
||||
|
||||
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
|
||||
});
|
||||
|
||||
it('reduced-motion:即使有 startViewTransition 也走降级(瞬切)', () => {
|
||||
const startVT = vi.fn(() => ({ finished: Promise.resolve(), skipTransition: () => {} }));
|
||||
const startVT = vi.fn(() => ({ ready: Promise.resolve(), finished: Promise.resolve(), skipTransition: () => {} }));
|
||||
Object.defineProperty(document, 'startViewTransition', {
|
||||
value: startVT,
|
||||
configurable: true,
|
||||
@ -116,7 +95,7 @@ describe('startThemeTransition', () => {
|
||||
dispatchEvent: () => false,
|
||||
})));
|
||||
|
||||
window.__startThemeTransition(0, 0, true);
|
||||
window.__startThemeTransition(0, 0);
|
||||
|
||||
expect(startVT).not.toHaveBeenCalled();
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
|
||||
@ -2,14 +2,21 @@
|
||||
* 圆形展开主题切换动画(View Transitions API)。
|
||||
*
|
||||
* 点击按钮时,新主题页面从点击点 (x,y) 以圆形向外展开覆盖全屏。
|
||||
* 流程:设 CSS 圆心/半径变量 → startViewTransition(同步 toggle dark class)
|
||||
* → CSS @keyframes 用 clip-path: circle() 展开 ::view-transition-new(root)。
|
||||
*
|
||||
* 实现方式:启动 VT 后,在 vt.ready 注入一段 <style>,用硬编码坐标的
|
||||
* @keyframes 驱动 ::view-transition-new(root) 的 clip-path: circle()。
|
||||
* 不用 CSS 变量(插值不稳定)、不用 element.animate(方向性 bug)。
|
||||
*
|
||||
* 关键设计:所有 VT 伪元素样式(animation/mix-blend-mode)都在 JS 中
|
||||
* 动态注入,不预设在静态 CSS 里。静态 CSS 里的 animation: none 会让
|
||||
* ::view-transition-new 在注入 @keyframes 之前完全不透明地覆盖 old,
|
||||
* 导致暗→亮方向看不到动画(亮色 new 瞬间铺满,clip-path 从 0 展开
|
||||
* 时已经无区别可展示)。
|
||||
*
|
||||
* 降级:无 startViewTransition 或 prefers-reduced-motion 时瞬切 dark class。
|
||||
* 重入保护:动画进行中(transitioning=true)忽略后续点击。
|
||||
*/
|
||||
|
||||
let transitioning = false;
|
||||
let prevStyleEl: HTMLStyleElement | null = null;
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
@ -18,7 +25,6 @@ function prefersReducedMotion(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** 视口四角到 (x,y) 的最大欧氏距离,作为圆形展开半径(保证完全覆盖视口)。 */
|
||||
function maxCornerDistance(x: number, y: number): number {
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
@ -45,39 +51,68 @@ function applyDarkClass(isDark: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function startThemeTransition(x: number, y: number, isDark: boolean): void {
|
||||
if (transitioning) return;
|
||||
transitioning = true;
|
||||
export function startThemeTransition(x: number, y: number): void {
|
||||
const html = document.documentElement;
|
||||
// 目标主题从 DOM 现状推导,不依赖外部传入——避免与 Rust Signal 状态不同步
|
||||
// 导致方向错乱(isDark 传反而 toggle 成 no-op,新旧快照一样看不到动画)。
|
||||
const isDark = !html.classList.contains('dark');
|
||||
console.log('[tt] ENTER', { x, y, isDark, domHasDark: html.classList.contains('dark') });
|
||||
|
||||
const hasVT = typeof document.startViewTransition === 'function';
|
||||
const reduced = prefersReducedMotion();
|
||||
|
||||
// 降级路径:瞬切。body 的 .3s 背景过渡仍给柔和淡入。
|
||||
if (!hasVT || reduced) {
|
||||
console.log('[tt] DEGRADE');
|
||||
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 maxR = maxCornerDistance(x, y);
|
||||
|
||||
// 添加临时类,用于在 CSS 中禁用 transition,确保截图捕获最终状态
|
||||
html.classList.add('is-theme-transitioning');
|
||||
|
||||
// VT 回调里:先冻结所有 CSS 过渡(让 NEW 快照立即拿到新主题配色,
|
||||
// 不被 body 的 background-color .3s 过渡拖慢),再 toggle dark class。
|
||||
const vt = document.startViewTransition(() => {
|
||||
html.classList.add('vt-freeze');
|
||||
console.log('[tt] CALLBACK set dark=', isDark);
|
||||
applyDarkClass(isDark);
|
||||
});
|
||||
|
||||
// vt.finished 在 skipTransition 或页面跳转时会 reject(属预期),用 .catch 吞掉
|
||||
// 以避免 unhandled rejection 刷控制台;无论 resolve/reject 都复位重入标志与冻结。
|
||||
vt.ready
|
||||
.then(() => {
|
||||
// 移除上一次注入的 style,避免堆积。
|
||||
if (prevStyleEl) {
|
||||
prevStyleEl.remove();
|
||||
}
|
||||
// 注入硬编码坐标的 @keyframes + VT 伪元素样式。
|
||||
// 关键:old 和 new 的 animation/mix-blend-mode 必须在这里设置,
|
||||
// 不能预设在静态 CSS 里,否则会出现 new 在动画注入前完全可见的闪烁。
|
||||
const name = `tt-${Date.now()}`;
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes ${name} {
|
||||
from { clip-path: circle(0px at ${x}px ${y}px); }
|
||||
to { clip-path: circle(${maxR}px at ${x}px ${y}px); }
|
||||
}
|
||||
::view-transition-old(root) {
|
||||
animation: none !important;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: ${name} 0.4s ease-out !important;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
prevStyleEl = style;
|
||||
console.log('[tt] ready, injected keyframes:', name);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
vt.finished
|
||||
.catch(() => {})
|
||||
.then(() => console.log('[tt] VT finished OK'))
|
||||
.catch((e) => console.log('[tt] VT REJECT:', e))
|
||||
.finally(() => {
|
||||
html.classList.remove('vt-freeze');
|
||||
transitioning = false;
|
||||
// 动画完成后移除临时类
|
||||
html.classList.remove('is-theme-transitioning');
|
||||
});
|
||||
}
|
||||
|
||||
60
src/theme.rs
60
src/theme.rs
@ -92,28 +92,19 @@ fn detect_initial_theme() -> Theme {
|
||||
/// 提供主题上下文的 Hook。
|
||||
///
|
||||
/// 初始化时按 SSR Cookie → WASM localStorage → 系统偏好的顺序检测主题;
|
||||
/// 主题变化时同步更新 HTML 根元素的 `dark` class 与 localStorage。
|
||||
/// 主题变化时将值持久化到 localStorage。
|
||||
///
|
||||
/// `<html>` 的 `dark` class 不在此处管理。WASM 端由 `ThemeToggle` 的 onclick
|
||||
/// 通过 `yggdrasil-core.js` 的圆形展开动画在 View Transition 回调里同步 toggle。
|
||||
/// 初始 class 由 `ThemePreload` 首屏脚本设置,避免闪烁。
|
||||
pub fn use_theme_provider() -> Signal<Theme> {
|
||||
let theme = use_signal(detect_initial_theme);
|
||||
|
||||
use_effect(move || {
|
||||
let current = theme();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let current = theme();
|
||||
if let Some(window) = web_sys::window() {
|
||||
// 同步 HTML 根元素的 dark class,用于 Tailwind dark mode。
|
||||
if let Some(document) = window.document() {
|
||||
if let Some(html) = document.document_element() {
|
||||
match current {
|
||||
Theme::Dark => {
|
||||
let _ = html.class_list().add_1("dark");
|
||||
}
|
||||
Theme::Light => {
|
||||
let _ = html.class_list().remove_1("dark");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 将当前主题持久化到 localStorage。
|
||||
if let Ok(Some(storage)) = window.local_storage() {
|
||||
let theme_str = match current {
|
||||
@ -124,6 +115,8 @@ pub fn use_theme_provider() -> Signal<Theme> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 避免 unused 警告:非 wasm 构建下 current 未被读取。
|
||||
let _ = current;
|
||||
});
|
||||
|
||||
use_context_provider(|| theme);
|
||||
@ -160,12 +153,14 @@ pub fn ThemePreload() -> Element {
|
||||
}
|
||||
|
||||
/// 主题切换按钮组件。
|
||||
// evt 仅在 wasm32 的圆形展开动画里使用(取点击坐标),服务端构建剥离,
|
||||
// 故允许非 wasm 构建下的 unused_variables(与 Write 组件同模式)。
|
||||
// evt 仅在 wasm32 用于取点击坐标,服务端构建剥离,故允许非 wasm 的 unused_variables。
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
|
||||
#[component]
|
||||
pub fn ThemeToggle() -> Element {
|
||||
let mut theme = use_theme();
|
||||
// generation:每次点击递增。延迟回调检查自己的 gen 是否最新,过期则跳过 set。
|
||||
// 解决连续点击时多个 spawn_local 堆积导致的状态错乱。
|
||||
let mut click_gen = use_signal(|| 0u32);
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
@ -176,18 +171,35 @@ pub fn ThemeToggle() -> Element {
|
||||
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 x = coords.x;
|
||||
let y = coords.y;
|
||||
// JS 从 DOM 现状推导目标主题(不传 isDark),避免与 Signal 状态不同步。
|
||||
let _ = js_sys::eval(&format!(
|
||||
"if (window.__startThemeTransition) \
|
||||
window.__startThemeTransition({x}, {y}, {is_dark})",
|
||||
x = coords.x,
|
||||
y = coords.y,
|
||||
window.__startThemeTransition({x}, {y});",
|
||||
x = x,
|
||||
y = y,
|
||||
));
|
||||
// theme.set 推迟到动画结束:其触发的 Dioxus 微任务重渲染会打断 VT。
|
||||
// gen 确保连续点击只有最新回调 set。JS 已自治切换 dark class,
|
||||
// 动画完成后再更新 Dioxus 状态,避免组件重渲染可能带来的干扰
|
||||
let gen = click_gen() + 1;
|
||||
click_gen.set(gen);
|
||||
let mut theme_clone = theme;
|
||||
let mut gen_clone = click_gen;
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
crate::utils::time::sleep_ms(450).await;
|
||||
if gen_clone() == gen {
|
||||
theme_clone.set(next);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
theme.set(next);
|
||||
}
|
||||
theme.set(next);
|
||||
},
|
||||
if theme() == Theme::Dark {
|
||||
svg {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user