226 lines
5.1 KiB
Vue
226 lines
5.1 KiB
Vue
<script setup lang="ts">
|
|
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',
|
|
dark: 'mdi:weather-night',
|
|
light: 'mdi:weather-sunny',
|
|
}
|
|
|
|
const labelMap: Record<Mode, string> = {
|
|
system: '跟随系统',
|
|
dark: '深色模式',
|
|
light: '浅色模式',
|
|
}
|
|
|
|
const cycleOrder = computed<Mode[]>(() => {
|
|
const isDark = colorMode.value === 'dark'
|
|
return isDark ? ['system', 'light', 'dark'] : ['system', 'dark', 'light']
|
|
})
|
|
|
|
const currentMode = computed(() => colorMode.preference as Mode)
|
|
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')
|
|
|
|
onMounted(() => {
|
|
isMounted.value = true
|
|
})
|
|
|
|
function getNextMode() {
|
|
const order = cycleOrder.value
|
|
const idx = order.indexOf(currentMode.value)
|
|
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="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"
|
|
>
|
|
<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>
|