refactor(ui-transitions): better structure

Signed-off-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
Neko Ayaka
2025-03-06 16:34:18 +08:00
parent 6e35621eb7
commit e2268879dd
6 changed files with 300 additions and 70 deletions
@@ -1,46 +1,26 @@
<script setup lang="ts">
import { RouterLink, useRoute } from 'vue-router'
import { RouterLink } from 'vue-router'
const route = useRoute()
// Define route configurations
const routes = [
{ number: 1, path: '/' },
{ number: 2, path: '/transition-2' },
{ number: 3, path: '/transition-3' },
{ number: 4, path: '/transition-4' },
{ number: 5, path: '/transition-5' },
{ number: 6, path: '/transition-6' },
{ number: 7, path: '/transition-7' },
]
</script>
<template>
<div flex="~ row gap-2" h-full items-center justify-center>
<RouterLink
rounded-lg px-7 py-3 transition="all duration-250 ease-in-out" to="/"
:class="[route.path === '/' ? 'bg-neutral-200 dark:bg-neutral-500' : 'bg-neutral-100 dark:bg-neutral-700']"
v-for="route in routes" :key="route.number" :to="route.path" rounded-lg px-7 py-3
transition="all duration-250 ease-in-out"
class="bg-neutral-100 [&.router-link-active]:bg-neutral-300 dark:bg-neutral-700 hover:bg-neutral-200 [&.router-link-active]:dark:bg-neutral-600 [&.router-link-active]:hover:bg-neutral-400 hover:dark:bg-neutral-600 [&.router-link-active]:hover:dark:bg-neutral-500"
>
1
</RouterLink>
<RouterLink
rounded-lg px-7 py-3 transition="all duration-250 ease-in-out" to="/transition-2"
:class="[route.path === '/transition-2' ? 'bg-neutral-200 dark:bg-neutral-500' : 'bg-neutral-100 dark:bg-neutral-700']"
>
2
</RouterLink>
<RouterLink
rounded-lg px-7 py-3 transition="all duration-250 ease-in-out" to="/transition-3"
:class="[route.path === '/transition-3' ? 'bg-neutral-200 dark:bg-neutral-500' : 'bg-neutral-100 dark:bg-neutral-700']"
>
3
</RouterLink>
<RouterLink
rounded-lg px-7 py-3 transition="all duration-250 ease-in-out" to="/transition-4"
:class="[route.path === '/transition-4' ? 'bg-neutral-200 dark:bg-neutral-500' : 'bg-neutral-100 dark:bg-neutral-700']"
>
4
</RouterLink>
<RouterLink
rounded-lg px-7 py-3 transition="all duration-250 ease-in-out" to="/transition-5"
:class="[route.path === '/transition-5' ? 'bg-neutral-200 dark:bg-neutral-500' : 'bg-neutral-100 dark:bg-neutral-700']"
>
5
</RouterLink>
<RouterLink
rounded-lg px-7 py-3 transition="all duration-250 ease-in-out" to="/transition-6"
:class="[route.path === '/transition-6' ? 'bg-neutral-200 dark:bg-neutral-500' : 'bg-neutral-100 dark:bg-neutral-700']"
>
6
{{ route.number }}
</RouterLink>
</div>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { definePage } from 'unplugin-vue-router/runtime'
import Buttons from '../components/Buttons.vue'
definePage({
meta: {
stageTransition: 'bubble-wave-out',
},
})
</script>
<template>
<Buttons />
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { onMounted } from 'vue'
const props = withDefaults(defineProps<{
colors?: string[]
delay?: number
duration?: number
}>(), {
colors: () => ['#eee', '#ebcb8b', '#c56370', '#3f3b52'],
delay: 0,
duration: 0.4,
})
onMounted(() => {
document.documentElement.style.setProperty('--circle-expansion-delay', `${props.delay}s`)
document.documentElement.style.setProperty('--circle-expansion-duration', `${props.duration}s`)
props.colors.forEach((color, index) => {
document.documentElement.style.setProperty(`--circle-expansion-color-${index + 1}`, color)
})
})
</script>
<template>
<div class="circle-expansion-transition">
<div v-for="(_, index) in props.colors" :key="index" />
</div>
</template>
<style scoped>
.circle-expansion-transition {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
pointer-events: none;
transform: translate(-50%, -50%);
}
.circle-expansion-transition div {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
transform: scale(0);
}
.circle-expansion-transition div:nth-child(1) {
background-color: var(--circle-expansion-color-1);
animation: circleExpand var(--circle-expansion-duration) ease-in calc(var(--circle-expansion-delay) + 0s) forwards;
}
.circle-expansion-transition div:nth-child(2) {
background-color: var(--circle-expansion-color-2);
animation: circleExpand var(--circle-expansion-duration) ease-in calc(var(--circle-expansion-delay) + 0.15s) forwards;
}
.circle-expansion-transition div:nth-child(3) {
background-color: var(--circle-expansion-color-3);
animation: circleExpand var(--circle-expansion-duration) ease-in calc(var(--circle-expansion-delay) + 0.3s) forwards;
}
.circle-expansion-transition div:nth-child(4) {
background-color: var(--circle-expansion-color-4);
animation: circleExpand var(--circle-expansion-duration) ease-in calc(var(--circle-expansion-delay) + 0.45s) forwards;
}
@keyframes circleExpand {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
</style>
@@ -5,19 +5,29 @@ const props = withDefaults(defineProps<{
primaryColor?: string
duration?: number
delay?: number
borderRadius?: number
borderRadius?: {
sm?: string
md?: string
lg?: string
}
}>(), {
primaryColor: '#eee',
duration: 0.6,
delay: 0,
borderRadius: 50,
borderRadius: () => ({
sm: '14rem',
md: '14rem',
lg: '50%',
}),
})
onMounted(() => {
document.documentElement.style.setProperty('--fantasy-fall-color', props.primaryColor)
document.documentElement.style.setProperty('--fantasy-fall-duration', `${props.duration}s`)
document.documentElement.style.setProperty('--fantasy-fall-delay', `${props.delay}s`)
document.documentElement.style.setProperty('--fantasy-fall-radius', `${props.borderRadius}%`)
document.documentElement.style.setProperty('--fantasy-fall-radius-sm', `${props.borderRadius.sm}`)
document.documentElement.style.setProperty('--fantasy-fall-radius-md', `${props.borderRadius.md}`)
document.documentElement.style.setProperty('--fantasy-fall-radius-lg', `${props.borderRadius.lg}`)
})
</script>
@@ -41,8 +51,8 @@ onMounted(() => {
height: 100%;
background-color: var(--fantasy-fall-color);
transform: translateY(-100%);
border-bottom-left-radius: var(--fantasy-fall-radius);
border-bottom-right-radius: var(--fantasy-fall-radius);
border-bottom-left-radius: var(--fantasy-fall-radius-sm);
border-bottom-right-radius: var(--fantasy-fall-radius-sm);
animation: fantasy-fall var(--fantasy-fall-duration) ease-out var(--fantasy-fall-delay) forwards;
}
}
@@ -62,4 +72,22 @@ onMounted(() => {
border-bottom-left-radius: 0%;
}
}
@media (min-width: 768px) {
.fantasy-fall-transition {
&::before {
border-bottom-left-radius: var(--fantasy-fall-radius-md);
border-bottom-right-radius: var(--fantasy-fall-radius-md);
}
}
}
@media (min-width: 1024px) {
.fantasy-fall-transition {
&::before {
border-bottom-left-radius: var(--fantasy-fall-radius-lg);
border-bottom-right-radius: var(--fantasy-fall-radius-lg);
}
}
}
</style>
@@ -3,6 +3,7 @@ import { ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import ArrowTransition from './ArrowTransition.vue'
import BubbleWaveOutTransition from './BubbleWaveOutTransition.vue'
import FantasyFallTransition from './FantasyFallTransition.vue'
import MultipleBlocksRevealTransition from './MultipleBlocksRevealTransition.vue'
import RectanglesRotateTransition from './RectanglesRotateTransition.vue'
@@ -24,15 +25,35 @@ type TransitionComponent =
| typeof MultipleBlocksRevealTransition
| typeof FantasyFallTransition
| typeof RectanglesRotateTransition
| typeof BubbleWaveOutTransition
const router = useRouter()
const showTransition = ref(false)
const activeTransitionName = ref('')
const transitionStage = ref<TransitionStage | null>(null)
const transitions = shallowRef<Record<string, {
// Define transition lifecycle events
export type TransitionStage =
| 'before-enter' // Just before animation starts
| 'enter-active' // Animation has started
| 'navigation' // Time to navigate (component still decides when)
| 'after-enter' // Entry animation completed
| 'before-leave' // Before exit animation starts
| 'leave-active' // Exit animation has started
| 'after-leave' // Complete animation cycle finished
// Define hook types
type TransitionHook = (stage: TransitionStage, data: any) => void | Promise<void>
type NavigationCallback = () => void
interface TransitionOptions {
component: TransitionComponent
duration: number
}>>({
exitDuration?: number
nextDelay?: number
}
const transitions = shallowRef<Record<string, TransitionOptions>>({
'slide': {
component: SlideTransition,
duration: 2700,
@@ -57,47 +78,154 @@ const transitions = shallowRef<Record<string, {
component: RectanglesRotateTransition,
duration: 2700,
},
'bubble-wave-out': {
component: BubbleWaveOutTransition,
duration: 2700,
},
})
function triggerTransition(name: string) {
return new Promise<void>((resolve) => {
const transition = transitions.value[name]
if (!transition) {
console.error(`Transition ${name} not found`)
resolve()
return
}
// Hook system
const lifecycleHooks = ref<TransitionHook[]>([])
// Add a hook
function addTransitionHook(hook: TransitionHook): () => void {
lifecycleHooks.value.push(hook)
// Return function to remove the hook
return () => {
const index = lifecycleHooks.value.indexOf(hook)
if (index >= 0) {
lifecycleHooks.value.splice(index, 1)
}
}
}
// Trigger all hooks for a stage
async function triggerHooks(stage: TransitionStage, data: any = {}) {
transitionStage.value = stage
// Execute all hooks and wait for them to complete
for (const hook of lifecycleHooks.value) {
try {
await Promise.resolve(hook(stage, data))
}
catch (error) {
console.error(`Error in transition hook at stage "${stage}":`, error)
}
}
}
async function triggerTransitionAsyncFn(name: string, next: NavigationCallback, resolve: (value: void | PromiseLike<void>) => void) {
const transition = transitions.value[name]
if (!transition) {
console.error(`Transition ${name} not found`)
next()
resolve()
return
}
// Get navigation timing from configuration, with fallback
const navTiming = transition.nextDelay !== undefined
? transition.nextDelay
: transition.duration / 3 // Fallback, but configurable
let hasNavigated = false
// Hook specifically for determining when to navigate
const navigationHook = (stage: TransitionStage) => {
if (stage === 'navigation' && !hasNavigated) {
hasNavigated = true
next()
}
}
// Add this hook temporarily
const removeNavHook = addTransitionHook(navigationHook)
try {
// Before enter
await triggerHooks('before-enter', { transitionName: name })
// Handle resetting current transition if needed
if (showTransition.value) {
// Fast track to exit if a transition is already active
await triggerHooks('before-leave', { transitionName: activeTransitionName.value })
activeTransitionName.value = ''
showTransition.value = false
await triggerHooks('after-leave', { transitionName: activeTransitionName.value })
setTimeout(() => {
activeTransitionName.value = name
showTransition.value = true
setTimeout(() => {
showTransition.value = false
activeTransitionName.value = ''
resolve()
}, transition.duration)
}, 50)
// Short delay before starting new transition
await new Promise(r => setTimeout(r, 50))
}
else {
activeTransitionName.value = name
showTransition.value = true
setTimeout(() => {
showTransition.value = false
activeTransitionName.value = ''
resolve()
}, transition.duration)
}
// Start new transition
activeTransitionName.value = name
showTransition.value = true
// Enter active phase
await triggerHooks('enter-active', { transitionName: name })
// Trigger navigation phase at the configured time
setTimeout(async () => {
await triggerHooks('navigation', {
transitionName: name,
config: transition, // Pass full config to hooks
})
// Ensure navigation happens even if no hook handled it
if (!hasNavigated) {
hasNavigated = true
next()
}
}, navTiming)
// After enter - entry phase complete
setTimeout(async () => {
await triggerHooks('after-enter', { transitionName: name })
}, transition.duration)
// Before leave - start exit phase
setTimeout(async () => {
await triggerHooks('before-leave', { transitionName: name })
}, transition.duration + 10) // Small offset after enter completes
// Leave active - exit animation running
setTimeout(async () => {
await triggerHooks('leave-active', { transitionName: name })
}, transition.duration + 20) // Small offset
// After leave - completely finished
const totalDuration = transition.exitDuration ?? 0
setTimeout(async () => {
showTransition.value = false
activeTransitionName.value = ''
await triggerHooks('after-leave', { transitionName: name })
resolve()
}, transition.duration + totalDuration)
}
finally {
// Always remove the navigation hook
removeNavHook()
// Safety - ensure navigation happens no matter what
setTimeout(() => {
if (!hasNavigated) {
hasNavigated = true
next()
}
}, transition.duration * 2) // Conservative timeout
}
}
// Improved transition trigger with navigation callback
function triggerTransition(name: string, next: NavigationCallback) {
return new Promise<void>((resolve) => {
triggerTransitionAsyncFn(name, next, resolve)
})
}
router.beforeEach((to, _from, next) => {
triggerTransition(to.meta.stageTransition as string).then(next)
triggerTransition(to.meta.stageTransition as string, next)
})
</script>
@@ -1,4 +1,5 @@
export { default as ArrowTransition } from './ArrowTransition.vue'
export { default as CircleExpansionTransition } from './CircleExpansionTransition.vue'
export { default as FantasyFallTransition } from './FantasyFallTransition.vue'
export { default as MultipleBlocksRevealTransition } from './MultipleBlocksRevealTransition.vue'
export { default as SlideTransition } from './SlideTransition.vue'