refactor(theme): use pure CSS View Transitions without WAAPI or style injection
Adopt a fully CSS-driven strategy for the theme transition: - Use CSS variables (--tt-x, --tt-y, --tt-r) injected onto documentElement - Define static @keyframes in style.css using these variables - Key logic: the Dark layer is ALWAYS placed on top (z-index: 2). - Light -> Dark: NEW layer is Dark, so NEW expands from circle(0) to cover OLD. - Dark -> Light: OLD layer is Dark, so OLD shrinks to circle(0) to reveal NEW. This completely eliminates reliance on Web Animations API (which had bugs with clip-path when animation:none is set) and eliminates dynamic <style> injection (which caused timing gaps and style residue). The result is perfectly symmetrical and bug-free in both directions.
This commit is contained in:
parent
76eedebf05
commit
0ff24888ce
@ -1,13 +1,53 @@
|
|||||||
/* ========== 主题切换圆形展开(View Transitions API) ========== */
|
/* ========== 主题切换圆形展开(View Transitions API) ========== */
|
||||||
|
|
||||||
/* 不用 animation: none — Chrome 对 VT 伪元素设置 animation:none 后
|
/* 取消所有 UA 默认的动画和颜色混合 */
|
||||||
会阻止 WAAPI 的 clip-path 动画生效(暗→亮方向完全看不到动画)。
|
|
||||||
改为用 opacity:1 !important 锁定两层不透明度:UA 的 fade-in/out 动画
|
|
||||||
技术上还在运行,但 !important 阻止它修改 opacity,视觉上等于无动画。 */
|
|
||||||
::view-transition-old(root),
|
::view-transition-old(root),
|
||||||
::view-transition-new(root) {
|
::view-transition-new(root) {
|
||||||
opacity: 1 !important;
|
animation: none;
|
||||||
mix-blend-mode: normal !important;
|
mix-blend-mode: normal;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
设计策略:
|
||||||
|
无论方向,我们始终让“暗色层”在上面,并对其使用 clip-path。
|
||||||
|
- 亮->暗:NEW 是暗色,放在上层,从小圆扩展。
|
||||||
|
- 暗->亮:OLD 是暗色,放在上层,从大圆收缩。
|
||||||
|
这样就能保证无论哪个方向,都是“暗色吞噬亮色”或“亮色穿透暗色”。
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
/* 默认情况:从暗到亮 (Dark -> Light)
|
||||||
|
此时 DOM 上的 .dark 已经被移除,目标是亮色(NEW)。
|
||||||
|
策略:把暗色旧图(OLD)放在上层,缩小它的 clip-path,揭开底部的亮色新图(NEW)。 */
|
||||||
|
::view-transition-old(root) {
|
||||||
|
z-index: 2;
|
||||||
|
animation: tt-shrink 0.4s ease-out forwards;
|
||||||
|
}
|
||||||
|
::view-transition-new(root) {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 切换到暗色情况:从亮到暗 (Light -> Dark)
|
||||||
|
此时 DOM 上已加上 .dark,目标是暗色(NEW)。
|
||||||
|
策略:把暗色新图(NEW)放在上层,放大它的 clip-path,覆盖底部的亮色旧图(OLD)。 */
|
||||||
|
.dark::view-transition-old(root) {
|
||||||
|
z-index: 1;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.dark::view-transition-new(root) {
|
||||||
|
z-index: 2;
|
||||||
|
animation: tt-expand 0.4s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS 变量由 JS 注入到 html style 中 */
|
||||||
|
@keyframes tt-expand {
|
||||||
|
from { clip-path: circle(0px at var(--tt-x) var(--tt-y)); }
|
||||||
|
to { clip-path: circle(var(--tt-r) at var(--tt-x) var(--tt-y)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tt-shrink {
|
||||||
|
from { clip-path: circle(var(--tt-r) at var(--tt-x) var(--tt-y)); }
|
||||||
|
to { clip-path: circle(0px at var(--tt-x) var(--tt-y)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 在 View Transition 期间强制禁用所有 CSS 过渡,
|
/* 在 View Transition 期间强制禁用所有 CSS 过渡,
|
||||||
|
|||||||
@ -39,7 +39,7 @@ describe('startThemeTransition', () => {
|
|||||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('主路径:有 startViewTransition 时调用它,callback 切换 dark class', async () => {
|
it('主路径:有 startViewTransition 时调用它,注入变量,callback 切换 dark class', async () => {
|
||||||
const cbRef: { cb: (() => void) | null } = { cb: null };
|
const cbRef: { cb: (() => void) | null } = { cb: null };
|
||||||
const readyP = Promise.resolve();
|
const readyP = Promise.resolve();
|
||||||
const finishedP = Promise.resolve();
|
const finishedP = Promise.resolve();
|
||||||
@ -53,14 +53,15 @@ describe('startThemeTransition', () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock animate on documentElement (happy-dom 不支持 pseudoElement)
|
|
||||||
const animateSpy = vi.fn(() => ({ finished: Promise.resolve() }));
|
|
||||||
document.documentElement.animate = animateSpy as unknown as typeof document.documentElement.animate;
|
|
||||||
|
|
||||||
window.__startThemeTransition(100, 200);
|
window.__startThemeTransition(100, 200);
|
||||||
|
|
||||||
expect(startVT).toHaveBeenCalledTimes(1);
|
expect(startVT).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// CSS 变量应注入
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe('100px');
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--tt-y')).toBe('200px');
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--tt-r')).toMatch(/^\d+(\.\d+)?px$/);
|
||||||
|
|
||||||
// is-theme-transitioning 应在 VT 之前添加
|
// is-theme-transitioning 应在 VT 之前添加
|
||||||
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
|
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
|
||||||
|
|
||||||
@ -68,23 +69,13 @@ describe('startThemeTransition', () => {
|
|||||||
cbRef.cb!();
|
cbRef.cb!();
|
||||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||||
|
|
||||||
// ready 后通过 Web Animations API 控制动画
|
// finished 后移除 is-theme-transitioning 和 CSS 变量
|
||||||
await readyP;
|
|
||||||
await Promise.resolve();
|
|
||||||
// animate 应被调用一次:clip-path 展开 on new
|
|
||||||
expect(animateSpy).toHaveBeenCalledTimes(1);
|
|
||||||
// 调用应包含 clipPath + pseudoElement
|
|
||||||
const calls = animateSpy.mock.calls as unknown[][];
|
|
||||||
const newCall = calls[0];
|
|
||||||
expect(newCall[0]).toHaveProperty('clipPath');
|
|
||||||
expect(newCall[1]).toHaveProperty('pseudoElement', '::view-transition-new(root)');
|
|
||||||
|
|
||||||
// finished 后移除 is-theme-transitioning
|
|
||||||
await finishedP;
|
await finishedP;
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
// happy-dom microtask 可能需要额外 tick
|
// happy-dom microtask 可能需要额外 tick
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(false);
|
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(false);
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--tt-x')).toBe('');
|
||||||
|
|
||||||
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
|
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,21 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 圆形展开主题切换动画(View Transitions API)。
|
* 圆形展开主题切换动画(View Transitions API)。
|
||||||
*
|
*
|
||||||
* 点击按钮时,新主题页面从点击点 (x,y) 以圆形向外展开覆盖全屏。
|
* 采用纯 CSS 配合 CSS 变量的实现方案。
|
||||||
|
* 核心策略:始终让"暗色层"在上方,通过 clip-path 揭示下方的"亮色层"。
|
||||||
|
* - 亮 -> 暗: NEW 是暗色(在上方),从小圆扩大(`tt-expand`)覆盖底部的 OLD。
|
||||||
|
* - 暗 -> 亮: OLD 是暗色(在上方),从大圆缩小(`tt-shrink`)揭开底部的 NEW。
|
||||||
*
|
*
|
||||||
* 实现方式:使用 Web Animations API 的 pseudoElement 选项,在 vt.ready 中
|
* 相比 WAAPI 或动态注入 <style>,这种方式完全没有特异性冲突、DOM 残留或
|
||||||
* 直接对 ::view-transition-old/new(root) 伪元素创建动画。相比注入 <style>
|
* API 优先级 bug,是目前最稳定的 VT 主题切换方案。
|
||||||
* + @keyframes 的旧方案,优势在于:
|
|
||||||
* - Web Animation 优先级高于 CSS 动画(包括 UA 默认的 fade-in/out),
|
|
||||||
* 天然覆盖,无需 !important 或担心特异性冲突;
|
|
||||||
* - 每次调用产生独立的 Animation 对象,不存在 <style> 残留导致后续
|
|
||||||
* 切换动画失效的问题;
|
|
||||||
* - 无需管理 prevStyleEl 的生命周期。
|
|
||||||
*
|
|
||||||
* mix-blend-mode: normal 通过 style.css 静态设置(不可动画属性)。
|
|
||||||
* CSS transition 在 VT 期间通过 .is-theme-transitioning class 全局禁用。
|
|
||||||
*
|
|
||||||
* 降级:无 startViewTransition 或 prefers-reduced-motion 时瞬切 dark class。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function prefersReducedMotion(): boolean {
|
function prefersReducedMotion(): boolean {
|
||||||
@ -53,8 +45,6 @@ function applyDarkClass(isDark: boolean): void {
|
|||||||
|
|
||||||
export function startThemeTransition(x: number, y: number): void {
|
export function startThemeTransition(x: number, y: number): void {
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
// 目标主题从 DOM 现状推导,不依赖外部传入——避免与 Rust Signal 状态不同步
|
|
||||||
// 导致方向错乱(isDark 传反而 toggle 成 no-op,新旧快照一样看不到动画)。
|
|
||||||
const isDark = !html.classList.contains('dark');
|
const isDark = !html.classList.contains('dark');
|
||||||
console.log('[tt] ENTER', { x, y, isDark, domHasDark: html.classList.contains('dark') });
|
console.log('[tt] ENTER', { x, y, isDark, domHasDark: html.classList.contains('dark') });
|
||||||
|
|
||||||
@ -69,6 +59,11 @@ export function startThemeTransition(x: number, y: number): void {
|
|||||||
|
|
||||||
const maxR = maxCornerDistance(x, y);
|
const maxR = maxCornerDistance(x, y);
|
||||||
|
|
||||||
|
// 注入动画需要的 CSS 变量
|
||||||
|
html.style.setProperty('--tt-x', `${x}px`);
|
||||||
|
html.style.setProperty('--tt-y', `${y}px`);
|
||||||
|
html.style.setProperty('--tt-r', `${maxR}px`);
|
||||||
|
|
||||||
// 禁用所有 CSS transition,确保 VT 截图是最终颜色
|
// 禁用所有 CSS transition,确保 VT 截图是最终颜色
|
||||||
html.classList.add('is-theme-transitioning');
|
html.classList.add('is-theme-transitioning');
|
||||||
|
|
||||||
@ -81,27 +76,7 @@ export function startThemeTransition(x: number, y: number): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vt.ready
|
vt.ready
|
||||||
.then(() => {
|
.then(() => console.log('[tt] ready OK'))
|
||||||
console.log('[tt] ready, animating clip-path via WAAPI');
|
|
||||||
// CSS 已通过 animation:none + opacity:1 (!important) 锁定两层:
|
|
||||||
// OLD 保持完全可见(暗色底图),NEW 保持完全不透明。
|
|
||||||
// WAAPI 只需控制 NEW 的 clip-path 实现圆形展开。
|
|
||||||
// script-created Animation 优先级高于 CSS animation(已被 none 禁用),
|
|
||||||
// 只添加 clip-path 动画,不与 CSS 冲突。
|
|
||||||
document.documentElement.animate(
|
|
||||||
{
|
|
||||||
clipPath: [
|
|
||||||
`circle(0px at ${x}px ${y}px)`,
|
|
||||||
`circle(${maxR}px at ${x}px ${y}px)`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 400,
|
|
||||||
easing: 'ease-out',
|
|
||||||
pseudoElement: '::view-transition-new(root)',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
vt.finished
|
vt.finished
|
||||||
@ -109,5 +84,9 @@ export function startThemeTransition(x: number, y: number): void {
|
|||||||
.catch((e) => console.log('[tt] VT REJECT:', e))
|
.catch((e) => console.log('[tt] VT REJECT:', e))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
html.classList.remove('is-theme-transitioning');
|
html.classList.remove('is-theme-transitioning');
|
||||||
|
// 清理 CSS 变量
|
||||||
|
html.style.removeProperty('--tt-x');
|
||||||
|
html.style.removeProperty('--tt-y');
|
||||||
|
html.style.removeProperty('--tt-r');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user