refactor(app): reuse menu shortcut metadata

This commit is contained in:
Danila Poyarkov 2026-05-17 14:44:37 +03:00
parent 6c8fd8fc59
commit 1a03c0e8e8
9 changed files with 44 additions and 41 deletions

View file

@ -6,6 +6,8 @@ import { nodeToXPath } from '@open-pencil/core/xpath'
import type { EditorStore } from '@/app/editor/active-store'
import { toast } from '@/app/shell/ui'
export const COPY_AS_PNG_SHORTCUT = 'MOD+SHIFT+C'
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
const bytes = new Uint8Array(data.length)
bytes.set(data)

View file

@ -1,6 +1,6 @@
import { computed } from 'vue'
import { useEditorCommands, useI18n } from '@open-pencil/vue'
import { useEditorCommands, useI18n, formatShortcut } from '@open-pencil/vue'
import type { MenuEntry } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
@ -15,10 +15,6 @@ export interface AppMenuGroup {
items: MenuEntry[]
}
function shortcutLabel(shortcut: string | undefined, mod: string): string | undefined {
return shortcut?.replaceAll('MOD', mod)
}
function isVisible(entry: { target?: string }): boolean {
return entry.target !== 'native'
}
@ -27,7 +23,7 @@ function isSeparator(entry: AppMenuEntry): entry is Extract<AppMenuEntry, { type
return entry.type === 'separator'
}
export function useAppMenu(mod: string) {
export function useAppMenu() {
const store = useEditorStore()
const { menuItem: commandMenuItem } = useEditorCommands()
const { locale, availableLocales, localeLabels, setLocale } = useI18n()
@ -55,6 +51,7 @@ export function useAppMenu(mod: string) {
save: () => void store.saveFigFile(),
'save-as': () => void store.saveFigFileAs(),
'export-selection': () => exportSelection('png'),
cut: () => document.execCommand('cut'),
'export-png': () => exportSelection('png'),
'export-svg': () => exportSelection('svg'),
'export-fig': () => exportSelection('fig'),
@ -110,12 +107,12 @@ export function useAppMenu(mod: string) {
}
if (entry.command) {
return commandMenuItem(entry.command, shortcutLabel(entry.shortcut, mod))
return commandMenuItem(entry.command, formatShortcut(entry.shortcut))
}
return {
label: entry.label,
shortcut: shortcutLabel(entry.shortcut, mod),
shortcut: formatShortcut(entry.shortcut),
action: itemAction(entry),
checked: checked(entry),
onCheckedChange: onCheckedChange(entry),

View file

@ -35,12 +35,12 @@ export const APP_MENU_SCHEMA = [
{ id: 'open', label: 'Open…', shortcut: 'MOD+O', accelerator: 'CmdOrCtrl+O' },
{ type: 'separator' },
{ id: 'save', label: 'Save', shortcut: 'MOD+S', accelerator: 'CmdOrCtrl+S' },
{ id: 'save-as', label: 'Save As…', shortcut: 'MOD+S', accelerator: 'CmdOrCtrl+Shift+S' },
{ id: 'save-as', label: 'Save As…', shortcut: 'MOD+SHIFT+S', accelerator: 'CmdOrCtrl+Shift+S' },
{ type: 'separator' },
{
id: 'export-selection',
label: 'Export Selection',
shortcut: 'MOD+E',
shortcut: 'MOD+SHIFT+E',
accelerator: 'CmdOrCtrl+Shift+E',
sub: [
{ id: 'export-png', label: 'PNG' },
@ -66,12 +66,13 @@ export const APP_MENU_SCHEMA = [
{
id: 'edit.redo',
label: 'Redo',
shortcut: 'MOD+Z',
shortcut: 'MOD+SHIFT+Z',
accelerator: 'CmdOrCtrl+Shift+Z',
command: 'edit.redo'
},
{ type: 'separator' },
{ id: 'copy', label: 'Copy', shortcut: 'MOD+C', accelerator: 'CmdOrCtrl+C' },
{ id: 'cut', label: 'Cut', shortcut: 'MOD+X', accelerator: 'CmdOrCtrl+X' },
{ id: 'paste', label: 'Paste', shortcut: 'MOD+V', accelerator: 'CmdOrCtrl+V' },
{
id: 'selection.duplicate',
@ -158,7 +159,7 @@ export const APP_MENU_SCHEMA = [
{
id: 'selection.ungroup',
label: 'Ungroup Selection',
shortcut: 'MOD+G',
shortcut: 'MOD+SHIFT+G',
accelerator: 'CmdOrCtrl+Shift+G',
command: 'selection.ungroup'
},
@ -166,7 +167,7 @@ export const APP_MENU_SCHEMA = [
{
id: 'selection.createComponent',
label: 'Create Component',
shortcut: 'MOD+K',
shortcut: 'MOD+ALT+K',
accelerator: 'CmdOrCtrl+Alt+K',
command: 'selection.createComponent'
},
@ -211,18 +212,18 @@ export const APP_MENU_SCHEMA = [
{
id: 'selection.wrapInAutoLayout',
label: 'Wrap in Auto Layout',
shortcut: 'A',
shortcut: 'SHIFT+A',
accelerator: 'Shift+A',
command: 'selection.wrapInAutoLayout'
},
{ type: 'separator' },
{ id: 'align-left', label: 'Align Left', shortcut: 'A', accelerator: 'Alt+A' },
{ id: 'align-center', label: 'Align Center', shortcut: 'H', accelerator: 'Alt+H' },
{ id: 'align-right', label: 'Align Right', shortcut: 'D', accelerator: 'Alt+D' },
{ id: 'align-left', label: 'Align Left', shortcut: 'ALT+A', accelerator: 'Alt+A' },
{ id: 'align-center', label: 'Align Center', shortcut: 'ALT+H', accelerator: 'Alt+H' },
{ id: 'align-right', label: 'Align Right', shortcut: 'ALT+D', accelerator: 'Alt+D' },
{ type: 'separator' },
{ id: 'align-top', label: 'Align Top', shortcut: 'W', accelerator: 'Alt+W' },
{ id: 'align-middle', label: 'Align Middle', shortcut: 'V', accelerator: 'Alt+V' },
{ id: 'align-bottom', label: 'Align Bottom', shortcut: 'S', accelerator: 'Alt+S' }
{ id: 'align-top', label: 'Align Top', shortcut: 'ALT+W', accelerator: 'Alt+W' },
{ id: 'align-middle', label: 'Align Middle', shortcut: 'ALT+V', accelerator: 'Alt+V' },
{ id: 'align-bottom', label: 'Align Bottom', shortcut: 'ALT+S', accelerator: 'Alt+S' }
]
}
] satisfies AppMenuGroupSchema[]

View file

@ -27,6 +27,6 @@ export function appMenuTinykeysShortcut(id: string): string | string[] | undefin
const shortcut = appMenuShortcut(id)
return shortcut
?.replaceAll('MOD', '$mod')
.replaceAll('', 'Shift')
.replaceAll('', 'Alt')
.replaceAll('SHIFT', 'Shift')
.replaceAll('ALT', 'Alt')
}

View file

@ -34,7 +34,7 @@ const COMMAND_MENU_IDS = new Set<string>([
export { importFileDialog, openFileDialog }
export { openFileFromPath } from '@/app/shell/menu/files'
function execBrowserCommand(command: 'copy' | 'paste'): void {
function execBrowserCommand(command: 'copy' | 'cut' | 'paste'): void {
document.execCommand(command)
}
@ -70,6 +70,7 @@ export function useMenu() {
store.state.autosaveEnabled = !store.state.autosaveEnabled
},
copy: () => execBrowserCommand('copy'),
cut: () => execBrowserCommand('cut'),
paste: () => execBrowserCommand('paste'),
'check-updates': () => void checkForAppUpdate({ messages: dialogs }),
...createSharedEditorMenuActions(setTheme)

View file

@ -19,11 +19,12 @@ import {
import IconChevronRight from '~icons/lucide/chevron-right'
import { vTestId, useI18n } from '@open-pencil/vue'
import { vTestId, useI18n, formatShortcut } from '@open-pencil/vue'
import { useMenuUI } from '@/components/ui/menu'
import { IS_TAURI } from '@/constants'
import { useAppMenu } from '@/app/shell/menu/app-menu'
import { useDocumentNameRename } from '@/app/shell/menu/document-name'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
import {
hasMenuSubItems,
isMenuCheckbox,
@ -47,11 +48,9 @@ watch(nameInput, (input) => {
if (input) void rename.focusInput(input)
})
const isMac = navigator.platform.includes('Mac')
const mod = isMac ? '⌘' : 'Ctrl+'
const { menu: t } = useI18n()
const { topMenus } = useAppMenu(mod)
const { topMenus } = useAppMenu()
const menuCls = useMenuUI()
const mainMenuCls = useMenuUI({ content: 'min-w-52' })
const subMenuCls = useMenuUI({ content: 'min-w-44' })
@ -77,7 +76,7 @@ const subMenuCls = useMenuUI({ content: 'min-w-44' })
@dblclick="startRename"
>{{ store.state.documentName }}</span
>
<Tip :label="`${t.toggleUI} (${mod}\\)`">
<Tip :label="`${t.toggleUI} (${formatShortcut(appMenuShortcut('toggle-ui'))})`">
<button
data-test-id="app-toggle-ui"
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted transition-colors hover:bg-hover hover:text-surface"

View file

@ -20,7 +20,8 @@ import {
import type { EditorCommandId } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { createCanvasMenuActions } from '@/app/editor/canvas/menu-actions'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
import { COPY_AS_PNG_SHORTCUT, createCanvasMenuActions } from '@/app/editor/canvas/menu-actions'
import { canvasMenuItemClass, canvasMenuShortcutClass } from '@/app/editor/canvas/menu-model'
import { menu, useMenuUI } from '@/components/ui/menu'
@ -63,7 +64,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="execCommand('copy')"
>
<span>{{ t.copy }}</span
><span class="text-[11px] text-muted">{{ formatShortcut('MOD+C') }}</span>
><span class="text-[11px] text-muted">{{ formatShortcut(appMenuShortcut('copy')) }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-cut"
@ -72,11 +73,11 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="execCommand('cut')"
>
<span>{{ t.cut }}</span
><span class="text-[11px] text-muted">{{ formatShortcut('MOD+X') }}</span>
><span class="text-[11px] text-muted">{{ formatShortcut(appMenuShortcut('cut')) }}</span>
</ContextMenuItem>
<ContextMenuItem data-test-id="context-paste" :class="cls.item" @select="execCommand('paste')">
<span>{{ t.pasteHere }}</span
><span class="text-[11px] text-muted">{{ formatShortcut('MOD+V') }}</span>
><span class="text-[11px] text-muted">{{ formatShortcut(appMenuShortcut('paste')) }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-duplicate"
@ -85,7 +86,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="getCommand('selection.duplicate').run()"
>
<span>{{ getCommand('selection.duplicate').label }}</span
><span class="text-[11px] text-muted">{{ formatShortcut('MOD+D') }}</span>
><span class="text-[11px] text-muted">{{ formatShortcut(editorCommandMetadata('selection.duplicate').shortcut) }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-delete"
@ -162,7 +163,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
>
<ContextMenuItem :class="cls.item" @select="copyAsPNG">
<span>{{ t.copyAsPNG }}</span
><span class="text-[11px] text-muted">{{ formatShortcut('MOD+SHIFT+C') }}</span>
><span class="text-[11px] text-muted">{{ formatShortcut(COPY_AS_PNG_SHORTCUT) }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-copy-as-jsx"

View file

@ -12,6 +12,7 @@ import { nextTick, ref, watch } from 'vue'
import { useEditorCommands, useI18n, formatShortcut } from '@open-pencil/vue'
import { menuItem, useMenuUI } from '@/components/ui/menu'
import { useEditorStore } from '@/app/editor/active-store'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
const store = useEditorStore()
const { getCommand } = useEditorCommands()
@ -69,7 +70,7 @@ function zoomOut() {
const ZOOM_PRESETS: ReadonlyArray<{ label: string; level: number; shortcut?: string }> = [
{ label: '50%', level: 0.5 },
{ label: '100%', level: 1, shortcut: 'MOD+0' },
{ label: '100%', level: 1, shortcut: appMenuShortcut('view.zoom100') },
{ label: '200%', level: 2 }
]
@ -126,15 +127,15 @@ watch(open, (v) => {
<DropdownMenuItem :class="itemCls" @select="zoomIn">
<span class="flex-1">{{ menuText.zoomIn }}</span>
<span class="text-[11px] text-muted">{{ formatShortcut('MOD++') }}</span>
<span class="text-[11px] text-muted">{{ formatShortcut(appMenuShortcut('zoom-in')) }}</span>
</DropdownMenuItem>
<DropdownMenuItem :class="itemCls" @select="zoomOut">
<span class="flex-1">{{ menuText.zoomOut }}</span>
<span class="text-[11px] text-muted">{{ formatShortcut('MOD+') }}</span>
<span class="text-[11px] text-muted">{{ formatShortcut(appMenuShortcut('zoom-out')) }}</span>
</DropdownMenuItem>
<DropdownMenuItem :class="itemCls" @select="getCommand('view.zoomFit').run()">
<span class="flex-1">{{ commands.zoomToFit }}</span>
<span class="text-[11px] text-muted">{{ formatShortcut('SHIFT+1') }}</span>
<span class="text-[11px] text-muted">{{ formatShortcut(appMenuShortcut('view.zoomFit')) }}</span>
</DropdownMenuItem>
<DropdownMenuItem
v-for="preset in ZOOM_PRESETS"

View file

@ -10,6 +10,7 @@ import AppSelect from '@/components/ui/AppSelect.vue'
import Tip from '@/components/ui/Tip.vue'
import { useSectionUI } from '@/components/ui/section'
import { loadFont } from '@/app/editor/fonts'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
const { panels, menu } = useI18n()
const sectionCls = useSectionUI()
@ -125,7 +126,7 @@ const fontLoader = { load: loadFont }
</ToggleGroupItem>
</ToggleGroupRoot>
<div class="flex gap-0.5">
<Tip :label="`${menu.bold} (${formatShortcut('MOD+B')})`">
<Tip :label="`${menu.bold} (${formatShortcut(appMenuShortcut('text.bold'))})`">
<button
data-test-id="typography-bold-button"
class="flex cursor-pointer items-center justify-center rounded border border-border bg-input px-2 py-1 font-bold text-muted hover:bg-hover hover:text-surface data-[state=on]:border-accent data-[state=on]:bg-accent data-[state=on]:text-white"
@ -135,7 +136,7 @@ const fontLoader = { load: loadFont }
<icon-lucide-bold class="size-3.5" />
</button>
</Tip>
<Tip :label="`${menu.italic} (${formatShortcut('MOD+I')})`">
<Tip :label="`${menu.italic} (${formatShortcut(appMenuShortcut('text.italic'))})`">
<button
class="flex cursor-pointer items-center justify-center rounded border border-border bg-input px-2 py-1 text-muted hover:bg-hover hover:text-surface data-[state=on]:border-accent data-[state=on]:bg-accent data-[state=on]:text-white"
:data-state="ctx.activeFormatting.value.includes('italic') ? 'on' : 'off'"
@ -144,7 +145,7 @@ const fontLoader = { load: loadFont }
<icon-lucide-italic class="size-3.5" />
</button>
</Tip>
<Tip :label="`${menu.underline} (${formatShortcut('MOD+U')})`">
<Tip :label="`${menu.underline} (${formatShortcut(appMenuShortcut('text.underline'))})`">
<button
class="flex cursor-pointer items-center justify-center rounded border border-border bg-input px-2 py-1 text-muted hover:bg-hover hover:text-surface data-[state=on]:border-accent data-[state=on]:bg-accent data-[state=on]:text-white"
:data-state="ctx.activeFormatting.value.includes('underline') ? 'on' : 'off'"