feat(chat): add right-click context menus on messages (M8.9)
Reusable ContextMenu.vue glass-card component positioned at cursor. Messages get Copy, Reply, Edit (user), Regenerate (assistant), and Branch from here options. Closes on Escape or outside click. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
37e8aec920
commit
cc5ecf3091
@@ -3,6 +3,7 @@
|
||||
class="group/msg flex animate-fade-up-fast"
|
||||
:class="isUser ? 'justify-end' : 'justify-start'"
|
||||
:style="{ animationDelay: `${index * 30}ms` }"
|
||||
@contextmenu.prevent="openContextMenu"
|
||||
>
|
||||
<div class="relative max-w-[85%] md:max-w-[75%]">
|
||||
<!-- Action buttons (hover on desktop, always visible on touch) -->
|
||||
@@ -248,12 +249,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context menu -->
|
||||
<ContextMenu ref="contextMenuRef">
|
||||
<ContextMenuItem v-if="isUser" @click="startEditingFromMenu">Edit</ContextMenuItem>
|
||||
<ContextMenuItem v-if="!isUser" @click="emitRegenerate">Regenerate</ContextMenuItem>
|
||||
<ContextMenuItem @click="emitReply">Reply</ContextMenuItem>
|
||||
<ContextMenuItem v-if="!isUser" @click="emitBranch">Branch from here</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyContent">Copy</ContextMenuItem>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenuItem from '@/components/ui/ContextMenuItem.vue'
|
||||
import type { Message, WebSearchResult } from '@aiui/core/types/message'
|
||||
import type { Film, Song, Podcast, Book, TVSeries, ImageItem, Place } from '@aiui/core/types/content'
|
||||
import { useContentPanel, type MagazineSection } from '@/composables/useContentPanel'
|
||||
@@ -497,4 +509,36 @@ function handleBubbleClick() {
|
||||
}
|
||||
if (hasContext.value) openPanel()
|
||||
}
|
||||
|
||||
// Context menu
|
||||
const contextMenuRef = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
|
||||
function openContextMenu(e: MouseEvent) {
|
||||
contextMenuRef.value?.open(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
function startEditingFromMenu() {
|
||||
contextMenuRef.value?.close()
|
||||
startEditing()
|
||||
}
|
||||
|
||||
function emitRegenerate() {
|
||||
contextMenuRef.value?.close()
|
||||
emit('regenerate')
|
||||
}
|
||||
|
||||
function emitReply() {
|
||||
contextMenuRef.value?.close()
|
||||
emit('reply', props.message.id, props.message.content)
|
||||
}
|
||||
|
||||
function emitBranch() {
|
||||
contextMenuRef.value?.close()
|
||||
emit('branch', props.message.id)
|
||||
}
|
||||
|
||||
function copyContent() {
|
||||
contextMenuRef.value?.close()
|
||||
navigator.clipboard.writeText(props.message.content).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[9998]"
|
||||
aria-hidden="true"
|
||||
@click="close"
|
||||
@contextmenu.prevent="close"
|
||||
/>
|
||||
<Transition name="context-menu">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed z-[9999] glass-card p-1.5 rounded-xl shadow-2xl animate-scale-in min-w-[140px]"
|
||||
:style="positionStyle"
|
||||
role="menu"
|
||||
@click.stop
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const x = ref(0)
|
||||
const y = ref(0)
|
||||
|
||||
const positionStyle = computed(() => {
|
||||
// Keep menu within viewport
|
||||
const menuWidth = 160
|
||||
const menuHeight = 200
|
||||
const adjustedX = Math.min(x.value, window.innerWidth - menuWidth - 8)
|
||||
const adjustedY = Math.min(y.value, window.innerHeight - menuHeight - 8)
|
||||
return {
|
||||
left: `${Math.max(8, adjustedX)}px`,
|
||||
top: `${Math.max(8, adjustedY)}px`,
|
||||
}
|
||||
})
|
||||
|
||||
function open(clientX: number, clientY: number) {
|
||||
x.value = clientX
|
||||
y.value = clientY
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && isOpen.value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
})
|
||||
|
||||
defineExpose({ open, close, isOpen })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu-enter-active {
|
||||
transition: all 0.15s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.context-menu-leave-active {
|
||||
transition: all 0.1s ease-in;
|
||||
}
|
||||
.context-menu-enter-from,
|
||||
.context-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<button
|
||||
class="w-full text-left px-3 py-2 rounded-lg text-xs transition-all flex items-center gap-2"
|
||||
:class="destructive
|
||||
? 'text-red-400/80 hover:text-red-400 hover:bg-white/10'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/10'"
|
||||
role="menuitem"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
destructive?: boolean
|
||||
}>(),
|
||||
{ destructive: false }
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user