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
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>
|
|
|