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:
parent
b542952619
commit
f0ea1a294f
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user