refactor(app): derive menu shortcut formats

This commit is contained in:
Danila Poyarkov 2026-05-17 14:50:43 +03:00
parent 1a03c0e8e8
commit 38d30e25e0
9 changed files with 94 additions and 68 deletions

View file

@ -83,6 +83,11 @@
"label": "Copy",
"accelerator": "CmdOrCtrl+C"
},
{
"id": "cut",
"label": "Cut",
"accelerator": "CmdOrCtrl+X"
},
{
"id": "paste",
"label": "Paste",
@ -170,8 +175,7 @@
},
{
"id": "dev-tools",
"label": "Developer Tools",
"accelerator": "CmdOrCtrl+Alt+I"
"label": "Developer Tools"
}
]
},

View file

@ -3,6 +3,7 @@ import { dirname } from 'node:path'
import { APP_MENU_SCHEMA } from '../src/app/shell/menu/schema'
import type { AppMenuEntry, AppMenuGroupSchema } from '../src/app/shell/menu/schema'
import { shortcutTokenToAccelerator } from '../src/app/shell/menu/shortcut'
function isNativeVisible(entry: { target?: string }): boolean {
return entry.target !== 'browser'
@ -14,7 +15,7 @@ function cleanEntry(entry: AppMenuEntry): unknown | null {
return {
id: entry.id,
label: entry.label,
accelerator: entry.accelerator,
accelerator: entry.accelerator ?? shortcutTokenToAccelerator(entry.shortcut),
checkbox: entry.checkbox,
sub: entry.sub?.map(cleanEntry).filter(Boolean)
}

View file

@ -1,12 +1,13 @@
import { computed } from 'vue'
import { useEditorCommands, useI18n, formatShortcut } from '@open-pencil/vue'
import { useEditorCommands, useI18n } from '@open-pencil/vue'
import type { MenuEntry } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { createSharedEditorMenuActions } from '@/app/shell/menu/editor-actions'
import { APP_MENU_SCHEMA } from '@/app/shell/menu/schema'
import type { AppMenuActionItem, AppMenuEntry, AppMenuGroupSchema } from '@/app/shell/menu/schema'
import { appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
import { openFileDialog } from '@/app/shell/menu/use'
import { useAppTheme } from '@/app/shell/theme'
@ -107,12 +108,12 @@ export function useAppMenu() {
}
if (entry.command) {
return commandMenuItem(entry.command, formatShortcut(entry.shortcut))
return commandMenuItem(entry.command, appMenuShortcutLabel(entry.id))
}
return {
label: entry.label,
shortcut: formatShortcut(entry.shortcut),
shortcut: appMenuShortcutLabel(entry.id),
action: itemAction(entry),
checked: checked(entry),
onCheckedChange: onCheckedChange(entry),

View file

@ -31,17 +31,16 @@ export const APP_MENU_SCHEMA = [
{
label: 'File',
items: [
{ id: 'new', label: 'New', shortcut: 'MOD+N', accelerator: 'CmdOrCtrl+N' },
{ id: 'open', label: 'Open…', shortcut: 'MOD+O', accelerator: 'CmdOrCtrl+O' },
{ id: 'new', label: 'New', shortcut: 'MOD+N' },
{ id: 'open', label: 'Open…', shortcut: 'MOD+O' },
{ type: 'separator' },
{ id: 'save', label: 'Save', shortcut: 'MOD+S', accelerator: 'CmdOrCtrl+S' },
{ id: 'save-as', label: 'Save As…', shortcut: 'MOD+SHIFT+S', accelerator: 'CmdOrCtrl+Shift+S' },
{ id: 'save', label: 'Save', shortcut: 'MOD+S' },
{ id: 'save-as', label: 'Save As…', shortcut: 'MOD+SHIFT+S' },
{ type: 'separator' },
{
id: 'export-selection',
label: 'Export Selection',
shortcut: 'MOD+SHIFT+E',
accelerator: 'CmdOrCtrl+Shift+E',
sub: [
{ id: 'export-png', label: 'PNG' },
{ id: 'export-svg', label: 'SVG' },
@ -50,7 +49,7 @@ export const APP_MENU_SCHEMA = [
},
{ type: 'separator' },
{ id: 'autosave', label: 'Autosave', checkbox: true },
{ id: 'close', label: 'Close Tab', shortcut: 'MOD+W', accelerator: 'CmdOrCtrl+W' }
{ id: 'close', label: 'Close Tab', shortcut: 'MOD+W' }
]
},
{
@ -60,32 +59,28 @@ export const APP_MENU_SCHEMA = [
id: 'edit.undo',
label: 'Undo',
shortcut: 'MOD+Z',
accelerator: 'CmdOrCtrl+Z',
command: 'edit.undo'
},
{
id: 'edit.redo',
label: 'Redo',
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: 'copy', label: 'Copy', shortcut: 'MOD+C' },
{ id: 'cut', label: 'Cut', shortcut: 'MOD+X' },
{ id: 'paste', label: 'Paste', shortcut: 'MOD+V' },
{
id: 'selection.duplicate',
label: 'Duplicate',
shortcut: 'MOD+D',
accelerator: 'CmdOrCtrl+D',
command: 'selection.duplicate'
},
{
id: 'selection.delete',
label: 'Delete',
shortcut: '⌫',
accelerator: 'Backspace',
command: 'selection.delete'
},
{ type: 'separator' },
@ -93,7 +88,6 @@ export const APP_MENU_SCHEMA = [
id: 'selection.selectAll',
label: 'Select All',
shortcut: 'MOD+A',
accelerator: 'CmdOrCtrl+A',
command: 'selection.selectAll'
}
]
@ -105,25 +99,22 @@ export const APP_MENU_SCHEMA = [
id: 'view.zoom100',
label: 'Zoom to 100%',
shortcut: 'MOD+0',
accelerator: 'CmdOrCtrl+0',
command: 'view.zoom100'
},
{
id: 'view.zoomFit',
label: 'Zoom to Fit',
shortcut: 'MOD+1',
accelerator: 'CmdOrCtrl+1',
command: 'view.zoomFit'
},
{
id: 'view.zoomSelection',
label: 'Zoom to Selection',
shortcut: 'MOD+2',
accelerator: 'CmdOrCtrl+2',
command: 'view.zoomSelection'
},
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'MOD+=', accelerator: 'CmdOrCtrl+=' },
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'MOD+-', accelerator: 'CmdOrCtrl+-' },
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'MOD+=' },
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'MOD+-' },
{ type: 'separator' },
{
id: 'theme',
@ -136,12 +127,11 @@ export const APP_MENU_SCHEMA = [
},
{ id: 'language', label: 'Language', target: 'browser' },
{ type: 'separator' },
{ id: 'toggle-ui', label: 'Toggle UI', shortcut: 'MOD+\\', accelerator: 'CmdOrCtrl+\\' },
{ id: 'toggle-ui', label: 'Toggle UI', shortcut: 'MOD+\\' },
{ id: 'profiler', label: 'Profiler', checkbox: true, target: 'browser' },
{
id: 'dev-tools',
label: 'Developer Tools',
accelerator: 'CmdOrCtrl+Alt+I',
target: 'native'
}
]
@ -153,14 +143,12 @@ export const APP_MENU_SCHEMA = [
id: 'selection.group',
label: 'Group Selection',
shortcut: 'MOD+G',
accelerator: 'CmdOrCtrl+G',
command: 'selection.group'
},
{
id: 'selection.ungroup',
label: 'Ungroup Selection',
shortcut: 'MOD+SHIFT+G',
accelerator: 'CmdOrCtrl+Shift+G',
command: 'selection.ungroup'
},
{ type: 'separator' },
@ -168,7 +156,6 @@ export const APP_MENU_SCHEMA = [
id: 'selection.createComponent',
label: 'Create Component',
shortcut: 'MOD+ALT+K',
accelerator: 'CmdOrCtrl+Alt+K',
command: 'selection.createComponent'
},
{
@ -186,14 +173,12 @@ export const APP_MENU_SCHEMA = [
id: 'selection.bringToFront',
label: 'Bring to Front',
shortcut: ']',
accelerator: ']',
command: 'selection.bringToFront'
},
{
id: 'selection.sendToBack',
label: 'Send to Back',
shortcut: '[',
accelerator: '[',
command: 'selection.sendToBack'
}
]
@ -201,9 +186,9 @@ export const APP_MENU_SCHEMA = [
{
label: 'Text',
items: [
{ id: 'text.bold', label: 'Bold', shortcut: 'MOD+B', accelerator: 'CmdOrCtrl+B' },
{ id: 'text.italic', label: 'Italic', shortcut: 'MOD+I', accelerator: 'CmdOrCtrl+I' },
{ id: 'text.underline', label: 'Underline', shortcut: 'MOD+U', accelerator: 'CmdOrCtrl+U' }
{ id: 'text.bold', label: 'Bold', shortcut: 'MOD+B' },
{ id: 'text.italic', label: 'Italic', shortcut: 'MOD+I' },
{ id: 'text.underline', label: 'Underline', shortcut: 'MOD+U' }
]
},
{
@ -213,17 +198,16 @@ export const APP_MENU_SCHEMA = [
id: 'selection.wrapInAutoLayout',
label: 'Wrap in Auto Layout',
shortcut: 'SHIFT+A',
accelerator: 'Shift+A',
command: 'selection.wrapInAutoLayout'
},
{ type: 'separator' },
{ 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' },
{ id: 'align-left', label: 'Align Left', shortcut: 'ALT+A' },
{ id: 'align-center', label: 'Align Center', shortcut: 'ALT+H' },
{ id: 'align-right', label: 'Align Right', shortcut: 'ALT+D' },
{ type: 'separator' },
{ 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' }
{ id: 'align-top', label: 'Align Top', shortcut: 'ALT+W' },
{ id: 'align-middle', label: 'Align Middle', shortcut: 'ALT+V' },
{ id: 'align-bottom', label: 'Align Bottom', shortcut: 'ALT+S' }
]
}
] satisfies AppMenuGroupSchema[]

View file

@ -1,3 +1,5 @@
import { formatShortcut } from '@open-pencil/vue'
import type { AppMenuActionItem, AppMenuEntry } from '@/app/shell/menu/schema'
import { APP_MENU_SCHEMA } from '@/app/shell/menu/schema'
@ -23,10 +25,36 @@ export function appMenuShortcut(id: string): string | undefined {
return undefined
}
export function appMenuTinykeysShortcut(id: string): string | string[] | undefined {
const shortcut = appMenuShortcut(id)
function normalizeShortcutToken(shortcut: string): string {
return shortcut === '⌫' ? 'Backspace' : shortcut
}
export function shortcutTokenToTinykeys(shortcut: string | undefined): string | undefined {
return shortcut
?.replaceAll('MOD', '$mod')
? normalizeShortcutToken(shortcut)
.replaceAll('MOD', '$mod')
.replaceAll('SHIFT', 'Shift')
.replaceAll('ALT', 'Alt')
: undefined
}
export function shortcutTokenToAccelerator(shortcut: string | undefined): string | undefined {
return shortcut
? normalizeShortcutToken(shortcut)
.replaceAll('MOD', 'CmdOrCtrl')
.replaceAll('SHIFT', 'Shift')
.replaceAll('ALT', 'Alt')
: undefined
}
export function appMenuShortcutLabel(id: string): string | undefined {
return formatShortcut(appMenuShortcut(id))
}
export function appMenuTinykeysShortcut(id: string): string | undefined {
return shortcutTokenToTinykeys(appMenuShortcut(id))
}
export function appMenuAccelerator(id: string): string | undefined {
return shortcutTokenToAccelerator(appMenuShortcut(id))
}

View file

@ -19,12 +19,12 @@ import {
import IconChevronRight from '~icons/lucide/chevron-right'
import { vTestId, useI18n, formatShortcut } from '@open-pencil/vue'
import { vTestId, useI18n } 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 { appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
import {
hasMenuSubItems,
isMenuCheckbox,
@ -76,7 +76,7 @@ const subMenuCls = useMenuUI({ content: 'min-w-44' })
@dblclick="startRename"
>{{ store.state.documentName }}</span
>
<Tip :label="`${t.toggleUI} (${formatShortcut(appMenuShortcut('toggle-ui'))})`">
<Tip :label="`${t.toggleUI} (${appMenuShortcutLabel('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,7 @@ import {
import type { EditorCommandId } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import { appMenuShortcut } from '@/app/shell/menu/shortcut'
import { appMenuShortcutLabel } 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'
@ -64,7 +64,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="execCommand('copy')"
>
<span>{{ t.copy }}</span
><span class="text-[11px] text-muted">{{ formatShortcut(appMenuShortcut('copy')) }}</span>
><span class="text-[11px] text-muted">{{ appMenuShortcutLabel('copy') }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-cut"
@ -73,11 +73,11 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="execCommand('cut')"
>
<span>{{ t.cut }}</span
><span class="text-[11px] text-muted">{{ formatShortcut(appMenuShortcut('cut')) }}</span>
><span class="text-[11px] text-muted">{{ appMenuShortcutLabel('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(appMenuShortcut('paste')) }}</span>
><span class="text-[11px] text-muted">{{ appMenuShortcutLabel('paste') }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-duplicate"
@ -86,7 +86,9 @@ 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(editorCommandMetadata('selection.duplicate').shortcut) }}</span>
><span class="text-[11px] text-muted">{{
formatShortcut(editorCommandMetadata('selection.duplicate').shortcut)
}}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-delete"
@ -95,7 +97,9 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="getCommand('selection.delete').run()"
>
<span>{{ getCommand('selection.delete').label }}</span
><span class="text-[11px] text-muted">{{ editorCommandMetadata('selection.delete').shortcut }}</span>
><span class="text-[11px] text-muted">{{
editorCommandMetadata('selection.delete').shortcut
}}</span>
</ContextMenuItem>
<template v-for="(item, i) in canvasMenu" :key="`menu-${i}`">
@ -163,7 +167,9 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
>
<ContextMenuItem :class="cls.item" @select="copyAsPNG">
<span>{{ t.copyAsPNG }}</span
><span class="text-[11px] text-muted">{{ formatShortcut(COPY_AS_PNG_SHORTCUT) }}</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,7 +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'
import { appMenuShortcut, appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
const store = useEditorStore()
const { getCommand } = useEditorCommands()
@ -127,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(appMenuShortcut('zoom-in')) }}</span>
<span class="text-[11px] text-muted">{{ appMenuShortcutLabel('zoom-in') }}</span>
</DropdownMenuItem>
<DropdownMenuItem :class="itemCls" @select="zoomOut">
<span class="flex-1">{{ menuText.zoomOut }}</span>
<span class="text-[11px] text-muted">{{ formatShortcut(appMenuShortcut('zoom-out')) }}</span>
<span class="text-[11px] text-muted">{{ appMenuShortcutLabel('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(appMenuShortcut('view.zoomFit')) }}</span>
<span class="text-[11px] text-muted">{{ appMenuShortcutLabel('view.zoomFit') }}</span>
</DropdownMenuItem>
<DropdownMenuItem
v-for="preset in ZOOM_PRESETS"
@ -145,7 +145,9 @@ watch(open, (v) => {
>
<icon-lucide-check v-if="isActivePreset(preset.level)" class="absolute left-2 size-3.5" />
<span class="flex-1">{{ preset.label }}</span>
<span v-if="preset.shortcut" class="text-[11px] text-muted">{{ formatShortcut(preset.shortcut) }}</span>
<span v-if="preset.shortcut" class="text-[11px] text-muted">{{
formatShortcut(preset.shortcut)
}}</span>
</DropdownMenuItem>
<DropdownMenuSeparator :class="menuCls.separator" />

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ToggleGroupItem, ToggleGroupRoot } from 'reka-ui'
import { TypographyControlsRoot, useI18n, formatShortcut } from '@open-pencil/vue'
import { TypographyControlsRoot, useI18n } from '@open-pencil/vue'
import FontPicker from '@/components/FontPicker.vue'
import FontSettingsPopover from '@/components/FontSettings/FontSettingsPopover.vue'
@ -10,7 +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'
import { appMenuShortcutLabel } from '@/app/shell/menu/shortcut'
const { panels, menu } = useI18n()
const sectionCls = useSectionUI()
@ -126,7 +126,7 @@ const fontLoader = { load: loadFont }
</ToggleGroupItem>
</ToggleGroupRoot>
<div class="flex gap-0.5">
<Tip :label="`${menu.bold} (${formatShortcut(appMenuShortcut('text.bold'))})`">
<Tip :label="`${menu.bold} (${appMenuShortcutLabel('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"
@ -136,7 +136,7 @@ const fontLoader = { load: loadFont }
<icon-lucide-bold class="size-3.5" />
</button>
</Tip>
<Tip :label="`${menu.italic} (${formatShortcut(appMenuShortcut('text.italic'))})`">
<Tip :label="`${menu.italic} (${appMenuShortcutLabel('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'"
@ -145,7 +145,7 @@ const fontLoader = { load: loadFont }
<icon-lucide-italic class="size-3.5" />
</button>
</Tip>
<Tip :label="`${menu.underline} (${formatShortcut(appMenuShortcut('text.underline'))})`">
<Tip :label="`${menu.underline} (${appMenuShortcutLabel('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'"