feat: 添加主题切换扩散动画
This commit is contained in:
@@ -2,7 +2,20 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||
const isAnimating = ref(false)
|
||||
const isMounted = ref(false)
|
||||
|
||||
type Mode = 'system' | 'dark' | 'light'
|
||||
type ViewTransition = {
|
||||
ready: Promise<void>
|
||||
finished: Promise<void>
|
||||
updateCallbackDone: Promise<void>
|
||||
}
|
||||
|
||||
type DocumentWithViewTransition = Document & {
|
||||
startViewTransition?: (callback: () => void | Promise<void>) => ViewTransition
|
||||
}
|
||||
|
||||
const iconMap: Record<Mode, string> = {
|
||||
system: 'mdi:monitor',
|
||||
@@ -22,27 +35,191 @@ const cycleOrder = computed<Mode[]>(() => {
|
||||
})
|
||||
|
||||
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<Mode>(() => 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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
@click="cycle"
|
||||
class="inline-flex items-center justify-center rounded-md w-8 h-8 transition-colors"
|
||||
class="theme-toggle inline-flex items-center justify-center rounded-md w-8 h-8 transition-colors"
|
||||
style="color: var(--color-text-secondary)"
|
||||
:class="[
|
||||
'hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]',
|
||||
]"
|
||||
:disabled="isAnimating"
|
||||
:aria-label="currentLabel"
|
||||
:aria-pressed="displayResolvedMode === 'dark'"
|
||||
:title="currentLabel"
|
||||
>
|
||||
<Icon :icon="currentIcon" class="w-5 h-5" />
|
||||
<span
|
||||
class="theme-toggle-icon"
|
||||
:class="{ 'is-dark': displayResolvedMode === 'dark' }"
|
||||
>
|
||||
<Icon
|
||||
:key="currentIcon"
|
||||
:icon="currentIcon"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-toggle {
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.theme-toggle:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.theme-toggle-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition:
|
||||
transform 0.28s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle-icon.is-dark {
|
||||
transform: rotate(-18deg) scale(0.96);
|
||||
}
|
||||
|
||||
.theme-toggle-icon :deep(svg) {
|
||||
animation: theme-icon-pop 0.28s ease;
|
||||
}
|
||||
|
||||
@keyframes theme-icon-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: rotate(-45deg) scale(0.72);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.theme-toggle-icon,
|
||||
.theme-toggle-icon :deep(svg) {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user