feat(stage-ui): long press / hold to popup chat actions on mobile, plus haptics

This commit is contained in:
Neko Ayaka
2026-04-07 01:39:49 +08:00
parent fe139f31cd
commit 9a1d79afc8
4 changed files with 173 additions and 11 deletions
@@ -1,9 +1,12 @@
<script setup lang="ts">
import type { MaybeComputedElementRef } from '@vueuse/core'
import type { ComponentPublicInstance } from 'vue'
import type { ChatActionMenuAction } from '.'
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { useElementVisibility, useIntervalFn } from '@vueuse/core'
import { createTimeline } from 'animejs'
import { clamp } from 'es-toolkit'
import {
ContextMenuContent,
@@ -17,7 +20,8 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger,
} from 'reka-ui'
import { computed, inject, shallowRef, useTemplateRef } from 'vue'
import { computed, inject, reactive, ref, shallowRef, toRef, useTemplateRef, watch } from 'vue'
import { useWebHaptics } from 'web-haptics/vue'
import { createChatActionMenuItems } from '.'
import { useBreakpoints } from '../../../../../composables/use-breakpoints'
@@ -47,6 +51,7 @@ defineSlots<{
}>()
const measuredElementRef = shallowRef<HTMLElement | null>(null)
const contextMenuContainerElementRef = useTemplateRef<HTMLElement>('contextMenuContainer')
const topSentinelRef = useTemplateRef<HTMLDivElement>('topSentinel')
const bottomSentinelRef = useTemplateRef<HTMLDivElement>('bottomSentinel')
const injectedScrollContainer = inject(chatScrollContainerKey, undefined)
@@ -72,6 +77,7 @@ const bottomSentinelVisible = useElementVisibility(bottomSentinelRef, {
scrollTarget: effectiveScrollTarget,
})
const { trigger } = useWebHaptics()
const { isMobile } = useBreakpoints()
const shouldDisableDropdownMenu = computed(() => (isStageWeb() || isStageCapacitor()) && isMobile.value)
@@ -147,13 +153,147 @@ function handleContextMenuOpenChange(open: boolean) {
function setMeasuredElement(element: Element | ComponentPublicInstance | null) {
measuredElementRef.value = element instanceof HTMLElement ? element : null
}
function useTouching(element: MaybeComputedElementRef) {
const elementRef = toRef(element)
const pressStartTime = ref(0)
const pressNow = ref(0)
const { resume, pause } = useIntervalFn(() => pressNow.value = Date.now(), 50)
const isTouching = ref(false)
const pressedFor = computed(() => {
if (!isTouching.value || pressStartTime.value === 0)
return 0
const result = pressNow.value - pressStartTime.value
if (result < 0)
return 0
return result
})
function handleTouchStart() {
isTouching.value = true
pressStartTime.value = Date.now()
resume()
}
function handleTouchMove() {
isTouching.value = true
}
function handleTouchEnd() {
isTouching.value = false
pressStartTime.value = 0
pause()
}
function handleTouchCancel() {
isTouching.value = false
pressStartTime.value = 0
pause()
}
watch(elementRef, (newElement) => {
if (newElement) {
const el = newElement as HTMLElement
el.addEventListener('touchstart', handleTouchStart, { passive: true })
el.addEventListener('touchmove', handleTouchMove, { passive: true })
el.addEventListener('touchend', handleTouchEnd, { passive: true })
el.addEventListener('touchcancel', handleTouchCancel, { passive: true })
}
else if (elementRef.value) {
const el = elementRef.value as HTMLElement
el.removeEventListener('touchstart', handleTouchStart)
el.removeEventListener('touchmove', handleTouchMove)
el.removeEventListener('touchend', handleTouchEnd)
el.removeEventListener('touchcancel', handleTouchCancel)
}
}, { immediate: true })
return {
isTouching,
pressedFor,
}
}
function useSetTimeoutFn(fn: () => void, options?: { delay?: number, onClear?: () => void }) {
let timeoutId: ReturnType<typeof setTimeout> | null = null
const delay = options?.delay ?? 1000
function trigger(options?: { delay?: number }) {
if (timeoutId !== null)
return
const effectiveDelay = options?.delay ?? delay
timeoutId = setTimeout(() => {
fn()
timeoutId = null
}, effectiveDelay)
}
function clear() {
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
options?.onClear?.()
}
}
return {
trigger,
clear,
}
}
const { isTouching } = useTouching(contextMenuContainerElementRef)
const pressedAnimatable = reactive({ scale: 100 })
const tl = createTimeline({ defaults: { duration: 500, autoplay: false } })
.add(pressedAnimatable, { scale: 90, ease: 'inOut', autoplay: false })
.reset()
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()
}
triggerTimer()
}
else {
tl.reset()
clearTimer()
}
})
</script>
<template>
<ContextMenuRoot @update:open="handleContextMenuOpenChange">
<ContextMenuTrigger as-child>
<div
:class="['group/chat-action relative w-fit']"
ref="contextMenuContainer"
:class="[
'group/chat-action relative w-fit',
'transition-transform duration-150 ease-in-out',
]"
:style="{
transform: `scale(${pressedAnimatable.scale / 100})`,
}"
>
<div
ref="topSentinel"
@@ -231,7 +371,10 @@ function setMeasuredElement(element: Element | ComponentPublicInstance | null) {
]"
:style="floatingTriggerStyle"
>
<DropdownMenuTrigger as-child>
<DropdownMenuTrigger
v-if="!shouldDisableDropdownMenu"
as-child
>
<button
:class="[
'pointer-events-auto h-8 w-8 flex items-center justify-center rounded-lg',
@@ -275,8 +418,9 @@ function setMeasuredElement(element: Element | ComponentPublicInstance | null) {
<ContextMenuPortal>
<ContextMenuContent
:class="contentClasses"
:side-offset="6"
:class="[
...contentClasses,
]"
>
<ContextMenuItem
v-for="item in menuItems"
@@ -289,8 +433,14 @@ function setMeasuredElement(element: Element | ComponentPublicInstance | null) {
]"
@select="() => void handleAction(item.action)"
>
<div :class="[item.icon, 'text-xs']" />
<span>{{ item.label }}</span>
<div
:class="[
item.icon, 'text-xs',
]"
/>
<span>
{{ item.label }}
</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ChatAssistantMessage, ChatHistoryItem, ChatSlices, ChatSlicesText } from '../../../../types/chat'
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { computed } from 'vue'
import ChatResponsePart from './response-part.vue'
@@ -64,7 +65,10 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
:ref="setMeasuredElement"
flex="~ col" shadow="sm primary-200/50 dark:none"
min-w-20 rounded-xl h="unset <sm:fit"
:class="boxClasses"
:class="[
boxClasses,
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
]"
>
<div>
<span text-sm text="black/60 dark:white/65" font-normal class="inline <sm:hidden">{{ label }}</span>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ChatHistoryItem, ErrorMessage } from '../../../../types/chat'
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { computed } from 'vue'
import { MarkdownRenderer } from '../../../markdown'
@@ -41,8 +42,11 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
:ref="setMeasuredElement"
flex="~ col" shadow="sm violet-200/50 dark:none"
min-w-20 rounded-xl h="unset <sm:fit"
class="bg-violet-100/80 dark:bg-violet-950/80"
:class="boxClasses"
:class="[
boxClasses,
'bg-violet-100/80 dark:bg-violet-950/80',
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
]"
>
<div flex="~ row" gap-2>
<div flex-1 class="inline <sm:hidden">
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ChatHistoryItem, ChatMessage } from '../../../../types/chat'
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { computed } from 'vue'
import { MarkdownRenderer } from '../../../markdown'
@@ -60,7 +61,10 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
:ref="setMeasuredElement"
flex="~ col" shadow="sm neutral-200/50 dark:none"
min-w-20 rounded-xl h="unset <sm:fit"
:class="boxClasses"
:class="[
boxClasses,
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
]"
>
<div>
<span text-sm text="black/60 dark:white/65" font-normal class="inline <sm:hidden">{{ label }}</span>