Files
geo/apps/admin-web/src/components/editor-common/EditorActionMenu.vue
T

369 lines
8.7 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type Component, type CSSProperties } from "vue";
import { DownOutlined } from "@ant-design/icons-vue";
type EditorActionMenuItem = {
key: string;
label: string;
icon?: Component;
danger?: boolean;
disabled?: boolean;
active?: boolean;
children?: EditorActionMenuItem[];
};
type EditorActionMenuGroup = {
key: string;
columns?: number;
items: EditorActionMenuItem[];
};
const props = defineProps<{
x: number;
y: number;
groups: EditorActionMenuGroup[];
variant?: "tile" | "inline";
minWidth?: string;
maxWidth?: string;
}>();
const emit = defineEmits<{
select: [key: string];
}>();
const menuRef = ref<HTMLElement | null>(null);
const frame = ref({
x: props.x,
y: props.y,
});
const resolvedVariant = computed(() => props.variant ?? "inline");
const menuStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {
left: `${frame.value.x}px`,
top: `${frame.value.y}px`,
};
if (props.minWidth) {
style.minWidth = props.minWidth;
}
if (props.maxWidth) {
style.maxWidth = props.maxWidth;
}
return style;
});
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function syncMenuFrame(): void {
void nextTick(() => {
const menu = menuRef.value;
if (!menu) {
return;
}
const offset = 16;
frame.value = {
x: clamp(props.x, offset, Math.max(offset, window.innerWidth - menu.offsetWidth - offset)),
y: clamp(props.y, offset, Math.max(offset, window.innerHeight - menu.offsetHeight - offset)),
};
});
}
function handleWindowResize(): void {
syncMenuFrame();
}
function handleSelect(item: EditorActionMenuItem): void {
if (item.disabled) {
return;
}
emit("select", item.key);
}
function resolveGroupStyle(group: EditorActionMenuGroup): CSSProperties {
return {
gridTemplateColumns: `repeat(${Math.max(group.columns ?? group.items.length, 1)}, minmax(0, 1fr))`,
};
}
watch(
() => ({
x: props.x,
y: props.y,
groups: props.groups,
minWidth: props.minWidth,
maxWidth: props.maxWidth,
variant: resolvedVariant.value,
}),
() => {
syncMenuFrame();
},
{
immediate: true,
deep: true,
flush: "post",
},
);
onMounted(() => {
window.addEventListener("resize", handleWindowResize);
syncMenuFrame();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleWindowResize);
});
</script>
<template>
<Teleport to="body">
<div
ref="menuRef"
class="editor-action-menu"
:class="`editor-action-menu--${resolvedVariant}`"
:style="menuStyle"
@pointerdown.stop
@contextmenu.prevent
>
<template v-for="(group, groupIndex) in groups" :key="group.key">
<div class="editor-action-menu__group" :style="resolveGroupStyle(group)">
<template v-for="item in group.items" :key="item.key">
<a-dropdown
v-if="item.children && item.children.length > 0"
:trigger="['click']"
overlay-class-name="editor-action-menu__dropdown-overlay"
:overlay-style="{ zIndex: 2000 }"
>
<button
type="button"
class="editor-action-menu__action"
:class="{
'editor-action-menu__action--active': item.active,
'editor-action-menu__action--danger': item.danger,
}"
:disabled="item.disabled"
>
<component :is="item.icon" v-if="item.icon" />
<span v-if="resolvedVariant === 'tile'">{{ item.label }}</span>
<DownOutlined class="editor-action-menu__dropdown-icon" />
</button>
<template #overlay>
<a-menu>
<a-menu-item
v-for="child in item.children"
:key="child.key"
:disabled="child.disabled"
@click="handleSelect(child)"
>
<span
class="editor-action-menu__dropdown-item"
:class="{ 'editor-action-menu__dropdown-item--danger': child.danger }"
>
<component :is="child.icon" v-if="child.icon" />
<span>{{ child.label }}</span>
</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<button
v-else
type="button"
class="editor-action-menu__action"
:class="{
'editor-action-menu__action--active': item.active,
'editor-action-menu__action--danger': item.danger,
}"
:disabled="item.disabled"
@click="handleSelect(item)"
>
<component :is="item.icon" v-if="item.icon" />
<span v-if="resolvedVariant === 'tile'">{{ item.label }}</span>
</button>
</template>
</div>
<div v-if="groupIndex < groups.length - 1" class="editor-action-menu__divider"></div>
</template>
</div>
</Teleport>
</template>
<style scoped>
.editor-action-menu {
position: fixed;
z-index: 1200;
display: flex;
flex-direction: column;
}
.editor-action-menu--tile {
gap: 16px;
padding: 16px;
border: 1px solid #edf2fa;
border-radius: 16px;
background: #ffffff;
box-shadow:
0 20px 48px rgba(15, 23, 42, 0.08),
0 6px 16px rgba(15, 23, 42, 0.04);
}
.editor-action-menu--inline {
gap: 10px;
padding: 12px;
border: 1px solid #e8eef8;
border-radius: 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 251, 255, 0.98) 100%);
box-shadow:
0 20px 48px rgba(15, 23, 42, 0.1),
0 6px 18px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(14px);
}
.editor-action-menu__group {
display: grid;
gap: 12px;
}
.editor-action-menu--inline .editor-action-menu__group {
gap: 10px;
}
.editor-action-menu__divider {
height: 1px;
background: #f0f3f8;
}
.editor-action-menu__action {
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
}
.editor-action-menu__action span {
line-height: 1.4;
}
.editor-action-menu__action :deep(svg) {
transition: color 0.2s ease;
}
.editor-action-menu--tile .editor-action-menu__action {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 86px;
padding: 12px 10px;
border-radius: 12px;
background: #f7f9fa;
color: #3f4a5c;
}
.editor-action-menu--tile .editor-action-menu__action span {
font-size: 13px;
font-weight: 600;
text-align: center;
}
.editor-action-menu--tile .editor-action-menu__action :deep(svg) {
font-size: 22px;
color: #5c6b81;
}
.editor-action-menu--tile .editor-action-menu__action:hover:not(:disabled) {
background: #eff4ff;
border-color: #c2d6ff;
color: #245bdb;
}
.editor-action-menu--tile .editor-action-menu__action:hover:not(:disabled) :deep(svg) {
color: #245bdb;
}
.editor-action-menu--inline .editor-action-menu__action {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 36px;
padding: 0 14px;
border-radius: 12px;
background: #f7f9fc;
color: #344054;
font-size: 13px;
font-weight: 600;
}
.editor-action-menu--inline .editor-action-menu__action :deep(svg) {
font-size: 18px;
color: #52607a;
}
.editor-action-menu--inline .editor-action-menu__action:hover:not(:disabled),
.editor-action-menu--inline .editor-action-menu__action--active {
border-color: #c8d8ff;
background: #edf4ff;
color: #245bdb;
transform: translateY(-1px);
}
.editor-action-menu--inline .editor-action-menu__action:hover:not(:disabled) :deep(svg),
.editor-action-menu--inline .editor-action-menu__action--active :deep(svg) {
color: #245bdb;
}
.editor-action-menu__action--danger:hover:not(:disabled) {
border-color: #ffd2cf;
background: #fff1f0;
color: #cf1322;
}
.editor-action-menu__action--danger:hover:not(:disabled) :deep(svg) {
color: #cf1322;
}
.editor-action-menu--tile .editor-action-menu__action:disabled {
opacity: 0.46;
}
.editor-action-menu__action:disabled {
cursor: not-allowed;
}
.editor-action-menu__dropdown-icon {
font-size: 10px !important;
color: #8c9eb5 !important;
transition: transform 0.2s;
}
.editor-action-menu__dropdown-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
color: #3f4a5c;
}
.editor-action-menu__dropdown-item--danger {
color: #cf1322;
}
.editor-action-menu__dropdown-item--danger :deep(svg) {
color: #cf1322;
}
:global(.editor-action-menu__dropdown-overlay) {
z-index: 2000;
margin-top: 10px;
}
</style>