refactor(theme): switch VT animation from CSS injection to Web Animations API

Replace dynamic <style> injection (@keyframes + pseudo-element rules)
with document.documentElement.animate({ pseudoElement }) calls.

Previous approach accumulated <style> elements across transitions,
causing subsequent dark→light animations to be invisible. The Web
Animations API:
- Creates per-call Animation objects with no DOM residue
- Has higher composite priority than CSS animations, naturally
  overriding UA default fade-in/out without !important
- Eliminates style element lifecycle management entirely

Static CSS now only sets mix-blend-mode: normal (not animatable via
WAAPI) and the is-theme-transitioning transition suppression.
This commit is contained in:
xfy 2026-06-26 18:02:26 +08:00
parent b542952619
commit f0ea1a294f
3 changed files with 78 additions and 59 deletions

View File

@ -1,9 +1,15 @@
/* ========== 主题切换圆形展开(View Transitions API) ========== */ /* ========== 主题切换圆形展开(View Transitions API) ========== */
/* View Transition 期间强制禁用所有 CSS 过渡确保截图捕获的是最终状态 /* mix-blend-mode 不可通过 Web Animations API 设置,只能用 CSS
不要在这里设置 ::view-transition-old/new animation: UA 默认 plus-lighter 会导致新旧层颜色叠加发亮,需覆盖为 normal
静态 animation: none 会让 new 在注入 @keyframes 之前完全不透明地覆盖 old, author 样式天然覆盖 UA 样式,无需 !important */
导致暗亮方向看不到动画所有 VT 伪元素样式由 JS vt.ready 中统一注入 */ ::view-transition-old(root),
::view-transition-new(root) {
mix-blend-mode: normal;
}
/* View Transition 期间强制禁用所有 CSS 过渡,
确保 VT 回调中 toggle dark class 后截图捕获的是最终颜色 */
html.is-theme-transitioning, html.is-theme-transitioning,
html.is-theme-transitioning *, html.is-theme-transitioning *,
html.is-theme-transitioning *::before, html.is-theme-transitioning *::before,

View File

@ -13,18 +13,14 @@ import './index';
describe('startThemeTransition', () => { describe('startThemeTransition', () => {
beforeEach(() => { beforeEach(() => {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('is-theme-transitioning');
document.documentElement.style.cssText = ''; document.documentElement.style.cssText = '';
document.head.querySelectorAll('style').forEach((s) => {
if (s.textContent?.includes('tt-')) s.remove();
});
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
afterEach(() => { afterEach(() => {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('is-theme-transitioning');
document.documentElement.style.cssText = ''; document.documentElement.style.cssText = '';
document.head.querySelectorAll('style').forEach((s) => {
if (s.textContent?.includes('tt-')) s.remove();
});
}); });
it('降级:无 startViewTransition 时,亮→暗(无 dark class 时 add)', () => { it('降级:无 startViewTransition 时,亮→暗(无 dark class 时 add)', () => {
@ -43,7 +39,7 @@ describe('startThemeTransition', () => {
expect(document.documentElement.classList.contains('dark')).toBe(false); expect(document.documentElement.classList.contains('dark')).toBe(false);
}); });
it('主路径:有 startViewTransition 时调用它,并注入带 keyframes 的 style', 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();
@ -57,25 +53,38 @@ 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);
// 样式预注入:在 startViewTransition 之前就已注入 <style> // is-theme-transitioning 应在 VT 之前添加
const style = document.head.querySelector('style'); expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(true);
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)');
// callback 里根据 DOM 现状(无 dark)切到 dark // callback 里根据 DOM 现状(无 dark)切到 dark
cbRef.cb!(); cbRef.cb!();
expect(document.documentElement.classList.contains('dark')).toBe(true); expect(document.documentElement.classList.contains('dark')).toBe(true);
// ready 后无需注入(已预注入) // ready 后通过 Web Animations API 控制动画
await readyP; await readyP;
await Promise.resolve(); await Promise.resolve();
// animate 应被调用两次:一次 old(opacity),一次 new(clipPath + opacity)
expect(animateSpy).toHaveBeenCalledTimes(2);
// 第二次调用(new)应包含 clipPath
const calls = animateSpy.mock.calls as unknown[][];
const newCall = calls[1];
expect(newCall[0]).toHaveProperty('clipPath');
expect(newCall[1]).toHaveProperty('pseudoElement', '::view-transition-new(root)');
// finished 后移除 is-theme-transitioning
await finishedP;
await Promise.resolve();
// happy-dom microtask 可能需要额外 tick
await new Promise((r) => setTimeout(r, 0));
expect(document.documentElement.classList.contains('is-theme-transitioning')).toBe(false);
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition; delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
}); });

View File

@ -3,20 +3,21 @@
* *
* , (x,y) * , (x,y)
* *
* : * 实现方式:使用 Web Animations API pseudoElement , vt.ready
* 1. VT ** startViewTransition ** <head>, * ::view-transition-old/new(root) <style>
* ::view-transition-old/new , * + @keyframes ,:
* ( vt.ready , NEW ) * - Web Animation CSS ( UA fade-in/out),
* 2. VT toggle dark class , getComputedStyle * , !important ;
* , NEW , CSS transition * - Animation , <style>
* 3. html.is-theme-transitioning class CSS transition, * ;
* body background-color 0.3s * - prevStyleEl
*
* mix-blend-mode: normal style.css ()
* CSS transition VT .is-theme-transitioning class
* *
* 降级: startViewTransition prefers-reduced-motion dark class * 降级: startViewTransition prefers-reduced-motion dark class
*/ */
let prevStyleEl: HTMLStyleElement | null = null;
function prefersReducedMotion(): boolean { function prefersReducedMotion(): boolean {
return ( return (
!!window.matchMedia && !!window.matchMedia &&
@ -68,46 +69,49 @@ export function startThemeTransition(x: number, y: number): void {
const maxR = maxCornerDistance(x, y); const maxR = maxCornerDistance(x, y);
// ── 1. 预注入 VT 伪元素样式(在 startViewTransition 之前) ── // 禁用所有 CSS transition,确保 VT 截图是最终颜色
// 移除上一次注入的 style,避免堆积。
if (prevStyleEl) {
prevStyleEl.remove();
}
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;
// ── 2. 禁用所有 CSS transition,确保截图是最终颜色 ──
html.classList.add('is-theme-transitioning'); html.classList.add('is-theme-transitioning');
// ── 3. 启动 View Transition ──
const vt = document.startViewTransition(() => { const vt = document.startViewTransition(() => {
console.log('[tt] CALLBACK set dark=', isDark); console.log('[tt] CALLBACK set dark=', isDark);
applyDarkClass(isDark); applyDarkClass(isDark);
// 强制同步样式重算:确保浏览器截取 NEW 快照前, // 强制同步样式重算:确保 body 的 background-color 解析为目标值
// body 的 background-color 已经解析为目标值。
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
getComputedStyle(document.body).backgroundColor; getComputedStyle(document.body).backgroundColor;
}); });
// 样式已预注入,vt.ready 不再需要处理
vt.ready vt.ready
.then(() => console.log('[tt] ready OK')) .then(() => {
console.log('[tt] ready, animating via WAAPI');
// ── Web Animations API ──
// script-created 动画优先级高于 CSS 动画(含 UA 默认 fade-in/out),
// 天然覆盖,无需 !important,无残留样式问题。
// OLD: 覆盖 UA 默认的 fade-out,保持完全可见作为底图
document.documentElement.animate(
{ opacity: [1, 1] },
{
duration: 400,
pseudoElement: '::view-transition-old(root)',
},
);
// NEW: 覆盖 UA 默认的 fade-in,改为 clip-path 圆形展开
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxR}px at ${x}px ${y}px)`,
],
opacity: [1, 1],
},
{
duration: 400,
easing: 'ease-out',
pseudoElement: '::view-transition-new(root)',
},
);
})
.catch(() => {}); .catch(() => {});
vt.finished vt.finished