refactor(vue): format command shortcuts by platform

This commit is contained in:
Danila Poyarkov 2026-05-17 14:19:37 +03:00
parent cc90c63952
commit 433d812b9d
6 changed files with 107 additions and 21 deletions

View file

@ -1,4 +1,5 @@
import { editorCommandMetadata } from './registry'
import { formatShortcut } from './shortcut'
import type { EditorCommand, EditorCommandId, EditorCommandMenuItem } from './types'
export function createEditorCommandActions(commands: Record<EditorCommandId, EditorCommand>) {
@ -19,7 +20,7 @@ export function createEditorCommandActions(commands: Record<EditorCommandId, Edi
return {
id,
label: command.label,
shortcut,
shortcut: formatShortcut(shortcut),
get disabled() {
return !command.enabled.value
},

View file

@ -7,27 +7,27 @@ export interface EditorCommandMetadata {
}
export const EDITOR_COMMAND_METADATA = {
'edit.undo': { shortcut: '⌘Z', keybinding: '$mod+KeyZ' },
'edit.redo': { shortcut: '⇧⌘Z', keybinding: ['$mod+Shift+KeyZ', '$mod+KeyY'] },
'selection.selectAll': { shortcut: '⌘A', keybinding: '$mod+KeyA' },
'edit.undo': { shortcut: 'MOD+Z', keybinding: '$mod+KeyZ' },
'edit.redo': { shortcut: 'MOD+SHIFT+Z', keybinding: ['$mod+Shift+KeyZ', '$mod+KeyY'] },
'selection.selectAll': { shortcut: 'MOD+A', keybinding: '$mod+KeyA' },
'selection.duplicate': {
shortcut: '⌘D',
shortcut: 'MOD+D',
keybinding: '$mod+KeyD',
contextTestId: 'context-duplicate'
},
'selection.delete': { shortcut: '⌫', contextTestId: 'context-delete' },
'selection.group': { shortcut: '⌘G', keybinding: '$mod+KeyG', contextTestId: 'context-group' },
'selection.ungroup': { shortcut: '⇧⌘G', keybinding: '$mod+Shift+KeyG' },
'selection.group': { shortcut: 'MOD+G', keybinding: '$mod+KeyG', contextTestId: 'context-group' },
'selection.ungroup': { shortcut: 'MOD+SHIFT+G', keybinding: '$mod+Shift+KeyG' },
'selection.createComponent': {
shortcut: '⌥⌘K',
shortcut: 'MOD+ALT+K',
keybinding: '$mod+Alt+KeyK',
contextTestId: 'context-create-component'
},
'selection.createComponentSet': { shortcut: '⇧⌘K', keybinding: '$mod+Shift+KeyK' },
'selection.detachInstance': { shortcut: '⌥⌘B', keybinding: '$mod+Alt+KeyB' },
'selection.createComponentSet': { shortcut: 'MOD+SHIFT+K', keybinding: '$mod+Shift+KeyK' },
'selection.detachInstance': { shortcut: 'MOD+ALT+B', keybinding: '$mod+Alt+KeyB' },
'selection.goToMainComponent': {},
'selection.createInstance': {},
'selection.wrapInAutoLayout': { shortcut: '⇧A', keybinding: 'Shift+KeyA' },
'selection.wrapInAutoLayout': { shortcut: 'SHIFT+A', keybinding: 'Shift+KeyA' },
'selection.bringToFront': {
shortcut: ']',
keybinding: 'BracketRight',
@ -39,22 +39,22 @@ export const EDITOR_COMMAND_METADATA = {
contextTestId: 'context-send-to-back'
},
'selection.toggleVisibility': {
shortcut: '⇧⌘H',
shortcut: 'MOD+SHIFT+H',
keybinding: '$mod+Shift+KeyH',
contextTestId: 'context-toggle-visibility'
},
'selection.toggleLock': {
shortcut: '⇧⌘L',
shortcut: 'MOD+SHIFT+L',
keybinding: '$mod+Shift+KeyL',
contextTestId: 'context-toggle-lock'
},
'selection.flipHorizontal': {
shortcut: '⇧H',
shortcut: 'SHIFT+H',
keybinding: 'Shift+KeyH',
contextTestId: 'context-flip-horizontal'
},
'selection.flipVertical': {
shortcut: '⇧V',
shortcut: 'SHIFT+V',
keybinding: 'Shift+KeyV',
contextTestId: 'context-flip-vertical'
},

View file

@ -0,0 +1,57 @@
export type ShortcutPlatform = 'mac' | 'windows' | 'linux'
const MODIFIER_DISPLAY: Record<ShortcutPlatform, Record<string, string>> = {
mac: {
MOD: '⌘',
SHIFT: '⇧',
ALT: '⌥',
CTRL: '⌃'
},
windows: {
MOD: 'Ctrl',
SHIFT: 'Shift',
ALT: 'Alt',
CTRL: 'Ctrl'
},
linux: {
MOD: 'Ctrl',
SHIFT: 'Shift',
ALT: 'Alt',
CTRL: 'Ctrl'
}
}
const MAC_MODIFIER_ORDER = ['CTRL', 'ALT', 'SHIFT', 'MOD']
const STANDARD_MODIFIER_ORDER = ['MOD', 'CTRL', 'ALT', 'SHIFT']
export function shortcutPlatform(userAgent = navigator.userAgent): ShortcutPlatform {
if (/Mac|iPhone|iPad|iPod/u.test(userAgent)) return 'mac'
if (/Win/u.test(userAgent)) return 'windows'
return 'linux'
}
function sortModifiers(modifiers: string[], platform: ShortcutPlatform): string[] {
const order = platform === 'mac' ? MAC_MODIFIER_ORDER : STANDARD_MODIFIER_ORDER
return [...modifiers].sort((a, b) => order.indexOf(a) - order.indexOf(b))
}
export function formatShortcut(
shortcut: string | undefined,
platform = shortcutPlatform()
): string | undefined {
if (!shortcut) return undefined
return shortcut
.split(' ')
.map((combo) => {
const parts = combo.split('+').filter(Boolean)
const modifiers = parts.filter((part) => part in MODIFIER_DISPLAY[platform])
const keys = parts.filter((part) => !(part in MODIFIER_DISPLAY[platform]))
const formattedModifiers = sortModifiers(modifiers, platform).map(
(part) => MODIFIER_DISPLAY[platform][part]
)
const separator = platform === 'mac' ? '' : '+'
return [...formattedModifiers, ...keys].join(separator)
})
.join(' ')
}

View file

@ -36,7 +36,9 @@ export { useSelectionCapabilities } from '#vue/editor/selection-capabilities/use
/** Command and menu composition helpers. */
export { useEditorCommands } from '#vue/editor/commands/use'
export { EDITOR_COMMAND_METADATA, editorCommandMetadata } from '#vue/editor/commands/registry'
export { formatShortcut, shortcutPlatform } from '#vue/editor/commands/shortcut'
export type { EditorCommandMetadata } from '#vue/editor/commands/registry'
export type { ShortcutPlatform } from '#vue/editor/commands/shortcut'
export type { EditorCommand, EditorCommandId } from '#vue/editor/commands/use'
export { useMenuModel } from '#vue/editor/menu-model/use'
export type { MenuActionNode, MenuEntry, MenuSeparatorNode } from '#vue/editor/menu-model/use'

View file

@ -14,7 +14,8 @@ import {
useI18n,
useMenuModel,
useSelectionState,
editorCommandMetadata
editorCommandMetadata,
formatShortcut
} from '@open-pencil/vue'
import type { EditorCommandId } from '@open-pencil/vue'
@ -62,7 +63,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="execCommand('copy')"
>
<span>{{ t.copy }}</span
><span class="text-[11px] text-muted">⌘C</span>
><span class="text-[11px] text-muted">{{ formatShortcut('MOD+C') }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-cut"
@ -71,11 +72,11 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
@select="execCommand('cut')"
>
<span>{{ t.cut }}</span
><span class="text-[11px] text-muted">⌘X</span>
><span class="text-[11px] text-muted">{{ formatShortcut('MOD+X') }}</span>
</ContextMenuItem>
<ContextMenuItem data-test-id="context-paste" :class="cls.item" @select="execCommand('paste')">
<span>{{ t.pasteHere }}</span
><span class="text-[11px] text-muted">⌘V</span>
><span class="text-[11px] text-muted">{{ formatShortcut('MOD+V') }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-duplicate"
@ -83,7 +84,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
:disabled="!hasSelection"
@select="getCommand('selection.duplicate').run()"
>
<span>Duplicate</span><span class="text-[11px] text-muted">⌘D</span>
<span>Duplicate</span><span class="text-[11px] text-muted">{{ formatShortcut('MOD+D') }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-delete"
@ -159,7 +160,7 @@ function contextCommandTestId(id: EditorCommandId | undefined): string | undefin
>
<ContextMenuItem :class="cls.item" @select="copyAsPNG">
<span>{{ t.copyAsPNG }}</span
><span class="text-[11px] text-muted">⇧⌘C</span>
><span class="text-[11px] text-muted">{{ formatShortcut('MOD+SHIFT+C') }}</span>
</ContextMenuItem>
<ContextMenuItem
data-test-id="context-copy-as-jsx"

View file

@ -0,0 +1,25 @@
import { describe, expect, test } from 'bun:test'
import { formatShortcut, shortcutPlatform } from '#vue/editor/commands/shortcut'
describe('formatShortcut', () => {
test('formats shortcuts for macOS', () => {
expect(formatShortcut('MOD+D', 'mac')).toBe('⌘D')
expect(formatShortcut('MOD+SHIFT+H', 'mac')).toBe('⇧⌘H')
expect(formatShortcut('MOD+ALT+K', 'mac')).toBe('⌥⌘K')
expect(formatShortcut('SHIFT+A', 'mac')).toBe('⇧A')
})
test('formats shortcuts for Windows and Linux', () => {
expect(formatShortcut('MOD+D', 'windows')).toBe('Ctrl+D')
expect(formatShortcut('MOD+SHIFT+H', 'windows')).toBe('Ctrl+Shift+H')
expect(formatShortcut('MOD+ALT+K', 'linux')).toBe('Ctrl+Alt+K')
expect(formatShortcut('SHIFT+A', 'linux')).toBe('Shift+A')
})
test('detects platform from user agent', () => {
expect(shortcutPlatform('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('mac')
expect(shortcutPlatform('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows')
expect(shortcutPlatform('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux')
})
})