You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

177 lines
3.9 KiB

<script setup lang="ts">
export interface ContextMenuItem {
key: string
label: string
icon?: string
danger?: boolean
divider?: boolean
}
const props = defineProps<{
visible: boolean
x: number
y: number
items: ContextMenuItem[]
}>()
const emit = defineEmits<{
select: [key: string]
close: []
}>()
const menuRef = ref<HTMLElement | null>(null)
const adjustedX = ref(0)
const adjustedY = ref(0)
async function adjustPosition() {
await nextTick()
if (!menuRef.value) return
const rect = menuRef.value.getBoundingClientRect()
const { innerWidth, innerHeight } = window
adjustedX.value = props.x + rect.width > innerWidth ? innerWidth - rect.width - 8 : props.x
adjustedY.value = props.y + rect.height > innerHeight ? innerHeight - rect.height - 8 : props.y
}
watch(() => [props.visible, props.x, props.y], () => {
if (props.visible) adjustPosition()
})
function onItemClick(item: ContextMenuItem) {
if (item.divider) return
emit('select', item.key)
emit('close')
}
function onOutsideClick(event: MouseEvent) {
if (!menuRef.value) return
if (!menuRef.value.contains(event.target as Node)) {
emit('close')
}
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') emit('close')
}
watch(() => props.visible, (v) => {
if (v) {
window.addEventListener('mousedown', onOutsideClick, true)
window.addEventListener('keydown', onKeydown)
window.addEventListener('contextmenu', onOutsideClick, true)
} else {
window.removeEventListener('mousedown', onOutsideClick, true)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('contextmenu', onOutsideClick, true)
}
})
onUnmounted(() => {
window.removeEventListener('mousedown', onOutsideClick, true)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('contextmenu', onOutsideClick, true)
})
</script>
<template>
<Teleport to="body">
<Transition name="ctx-menu">
<div
v-if="visible"
ref="menuRef"
class="context-menu"
:style="{ left: `${adjustedX}px`, top: `${adjustedY}px` }"
@contextmenu.prevent
>
<template v-for="(item, i) in items" :key="item.key || i">
<div v-if="item.divider" class="ctx-divider" />
<button
v-else
class="ctx-item"
:class="{ danger: item.danger }"
@click="onItemClick(item)"
>
<Icon v-if="item.icon" :name="item.icon" class="ctx-icon" />
<span class="ctx-label">{{ item.label }}</span>
</button>
</template>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.context-menu {
position: fixed;
z-index: 1000;
min-width: 180px;
background: var(--color-canvas);
border: 1px solid var(--color-hairline);
border-radius: 10px;
padding: 6px;
box-shadow: 0 12px 36px rgba(20, 20, 19, 0.15), 0 2px 8px rgba(20, 20, 19, 0.08);
display: flex;
flex-direction: column;
gap: 2px;
}
.ctx-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
color: var(--color-body);
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: background 0.12s ease, color 0.12s ease;
}
.ctx-item:hover {
background: var(--color-surface-card);
color: var(--color-ink);
}
.ctx-item.danger {
color: #c64545;
}
.ctx-item.danger:hover {
background: rgba(198, 69, 69, 0.08);
color: #c64545;
}
.ctx-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
opacity: 0.8;
}
.ctx-label {
flex: 1;
}
.ctx-divider {
height: 1px;
background: var(--color-hairline-soft);
margin: 4px 0;
}
.ctx-menu-enter-active,
.ctx-menu-leave-active {
transition: opacity 0.14s ease, transform 0.14s ease;
transform-origin: top left;
}
.ctx-menu-enter-from,
.ctx-menu-leave-to {
opacity: 0;
transform: scale(0.96);
}
</style>