131 lines
3.0 KiB
Vue
131 lines
3.0 KiB
Vue
<!-- Generic right-click style picker. Mounted once in App.vue via Teleport. -->
|
|
<template>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="state.visible"
|
|
ref="menuEl"
|
|
class="style-ctx-menu"
|
|
:style="menuPos"
|
|
@click.stop
|
|
>
|
|
<div class="ctx-title">{{ state.title }}</div>
|
|
<template v-for="item in state.items" :key="item.value">
|
|
<div v-if="item.isHeader" class="ctx-header">{{ item.label }}</div>
|
|
<button
|
|
v-else
|
|
class="ctx-item"
|
|
:class="{ 'ctx-item--active': item.value === state.current }"
|
|
@click="pick(item.value)"
|
|
>
|
|
<span v-if="item.swatch" class="ctx-swatch" :style="{ background: item.swatch }" />
|
|
{{ item.label }}
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
import { useContextMenu, closeContextMenu } from '@/composables/useContextMenu';
|
|
|
|
const { state } = useContextMenu();
|
|
const menuEl = ref<HTMLElement | null>(null);
|
|
|
|
const menuPos = computed(() => ({
|
|
top: `${Math.min(state.y, window.innerHeight - 260)}px`,
|
|
left: `${Math.min(state.x, window.innerWidth - 175)}px`,
|
|
}));
|
|
|
|
function pick(value: string): void {
|
|
state.onSelect(value);
|
|
closeContextMenu();
|
|
}
|
|
|
|
function onMouseDown(e: MouseEvent): void {
|
|
if (state.visible && menuEl.value && !menuEl.value.contains(e.target as Node)) {
|
|
closeContextMenu();
|
|
}
|
|
}
|
|
function onKeyDown(e: KeyboardEvent): void {
|
|
if (e.key === 'Escape') closeContextMenu();
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('mousedown', onMouseDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
});
|
|
onUnmounted(() => {
|
|
document.removeEventListener('mousedown', onMouseDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.style-ctx-menu {
|
|
position: fixed;
|
|
z-index: 9999;
|
|
min-width: 160px;
|
|
background: #111118;
|
|
border: 1px solid #2a2a3a;
|
|
border-radius: 6px;
|
|
box-shadow: 0 8px 32px #000a, 0 0 0 1px #ffffff08;
|
|
padding: 4px 0;
|
|
font-family: Arial, sans-serif;
|
|
}
|
|
|
|
.ctx-title {
|
|
font-size: 10px;
|
|
color: #44445a;
|
|
padding: 4px 12px 3px;
|
|
letter-spacing: 0.5px;
|
|
text-transform: uppercase;
|
|
border-bottom: 1px solid #1e1e2a;
|
|
margin-bottom: 3px;
|
|
}
|
|
|
|
.ctx-header {
|
|
font-size: 9px;
|
|
color: #33334a;
|
|
padding: 6px 12px 2px;
|
|
letter-spacing: 0.5px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.ctx-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
padding: 5px 12px;
|
|
background: none;
|
|
border: none;
|
|
color: #9999bb;
|
|
font-family: Arial, sans-serif;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background 0.1s, color 0.1s;
|
|
}
|
|
.ctx-item:hover {
|
|
background: #1a1a28;
|
|
color: #ffffff;
|
|
}
|
|
.ctx-item--active {
|
|
color: #00ddff;
|
|
}
|
|
.ctx-item--active::after {
|
|
content: '✓';
|
|
margin-left: auto;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.ctx-swatch {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
border: 1px solid #ffffff22;
|
|
}
|
|
</style>
|