fix(stage-web): better chat bubble animation (#2340)

This commit is contained in:
Neko
2026-08-22 19:54:24 +08:00
committed by GitHub
parent 67a0410c4d
commit 9694f282d6
2 changed files with 103 additions and 37 deletions
@@ -433,18 +433,6 @@ onUnmounted(() => {
animation: scan 2s infinite linear;
}
.chat-history {
--gradient: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 20%);
-webkit-mask-image: var(--gradient);
mask-image: var(--gradient);
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: bottom;
mask-position: bottom;
}
.controls-island-scroll--overflowing {
--controls-island-mask: linear-gradient(
to bottom,
@@ -6,7 +6,7 @@ import type { ChatActionMenuAction } from '.'
import { errorMessageFromValue, isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { useElementVisibility, useIntervalFn } from '@vueuse/core'
import { createTimeline } from 'animejs'
import { animate } from 'animejs'
import { clamp } from 'es-toolkit'
import {
ContextMenuContent,
@@ -20,7 +20,7 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger,
} from 'reka-ui'
import { computed, reactive, ref, shallowRef, toRef, useTemplateRef, watch } from 'vue'
import { computed, onUnmounted, reactive, ref, shallowRef, toRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWebHaptics } from 'web-haptics/vue'
@@ -101,11 +101,9 @@ const hasMenuItems = computed(() => menuItems.value.length > 0)
const forceVisible = computed(() => contextMenuOpen.value || dropdownMenuOpen.value)
const contentClasses = [
'z-10000 min-w-36 rounded-xl p-1 shadow-md outline-none',
'chat-action-menu-content z-10000 min-w-36 rounded-xl p-1 shadow-md outline-none',
'border border-neutral-100/70 bg-white/90 text-neutral-700 backdrop-blur-md',
'dark:border-neutral-900/80 dark:bg-neutral-900/90 dark:text-neutral-100',
'data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
'data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade',
]
const itemClasses = [
@@ -137,6 +135,9 @@ const triggerStyle = computed(() => (
function handleContextMenuOpenChange(open: boolean) {
contextMenuOpen.value = open
if (open)
animateReleasedState()
}
function handleDropdownMenuOpenChange(open: boolean) {
@@ -278,42 +279,61 @@ async function handleAction(action: ChatActionMenuAction) {
}
const pressedAnimatable = reactive({ scale: 100 })
const tl = createTimeline({ defaults: { duration: 500, autoplay: false } })
.add(pressedAnimatable, { scale: 90, ease: 'inOut', autoplay: false })
.reset()
const contextMenuPressOpenDelay = 250
let scaleAnimation: ReturnType<typeof animate> | undefined
let gestureReleased = true
function animatePressedState() {
gestureReleased = false
scaleAnimation?.cancel()
scaleAnimation = animate(pressedAnimatable, {
scale: 95,
duration: contextMenuPressOpenDelay,
ease: 'linear',
})
}
function animateReleasedState() {
if (gestureReleased)
return
gestureReleased = true
scaleAnimation?.cancel()
scaleAnimation = animate(pressedAnimatable, {
scale: 100,
duration: 220,
ease: 'inOut(2)',
})
}
const { trigger: triggerTimer, clear: clearTimer } = useSetTimeoutFn(() => {
trigger('medium')
tl.reset()
}, { delay: 700 })
watch(isTouching, (val) => {
if (val) {
if (tl.completed || tl.paused) {
tl.restart()
}
else {
tl.play()
}
}, { delay: contextMenuPressOpenDelay })
watch(isTouching, (touching) => {
if (touching) {
animatePressedState()
triggerTimer()
return
}
else {
tl.reset()
clearTimer()
}
clearTimer()
animateReleasedState()
})
onUnmounted(() => scaleAnimation?.cancel())
</script>
<template>
<ContextMenuRoot @update:open="handleContextMenuOpenChange">
<ContextMenuRoot
:press-open-delay="contextMenuPressOpenDelay"
@update:open="handleContextMenuOpenChange"
>
<ContextMenuTrigger as-child>
<div
ref="contextMenuContainer"
:class="[
'group/chat-action relative w-fit',
'transition-transform duration-150 ease-in-out',
]"
:style="{
transform: `scale(${pressedAnimatable.scale / 100})`,
@@ -421,3 +441,61 @@ watch(isTouching, (val) => {
</ContextMenuPortal>
</ContextMenuRoot>
</template>
<style>
.chat-action-menu-content {
transform-origin: var(--reka-context-menu-content-transform-origin);
will-change: opacity, transform;
}
.chat-action-menu-content[data-state="open"] {
animation: chat-action-menu-elastic-in 220ms linear both;
}
.chat-action-menu-content[data-state="closed"] {
animation: chat-action-menu-out 120ms ease-in both;
}
@keyframes chat-action-menu-elastic-in {
0% {
opacity: 0;
transform: scale(0.72);
}
42% {
opacity: 1;
transform: scale(1.035);
}
64% {
transform: scale(0.985);
}
82% {
transform: scale(1.006);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes chat-action-menu-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.chat-action-menu-content[data-state] {
animation: none;
}
}
</style>