feat(stage-ui): long press / hold to popup chat actions on mobile, plus haptics
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user