diff --git a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue index db638edc0..e4f4d95dc 100644 --- a/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue +++ b/packages/stage-layouts/src/components/Layouts/MobileInteractiveArea.vue @@ -16,8 +16,10 @@ import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d' import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge' import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings' import { BasicTextarea, useTheme } from '@proj-airi/ui' +import { onLongPress, useEventListener, usePointerSwipe } from '@vueuse/core' +import { animate, spring } from 'animejs' import { storeToRefs } from 'pinia' -import { computed, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue' +import { computed, nextTick, onMounted, onUnmounted, shallowRef, useTemplateRef, watch } from 'vue' import { useI18n } from 'vue-i18n' import { RouterLink } from 'vue-router' @@ -92,10 +94,16 @@ function handleCleanupMessages() { const messageInput = shallowRef('') const isComposing = shallowRef(false) +const inputBubbleDocked = shallowRef(false) +const inputBubbleDragging = shallowRef(false) +const inputBubbleAnimating = shallowRef(false) const backgroundDialogOpen = shallowRef(false) const sessionsDrawerOpen = shallowRef(false) const mobileInteractiveArea = useTemplateRef('mobileInteractiveArea') const messageComposer = useTemplateRef('messageComposer') +const inputBubble = useTemplateRef('inputBubble') +const inputBubbleDockTarget = useTemplateRef('inputBubbleDockTarget') +const inputBubbleIcon = useTemplateRef('inputBubbleIcon') const interactionControls = useTemplateRef('interactionControls') const controlsIsland = useTemplateRef('controlsIsland') const controlsIslandContent = useTemplateRef('controlsIslandContent') @@ -128,12 +136,14 @@ const chatHistoryClass = computed(() => [ props.keyboardAvoidance ? undefined : 'max-h-[35dvh]', ]) const controlsIslandClass = computed(() => [ - 'controls-island-scroll absolute right-0 translate-y-[-100%]', + 'absolute right-0 translate-y-[-100%]', 'max-w-full overflow-y-auto overscroll-contain px-3 py-3 font-sans scrollbar-none', 'transition-[height] duration-250 ease-out', - controlsIslandOverflowing.value - ? 'controls-island-scroll--overflowing' - : undefined, + controlsIslandOverflowing.value && [ + '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0,black_1rem,black_calc(100%_-_1rem),transparent_100%)]', + '[mask-image:linear-gradient(to_bottom,transparent_0,black_1rem,black_calc(100%_-_1rem),transparent_100%)]', + '[-webkit-mask-repeat:no-repeat] [mask-repeat:no-repeat]', + ], ]) const { themeColorsHueDynamic } = storeToRefs(useSettings()) const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl() @@ -159,6 +169,142 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription } = const { showStopSpeakingButton, speechMuted, stopSpeakingFromChat, toggleSpeechMuted } = useStopSpeakingButton() const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription() +let suppressNextInputBubbleClick = false + +async function resetInputBubblePosition() { + await animate(inputBubble.value!, { + transform: 'translate3d(0px, 0px, 0px) scale(1)', + ease: spring({ bounce: 0.3, duration: 320 }), + }) +} + +async function setInputBubbleDocked(docked: boolean) { + if (inputBubbleDocked.value === docked) + return + + inputBubbleAnimating.value = true + const bubble = inputBubble.value! + const source = bubble.getBoundingClientRect() + bubble.style.transform = 'translate3d(0px, 0px, 0px) scale(1)' + if (!docked) { + bubble.style.removeProperty('width') + bubble.style.removeProperty('max-width') + bubble.style.removeProperty('height') + } + inputBubbleDocked.value = docked + await nextTick() + + const destination = bubble.getBoundingClientRect() + const target = docked ? inputBubbleDockTarget.value!.getBoundingClientRect() : destination + const startX = source.left + source.width / 2 - destination.left - destination.width / 2 + const startY = source.top - destination.top + const endX = target.left + target.width / 2 - destination.left - destination.width / 2 + const endY = target.top + target.height / 2 - destination.top - destination.height / 2 + const messageInput = bubble.querySelector('textarea')! + + await Promise.all([ + animate(bubble, { + width: [`${source.width}px`, `${target.width}px`], + maxWidth: [`${source.width}px`, `${target.width}px`], + height: [`${source.height}px`, `${target.height}px`], + transform: [ + `translate3d(${startX}px, ${startY}px, 0)`, + `translate3d(${endX}px, ${endY}px, 0)`, + ], + ease: spring({ bounce: docked ? 0.35 : 0.25, duration: 400 }), + }), + animate(messageInput, { + opacity: docked ? 0 : 1, + duration: 120, + ease: 'out(2)', + }), + animate(inputBubbleIcon.value!, { + opacity: docked ? 1 : 0, + duration: 120, + ease: 'out(2)', + }), + ]) + + if (!docked) { + bubble.style.removeProperty('width') + bubble.style.removeProperty('max-width') + bubble.style.removeProperty('height') + } + inputBubbleAnimating.value = false + await nextTick() +} + +const { + distanceX: inputBubbleDistanceX, + distanceY: inputBubbleDistanceY, +} = usePointerSwipe(inputBubble, { + threshold: 0, + onSwipe: handleInputBubbleSwipe, + onSwipeEnd: finishInputBubbleDrag, +}) + +async function finishInputBubbleDrag() { + if (!inputBubbleDragging.value) + return + + inputBubbleDragging.value = false + const upwardDistance = inputBubbleDistanceY.value + const draggedTowardDock = upwardDistance >= 64 + && upwardDistance > Math.abs(inputBubbleDistanceX.value) + if (draggedTowardDock) + await setInputBubbleDocked(true) + else + await resetInputBubblePosition() +} + +function handleInputBubbleSwipe() { + if (!inputBubbleDragging.value) + return + + inputBubble.value!.style.transform = `translate3d(${-inputBubbleDistanceX.value}px, ${-inputBubbleDistanceY.value}px, 0) scale(.98)` +} + +function handleInputBubbleLongPress() { + if (inputBubbleDocked.value) + return + + suppressNextInputBubbleClick = true + inputBubbleDragging.value = true + inputBubble.value!.style.transform = 'translate3d(0, 0, 0) scale(.98)' +} + +onLongPress(inputBubble, handleInputBubbleLongPress, { + delay: 500, + distanceThreshold: 10, + modifiers: { prevent: true }, + onMouseUp: (_duration, _distance, longPressed) => longPressed && finishInputBubbleDrag(), +}) + +async function handleInputBubbleClick() { + if (suppressNextInputBubbleClick) { + suppressNextInputBubbleClick = false + return + } + + if (inputBubbleDocked.value) { + await setInputBubbleDocked(false) + return + } + + inputBubble.value!.querySelector('textarea')!.focus() +} + +async function handleInputBubblePointerCancel() { + inputBubbleDragging.value = false + await resetInputBubblePosition() +} + +useEventListener(inputBubble, 'pointercancel', handleInputBubblePointerCancel) + +onMounted(() => { + inputBubble.value!.style.setProperty('touch-action', 'none') +}) + async function handleSubmit() { if (!isMobileDevice()) { await handleSend() @@ -258,17 +404,8 @@ onUnmounted(() => { data-testid="mobile-interaction-controls" :class="[ 'pointer-events-auto relative w-full shrink-0 self-end', - 'bg-white dark:bg-neutral-800', ]" > -