feat(stage-ui): add copy feedback to chat action menu (#1689)
--------- Co-authored-by: Neko <neko@ayaka.moe> Co-authored-by-agent: Unknown <unknown@example.com>
This commit is contained in:
+37
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createChatActionMenuItems } from './menu-items'
|
||||
import { createChatActionMenuItems, createChatActionMenuTriggerState } from './menu-items'
|
||||
|
||||
/**
|
||||
* @example
|
||||
@@ -44,3 +44,39 @@ describe('createChatActionMenuItems', () => {
|
||||
expect(items.map(item => item.action)).toEqual(['copy', 'delete'])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* describe('createChatActionMenuTriggerState', () => {
|
||||
* it('uses a success checkmark while copy feedback is active', () => {})
|
||||
* })
|
||||
*/
|
||||
describe('createChatActionMenuTriggerState', () => {
|
||||
/**
|
||||
* @example
|
||||
* it('uses a success checkmark while copy feedback is active', () => {
|
||||
* const state = createChatActionMenuTriggerState({ copyFeedbackActive: true })
|
||||
* expect(state.tone).toBe('success')
|
||||
* })
|
||||
*/
|
||||
it('uses a success checkmark while copy feedback is active', () => {
|
||||
const state = createChatActionMenuTriggerState({ copyFeedbackActive: true })
|
||||
|
||||
expect(state.icon).toBe('i-carbon:checkmark')
|
||||
expect(state.tone).toBe('success')
|
||||
})
|
||||
|
||||
/**
|
||||
* @example
|
||||
* it('uses the default menu icon without copy feedback', () => {
|
||||
* const state = createChatActionMenuTriggerState({})
|
||||
* expect(state.tone).toBe('default')
|
||||
* })
|
||||
*/
|
||||
it('uses the default menu icon without copy feedback', () => {
|
||||
const state = createChatActionMenuTriggerState({})
|
||||
|
||||
expect(state.icon).toBe('i-solar:menu-dots-bold')
|
||||
expect(state.tone).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { default as ChatActionMenu } from './index.vue'
|
||||
export type { ChatActionMenuAction, ChatActionMenuItem } from './menu-items'
|
||||
export { createChatActionMenuItems } from './menu-items'
|
||||
|
||||
export type { ChatActionMenuAction, ChatActionMenuItem, ChatActionMenuTriggerState } from './menu-items'
|
||||
|
||||
export { createChatActionMenuItems, createChatActionMenuTriggerState } from './menu-items'
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import type { ChatActionMenuAction } from '.'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
|
||||
import { useElementVisibility, useIntervalFn } from '@vueuse/core'
|
||||
import { createTimeline } from 'animejs'
|
||||
@@ -24,7 +25,7 @@ import { computed, inject, reactive, ref, shallowRef, toRef, useTemplateRef, wat
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useWebHaptics } from 'web-haptics/vue'
|
||||
|
||||
import { createChatActionMenuItems } from '.'
|
||||
import { createChatActionMenuItems, createChatActionMenuTriggerState } from '.'
|
||||
import { useBreakpoints } from '../../../../../composables/use-breakpoints'
|
||||
import { useElementScroll } from '../../composables/use-element-scroll'
|
||||
import { chatScrollContainerKey } from '../../constants'
|
||||
@@ -61,6 +62,7 @@ const bottomSentinelRef = useTemplateRef<HTMLDivElement>('bottomSentinel')
|
||||
const injectedScrollContainer = inject(chatScrollContainerKey, undefined)
|
||||
const scrollTarget = computed(() => injectedScrollContainer?.value ?? null)
|
||||
const contextMenuOpen = shallowRef(false)
|
||||
const dropdownMenuOpen = shallowRef(false)
|
||||
const {
|
||||
innerHeight,
|
||||
innerTop,
|
||||
@@ -85,6 +87,7 @@ const { trigger } = useWebHaptics()
|
||||
const { isMobile } = useBreakpoints()
|
||||
const { t } = useI18n()
|
||||
const shouldDisableDropdownMenu = computed(() => (isStageWeb() || isStageCapacitor()) && isMobile.value)
|
||||
const copyFeedbackActive = shallowRef(false)
|
||||
|
||||
const menuItems = computed(() => createChatActionMenuItems({
|
||||
canCopy: props.canCopy && props.copyText.trim().length > 0,
|
||||
@@ -92,8 +95,11 @@ const menuItems = computed(() => createChatActionMenuItems({
|
||||
canDelete: props.canDelete,
|
||||
retryLabel: t('stage.chat.actions.retry'),
|
||||
}))
|
||||
const triggerState = computed(() => createChatActionMenuTriggerState({
|
||||
copyFeedbackActive: copyFeedbackActive.value,
|
||||
}))
|
||||
const hasMenuItems = computed(() => menuItems.value.length > 0)
|
||||
const forceVisible = computed(() => contextMenuOpen.value)
|
||||
const forceVisible = computed(() => contextMenuOpen.value || dropdownMenuOpen.value)
|
||||
|
||||
const contentClasses = [
|
||||
'z-10000 min-w-36 rounded-xl p-1 shadow-md outline-none',
|
||||
@@ -122,46 +128,22 @@ const floatingTop = computed(() => {
|
||||
return clamp(relativeInnerMiddle, 0, Math.max(elementHeight.value - buttonSize, 0))
|
||||
})
|
||||
|
||||
const showFloatingTrigger = computed(() => {
|
||||
if (!hasMenuItems.value || !messageIsVisible.value)
|
||||
return false
|
||||
const showFloatingTrigger = computed(() => !topIsVisible.value)
|
||||
|
||||
return !topIsVisible.value || forceVisible.value
|
||||
})
|
||||
|
||||
const floatingTriggerStyle = computed(() => (
|
||||
const triggerStyle = computed(() => (
|
||||
bottomIsVisible.value
|
||||
? undefined
|
||||
: { top: `${floatingTop.value}px` }
|
||||
))
|
||||
|
||||
const inlineTriggerStyle = computed(() => (
|
||||
bottomIsVisible.value
|
||||
? undefined
|
||||
: { top: `${floatingTop.value}px` }
|
||||
))
|
||||
|
||||
async function handleAction(action: ChatActionMenuAction) {
|
||||
if (action === 'copy') {
|
||||
if (props.copyText.trim()) {
|
||||
await navigator.clipboard.writeText(props.copyText)
|
||||
emit('copy')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'retry') {
|
||||
emit('retry')
|
||||
return
|
||||
}
|
||||
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
function handleContextMenuOpenChange(open: boolean) {
|
||||
contextMenuOpen.value = open
|
||||
}
|
||||
|
||||
function handleDropdownMenuOpenChange(open: boolean) {
|
||||
dropdownMenuOpen.value = open
|
||||
}
|
||||
|
||||
function setMeasuredElement(element: Element | ComponentPublicInstance | null) {
|
||||
measuredElementRef.value = element instanceof HTMLElement ? element : null
|
||||
}
|
||||
@@ -265,6 +247,37 @@ function useSetTimeoutFn(fn: () => void, options?: { delay?: number, onClear?: (
|
||||
|
||||
const { isTouching } = useTouching(contextMenuContainerElementRef)
|
||||
|
||||
const { trigger: triggerCopyFeedbackReset, clear: clearCopyFeedbackReset } = useSetTimeoutFn(() => {
|
||||
copyFeedbackActive.value = false
|
||||
}, { delay: 1000 })
|
||||
|
||||
async function handleAction(action: ChatActionMenuAction) {
|
||||
if (action === 'copy') {
|
||||
if (!props.copyText.trim())
|
||||
return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.copyText)
|
||||
copyFeedbackActive.value = true
|
||||
clearCopyFeedbackReset()
|
||||
emit('copy')
|
||||
triggerCopyFeedbackReset()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to copy text:', errorMessageFrom(error) ?? String(error))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'retry') {
|
||||
emit('retry')
|
||||
return
|
||||
}
|
||||
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
const pressedAnimatable = reactive({ scale: 100 })
|
||||
const tl = createTimeline({ defaults: { duration: 500, autoplay: false } })
|
||||
.add(pressedAnimatable, { scale: 90, ease: 'inOut', autoplay: false })
|
||||
@@ -318,28 +331,35 @@ watch(isTouching, (val) => {
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-px opacity-0"
|
||||
/>
|
||||
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuRoot @update:open="handleDropdownMenuOpenChange">
|
||||
<DropdownMenuTrigger
|
||||
v-if="!shouldDisableDropdownMenu"
|
||||
v-if="hasMenuItems && !shouldDisableDropdownMenu"
|
||||
as-child
|
||||
:class="[
|
||||
'absolute z-10 opacity-0 transition-opacity duration-200',
|
||||
'group-hover/chat-action:opacity-100 group-focus-within/chat-action:opacity-100',
|
||||
forceVisible ? 'opacity-100' : '',
|
||||
props.placement === 'left' ? 'left-0 top-0 translate-x-[calc(-100%-8px)]' : 'right-0 top-0 translate-x-[calc(100%+8px)]',
|
||||
props.placement === 'left' ? 'left-0 translate-x-[calc(-100%-8px)]' : 'right-0 translate-x-[calc(100%+8px)]',
|
||||
showFloatingTrigger && bottomIsVisible ? 'bottom-0' : 'top-0',
|
||||
]"
|
||||
:style="inlineTriggerStyle"
|
||||
:style="triggerStyle"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'pointer-events-auto h-8 w-8 flex items-center justify-center rounded-lg',
|
||||
'h-8 w-8 flex items-center justify-center rounded-lg',
|
||||
'bg-white/85 text-neutral-500 backdrop-blur-sm',
|
||||
'dark:bg-neutral-900/85 dark:text-neutral-300',
|
||||
'transition-colors hover:text-primary-500 dark:hover:text-primary-300',
|
||||
]"
|
||||
:aria-label="menuLabel"
|
||||
>
|
||||
<div class="i-solar:menu-dots-bold text-base" />
|
||||
<div
|
||||
:class="[
|
||||
triggerState.icon,
|
||||
'text-base',
|
||||
triggerState.tone === 'success' ? 'text-emerald-600 dark:text-emerald-300' : '',
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -369,62 +389,6 @@ watch(isTouching, (val) => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
|
||||
<DropdownMenuRoot v-if="showFloatingTrigger">
|
||||
<div
|
||||
:class="[
|
||||
'pointer-events-none flex absolute',
|
||||
'group-hover/chat-action:opacity-100 group-focus-within/chat-action:opacity-100',
|
||||
'transition-opacity duration-200',
|
||||
forceVisible ? 'opacity-100' : '',
|
||||
props.placement === 'left' ? 'left-0' : 'right-0',
|
||||
props.placement === 'left' ? 'translate-x-[calc(-100%-8px)]' : 'translate-x-[calc(100%+8px)]',
|
||||
bottomIsVisible ? 'bottom-0' : 'top-0',
|
||||
]"
|
||||
:style="floatingTriggerStyle"
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
v-if="!shouldDisableDropdownMenu"
|
||||
as-child
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'pointer-events-auto h-8 w-8 flex items-center justify-center rounded-lg',
|
||||
'bg-white/85 text-neutral-500 backdrop-blur-sm',
|
||||
'dark:bg-neutral-900/85 dark:text-neutral-300',
|
||||
'transition-colors hover:text-primary-500 dark:hover:text-primary-300',
|
||||
]"
|
||||
:aria-label="menuLabel"
|
||||
>
|
||||
<div class="i-solar:menu-dots-bold text-base" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
:side-offset="6"
|
||||
:class="contentClasses"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
v-for="item in menuItems"
|
||||
:key="`${item.action}-floating`"
|
||||
:class="[
|
||||
...itemClasses,
|
||||
item.danger
|
||||
? 'text-red-500 data-[highlighted]:bg-red-50/80 dark:data-[highlighted]:bg-red-950/40'
|
||||
: '',
|
||||
]"
|
||||
@select="() => void handleAction(item.action)"
|
||||
>
|
||||
<div :class="[item.icon, 'text-xs']" />
|
||||
<span>{{ item.label }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
|
||||
@@ -1,12 +1,57 @@
|
||||
/**
|
||||
* Represents supported chat message action identifiers.
|
||||
*/
|
||||
export type ChatActionMenuAction = 'copy' | 'retry' | 'delete'
|
||||
|
||||
/**
|
||||
* Represents one visible action in a chat message action menu.
|
||||
*/
|
||||
export interface ChatActionMenuItem {
|
||||
/**
|
||||
* Action emitted when the menu item is selected.
|
||||
*/
|
||||
action: ChatActionMenuAction
|
||||
/**
|
||||
* Human-readable menu label.
|
||||
*/
|
||||
label: string
|
||||
/**
|
||||
* UnoCSS Iconify class used for the item icon.
|
||||
*/
|
||||
icon: string
|
||||
/**
|
||||
* Marks destructive actions for danger styling.
|
||||
*/
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the visual state for the compact action menu trigger.
|
||||
*/
|
||||
export interface ChatActionMenuTriggerState {
|
||||
/**
|
||||
* UnoCSS Iconify class used for the trigger icon.
|
||||
*/
|
||||
icon: string
|
||||
/**
|
||||
* Visual tone applied to the trigger icon.
|
||||
*/
|
||||
tone: 'default' | 'success'
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates chat action menu items from action availability flags.
|
||||
*
|
||||
* Use when:
|
||||
* - Rendering dropdown or context menu entries for a chat message
|
||||
* - Keeping action ordering consistent across menu surfaces
|
||||
*
|
||||
* Expects:
|
||||
* - Boolean flags already reflect message capability and visibility rules
|
||||
*
|
||||
* Returns:
|
||||
* - Menu items ordered as copy, retry, delete
|
||||
*/
|
||||
export function createChatActionMenuItems(options: {
|
||||
canCopy: boolean
|
||||
canRetry: boolean
|
||||
@@ -38,3 +83,32 @@ export function createChatActionMenuItems(options: {
|
||||
: null,
|
||||
].filter(Boolean) as ChatActionMenuItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the compact trigger icon state for chat action menus.
|
||||
*
|
||||
* Use when:
|
||||
* - Rendering trigger feedback after a copy action
|
||||
* - Keeping trigger icon and tone selection outside the Vue template
|
||||
*
|
||||
* Expects:
|
||||
* - `copyFeedbackActive` is true only while copy feedback should be visible
|
||||
*
|
||||
* Returns:
|
||||
* - A default menu icon state or a success checkmark state
|
||||
*/
|
||||
export function createChatActionMenuTriggerState(options: {
|
||||
copyFeedbackActive?: boolean
|
||||
}): ChatActionMenuTriggerState {
|
||||
if (options.copyFeedbackActive) {
|
||||
return {
|
||||
icon: 'i-carbon:checkmark',
|
||||
tone: 'success',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: 'i-solar:menu-dots-bold',
|
||||
tone: 'default',
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user