feat(stage-web): new settings animation (#100)

* feat(stage-web): new settings animation

* fix: optimize, issues

* fix: allow disable icon animation

* fix: typo

* fix: disable icon animation when disable all animation
This commit is contained in:
LemonNeko
2025-03-27 21:07:05 +08:00
committed by GitHub
parent a1afd159f2
commit eccd6b2cd9
13 changed files with 341 additions and 59 deletions
+3
View File
@@ -46,6 +46,9 @@ settings:
animations:
stage-transitions:
title: Disable Stage Transitions
use-page-specific-transitions:
title: Use Page Specific Transitions
description: Some pages will have their own transitions, this will override the stage transitions
language:
chinese: 简体中文
english: English
+3
View File
@@ -34,6 +34,9 @@ settings:
animations:
stage-transitions:
title: 是否开启舞台动画
use-page-specific-transitions:
title: 是否使用页面特定过场动画
description: 某些页面会有自己的过场动画,这将覆盖舞台过场动画
language:
chinese: 简体中文
english: English
+1
View File
@@ -54,6 +54,7 @@ watch(settings.themeColorsHueDynamic, () => {
:colors="colors"
:z-index="100"
:disable-transitions="settings.disableTransitions.value"
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
>
<RouterView />
</StageTransitionGroup>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = defineProps<{
icon: string
iconSize: number // avoid conflict with unocss size prop
position: string
duration: number
started: boolean
textColor: string
isReverse?: boolean
zIndex?: number
}>()
const emit = defineEmits<{
(e: 'animationEnded'): void
}>()
const isAnimating = ref(false)
watch(() => props.started, (newVal) => {
if (newVal) {
requestAnimationFrame(() => {
isAnimating.value = true
})
}
})
const clsAndProps = computed(() => {
return {
opacity: isAnimating.value !== props.isReverse ? 1 : 0, // this equals to opacity: isAnimating.value ? props.isReverse ? 1 : 0 : props.isReverse ? 0 : 1
size: isAnimating.value !== props.isReverse ? 25 : props.iconSize,
position: isAnimating.value !== props.isReverse ? `calc(50dvw - 12.5rem), calc(50dvh - 12.5rem)` : props.position,
textColor: isAnimating.value !== props.isReverse ? 'text-white' : props.textColor,
}
})
const animationEndProps = ref<string[]>([])
const animationEnded = ref(false)
function handleAnimationEnded(e: TransitionEvent) {
animationEndProps.value.push(e.propertyName)
if (animationEndProps.value.includes('color') && animationEndProps.value.includes('width') && animationEndProps.value.includes('height') && animationEndProps.value.includes('transform')) {
animationEnded.value = true
emit('animationEnded')
}
}
</script>
<template>
<div
pointer-events-none fixed w="100dvw" h="100dvh"
:style="{
zIndex: animationEnded ? zIndex : undefined,
}"
>
<div
bg-primary-500 fixed inset-0 transition-opacity ease-linear :style="{
opacity: clsAndProps.opacity,
transitionDuration: `${duration}ms`,
}"
/>
<div
fixed inset-0 ease-in-out :style="{
width: `${clsAndProps.size}rem`,
height: `${clsAndProps.size}rem`,
transform: `translate(${clsAndProps.position})`,
transitionDuration: `${duration}ms`,
}" :class="[
clsAndProps.textColor,
props.icon,
{ 'transition-all': isAnimating },
]"
@transitionend="handleAnimationEnded"
/>
</div>
</template>
@@ -3,6 +3,7 @@ defineProps<{
text: string
iconOn: string
iconOff: string
description?: string
}>()
const model = defineModel<boolean>()
</script>
@@ -14,7 +15,12 @@ const model = defineModel<boolean>()
hover="bg-neutral-200 dark:bg-neutral-700"
>
<input v-model="model" :aria-checked="model" type="checkbox" hidden>
{{ $t(text) }}
<div>
{{ $t(text) }}
<div v-if="description" text="sm neutral-500">
{{ $t(description) }}
</div>
</div>
<Transition name="slide-away" mode="out-in">
<div v-if="model" :class="iconOn" transition="all ease-in-out duration-250" />
<div v-else :class="iconOff" transition="all ease-in-out duration-250" />
@@ -0,0 +1,29 @@
import { useSettings } from '@proj-airi/stage-ui/stores'
import { computed, onMounted, onUnmounted, ref } from 'vue'
export function useIconAnimation(icon: string) {
const iconAnimationStarted = ref(false)
const showAnimationComponent = ref(false)
const animationIcon = ref(icon)
const settingsStore = useSettings()
const showIconAnimation = computed(() => showAnimationComponent.value && !settingsStore.disableTransitions && settingsStore.usePageSpecificTransitions)
onMounted(() => {
showAnimationComponent.value = true
requestAnimationFrame(() => {
iconAnimationStarted.value = true
})
})
onUnmounted(() => {
iconAnimationStarted.value = false
showAnimationComponent.value = false
})
return {
iconAnimationStarted,
showIconAnimation,
animationIcon,
}
}
@@ -1,16 +1,26 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useDark } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import CheckBar from '../../../components/Settings/CheckBar.vue'
import ColorPalette from '../../../components/Settings/ColorPalette.vue'
import Section from '../../../components/Settings/Section.vue'
import { useIconAnimation } from '../../../composables/useIconAnimation'
import COLOR_PRESETS from './color-presets.json'
const router = useRouter()
const settings = useSettings()
const dark = useDark()
const usePageSpecificTransitionsSettingChanged = ref(false)
const { iconAnimationStarted, showIconAnimation, animationIcon } = useIconAnimation('i-lucide:paintbrush')
// avoid showing the animation component when the page specific transitions are enabled
watch(() => [settings.usePageSpecificTransitions, settings.disableTransitions], () => {
usePageSpecificTransitionsSettingChanged.value = true
})
</script>
<template>
@@ -140,9 +150,28 @@ const dark = useDark()
icon-off="i-solar:running-2-line-duotone"
text="settings.animations.stage-transitions.title"
/>
<CheckBar
v-model="settings.usePageSpecificTransitions"
:disabled="settings.disableTransitions"
icon-on="i-solar:running-2-line-duotone"
icon-off="i-solar:people-nearby-bold-duotone"
text="settings.animations.use-page-specific-transitions.title"
description="settings.animations.use-page-specific-transitions.description"
/>
</Section>
<div text="neutral-200/50 dark:neutral-600/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<IconAnimation
v-if="showIconAnimation && !usePageSpecificTransitionsSettingChanged"
:z-index="-1"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
:icon="animationIcon"
:icon-size="12"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div v-if="!settings.usePageSpecificTransitions" text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:paintbrush />
</div>
</template>
@@ -202,4 +231,5 @@ const dark = useDark()
meta:
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
+109 -48
View File
@@ -1,8 +1,96 @@
<script setup lang="ts">
import { IconItem } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import IconAnimation from '../../components/IconAnimation.vue'
const router = useRouter()
const iconAnimationStarted = ref(false)
const iconAnimation = ref<InstanceType<typeof IconAnimation>>()
const resolveAnimation = ref<() => void>()
const { t } = useI18n()
const animationIcon = ref('')
const animationPosition = ref('')
const showAnimationComponent = ref(false)
const settingsStore = useSettings()
function handleAnimationEnded() {
resolveAnimation.value?.()
}
async function handleIconItemClick(event: MouseEvent, setting: typeof settings.value[0]) {
const target = event.currentTarget as HTMLElement
const iconElement = target.querySelector('.menu-icon-item-icon') as HTMLElement
if (!iconElement)
return
// get the position of the icon element
const rect = iconElement.getBoundingClientRect()
const position = `${rect.left}px, ${rect.top}px`
// set the icon and position
animationIcon.value = setting.icon
animationPosition.value = position
// show the animation component
showAnimationComponent.value = true
// wait for the DOM to update
await nextTick()
// start the animation
iconAnimationStarted.value = true
}
const removeBeforeEach = router.beforeEach(async (_, __, next) => {
if (!settingsStore.usePageSpecificTransitions || settingsStore.disableTransitions) {
next()
return
}
await new Promise<void>((resolve) => {
resolveAnimation.value = resolve
})
removeBeforeEach()
next()
})
const settings = computed(() => [
{
title: t('settings.pages.modules.title'),
description: t('settings.pages.modules.description'),
icon: 'i-lucide:blocks',
to: '/settings/modules',
},
{
title: t('settings.pages.models.title'),
description: t('settings.pages.models.description'),
icon: 'i-lucide:person-standing',
to: '/settings/models',
},
{
title: t('settings.pages.memory.title'),
description: t('settings.pages.memory.description'),
icon: 'i-lucide:sprout',
to: '/settings/memory',
},
{
title: t('settings.pages.providers.title'),
description: t('settings.pages.providers.description'),
icon: 'i-lucide:brain',
to: '/settings/providers',
},
{
title: t('settings.pages.themes.title'),
description: t('settings.pages.themes.description'),
icon: 'i-lucide:paintbrush',
to: '/settings/appearance',
},
])
</script>
<template>
@@ -23,63 +111,36 @@ const router = useRouter()
<div flex="~ col gap-4">
<div flex="~ col gap-4">
<IconItem
v-for="(setting, index) in settings"
:key="setting.to"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:title="$t('settings.pages.modules.title')"
:description="$t('settings.pages.modules.description')"
icon="i-lucide:blocks"
to="/settings/modules"
/>
<IconItem
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="50"
:title="$t('settings.pages.models.title')"
:description="$t('settings.pages.models.description')"
icon="i-lucide:person-standing"
to="/settings/models"
/>
<IconItem
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="100"
:title="$t('settings.pages.memory.title')"
:description="$t('settings.pages.memory.description')"
icon="i-lucide:sprout"
to="/settings/memory"
/>
<IconItem
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="100"
:title="$t('settings.pages.providers.title')"
:description="$t('settings.pages.providers.description')"
icon="i-lucide:brain"
to="/settings/providers"
/>
<IconItem
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:delay="150"
:title="$t('settings.pages.themes.title')"
:description="$t('settings.pages.themes.description')"
icon="i-lucide:paintbrush"
to="/settings/appearance"
:style="{
transitionDelay: `${index * 50}ms`, // delay between each item, unocss doesn't support dynamic generation of classes now
}"
:title="setting.title"
:description="setting.description"
:icon="setting.icon"
:to="setting.to"
@click="(e) => handleIconItemClick(e, setting)"
/>
</div>
<div text="neutral-200/50 dark:neutral-600/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div v-motion text="60" i-lucide:cog />
</div>
<IconAnimation
v-if="showAnimationComponent && !settingsStore.disableTransitions && settingsStore.usePageSpecificTransitions"
ref="iconAnimation"
:icon="animationIcon"
:icon-size="6 * 1.2"
:position="animationPosition"
:duration="1000"
text-color="text-neutral-400/50 dark:text-neutral-600/20"
:started="iconAnimationStarted"
@animation-ended.once="handleAnimationEnded"
/>
</div>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import Live2DCanvas from '@proj-airi/stage-ui/components/Live2D/Canvas.vue'
import Live2DModel from '@proj-airi/stage-ui/components/Live2D/Model.vue'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useElementBounding } from '@vueuse/core'
import { Vibrant } from 'node-vibrant/browser'
import { ref } from 'vue'
@@ -8,12 +9,14 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import Live2DSettings from '../../../components/Widgets/Live2DSettings.vue'
import { useIconAnimation } from '../../../composables/useIconAnimation'
const { t } = useI18n()
const router = useRouter()
const live2dContainerRef = ref<HTMLDivElement>()
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const { width, height } = useElementBounding(live2dContainerRef)
const settingsStore = useSettings()
const palette = ref<string[]>([])
@@ -38,6 +41,12 @@ async function extractColorsFromModel() {
URL.revokeObjectURL(frameUrl)
}
}
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-lucide:person-standing')
</script>
<template>
@@ -68,7 +77,18 @@ async function extractColorsFromModel() {
<Live2DSettings w="50%" h="80vh" :palette="palette" @extract-colors-from-model="extractColorsFromModel" />
</div>
<div text="neutral-200/50 dark:neutral-600/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div v-if="!settingsStore.usePageSpecificTransitions" text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:person-standing />
</div>
</template>
@@ -77,4 +97,5 @@ async function extractColorsFromModel() {
meta:
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,11 +1,16 @@
<script setup lang="ts">
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import IconAnimation from '../../../components/IconAnimation.vue'
import { useIconAnimation } from '../../../composables/useIconAnimation'
const router = useRouter()
const { t } = useI18n()
const settingsStore = useSettings()
interface Module {
id: string
@@ -101,6 +106,12 @@ const modulesList = computed<Module[]>(() => [
configured: false,
},
])
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-lucide:blocks')
</script>
<template>
@@ -142,7 +153,18 @@ const modulesList = computed<Module[]>(() => [
:configured="module.configured"
/>
</div>
<div text="neutral-200/50 dark:neutral-600/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<IconAnimation
v-if="showIconAnimation"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
:z-index="-1"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
/>
<div v-if="!settingsStore.usePageSpecificTransitions" text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:blocks />
</div>
</template>
@@ -151,4 +173,5 @@ const modulesList = computed<Module[]>(() => [
meta:
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -1,14 +1,23 @@
<script setup lang="ts">
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { useProvidersStore } from '@proj-airi/stage-ui/stores'
import { useProvidersStore, useSettings } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useIconAnimation } from '../../../composables/useIconAnimation'
const { t } = useI18n()
const router = useRouter()
const providersStore = useProvidersStore()
const { allProvidersMetadata } = storeToRefs(providersStore)
const settingsStore = useSettings()
const {
iconAnimationStarted,
showIconAnimation,
animationIcon,
} = useIconAnimation('i-lucide:brain')
</script>
<template>
@@ -50,7 +59,18 @@ const { allProvidersMetadata } = storeToRefs(providersStore)
:configured="provider.configured"
/>
</div>
<div text="neutral-200/50 dark:neutral-600/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<IconAnimation
v-if="showIconAnimation"
:z-index="-1"
:icon="animationIcon"
:icon-size="12"
:duration="1000"
:started="iconAnimationStarted"
:is-reverse="true"
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div v-if="!settingsStore.usePageSpecificTransitions" text="neutral-200/50 dark:neutral-500/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:brain />
</div>
</template>
@@ -59,4 +79,5 @@ const { allProvidersMetadata } = storeToRefs(providersStore)
meta:
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
+2
View File
@@ -29,6 +29,7 @@ export const useSettings = defineStore('settings', () => {
const live2dMotionMap = useLocalStorage<Record<string, string>>('settings/live2d/motion-map', {})
const disableTransitions = useLocalStorage('settings/disable-transitions', true)
const usePageSpecificTransitions = useLocalStorage('settings/use-page-specific-transitions', true)
const themeColorsHue = useLocalStorage('settings/theme/colors/hue', DEFAULT_THEME_COLORS_HUE)
const themeColorsHueDynamic = useLocalStorage('settings/theme/colors/hue-dynamic', false)
@@ -87,6 +88,7 @@ export const useSettings = defineStore('settings', () => {
live2dMotionMap,
loadingLive2dModel,
disableTransitions,
usePageSpecificTransitions,
language,
stageView,
themeColorsHue,
@@ -17,6 +17,7 @@ const props = defineProps<{
colors?: string[]
zIndex?: number
disableTransitions?: boolean
usePageSpecificTransitions?: boolean
}>()
interface StageTransitionCommonParams {
@@ -27,6 +28,7 @@ interface StageTransitionCommonParams {
direction?: 'top' | 'bottom' | 'left' | 'right'
colors?: string[]
zIndex?: number
pageSpecificAvailable?: boolean
}
type TransitionComponent =
@@ -247,12 +249,21 @@ function triggerTransition(params: StageTransitionCommonParams, next: Navigation
}
router.beforeEach((to, _from, next) => {
if (props.disableTransitions) {
next()
return
}
if (typeof to.meta.stageTransition !== 'object') {
next()
return
}
const stageTransition = to.meta.stageTransition as StageTransitionCommonParams
if (props.usePageSpecificTransitions && stageTransition.pageSpecificAvailable) {
next()
return
}
if (typeof props.primaryColor !== 'undefined') {
stageTransition.primaryColor = props.primaryColor
}
@@ -269,11 +280,6 @@ router.beforeEach((to, _from, next) => {
stageTransition.zIndex = props.zIndex
}
if (props.disableTransitions) {
next()
return
}
triggerTransition(stageTransition, next)
})
</script>