193 lines
6.8 KiB
Vue
193 lines
6.8 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
ContextMenuContent,
|
||
ContextMenuItem,
|
||
ContextMenuSeparator,
|
||
ContextMenuSub,
|
||
ContextMenuSubTrigger,
|
||
ContextMenuSubContent,
|
||
ContextMenuPortal
|
||
} from 'reka-ui'
|
||
import IconCombine from '~icons/lucide/combine'
|
||
import IconCopyMinus from '~icons/lucide/copy-minus'
|
||
import IconCopyX from '~icons/lucide/copy-x'
|
||
import IconListCollapse from '~icons/lucide/list-collapse'
|
||
import IconSpline from '~icons/lucide/spline'
|
||
import IconTypeOutline from '~icons/lucide/type-outline'
|
||
import IconSquaresIntersect from '~icons/lucide/squares-intersect'
|
||
import {
|
||
vTestId,
|
||
useEditorCommands,
|
||
useI18n,
|
||
useMenuModel,
|
||
useSelectionState,
|
||
editorCommandMetadata,
|
||
formatShortcut
|
||
} from '@open-pencil/vue'
|
||
import type { Component } from 'vue'
|
||
import type { EditorCommandId } from '@open-pencil/vue'
|
||
|
||
import { useEditorStore } from '@/app/editor/active-store'
|
||
import { appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
|
||
import { createCanvasMenuActions } from '@/app/editor/canvas/menu/actions'
|
||
import { useCanvasContextMenu } from '@/app/editor/canvas/menu/context'
|
||
import { canvasMenuItemClass, canvasMenuShortcutClass } from '@/app/editor/canvas/menu/model'
|
||
import AppShortcutText from '@/components/ui/AppShortcutText.vue'
|
||
import { menu, useMenuUI } from '@/components/ui/menu'
|
||
|
||
const store = useEditorStore()
|
||
|
||
const { editor, selectedIds, hasSelection } = useSelectionState()
|
||
const { getCommand } = useEditorCommands()
|
||
const { canvasMenu } = useMenuModel()
|
||
const { menu: t } = useI18n()
|
||
|
||
const canvasMenuActions = createCanvasMenuActions(store, selectedIds)
|
||
const { execCommand } = canvasMenuActions
|
||
const contextMenu = useCanvasContextMenu(canvasMenu, hasSelection, editor, canvasMenuActions, t)
|
||
|
||
const menuCls = useMenuUI({
|
||
content: 'min-w-56 shadow-[0_8px_30px_rgb(0_0_0/0.4)] animate-in fade-in zoom-in-95',
|
||
separator: 'my-1'
|
||
})
|
||
const componentMenu = menu({ tone: 'component' })
|
||
|
||
const cls = {
|
||
menu: menuCls.content,
|
||
submenu: menuCls.content.replace('min-w-56', 'min-w-0 w-max'),
|
||
item: menuCls.item,
|
||
component: componentMenu.item(),
|
||
sep: menuCls.separator
|
||
}
|
||
|
||
const booleanCommandIcons = {
|
||
'selection.booleanUnion': IconCombine,
|
||
'selection.booleanSubtract': IconCopyMinus,
|
||
'selection.booleanIntersect': IconSquaresIntersect,
|
||
'selection.booleanExclude': IconCopyX,
|
||
'selection.flatten': IconListCollapse,
|
||
'selection.outlineText': IconTypeOutline,
|
||
'selection.outlineStroke': IconSpline
|
||
} satisfies Partial<Record<EditorCommandId, Component>>
|
||
|
||
function contextCommandTestId(id: EditorCommandId | undefined): string | undefined {
|
||
return id ? editorCommandMetadata(id).contextTestId : undefined
|
||
}
|
||
|
||
function contextCommandIcon(id: EditorCommandId | undefined): Component | undefined {
|
||
if (!id) return undefined
|
||
return (booleanCommandIcons as Partial<Record<EditorCommandId, Component>>)[id]
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<ContextMenuContent :class="cls.menu" :side-offset="2" align="start">
|
||
<ContextMenuItem
|
||
data-test-id="context-copy"
|
||
:class="cls.item"
|
||
:disabled="!hasSelection"
|
||
@select="execCommand('copy')"
|
||
>
|
||
<span>{{ t.copy }}</span
|
||
><AppShortcutText>{{ appMenuShortcutLabel('copy') }}</AppShortcutText>
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
data-test-id="context-cut"
|
||
:class="cls.item"
|
||
:disabled="!hasSelection"
|
||
@select="execCommand('cut')"
|
||
>
|
||
<span>{{ t.cut }}</span
|
||
><AppShortcutText>{{ appMenuShortcutLabel('cut') }}</AppShortcutText>
|
||
</ContextMenuItem>
|
||
<ContextMenuItem data-test-id="context-paste" :class="cls.item" @select="execCommand('paste')">
|
||
<span>{{ t.pasteHere }}</span
|
||
><AppShortcutText>{{ appMenuShortcutLabel('paste') }}</AppShortcutText>
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
data-test-id="context-paste-to-replace"
|
||
:class="cls.item"
|
||
:disabled="!hasSelection"
|
||
@select="canvasMenuActions.pasteToReplace"
|
||
>
|
||
<span>{{ t.pasteToReplace }}</span>
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
data-test-id="context-duplicate"
|
||
:class="cls.item"
|
||
:disabled="!hasSelection"
|
||
@select="getCommand('selection.duplicate').run()"
|
||
>
|
||
<span>{{ getCommand('selection.duplicate').label }}</span
|
||
><AppShortcutText>{{
|
||
formatShortcut(editorCommandMetadata('selection.duplicate').shortcut)
|
||
}}</AppShortcutText>
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
data-test-id="context-delete"
|
||
:class="cls.item"
|
||
:disabled="!hasSelection"
|
||
@select="getCommand('selection.delete').run()"
|
||
>
|
||
<span>{{ getCommand('selection.delete').label }}</span
|
||
><AppShortcutText>{{ editorCommandMetadata('selection.delete').shortcut }}</AppShortcutText>
|
||
</ContextMenuItem>
|
||
|
||
<template v-for="(item, i) in contextMenu" :key="`menu-${i}`">
|
||
<ContextMenuSeparator v-if="item.separator" :class="cls.sep" />
|
||
<ContextMenuSub v-else-if="item.sub">
|
||
<ContextMenuSubTrigger v-test-id="item.testId" :class="cls.item">
|
||
<span>{{ item.label }}</span
|
||
><span class="text-sm text-muted">›</span>
|
||
</ContextMenuSubTrigger>
|
||
<ContextMenuPortal>
|
||
<ContextMenuSubContent :class="cls.submenu">
|
||
<ContextMenuItem
|
||
v-for="(sub, j) in item.sub"
|
||
:key="j"
|
||
:class="cls.item"
|
||
v-test-id="sub.separator ? undefined : sub.testId"
|
||
:disabled="sub.separator ? true : sub.disabled"
|
||
@select="!sub.separator && sub.action?.()"
|
||
>
|
||
<template v-if="!sub.separator">
|
||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||
<component
|
||
:is="contextCommandIcon(sub.id)"
|
||
v-if="contextCommandIcon(sub.id)"
|
||
class="size-3.5 shrink-0 text-muted"
|
||
/>
|
||
<span class="truncate">{{ sub.label }}</span>
|
||
</span>
|
||
<AppShortcutText v-if="sub.shortcut">{{ sub.shortcut }}</AppShortcutText>
|
||
</template>
|
||
</ContextMenuItem>
|
||
</ContextMenuSubContent>
|
||
</ContextMenuPortal>
|
||
</ContextMenuSub>
|
||
<ContextMenuItem
|
||
v-else
|
||
v-test-id="contextCommandTestId(item.id)"
|
||
:class="canvasMenuItemClass(item.label, cls)"
|
||
:disabled="item.disabled"
|
||
@select="item.action?.()"
|
||
>
|
||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||
<component
|
||
:is="contextCommandIcon(item.id)"
|
||
v-if="contextCommandIcon(item.id)"
|
||
class="size-3.5 shrink-0 text-muted"
|
||
/>
|
||
<span class="truncate">{{ item.label }}</span>
|
||
</span>
|
||
<span
|
||
v-if="item.shortcut"
|
||
class="text-[11px]"
|
||
:class="canvasMenuShortcutClass(item.label)"
|
||
>{{ item.shortcut }}</span
|
||
>
|
||
</ContextMenuItem>
|
||
</template>
|
||
</ContextMenuContent>
|
||
</template>
|