fix(theme): pre-inject VT styles before startViewTransition

Previous approach injected ::view-transition-new animation in vt.ready,
creating a timing gap where the NEW layer had no clip-path and was fully
visible, making dark→light transitions invisible.

Now:
- Inject <style> with @keyframes + VT pseudo-element rules BEFORE
  calling startViewTransition, so styles are ready when pseudo-elements
  are created.
- Force synchronous getComputedStyle() in VT callback to ensure the
  NEW snapshot captures final background-color, not a mid-transition
  value.
This commit is contained in:
xfy 2026-06-26 17:55:45 +08:00
parent ba9db2d75a
commit b542952619
3 changed files with 50 additions and 46 deletions

View File

@ -5,7 +5,7 @@ import './style.css';
declare global {
interface Window {
__initPostContent: (selector: string) => void;
__startThemeTransition: (x: number, y: number, isDark: boolean) => void;
__startThemeTransition: (x: number, y: number) => void;
}
}

View File

@ -60,13 +60,8 @@ describe('startThemeTransition', () => {
window.__startThemeTransition(100, 200);
expect(startVT).toHaveBeenCalledTimes(1);
// callback 里根据 DOM 现状(无 dark)切到 dark
cbRef.cb!();
expect(document.documentElement.classList.contains('dark')).toBe(true);
// ready 后注入带 keyframes 的 style
await readyP;
await Promise.resolve();
// 样式预注入:在 startViewTransition 之前就已注入 <style>
const style = document.head.querySelector('style');
expect(style).not.toBeNull();
expect(style?.textContent).toContain('circle(0px at 100px 200px)');
@ -74,6 +69,14 @@ describe('startThemeTransition', () => {
expect(style?.textContent).toContain('::view-transition-old(root)');
expect(style?.textContent).toContain('::view-transition-new(root)');
// callback 里根据 DOM 现状(无 dark)切到 dark
cbRef.cb!();
expect(document.documentElement.classList.contains('dark')).toBe(true);
// ready 后无需注入(已预注入)
await readyP;
await Promise.resolve();
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
});

View File

@ -3,15 +3,14 @@
*
* , (x,y)
*
* 实现方式:启动 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
* )
* :
* 1. VT ** startViewTransition ** <head>,
* ::view-transition-old/new ,
* ( vt.ready , NEW )
* 2. VT toggle dark class , getComputedStyle
* , NEW , CSS transition
* 3. html.is-theme-transitioning class CSS transition,
* body background-color 0.3s
*
* 降级: startViewTransition prefers-reduced-motion dark class
*/
@ -69,50 +68,52 @@ export function startThemeTransition(x: number, y: number): void {
const maxR = maxCornerDistance(x, y);
// 添加临时类,用于在 CSS 中禁用 transition确保截图捕获最终状态
// ── 1. 预注入 VT 伪元素样式(在 startViewTransition 之前) ──
// 移除上一次注入的 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');
// ── 3. 启动 View Transition ──
const vt = document.startViewTransition(() => {
console.log('[tt] CALLBACK set dark=', isDark);
applyDarkClass(isDark);
// 强制同步样式重算:确保浏览器截取 NEW 快照前,
// body 的 background-color 已经解析为目标值。
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
getComputedStyle(document.body).backgroundColor;
});
// 样式已预注入,vt.ready 不再需要处理
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);
})
.then(() => console.log('[tt] ready OK'))
.catch(() => {});
vt.finished
.then(() => console.log('[tt] VT finished OK'))
.catch((e) => console.log('[tt] VT REJECT:', e))
.finally(() => {
// 动画完成后移除临时类
html.classList.remove('is-theme-transitioning');
});
}