feat: 添加主题切换扩散动画
This commit is contained in:
@@ -87,6 +87,27 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-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 {
|
@keyframes fade-in-up {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -2,7 +2,20 @@
|
|||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
|
const buttonRef = ref<HTMLButtonElement | null>(null)
|
||||||
|
const isAnimating = ref(false)
|
||||||
|
const isMounted = ref(false)
|
||||||
|
|
||||||
type Mode = 'system' | 'dark' | 'light'
|
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> = {
|
const iconMap: Record<Mode, string> = {
|
||||||
system: 'mdi:monitor',
|
system: 'mdi:monitor',
|
||||||
@@ -22,27 +35,191 @@ const cycleOrder = computed<Mode[]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const currentMode = computed(() => colorMode.preference as Mode)
|
const currentMode = computed(() => colorMode.preference as Mode)
|
||||||
const currentIcon = computed(() => iconMap[currentMode.value] || iconMap.system)
|
const displayMode = computed<Mode>(() => isMounted.value ? currentMode.value : 'system')
|
||||||
const currentLabel = computed(() => labelMap[currentMode.value] || labelMap.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 order = cycleOrder.value
|
||||||
const idx = order.indexOf(currentMode.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
|
ref="buttonRef"
|
||||||
@click="cycle"
|
@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)"
|
style="color: var(--color-text-secondary)"
|
||||||
:class="[
|
:class="[
|
||||||
'hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]',
|
'hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text)]',
|
||||||
]"
|
]"
|
||||||
|
:disabled="isAnimating"
|
||||||
:aria-label="currentLabel"
|
:aria-label="currentLabel"
|
||||||
|
:aria-pressed="displayResolvedMode === 'dark'"
|
||||||
:title="currentLabel"
|
: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>
|
</button>
|
||||||
</template>
|
</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