diff --git a/assets/css/main.css b/assets/css/main.css index f0538c1..3d8efe0 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -87,6 +87,27 @@ body { -moz-osx-font-smoothing: grayscale; } +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} + +::view-transition-new(root) { + z-index: 1; +} + +::view-transition-old(root) { + z-index: 0; +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + } +} + /* 入场动画 */ @keyframes fade-in-up { from { diff --git a/components/layout/ThemeToggle.vue b/components/layout/ThemeToggle.vue index 9a7a717..22eb699 100644 --- a/components/layout/ThemeToggle.vue +++ b/components/layout/ThemeToggle.vue @@ -2,7 +2,20 @@ import { Icon } from '@iconify/vue' const colorMode = useColorMode() +const buttonRef = ref(null) +const isAnimating = ref(false) +const isMounted = ref(false) + type Mode = 'system' | 'dark' | 'light' +type ViewTransition = { + ready: Promise + finished: Promise + updateCallbackDone: Promise +} + +type DocumentWithViewTransition = Document & { + startViewTransition?: (callback: () => void | Promise) => ViewTransition +} const iconMap: Record = { system: 'mdi:monitor', @@ -22,27 +35,191 @@ const cycleOrder = computed(() => { }) const currentMode = computed(() => colorMode.preference as Mode) -const currentIcon = computed(() => iconMap[currentMode.value] || iconMap.system) -const currentLabel = computed(() => labelMap[currentMode.value] || labelMap.system) +const displayMode = computed(() => isMounted.value ? currentMode.value : 'system') +const currentIcon = computed(() => iconMap[displayMode.value] || iconMap.system) +const currentLabel = computed(() => labelMap[displayMode.value] || labelMap.system) +const resolvedMode = computed(() => colorMode.value) +const displayResolvedMode = computed(() => isMounted.value ? resolvedMode.value : 'light') -function cycle() { +onMounted(() => { + isMounted.value = true +}) + +function getNextMode() { const order = cycleOrder.value const idx = order.indexOf(currentMode.value) - colorMode.preference = order[(idx + 1) % order.length] + return order[(idx + 1) % order.length] +} + +function getTransitionOrigin() { + const rect = buttonRef.value?.getBoundingClientRect() + if (!rect) { + return { + x: window.innerWidth / 2, + y: window.innerHeight / 2, + } + } + + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + } +} + +function getMaxRadius(x: number, y: number) { + return Math.hypot( + Math.max(x, window.innerWidth - x), + Math.max(y, window.innerHeight - y) + ) +} + +function wait(ms: number) { + return new Promise(resolve => window.setTimeout(resolve, ms)) +} + +async function cycle() { + if (isAnimating.value) return + + const nextMode = getNextMode() + + if (!import.meta.client) { + colorMode.preference = nextMode + return + } + + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const documentWithTransition = document as DocumentWithViewTransition + + if (prefersReducedMotion || !documentWithTransition.startViewTransition) { + colorMode.preference = nextMode + return + } + + const { x, y } = getTransitionOrigin() + const endRadius = getMaxRadius(x, y) + + isAnimating.value = true + const fallbackTimer = window.setTimeout(() => { + isAnimating.value = false + }, 900) + + const applyMode = async () => { + colorMode.preference = nextMode + await nextTick() + } + + let transition: ViewTransition + + try { + transition = documentWithTransition.startViewTransition(applyMode) + } catch { + await applyMode() + window.clearTimeout(fallbackTimer) + isAnimating.value = false + return + } + + try { + await transition.ready + + const animation = document.documentElement.animate( + { + clipPath: [ + `circle(0px at ${x}px ${y}px)`, + `circle(${endRadius}px at ${x}px ${y}px)`, + ], + }, + { + duration: 620, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + pseudoElement: '::view-transition-new(root)', + } as KeyframeAnimationOptions + ) + + await Promise.race([ + animation.finished.catch(() => undefined), + wait(700), + ]) + void transition.finished.catch(() => undefined) + } catch { + await transition.updateCallbackDone.catch(applyMode) + } finally { + window.clearTimeout(fallbackTimer) + isAnimating.value = false + } } + +