feat(stage-pocket): add dockable input bubble (#2363)
This commit is contained in:
@@ -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 { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
|
||||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||||
import { BasicTextarea, useTheme } from '@proj-airi/ui'
|
import { BasicTextarea, useTheme } from '@proj-airi/ui'
|
||||||
|
import { onLongPress, useEventListener, usePointerSwipe } from '@vueuse/core'
|
||||||
|
import { animate, spring } from 'animejs'
|
||||||
import { storeToRefs } from 'pinia'
|
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 { useI18n } from 'vue-i18n'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
@@ -92,10 +94,16 @@ function handleCleanupMessages() {
|
|||||||
|
|
||||||
const messageInput = shallowRef('')
|
const messageInput = shallowRef('')
|
||||||
const isComposing = shallowRef(false)
|
const isComposing = shallowRef(false)
|
||||||
|
const inputBubbleDocked = shallowRef(false)
|
||||||
|
const inputBubbleDragging = shallowRef(false)
|
||||||
|
const inputBubbleAnimating = shallowRef(false)
|
||||||
const backgroundDialogOpen = shallowRef(false)
|
const backgroundDialogOpen = shallowRef(false)
|
||||||
const sessionsDrawerOpen = shallowRef(false)
|
const sessionsDrawerOpen = shallowRef(false)
|
||||||
const mobileInteractiveArea = useTemplateRef<HTMLElement>('mobileInteractiveArea')
|
const mobileInteractiveArea = useTemplateRef<HTMLElement>('mobileInteractiveArea')
|
||||||
const messageComposer = useTemplateRef<HTMLElement>('messageComposer')
|
const messageComposer = useTemplateRef<HTMLElement>('messageComposer')
|
||||||
|
const inputBubble = useTemplateRef<HTMLElement>('inputBubble')
|
||||||
|
const inputBubbleDockTarget = useTemplateRef<HTMLElement>('inputBubbleDockTarget')
|
||||||
|
const inputBubbleIcon = useTemplateRef<HTMLElement>('inputBubbleIcon')
|
||||||
const interactionControls = useTemplateRef<HTMLElement>('interactionControls')
|
const interactionControls = useTemplateRef<HTMLElement>('interactionControls')
|
||||||
const controlsIsland = useTemplateRef<HTMLElement>('controlsIsland')
|
const controlsIsland = useTemplateRef<HTMLElement>('controlsIsland')
|
||||||
const controlsIslandContent = useTemplateRef<HTMLElement>('controlsIslandContent')
|
const controlsIslandContent = useTemplateRef<HTMLElement>('controlsIslandContent')
|
||||||
@@ -128,12 +136,14 @@ const chatHistoryClass = computed(() => [
|
|||||||
props.keyboardAvoidance ? undefined : 'max-h-[35dvh]',
|
props.keyboardAvoidance ? undefined : 'max-h-[35dvh]',
|
||||||
])
|
])
|
||||||
const controlsIslandClass = computed(() => [
|
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',
|
'max-w-full overflow-y-auto overscroll-contain px-3 py-3 font-sans scrollbar-none',
|
||||||
'transition-[height] duration-250 ease-out',
|
'transition-[height] duration-250 ease-out',
|
||||||
controlsIslandOverflowing.value
|
controlsIslandOverflowing.value && [
|
||||||
? 'controls-island-scroll--overflowing'
|
'[-webkit-mask-image:linear-gradient(to_bottom,transparent_0,black_1rem,black_calc(100%_-_1rem),transparent_100%)]',
|
||||||
: undefined,
|
'[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 { themeColorsHueDynamic } = storeToRefs(useSettings())
|
||||||
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
|
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
|
||||||
@@ -159,6 +169,142 @@ const { isListening, startStreamingTranscription, stopStreamingTranscription } =
|
|||||||
const { showStopSpeakingButton, speechMuted, stopSpeakingFromChat, toggleSpeechMuted } = useStopSpeakingButton()
|
const { showStopSpeakingButton, speechMuted, stopSpeakingFromChat, toggleSpeechMuted } = useStopSpeakingButton()
|
||||||
const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
|
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<HTMLTextAreaElement>('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<HTMLTextAreaElement>('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() {
|
async function handleSubmit() {
|
||||||
if (!isMobileDevice()) {
|
if (!isMobileDevice()) {
|
||||||
await handleSend()
|
await handleSend()
|
||||||
@@ -258,17 +404,8 @@ onUnmounted(() => {
|
|||||||
data-testid="mobile-interaction-controls"
|
data-testid="mobile-interaction-controls"
|
||||||
:class="[
|
:class="[
|
||||||
'pointer-events-auto relative w-full shrink-0 self-end',
|
'pointer-events-auto relative w-full shrink-0 self-end',
|
||||||
'bg-white dark:bg-neutral-800',
|
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
|
||||||
data-testid="mobile-composer-underlay"
|
|
||||||
aria-hidden="true"
|
|
||||||
:class="[
|
|
||||||
'pointer-events-none absolute inset-x-0 top-full h-100dvh',
|
|
||||||
'bg-white dark:bg-neutral-800',
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<div translate-y="[-100%]" absolute left-0 px-3 pb-3 font-sans>
|
<div translate-y="[-100%]" absolute left-0 px-3 pb-3 font-sans>
|
||||||
<div flex="~ col" gap-1>
|
<div flex="~ col" gap-1>
|
||||||
<slot name="status" />
|
<slot name="status" />
|
||||||
@@ -286,6 +423,11 @@ onUnmounted(() => {
|
|||||||
'flex flex-col gap-1',
|
'flex flex-col gap-1',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
ref="inputBubbleDockTarget"
|
||||||
|
data-testid="mobile-input-bubble-dock-target"
|
||||||
|
class="invisible size-10 shrink-0 self-end"
|
||||||
|
/>
|
||||||
<ActionAbout />
|
<ActionAbout />
|
||||||
<div flex="~ col" items-end gap-1>
|
<div flex="~ col" items-end gap-1>
|
||||||
<button
|
<button
|
||||||
@@ -371,26 +513,58 @@ onUnmounted(() => {
|
|||||||
data-testid="mobile-message-composer"
|
data-testid="mobile-message-composer"
|
||||||
:class="[
|
:class="[
|
||||||
'max-h-100dvh max-w-100dvw w-full',
|
'max-h-100dvh max-w-100dvw w-full',
|
||||||
'flex gap-1 overflow-auto px-3 pt-2',
|
'flex gap-1 px-3 pt-2',
|
||||||
'bg-white dark:bg-neutral-800',
|
|
||||||
]"
|
]"
|
||||||
:style="messageComposerStyle"
|
:style="messageComposerStyle"
|
||||||
>
|
>
|
||||||
<BasicTextarea
|
<div
|
||||||
v-model="messageInput"
|
ref="inputBubble"
|
||||||
:placeholder="t('stage.message')"
|
data-testid="mobile-input-bubble"
|
||||||
|
:data-dragging="inputBubbleDragging"
|
||||||
:class="[
|
:class="[
|
||||||
'font-cute',
|
'group relative mx-auto min-h-10 flex origin-center',
|
||||||
'max-h-[10lh] min-h-[calc(1lh+4px+4px)] w-full resize-none overflow-y-scroll rounded-[1lh] px-4 py-0.5 outline-none backdrop-blur-md scrollbar-none',
|
'touch-none select-none focus-within:touch-auto focus-within:select-text',
|
||||||
'border-2 border-solid border-neutral-200/60 bg-neutral-100/80 text-neutral-500 dark:border-neutral-700/60 dark:bg-neutral-950/80 dark:text-neutral-100',
|
inputBubbleDragging || inputBubbleAnimating
|
||||||
'transition-all duration-250 ease-in-out hover:text-neutral-600 placeholder:text-[14px] placeholder:vertical-middle placeholder:leading-6 placeholder:text-neutral-400 placeholder:transition-all placeholder:duration-250 placeholder:ease-in-out placeholder:hover:text-neutral-500 dark:hover:text-neutral-200 dark:placeholder:text-neutral-500 dark:placeholder:hover:text-neutral-400',
|
? 'transition-none'
|
||||||
themeColorsHueDynamic ? 'transition-colors-none placeholder:transition-colors-none' : undefined,
|
: 'transition-[max-width] duration-320 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)]',
|
||||||
|
inputBubbleDocked
|
||||||
|
? [
|
||||||
|
'h-10 max-w-10 w-10 cursor-pointer rounded-xl border-2 border-solid backdrop-blur-md',
|
||||||
|
'border-neutral-100/60 bg-neutral-50/70 dark:border-neutral-800/30 dark:bg-neutral-800/70',
|
||||||
|
]
|
||||||
|
: 'max-w-[70%] w-full focus-within:max-w-full',
|
||||||
]"
|
]"
|
||||||
default-height="1lh"
|
@click="handleInputBubbleClick"
|
||||||
@submit="handleSubmit"
|
@pointerdown="suppressNextInputBubbleClick = false"
|
||||||
@compositionstart="isComposing = true"
|
>
|
||||||
@compositionend="isComposing = false"
|
<BasicTextarea
|
||||||
/>
|
v-model="messageInput"
|
||||||
|
:placeholder="t('stage.message')"
|
||||||
|
:class="[
|
||||||
|
'font-cute',
|
||||||
|
'max-h-[10lh] min-h-[calc(1lh+4px+4px)] w-full resize-none overflow-y-scroll scrollbar-none',
|
||||||
|
'border-2 border-solid px-4 py-0.5 outline-none backdrop-blur-md',
|
||||||
|
'text-neutral-500 dark:text-neutral-100',
|
||||||
|
'rounded-[1lh] border-neutral-200/60 bg-neutral-100/80 dark:border-neutral-700/60 dark:bg-neutral-950/80',
|
||||||
|
'transition-colors duration-250 ease-in-out hover:text-neutral-600 dark:hover:text-neutral-200',
|
||||||
|
'placeholder:text-[14px] placeholder:vertical-middle placeholder:leading-6 placeholder:text-neutral-400',
|
||||||
|
'placeholder:transition-all placeholder:duration-250 placeholder:ease-in-out placeholder:hover:text-neutral-500 dark:placeholder:text-neutral-500 dark:placeholder:hover:text-neutral-400',
|
||||||
|
'pointer-events-none group-focus-within:pointer-events-auto',
|
||||||
|
themeColorsHueDynamic ? 'transition-colors-none placeholder:transition-colors-none' : undefined,
|
||||||
|
]"
|
||||||
|
default-height="1lh"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@compositionstart="isComposing = true"
|
||||||
|
@compositionend="isComposing = false"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref="inputBubbleIcon"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center text-neutral-500 opacity-0 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
<div class="i-solar:keyboard-bold-duotone size-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="showStopSpeakingButton"
|
v-if="showStopSpeakingButton"
|
||||||
data-testid="stop-speaking-button"
|
data-testid="stop-speaking-button"
|
||||||
@@ -419,33 +593,3 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@keyframes scan {
|
|
||||||
0% {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(400%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-scan {
|
|
||||||
animation: scan 2s infinite linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-island-scroll--overflowing {
|
|
||||||
--controls-island-mask: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0,
|
|
||||||
black 1rem,
|
|
||||||
black calc(100% - 1rem),
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
-webkit-mask-image: var(--controls-island-mask);
|
|
||||||
mask-image: var(--controls-island-mask);
|
|
||||||
-webkit-mask-repeat: no-repeat;
|
|
||||||
mask-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user