Skip to content

feat(defineShortcuts): add layoutIndependent option for keyboard shortcuts #4251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: v3
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 116 additions & 59 deletions src/runtime/composables/defineShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ShortcutsConfig {

export interface ShortcutsOptions {
chainDelay?: number
layoutIndependent?: boolean
}

interface Shortcut {
Expand All @@ -37,6 +38,36 @@ interface Shortcut {
const chainedShortcutRegex = /^[^-]+.*-.*[^-]+$/
const combinedShortcutRegex = /^[^_]+.*_.*[^_]+$/

// Simple key to code conversion for layout independence
function convertKeyToCode(key: string): string {
// Handle single letters
if (/^[a-z]$/i.test(key)) {
return `Key${key.toUpperCase()}`
}
// Handle digits
if (/^\d$/.test(key)) {
return `Digit${key}`
}
// Handle function keys
if (/^f\d+$/i.test(key)) {
return key.toUpperCase()
}
// Handle common special keys
const specialKeys: Record<string, string> = {
space: 'Space',
enter: 'Enter',
escape: 'Escape',
tab: 'Tab',
backspace: 'Backspace',
delete: 'Delete',
arrowup: 'ArrowUp',
arrowdown: 'ArrowDown',
arrowleft: 'ArrowLeft',
arrowright: 'ArrowRight'
}
return specialKeys[key.toLowerCase()] || key
}

export function extractShortcuts(items: any[] | any[][]) {
const shortcuts: Record<string, Handler> = {}

Expand Down Expand Up @@ -77,15 +108,20 @@ export function defineShortcuts(config: MaybeRef<ShortcutsConfig>, options: Shor
}

const alphabeticalKey = /^[a-z]{1}$/i.test(e.key)
const keyToMatch = options.layoutIndependent ? e.code : e.key

let chainedKey
chainedInputs.value.push(e.key)
chainedInputs.value.push(keyToMatch)
// try matching a chained shortcut
if (chainedInputs.value.length >= 2) {
chainedKey = chainedInputs.value.slice(-2).join('-')

for (const shortcut of shortcuts.value.filter(s => s.chained)) {
if (shortcut.key !== chainedKey) {
const shortcutKey = options.layoutIndependent
? shortcut.key.split('-').map(convertKeyToCode).join('-')
: shortcut.key

if (shortcutKey !== chainedKey) {
continue
}

Expand All @@ -100,7 +136,10 @@ export function defineShortcuts(config: MaybeRef<ShortcutsConfig>, options: Shor

// try matching a standard shortcut
for (const shortcut of shortcuts.value.filter(s => !s.chained)) {
if (e.key.toLowerCase() !== shortcut.key) {
const eventKey = options.layoutIndependent ? e.code : e.key.toLowerCase()
const shortcutKey = options.layoutIndependent ? convertKeyToCode(shortcut.key) : shortcut.key

if (eventKey !== shortcutKey) {
continue
}
if (e.metaKey !== shortcut.metaKey) {
Expand Down Expand Up @@ -132,7 +171,13 @@ export function defineShortcuts(config: MaybeRef<ShortcutsConfig>, options: Shor
const tagName = activeElement.value?.tagName
const contentEditable = activeElement.value?.contentEditable

const usingInput = !!(tagName === 'INPUT' || tagName === 'TEXTAREA' || contentEditable === 'true' || contentEditable === 'plaintext-only')
const usingInput
= !!(
tagName === 'INPUT'
|| tagName === 'TEXTAREA'
|| contentEditable === 'true'
|| contentEditable === 'plaintext-only'
)

if (usingInput) {
return ((activeElement.value as any)?.name as string) || true
Expand All @@ -143,71 +188,83 @@ export function defineShortcuts(config: MaybeRef<ShortcutsConfig>, options: Shor

// Map config to full detailled shortcuts
const shortcuts = computed<Shortcut[]>(() => {
return Object.entries(toValue(config)).map(([key, shortcutConfig]) => {
if (!shortcutConfig) {
return null
}

// Parse key and modifiers
let shortcut: Partial<Shortcut>
return Object.entries(toValue(config))
.map(([key, shortcutConfig]) => {
if (!shortcutConfig) {
return null
}

if (key.includes('-') && key !== '-' && !key.match(chainedShortcutRegex)?.length) {
console.trace(`[Shortcut] Invalid key: "${key}"`)
}
// Parse key and modifiers
let shortcut: Partial<Shortcut>

if (key.includes('_') && key !== '_' && !key.match(combinedShortcutRegex)?.length) {
console.trace(`[Shortcut] Invalid key: "${key}"`)
}
if (
key.includes('-')
&& key !== '-'
&& !key.match(chainedShortcutRegex)
?.length
) {
console.trace(`[Shortcut] Invalid key: "${key}"`)
}

const chained = key.includes('-') && key !== '-'
if (chained) {
shortcut = {
key: key.toLowerCase(),
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false
if (
key.includes('_')
&& key !== '_'
&& !key.match(combinedShortcutRegex)
?.length
) {
console.trace(`[Shortcut] Invalid key: "${key}"`)
}
} else {
const keySplit = key.toLowerCase().split('_').map(k => k)
shortcut = {
key: keySplit.filter(k => !['meta', 'command', 'ctrl', 'shift', 'alt', 'option'].includes(k)).join('_'),
metaKey: keySplit.includes('meta') || keySplit.includes('command'),
ctrlKey: keySplit.includes('ctrl'),
shiftKey: keySplit.includes('shift'),
altKey: keySplit.includes('alt') || keySplit.includes('option')

const chained = key.includes('-') && key !== '-'
if (chained) {
shortcut = {
key: key.toLowerCase(),
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false
}
} else {
const keySplit = key.toLowerCase().split('_').map(k => k)
shortcut = {
key: keySplit.filter(k => !['meta', 'command', 'ctrl', 'shift', 'alt', 'option'].includes(k)).join('_'),
metaKey: keySplit.includes('meta') || keySplit.includes('command'),
ctrlKey: keySplit.includes('ctrl'),
shiftKey: keySplit.includes('shift'),
altKey: keySplit.includes('alt') || keySplit.includes('option')
}
}
}
shortcut.chained = chained
shortcut.chained = chained

// Convert Meta to Ctrl for non-MacOS
if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) {
shortcut.metaKey = false
shortcut.ctrlKey = true
}
// Convert Meta to Ctrl for non-MacOS
if (!macOS.value && shortcut.metaKey && !shortcut.ctrlKey) {
shortcut.metaKey = false
shortcut.ctrlKey = true
}

// Retrieve handler function
if (typeof shortcutConfig === 'function') {
shortcut.handler = shortcutConfig
} else if (typeof shortcutConfig === 'object') {
shortcut = { ...shortcut, handler: shortcutConfig.handler }
}
// Retrieve handler function
if (typeof shortcutConfig === 'function') {
shortcut.handler = shortcutConfig
} else if (typeof shortcutConfig === 'object') {
shortcut = { ...shortcut, handler: shortcutConfig.handler }
}

if (!shortcut.handler) {
console.trace('[Shortcut] Invalid value')
return null
}
if (!shortcut.handler) {
console.trace('[Shortcut] Invalid value')
return null
}

let enabled = true
if (!(shortcutConfig as ShortcutConfig).usingInput) {
enabled = !usingInput.value
} else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') {
enabled = usingInput.value === (shortcutConfig as ShortcutConfig).usingInput
}
shortcut.enabled = enabled
let enabled = true
if (!(shortcutConfig as ShortcutConfig).usingInput) {
enabled = !usingInput.value
} else if (typeof (shortcutConfig as ShortcutConfig).usingInput === 'string') {
enabled = usingInput.value === (shortcutConfig as ShortcutConfig).usingInput
}
shortcut.enabled = enabled

return shortcut
}).filter(Boolean) as Shortcut[]
return shortcut
})
.filter(Boolean) as Shortcut[]
})

return useEventListener('keydown', onKeyDown)
Expand Down