100 lines
4.8 KiB
TypeScript
100 lines
4.8 KiB
TypeScript
/**
|
|
* 테마 전환 애니메이션 — View Transitions API + soft circular reveal.
|
|
*
|
|
* 동작: 클릭한 (x, y) 위치에서 새 테마가 부드러운 가장자리(radial-gradient feather)를 가진
|
|
* 원형 mask 로 퍼져나간다. clip-path 와 달리 가장자리가 sharp 하지 않고 색이 자연스럽게
|
|
* 번지는 느낌을 준다.
|
|
*
|
|
* 실제 애니메이션은 globals.css 의 ::view-transition-new(root) 의 mask-image + @property
|
|
* 등록된 --reveal-radius 키프레임이 담당한다. 이 함수는:
|
|
* 1) CSS 커스텀 변수 (--reveal-x/-y/-max) 를 root 에 세팅
|
|
* 2) startViewTransition() 으로 DOM 캡처 → 새 root 의 dark/light 클래스 swap
|
|
*
|
|
* View Transitions API 미지원 브라우저(주로 Firefox)는 즉시 swap 으로 fallback.
|
|
*/
|
|
|
|
type ApplyTheme = (theme: "light" | "dark") => void;
|
|
|
|
type DocumentWithVT = Document & {
|
|
startViewTransition?: (cb: () => void | Promise<void>) => {
|
|
ready: Promise<void>;
|
|
finished: Promise<void>;
|
|
};
|
|
};
|
|
|
|
export function animatedThemeChange(
|
|
next: "light" | "dark",
|
|
applyTheme: ApplyTheme,
|
|
origin?: { x: number; y: number },
|
|
): void {
|
|
const doc = document as DocumentWithVT;
|
|
|
|
// 클릭 위치(없으면 화면 중앙)
|
|
const x = origin?.x ?? window.innerWidth / 2;
|
|
const y = origin?.y ?? window.innerHeight / 2;
|
|
// 클릭 지점에서 화면 가장 먼 모서리까지 거리
|
|
const cornerDistance = Math.hypot(
|
|
Math.max(x, window.innerWidth - x),
|
|
Math.max(y, window.innerHeight - y),
|
|
);
|
|
// ★ 중요: 그라데이션의 안쪽 25% 만 solid (alpha 1.0) 이고 나머지 75% 는 페이드.
|
|
// 애니메이션 끝났을 때 화면 모서리가 페이드 구간이 아니라 solid 구간에 들어가야 새 테마가
|
|
// 완전히 화면을 덮은 것처럼 보임. 모서리가 그라데이션의 0.22 (= 1/4.5) 위치에 떨어지도록
|
|
// endRadius 를 모서리 거리의 4.5 배로 설정 → 안쪽 25% 영역 안에 안전하게 들어감.
|
|
const endRadius = cornerDistance * 4.5;
|
|
|
|
// CSS 가 사용할 변수 세팅 (root 에 두면 ::view-transition-new(root) 가 상속)
|
|
const root = document.documentElement;
|
|
root.style.setProperty("--reveal-x", `${x}px`);
|
|
root.style.setProperty("--reveal-y", `${y}px`);
|
|
root.style.setProperty("--reveal-max", `${endRadius}px`);
|
|
// Perceived speed 보정: 다크로 갈 땐 검정 컨트라스트가 강해서 같은 duration 도 빠르게 느껴짐.
|
|
// dark 방향만 약 25% 더 길게 잡아 두 방향이 비슷한 속도로 인지되도록 함.
|
|
root.style.setProperty("--vt-duration", next === "dark" ? "2200ms" : "1700ms");
|
|
|
|
// View Transitions API 미지원 → 즉시 적용
|
|
if (!doc.startViewTransition) {
|
|
applyTheme(next);
|
|
return;
|
|
}
|
|
|
|
// ★ 핵심: VT 캡처 전에 모든 CSS transition 을 일시 비활성화.
|
|
// v5-layout.css 등에서 `transition: all .3s` 같은 규칙이 여러 곳에 깔려있어서, 다크 클래스를
|
|
// 토글하면 background-color / color 가 자체 transition 으로 천천히 보간된다. 그 결과 VT 가
|
|
// 끝난 뒤에도 진짜 DOM 에서 색이 계속 변하는 것처럼 보임("끝났는데 더 어두워지는 느낌").
|
|
// disable style 을 콜백 직전에 주입 → VT 가 새 스냅샷을 캡처할 땐 이미 transition 무효화 →
|
|
// transition.ready 후 제거하면 평소 hover 등은 정상 동작.
|
|
const disableStyle = document.createElement("style");
|
|
disableStyle.setAttribute("data-vt-theme-disable", "");
|
|
// ★ transition 만 죽이고 animation 은 그대로 둘 것!
|
|
// animation-duration:0s 를 넣으면 VT 의 vt-soft-reveal 애니메이션이 영향을 받아
|
|
// 퍼지는 속도가 빨라진다. 우리가 막고 싶은 건 색깔 보간(transition)만이지 animation 이 아님.
|
|
disableStyle.appendChild(
|
|
document.createTextNode(
|
|
"*,*::before,*::after{transition:none!important;}",
|
|
),
|
|
);
|
|
|
|
const transition = doc.startViewTransition(() => {
|
|
// disable 주입 → 클래스 토글이 transition 없이 즉시 반영되도록
|
|
document.head.appendChild(disableStyle);
|
|
// VT 의 캡처 시점에 DOM 이 새 테마 상태가 되도록 즉시 클래스 토글.
|
|
// next-themes 도 함께 호출해서 localStorage / context 동기화.
|
|
document.documentElement.classList.toggle("dark", next === "dark");
|
|
applyTheme(next);
|
|
});
|
|
|
|
// 새 스냅샷 캡처 완료 후 disable 제거 — 이 시점엔 진짜 DOM 은 VT pseudo 뒤에 가려져 있어
|
|
// 사용자 눈에는 안 보이지만, transition 은 평소대로 복구되어 hover 등에 영향 없음
|
|
transition.ready
|
|
.then(() => {
|
|
// 강제 reflow 후 제거 (브라우저가 disable 상태를 확실히 적용한 뒤 풀도록)
|
|
void document.body.offsetHeight;
|
|
disableStyle.remove();
|
|
})
|
|
.catch(() => {
|
|
// VT 실패 시에도 disable 은 제거해야 함
|
|
disableStyle.remove();
|
|
});
|
|
}
|