From 9694f282d6ce7ea0d6475834dea0a2fd43098e88 Mon Sep 17 00:00:00 2001 From: Neko Date: Sat, 22 Aug 2026 19:54:24 +0800 Subject: [PATCH] fix(stage-web): better chat bubble animation (#2340) --- .../Layouts/MobileInteractiveArea.vue | 12 -- .../chat/components/action-menu/index.vue | 128 ++++++++++++++---- 2 files changed, 103 insertions(+), 37 deletions(-) diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue index ccd713459..91e7a1ef7 100644 --- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue @@ -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, diff --git a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue index 2c9eba3ef..5ae312ed7 100644 --- a/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue +++ b/packages/stage-ui/src/components/scenarios/chat/components/action-menu/index.vue @@ -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 | 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())