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 type { EditorStore } from '@/app/editor/active-store'
import { toast } from '@/app/shell/ui' import { toast } from '@/app/shell/ui'
export const COPY_AS_PNG_SHORTCUT = 'MOD+SHIFT+C'
function toArrayBuffer(data: Uint8Array): ArrayBuffer { function toArrayBuffer(data: Uint8Array): ArrayBuffer {
const bytes = new Uint8Array(data.length) const bytes = new Uint8Array(data.length)
bytes.set(data) bytes.set(data)

View file

@ -1,6 +1,6 @@
import { computed } from 'vue' 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 type { MenuEntry } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store' import { useEditorStore } from '@/app/editor/active-store'
@ -15,10 +15,6 @@ export interface AppMenuGroup {
items: MenuEntry[] items: MenuEntry[]
} }
function shortcutLabel(shortcut: string | undefined, mod: string): string | undefined {
return shortcut?.replaceAll('MOD', mod)
}
function isVisible(entry: { target?: string }): boolean { function isVisible(entry: { target?: string }): boolean {
return entry.target !== 'native' return entry.target !== 'native'
} }
@ -27,7 +23,7 @@ function isSeparator(entry: AppMenuEntry): entry is Extract<AppMenuEntry, { type
return entry.type === 'separator' return entry.type === 'separator'
} }
export function useAppMenu(mod: string) { export function useAppMenu() {
const store = useEditorStore() const store = useEditorStore()
const { menuItem: commandMenuItem } = useEditorCommands() const { menuItem: commandMenuItem } = useEditorCommands()
const { locale, availableLocales, localeLabels, setLocale } = useI18n() const { locale, availableLocales, localeLabels, setLocale } = useI18n()
@ -55,6 +51,7 @@ export function useAppMenu(mod: string) {
save: () => void store.saveFigFile(), save: () => void store.saveFigFile(),
'save-as': () => void store.saveFigFileAs(), 'save-as': () => void store.saveFigFileAs(),
'export-selection': () => exportSelection('png'), 'export-selection': () => exportSelection('png'),
cut: () => document.execCommand('cut'),
'export-png': () => exportSelection('png'), 'export-png': () => exportSelection('png'),
'export-svg': () => exportSelection('svg'), 'export-svg': () => exportSelection('svg'),
'export-fig': () => exportSelection('fig'), 'export-fig': () => exportSelection('fig'),
@ -110,12 +107,12 @@ export function useAppMenu(mod: string) {
} }
if (entry.command) { if (entry.command) {
return commandMenuItem(entry.command, shortcutLabel(entry.shortcut, mod)) return commandMenuItem(entry.command, formatShortcut(entry.shortcut))
} }
return { return {
label: entry.label, label: entry.label,
shortcut: shortcutLabel(entry.shortcut, mod), shortcut: formatShortcut(entry.shortcut),
action: itemAction(entry), action: itemAction(entry),
checked: checked(entry), checked: checked(entry),
onCheckedChange: onCheckedChange(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' }, { id: 'open', label: 'Open…', shortcut: 'MOD+O', accelerator: 'CmdOrCtrl+O' },
{ type: 'separator' }, { type: 'separator' },
{ id: 'save', label: 'Save', shortcut: 'MOD+S', accelerator: 'CmdOrCtrl+S' }, { 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' }, { type: 'separator' },
{ {
id: 'export-selection', id: 'export-selection',
label: 'Export Selection', label: 'Export Selection',
shortcut: 'MOD+E', shortcut: 'MOD+SHIFT+E',
accelerator: 'CmdOrCtrl+Shift+E', accelerator: 'CmdOrCtrl+Shift+E',
sub: [ sub: [
{ id: 'export-png', label: 'PNG' }, { id: 'export-png', label: 'PNG' },
@ -66,12 +66,13 @@ export const APP_MENU_SCHEMA = [
{ {
id: 'edit.redo', id: 'edit.redo',
label: 'Redo', label: 'Redo',
shortcut: 'MOD+Z', shortcut: 'MOD+SHIFT+Z',
accelerator: 'CmdOrCtrl+Shift+Z', accelerator: 'CmdOrCtrl+Shift+Z',
command: 'edit.redo' command: 'edit.redo'
}, },
{ type: 'separator' }, { type: 'separator' },
{ id: 'copy', label: 'Copy', shortcut: 'MOD+C', accelerator: 'CmdOrCtrl+C' }, { 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: 'paste', label: 'Paste', shortcut: 'MOD+V', accelerator: 'CmdOrCtrl+V' },
{ {
id: 'selection.duplicate', id: 'selection.duplicate',
@ -158,7 +159,7 @@ export const APP_MENU_SCHEMA = [
{ {
id: 'selection.ungroup', id: 'selection.ungroup',
label: 'Ungroup Selection', label: 'Ungroup Selection',
shortcut: 'MOD+G', shortcut: 'MOD+SHIFT+G',
accelerator: 'CmdOrCtrl+Shift+G', accelerator: 'CmdOrCtrl+Shift+G',
command: 'selection.ungroup' command: 'selection.ungroup'
}, },
@ -166,7 +167,7 @@ export const APP_MENU_SCHEMA = [
{ {
id: 'selection.createComponent', id: 'selection.createComponent',
label: 'Create Component', label: 'Create Component',
shortcut: 'MOD+K', shortcut: 'MOD+ALT+K',
accelerator: 'CmdOrCtrl+Alt+K', accelerator: 'CmdOrCtrl+Alt+K',
command: 'selection.createComponent' command: 'selection.createComponent'
}, },
@ -211,18 +212,18 @@ export const APP_MENU_SCHEMA = [
{ {
id: 'selection.wrapInAutoLayout', id: 'selection.wrapInAutoLayout',
label: 'Wrap in Auto Layout', label: 'Wrap in Auto Layout',
shortcut: 'A', shortcut: 'SHIFT+A',
accelerator: 'Shift+A', accelerator: 'Shift+A',
command: 'selection.wrapInAutoLayout' command: 'selection.wrapInAutoLayout'
}, },
{ type: 'separator' }, { type: 'separator' },
{ id: 'align-left', label: 'Align Left', shortcut: 'A', accelerator: 'Alt+A' }, { id: 'align-left', label: 'Align Left', shortcut: 'ALT+A', accelerator: 'Alt+A' },
{ id: 'align-center', label: 'Align Center', shortcut: 'H', accelerator: 'Alt+H' }, { id: 'align-center', label: 'Align Center', shortcut: 'ALT+H', accelerator: 'Alt+H' },
{ id: 'align-right', label: 'Align Right', shortcut: 'D', accelerator: 'Alt+D' }, { id: 'align-right', label: 'Align Right', shortcut: 'ALT+D', accelerator: 'Alt+D' },
{ type: 'separator' }, { type: 'separator' },
{ id: 'align-top', label: 'Align Top', shortcut: 'W', accelerator: 'Alt+W' }, { id: 'align-top', label: 'Align Top', shortcut: 'ALT+W', accelerator: 'Alt+W' },
{ id: 'align-middle', label: 'Align Middle', shortcut: 'V', accelerator: 'Alt+V' }, { id: 'align-middle', label: 'Align Middle', shortcut: 'ALT+V', accelerator: 'Alt+V' },
{ id: 'align-bottom', label: 'Align Bottom', shortcut: 'S', accelerator: 'Alt+S' } { id: 'align-bottom', label: 'Align Bottom', shortcut: 'ALT+S', accelerator: 'Alt+S' }
] ]
} }
] satisfies AppMenuGroupSchema[] ] satisfies AppMenuGroupSchema[]

View file

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

View file

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

View file

@ -19,11 +19,12 @@ import {
import IconChevronRight from '~icons/lucide/chevron-right' 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 { useMenuUI } from '@/components/ui/menu'
import { IS_TAURI } from '@/constants' import { IS_TAURI } from '@/constants'
import { useAppMenu } from '@/app/shell/menu/app-menu' import { useAppMenu } from '@/app/shell/menu/app-menu'
import { useDocumentNameRename } from '@/app/shell/menu/document-name' import { useDocumentNameRename } from '@/app/shell/menu/document-name'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
import { import {
hasMenuSubItems, hasMenuSubItems,
isMenuCheckbox, isMenuCheckbox,
@ -47,11 +48,9 @@ watch(nameInput, (input) => {
if (input) void rename.focusInput(input) if (input) void rename.focusInput(input)
}) })
const isMac = navigator.platform.includes('Mac')
const mod = isMac ? '⌘' : 'Ctrl+'
const { menu: t } = useI18n() const { menu: t } = useI18n()
const { topMenus } = useAppMenu(mod) const { topMenus } = useAppMenu()
const menuCls = useMenuUI() const menuCls = useMenuUI()
const mainMenuCls = useMenuUI({ content: 'min-w-52' }) const mainMenuCls = useMenuUI({ content: 'min-w-52' })
const subMenuCls = useMenuUI({ content: 'min-w-44' }) const subMenuCls = useMenuUI({ content: 'min-w-44' })
@ -77,7 +76,7 @@ const subMenuCls = useMenuUI({ content: 'min-w-44' })
@dblclick="startRename" @dblclick="startRename"
>{{ store.state.documentName }}</span >{{ store.state.documentName }}</span
> >
<Tip :label="`${t.toggleUI} (${mod}\\)`"> <Tip :label="`${t.toggleUI} (${formatShortcut(appMenuShortcut('toggle-ui'))})`">
<button <button
data-test-id="app-toggle-ui" 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" 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 type { EditorCommandId } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store' 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 { canvasMenuItemClass, canvasMenuShortcutClass } from '@/app/editor/canvas/menu-model'
import { menu, useMenuUI } from '@/components/ui/menu' import { menu, useMenuUI } from '@/components/ui/menu'
@ -63,7 +64,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="execCommand('copy')" @select="execCommand('copy')"
> >
<span>{{ t.copy }}</span <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>
<ContextMenuItem <ContextMenuItem
data-test-id="context-cut" data-test-id="context-cut"
@ -72,11 +73,11 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="execCommand('cut')" @select="execCommand('cut')"
> >
<span>{{ t.cut }}</span <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>
<ContextMenuItem data-test-id="context-paste" :class="cls.item" @select="execCommand('paste')"> <ContextMenuItem data-test-id="context-paste" :class="cls.item" @select="execCommand('paste')">
<span>{{ t.pasteHere }}</span <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>
<ContextMenuItem <ContextMenuItem
data-test-id="context-duplicate" data-test-id="context-duplicate"
@ -85,7 +86,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="getCommand('selection.duplicate').run()" @select="getCommand('selection.duplicate').run()"
> >
<span>{{ getCommand('selection.duplicate').label }}</span <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>
<ContextMenuItem <ContextMenuItem
data-test-id="context-delete" data-test-id="context-delete"
@ -162,7 +163,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
> >
<ContextMenuItem :class="cls.item" @select="copyAsPNG"> <ContextMenuItem :class="cls.item" @select="copyAsPNG">
<span>{{ t.copyAsPNG }}</span <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>
<ContextMenuItem <ContextMenuItem
data-test-id="context-copy-as-jsx" 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 { useEditorCommands, useI18n, formatShortcut } from '@open-pencil/vue'
import { menuItem, useMenuUI } from '@/components/ui/menu' import { menuItem, useMenuUI } from '@/components/ui/menu'
import { useEditorStore } from '@/app/editor/active-store' import { useEditorStore } from '@/app/editor/active-store'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
const store = useEditorStore() const store = useEditorStore()
const { getCommand } = useEditorCommands() const { getCommand } = useEditorCommands()
@ -69,7 +70,7 @@ function zoomOut() {
const ZOOM_PRESETS: ReadonlyArray<{ label: string; level: number; shortcut?: string }> = [ const ZOOM_PRESETS: ReadonlyArray<{ label: string; level: number; shortcut?: string }> = [
{ label: '50%', level: 0.5 }, { label: '50%', level: 0.5 },
{ label: '100%', level: 1, shortcut: 'MOD+0' }, { label: '100%', level: 1, shortcut: appMenuShortcut('view.zoom100') },
{ label: '200%', level: 2 } { label: '200%', level: 2 }
] ]
@ -126,15 +127,15 @@ watch(open, (v) => {
<DropdownMenuItem :class="itemCls" @select="zoomIn"> <DropdownMenuItem :class="itemCls" @select="zoomIn">
<span class="flex-1">{{ menuText.zoomIn }}</span> <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>
<DropdownMenuItem :class="itemCls" @select="zoomOut"> <DropdownMenuItem :class="itemCls" @select="zoomOut">
<span class="flex-1">{{ menuText.zoomOut }}</span> <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>
<DropdownMenuItem :class="itemCls" @select="getCommand('view.zoomFit').run()"> <DropdownMenuItem :class="itemCls" @select="getCommand('view.zoomFit').run()">
<span class="flex-1">{{ commands.zoomToFit }}</span> <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>
<DropdownMenuItem <DropdownMenuItem
v-for="preset in ZOOM_PRESETS" 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 Tip from '@/components/ui/Tip.vue'
import { useSectionUI } from '@/components/ui/section' import { useSectionUI } from '@/components/ui/section'
import { loadFont } from '@/app/editor/fonts' import { loadFont } from '@/app/editor/fonts'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
const { panels, menu } = useI18n() const { panels, menu } = useI18n()
const sectionCls = useSectionUI() const sectionCls = useSectionUI()
@ -125,7 +126,7 @@ const fontLoader = { load: loadFont }
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroupRoot> </ToggleGroupRoot>
<div class="flex gap-0.5"> <div class="flex gap-0.5">
<Tip :label="`${menu.bold} (${formatShortcut('MOD+B')})`"> <Tip :label="`${menu.bold} (${formatShortcut(appMenuShortcut('text.bold'))})`">
<button <button
data-test-id="typography-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" 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" /> <icon-lucide-bold class="size-3.5" />
</button> </button>
</Tip> </Tip>
<Tip :label="`${menu.italic} (${formatShortcut('MOD+I')})`"> <Tip :label="`${menu.italic} (${formatShortcut(appMenuShortcut('text.italic'))})`">
<button <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" 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'" :data-state="ctx.activeFormatting.value.includes('italic') ? 'on' : 'off'"
@ -144,7 +145,7 @@ const fontLoader = { load: loadFont }
<icon-lucide-italic class="size-3.5" /> <icon-lucide-italic class="size-3.5" />
</button> </button>
</Tip> </Tip>
<Tip :label="`${menu.underline} (${formatShortcut('MOD+U')})`"> <Tip :label="`${menu.underline} (${formatShortcut(appMenuShortcut('text.underline'))})`">
<button <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" 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'" :data-state="ctx.activeFormatting.value.includes('underline') ? 'on' : 'off'"