mirror of
https://github.com/moeru-ai/airi.git
synced 2026-04-28 06:29:33 +00:00
feat(stage-tamagotchi,stage-shared): essentials for global shortcut
Some checks are pending
CI / Lint (push) Waiting to run
CI / Build Test (stage-tamagotchi) (push) Waiting to run
CI / Build Test (stage-tamagotchi-godot) (push) Waiting to run
CI / Build Test (stage-web) (push) Waiting to run
CI / Build Test (ui-loading-screens) (push) Waiting to run
CI / Build Test (ui-transitions) (push) Waiting to run
CI / Type Check (push) Waiting to run
CI / Check Provenance (push) Waiting to run
Cloudflare Workers / Deploy - stage-web (push) Waiting to run
Some checks are pending
CI / Lint (push) Waiting to run
CI / Build Test (stage-tamagotchi) (push) Waiting to run
CI / Build Test (stage-tamagotchi-godot) (push) Waiting to run
CI / Build Test (stage-web) (push) Waiting to run
CI / Build Test (ui-loading-screens) (push) Waiting to run
CI / Build Test (ui-transitions) (push) Waiting to run
CI / Type Check (push) Waiting to run
CI / Check Provenance (push) Waiting to run
Cloudflare Workers / Deploy - stage-web (push) Waiting to run
This commit is contained in:
parent
f292ab9b99
commit
3566e8b4a8
7 changed files with 810 additions and 0 deletions
|
|
@ -1,5 +1,9 @@
|
|||
import type { Locale } from '@intlify/core'
|
||||
import type { ServerOptions } from '@proj-airi/server-runtime/server'
|
||||
import type {
|
||||
ShortcutBinding,
|
||||
ShortcutRegistrationResult,
|
||||
} from '@proj-airi/stage-shared/global-shortcut'
|
||||
import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-channel-qr'
|
||||
import type {
|
||||
ThreeHitTestReadTracePayload,
|
||||
|
|
@ -315,6 +319,34 @@ export const electronGodotStageGetStatus = defineInvokeEventa<ElectronGodotStage
|
|||
export const electronGodotStageApplySceneInput = defineInvokeEventa<void, ElectronGodotStageSceneInputPayload>('eventa:invoke:electron:godot-stage:apply-scene-input')
|
||||
export const electronGodotStageStatusChanged = defineEventa<ElectronGodotStageStatus>('eventa:event:electron:godot-stage:status-changed')
|
||||
|
||||
// Global shortcut ->
|
||||
|
||||
/**
|
||||
* Phase of a shortcut trigger event.
|
||||
*
|
||||
* - `down` — key-combination pressed
|
||||
* - `up` — key-combination released; only emitted by drivers that set
|
||||
* `ok: true` for bindings with `receiveKeyUps: true`
|
||||
*/
|
||||
export type ElectronShortcutTriggerPhase = 'down' | 'up'
|
||||
|
||||
/**
|
||||
* Payload broadcast to all subscribed windows when a registered shortcut
|
||||
* fires. Renderer composables filter by `id` to dispatch local handlers.
|
||||
*/
|
||||
export interface ElectronShortcutTriggerPayload {
|
||||
id: string
|
||||
phase: ElectronShortcutTriggerPhase
|
||||
}
|
||||
|
||||
export const electronShortcutRegister = defineInvokeEventa<ShortcutRegistrationResult, ShortcutBinding>('eventa:invoke:electron:shortcut:register')
|
||||
export const electronShortcutUnregister = defineInvokeEventa<void, { id: string }>('eventa:invoke:electron:shortcut:unregister')
|
||||
export const electronShortcutUnregisterAll = defineInvokeEventa<void>('eventa:invoke:electron:shortcut:unregister-all')
|
||||
export const electronShortcutList = defineInvokeEventa<ShortcutBinding[]>('eventa:invoke:electron:shortcut:list')
|
||||
export const electronShortcutTriggered = defineEventa<ElectronShortcutTriggerPayload>('eventa:event:electron:shortcut:triggered')
|
||||
|
||||
// <- Global shortcut
|
||||
|
||||
export type StageThreeRuntimeTraceEnvelope
|
||||
= | { type: 'three-render-info', payload: ThreeSceneRenderInfoTracePayload }
|
||||
| { type: 'three-hit-test-read', payload: ThreeHitTestReadTracePayload }
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ words:
|
|||
- autoplay
|
||||
- awilix
|
||||
- Ayaka
|
||||
- Backquote
|
||||
- baichuan
|
||||
- baiducloud
|
||||
- bailian
|
||||
|
|
@ -49,11 +50,13 @@ words:
|
|||
- cjkfonts
|
||||
- clippy
|
||||
- clustr
|
||||
- cmdorctrl
|
||||
- codesign
|
||||
- collectblock
|
||||
- colorjs
|
||||
- cometapi
|
||||
- Comfortaa
|
||||
- commandorcontrol
|
||||
- composables
|
||||
- cooldown
|
||||
- coreml
|
||||
|
|
@ -285,6 +288,7 @@ words:
|
|||
- softprops
|
||||
- sonarjs
|
||||
- sonner
|
||||
- Spacebar
|
||||
- specta
|
||||
- splitpanes
|
||||
- splt
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
".": "./src/index.ts",
|
||||
"./auth": "./src/auth/index.ts",
|
||||
"./beat-sync": "./src/beat-sync/index.ts",
|
||||
"./global-shortcut": "./src/global-shortcut/index.ts",
|
||||
"./server-channel-qr": "./src/server-channel-qr.ts",
|
||||
"./electron-renderer": "./src/electron-renderer.d.ts",
|
||||
"./composables": "./src/composables/index.ts",
|
||||
|
|
|
|||
231
packages/stage-shared/src/global-shortcut/accelerators.test.ts
Normal file
231
packages/stage-shared/src/global-shortcut/accelerators.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatAccelerator,
|
||||
formatElectronAccelerator,
|
||||
isValidAccelerator,
|
||||
KEY_NAMES,
|
||||
parseAccelerator,
|
||||
} from './accelerators'
|
||||
|
||||
describe('parseAccelerator', () => {
|
||||
it('parses a single key with no modifiers', () => {
|
||||
// @example "Escape" — bare key, valid on its own
|
||||
expect(parseAccelerator('Escape')).toEqual({ modifiers: [], key: 'Escape' })
|
||||
})
|
||||
|
||||
it('parses a typical accelerator with modifiers', () => {
|
||||
// @example "Mod+Shift+K" — common Cmd/Ctrl shortcut
|
||||
expect(parseAccelerator('Mod+Shift+K'))
|
||||
.toEqual({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' })
|
||||
})
|
||||
|
||||
it('treats Mod, CmdOrCtrl, and CommandOrControl as the same modifier', () => {
|
||||
const a = parseAccelerator('Mod+K')
|
||||
const b = parseAccelerator('CmdOrCtrl+K')
|
||||
const c = parseAccelerator('CommandOrControl+K')
|
||||
expect(a).toEqual(b)
|
||||
expect(b).toEqual(c)
|
||||
expect(a.modifiers).toEqual(['cmd-or-ctrl'])
|
||||
})
|
||||
|
||||
it('treats Cmd and Command as the same modifier', () => {
|
||||
expect(parseAccelerator('Cmd+K')).toEqual(parseAccelerator('Command+K'))
|
||||
expect(parseAccelerator('Cmd+K').modifiers).toEqual(['cmd'])
|
||||
})
|
||||
|
||||
it('treats Ctrl and Control as the same modifier', () => {
|
||||
expect(parseAccelerator('Ctrl+K')).toEqual(parseAccelerator('Control+K'))
|
||||
expect(parseAccelerator('Ctrl+K').modifiers).toEqual(['ctrl'])
|
||||
})
|
||||
|
||||
it('treats Alt and Option as the same modifier', () => {
|
||||
expect(parseAccelerator('Alt+F12')).toEqual(parseAccelerator('Option+F12'))
|
||||
expect(parseAccelerator('Alt+F12').modifiers).toEqual(['alt'])
|
||||
})
|
||||
|
||||
it('is case-insensitive on modifier tokens', () => {
|
||||
// @example "mod+shift+k" parses identically to "Mod+Shift+K"
|
||||
expect(parseAccelerator('mod+shift+k'))
|
||||
.toEqual(parseAccelerator('Mod+Shift+K'))
|
||||
})
|
||||
|
||||
it('expands single-letter shorthand to W3C key code', () => {
|
||||
// @example "K" -> "KeyK"
|
||||
expect(parseAccelerator('K').key).toBe('KeyK')
|
||||
expect(parseAccelerator('a').key).toBe('KeyA')
|
||||
})
|
||||
|
||||
it('expands single-digit shorthand to W3C key code', () => {
|
||||
// @example "1" -> "Digit1"
|
||||
expect(parseAccelerator('1').key).toBe('Digit1')
|
||||
expect(parseAccelerator('Mod+9').key).toBe('Digit9')
|
||||
})
|
||||
|
||||
it('accepts canonical W3C key codes verbatim', () => {
|
||||
expect(parseAccelerator('KeyK').key).toBe('KeyK')
|
||||
expect(parseAccelerator('Digit1').key).toBe('Digit1')
|
||||
expect(parseAccelerator('F12').key).toBe('F12')
|
||||
expect(parseAccelerator('ArrowUp').key).toBe('ArrowUp')
|
||||
})
|
||||
|
||||
it('expands key shorthand aliases to canonical names', () => {
|
||||
// @example "Up" -> "ArrowUp", "Esc" -> "Escape"
|
||||
expect(parseAccelerator('Up').key).toBe('ArrowUp')
|
||||
expect(parseAccelerator('Esc').key).toBe('Escape')
|
||||
expect(parseAccelerator('Return').key).toBe('Enter')
|
||||
})
|
||||
|
||||
it('tolerates whitespace around tokens', () => {
|
||||
expect(parseAccelerator(' Mod + Shift + K '))
|
||||
.toEqual(parseAccelerator('Mod+Shift+K'))
|
||||
})
|
||||
|
||||
it('throws on empty input', () => {
|
||||
expect(() => parseAccelerator('')).toThrow(/empty string/)
|
||||
expect(() => parseAccelerator(' ')).toThrow(/empty string/)
|
||||
})
|
||||
|
||||
it('throws on empty token (trailing or leading +)', () => {
|
||||
expect(() => parseAccelerator('Mod+')).toThrow(/empty token/)
|
||||
expect(() => parseAccelerator('+K')).toThrow(/empty token/)
|
||||
expect(() => parseAccelerator('Mod++K')).toThrow(/empty token/)
|
||||
})
|
||||
|
||||
it('throws when the input has no key token', () => {
|
||||
expect(() => parseAccelerator('Mod+Shift')).toThrow(/no key token/)
|
||||
})
|
||||
|
||||
it('throws when the input has multiple non-modifier tokens', () => {
|
||||
expect(() => parseAccelerator('Mod+K+L')).toThrow(/multiple non-modifier keys/)
|
||||
})
|
||||
|
||||
it('throws on duplicate modifier (alias-aware)', () => {
|
||||
// @example "Mod+CmdOrCtrl+K" — both alias to "cmd-or-ctrl"
|
||||
expect(() => parseAccelerator('Mod+CmdOrCtrl+K'))
|
||||
.toThrow(/duplicate modifier/)
|
||||
expect(() => parseAccelerator('Shift+Shift+K'))
|
||||
.toThrow(/duplicate modifier/)
|
||||
})
|
||||
|
||||
it('throws on unknown key token', () => {
|
||||
// @example "Mod+Foo" — "Foo" is not a known key
|
||||
expect(() => parseAccelerator('Mod+Foo'))
|
||||
.toThrow(/unknown key/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidAccelerator', () => {
|
||||
it('returns true for well-formed accelerators', () => {
|
||||
expect(isValidAccelerator('Mod+Shift+K')).toBe(true)
|
||||
expect(isValidAccelerator(' CmdOrCtrl + K ')).toBe(true)
|
||||
expect(isValidAccelerator('Escape')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for malformed input without throwing', () => {
|
||||
expect(isValidAccelerator('')).toBe(false)
|
||||
expect(isValidAccelerator('Mod+')).toBe(false)
|
||||
expect(isValidAccelerator('Mod+Shift')).toBe(false)
|
||||
expect(isValidAccelerator('Mod+K+L')).toBe(false)
|
||||
expect(isValidAccelerator('Mod+Foo')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAccelerator', () => {
|
||||
it('emits canonical IR with modifier ordering normalized', () => {
|
||||
// @example modifiers given in author order ['shift', 'cmd-or-ctrl']
|
||||
// serialize as 'Mod+Shift+KeyK', not 'Shift+Mod+KeyK'
|
||||
expect(formatAccelerator({ modifiers: ['shift', 'cmd-or-ctrl'], key: 'KeyK' }))
|
||||
.toBe('Mod+Shift+KeyK')
|
||||
})
|
||||
|
||||
it('emits a bare key when there are no modifiers', () => {
|
||||
expect(formatAccelerator({ modifiers: [], key: 'Escape' })).toBe('Escape')
|
||||
})
|
||||
|
||||
it('orders modifiers as cmd-or-ctrl, cmd, ctrl, alt, shift, super', () => {
|
||||
expect(formatAccelerator({
|
||||
modifiers: ['super', 'shift', 'alt', 'ctrl', 'cmd', 'cmd-or-ctrl'],
|
||||
key: 'KeyK',
|
||||
})).toBe('Mod+Cmd+Ctrl+Alt+Shift+Super+KeyK')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatElectronAccelerator', () => {
|
||||
it('rewrites cmd-or-ctrl to CmdOrCtrl', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' }))
|
||||
.toBe('CmdOrCtrl+Shift+K')
|
||||
})
|
||||
|
||||
it('strips the Key prefix from letter keys', () => {
|
||||
// @example "KeyK" -> "K"
|
||||
expect(formatElectronAccelerator({ modifiers: ['alt'], key: 'KeyA' }))
|
||||
.toBe('Alt+A')
|
||||
})
|
||||
|
||||
it('strips the Digit prefix from digit keys', () => {
|
||||
// @example "Digit1" -> "1"
|
||||
expect(formatElectronAccelerator({ modifiers: ['cmd-or-ctrl'], key: 'Digit1' }))
|
||||
.toBe('CmdOrCtrl+1')
|
||||
})
|
||||
|
||||
it('rewrites arrow keys to Electron short names', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'ArrowUp' })).toBe('Up')
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'ArrowDown' })).toBe('Down')
|
||||
})
|
||||
|
||||
it('rewrites Escape to Esc', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'Escape' })).toBe('Esc')
|
||||
})
|
||||
|
||||
it('rewrites punctuation keys to literal characters', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'Equal' })).toBe('=')
|
||||
expect(formatElectronAccelerator({ modifiers: ['shift'], key: 'Slash' })).toBe('Shift+/')
|
||||
})
|
||||
|
||||
it('passes function and named keys through unchanged', () => {
|
||||
expect(formatElectronAccelerator({ modifiers: ['alt'], key: 'F12' })).toBe('Alt+F12')
|
||||
expect(formatElectronAccelerator({ modifiers: [], key: 'Space' })).toBe('Space')
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip parse -> format', () => {
|
||||
it('is idempotent on canonical IR strings', () => {
|
||||
for (const input of [
|
||||
'Mod+Shift+KeyK',
|
||||
'Alt+F12',
|
||||
'Escape',
|
||||
'Mod+Alt+ArrowUp',
|
||||
]) {
|
||||
expect(formatAccelerator(parseAccelerator(input))).toBe(input)
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes non-canonical inputs to canonical IR', () => {
|
||||
// @example "Shift+Mod+k" normalizes to "Mod+Shift+KeyK"
|
||||
expect(formatAccelerator(parseAccelerator('Shift+Mod+k'))).toBe('Mod+Shift+KeyK')
|
||||
expect(formatAccelerator(parseAccelerator(' CmdOrCtrl + Shift + KeyK ')))
|
||||
.toBe('Mod+Shift+KeyK')
|
||||
})
|
||||
})
|
||||
|
||||
describe('constant KEY_NAMES', () => {
|
||||
it('includes a representative subset of W3C key codes', () => {
|
||||
expect(KEY_NAMES.has('KeyA')).toBe(true)
|
||||
expect(KEY_NAMES.has('KeyZ')).toBe(true)
|
||||
expect(KEY_NAMES.has('Digit0')).toBe(true)
|
||||
expect(KEY_NAMES.has('F1')).toBe(true)
|
||||
expect(KEY_NAMES.has('F24')).toBe(true)
|
||||
expect(KEY_NAMES.has('ArrowUp')).toBe(true)
|
||||
expect(KEY_NAMES.has('Escape')).toBe(true)
|
||||
expect(KEY_NAMES.has('Space')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not include shorthand aliases', () => {
|
||||
// KEY_NAMES is the canonical set; shorthand resolves through the
|
||||
// parser, not the exported set.
|
||||
expect(KEY_NAMES.has('K')).toBe(false)
|
||||
expect(KEY_NAMES.has('Up')).toBe(false)
|
||||
expect(KEY_NAMES.has('Esc')).toBe(false)
|
||||
})
|
||||
})
|
||||
434
packages/stage-shared/src/global-shortcut/accelerators.ts
Normal file
434
packages/stage-shared/src/global-shortcut/accelerators.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import type { ShortcutAccelerator, ShortcutKey, ShortcutModifier } from './types'
|
||||
|
||||
/**
|
||||
* Accelerator parsing, validation, and serialization.
|
||||
*
|
||||
* The canonical form is the structured `ShortcutAccelerator`. Strings
|
||||
* are an ergonomic input/output format only.
|
||||
*
|
||||
* Two output flavours are provided:
|
||||
* - `formatAccelerator` — canonical IR (`"Mod+Shift+KeyK"`),
|
||||
* round-trips losslessly through
|
||||
* `parseAccelerator`.
|
||||
* - `formatElectronAccelerator` — Electron's accelerator string
|
||||
* (`"CmdOrCtrl+Shift+K"`), suitable for
|
||||
* passing directly to
|
||||
* `globalShortcut.register`.
|
||||
*
|
||||
* Future C# drivers consume the structured value and never call into
|
||||
* these utilities; they exist only at the renderer/config-author edges.
|
||||
*/
|
||||
|
||||
const LETTER_KEYS: ReadonlySet<ShortcutKey> = new Set([
|
||||
'KeyA',
|
||||
'KeyB',
|
||||
'KeyC',
|
||||
'KeyD',
|
||||
'KeyE',
|
||||
'KeyF',
|
||||
'KeyG',
|
||||
'KeyH',
|
||||
'KeyI',
|
||||
'KeyJ',
|
||||
'KeyK',
|
||||
'KeyL',
|
||||
'KeyM',
|
||||
'KeyN',
|
||||
'KeyO',
|
||||
'KeyP',
|
||||
'KeyQ',
|
||||
'KeyR',
|
||||
'KeyS',
|
||||
'KeyT',
|
||||
'KeyU',
|
||||
'KeyV',
|
||||
'KeyW',
|
||||
'KeyX',
|
||||
'KeyY',
|
||||
'KeyZ',
|
||||
])
|
||||
|
||||
const DIGIT_KEYS: ReadonlySet<ShortcutKey> = new Set([
|
||||
'Digit0',
|
||||
'Digit1',
|
||||
'Digit2',
|
||||
'Digit3',
|
||||
'Digit4',
|
||||
'Digit5',
|
||||
'Digit6',
|
||||
'Digit7',
|
||||
'Digit8',
|
||||
'Digit9',
|
||||
])
|
||||
|
||||
const FUNCTION_KEYS: ReadonlySet<ShortcutKey> = new Set([
|
||||
'F1',
|
||||
'F2',
|
||||
'F3',
|
||||
'F4',
|
||||
'F5',
|
||||
'F6',
|
||||
'F7',
|
||||
'F8',
|
||||
'F9',
|
||||
'F10',
|
||||
'F11',
|
||||
'F12',
|
||||
'F13',
|
||||
'F14',
|
||||
'F15',
|
||||
'F16',
|
||||
'F17',
|
||||
'F18',
|
||||
'F19',
|
||||
'F20',
|
||||
'F21',
|
||||
'F22',
|
||||
'F23',
|
||||
'F24',
|
||||
])
|
||||
|
||||
const NAMED_KEYS: ReadonlySet<ShortcutKey> = new Set([
|
||||
'Space',
|
||||
'Tab',
|
||||
'Enter',
|
||||
'Escape',
|
||||
'Backspace',
|
||||
'Delete',
|
||||
'Insert',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Home',
|
||||
'End',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
'Backquote',
|
||||
'Minus',
|
||||
'Equal',
|
||||
'BracketLeft',
|
||||
'BracketRight',
|
||||
'Backslash',
|
||||
'Semicolon',
|
||||
'Quote',
|
||||
'Comma',
|
||||
'Period',
|
||||
'Slash',
|
||||
])
|
||||
|
||||
/**
|
||||
* Full set of accepted W3C `KeyboardEvent.code` key names. Drivers map
|
||||
* each value to the native key code understood by their platform API.
|
||||
*/
|
||||
export const KEY_NAMES: ReadonlySet<ShortcutKey> = new Set<ShortcutKey>([
|
||||
...LETTER_KEYS,
|
||||
...DIGIT_KEYS,
|
||||
...FUNCTION_KEYS,
|
||||
...NAMED_KEYS,
|
||||
])
|
||||
|
||||
/**
|
||||
* Shorthand-to-canonical key alias map used by the parser.
|
||||
*
|
||||
* Lets authors write `Up` instead of `ArrowUp`, `Esc` instead of
|
||||
* `Escape`, etc. Single letters and digits are handled by inline
|
||||
* regex fallbacks rather than entries here, so `K` becomes `KeyK`
|
||||
* and `1` becomes `Digit1` without lookup.
|
||||
*/
|
||||
const KEY_ALIASES: ReadonlyMap<string, ShortcutKey> = new Map([
|
||||
['Up', 'ArrowUp'],
|
||||
['Down', 'ArrowDown'],
|
||||
['Left', 'ArrowLeft'],
|
||||
['Right', 'ArrowRight'],
|
||||
['Esc', 'Escape'],
|
||||
['Return', 'Enter'],
|
||||
['Spacebar', 'Space'],
|
||||
])
|
||||
|
||||
/**
|
||||
* Modifier alias map (case-insensitive lookup; keys are lowercase).
|
||||
*
|
||||
* Accepts the union of names used by Electron, Tauri, and informal
|
||||
* style so authors can paste from existing docs without translation.
|
||||
*/
|
||||
const MODIFIER_ALIASES: ReadonlyMap<string, ShortcutModifier> = new Map<string, ShortcutModifier>([
|
||||
['mod', 'cmd-or-ctrl'],
|
||||
['cmdorctrl', 'cmd-or-ctrl'],
|
||||
['commandorcontrol', 'cmd-or-ctrl'],
|
||||
['cmd', 'cmd'],
|
||||
['command', 'cmd'],
|
||||
['ctrl', 'ctrl'],
|
||||
['control', 'ctrl'],
|
||||
['alt', 'alt'],
|
||||
['option', 'alt'],
|
||||
['shift', 'shift'],
|
||||
['super', 'super'],
|
||||
])
|
||||
|
||||
/**
|
||||
* Canonical modifier order used when serializing back to a string.
|
||||
* Two structurally identical accelerators always serialize to the
|
||||
* same string regardless of the order in which the author wrote
|
||||
* modifiers.
|
||||
*/
|
||||
const MODIFIER_CANONICAL_ORDER: readonly ShortcutModifier[] = [
|
||||
'cmd-or-ctrl',
|
||||
'cmd',
|
||||
'ctrl',
|
||||
'alt',
|
||||
'shift',
|
||||
'super',
|
||||
]
|
||||
|
||||
/**
|
||||
* Title-case modifier tokens used by `formatAccelerator` (canonical IR
|
||||
* output). Mirrors Tauri/Electron casing so output is recognizable.
|
||||
*/
|
||||
const MODIFIER_TO_IR_TOKEN: Readonly<Record<ShortcutModifier, string>> = {
|
||||
'cmd-or-ctrl': 'Mod',
|
||||
'cmd': 'Cmd',
|
||||
'ctrl': 'Ctrl',
|
||||
'alt': 'Alt',
|
||||
'shift': 'Shift',
|
||||
'super': 'Super',
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens used by `formatElectronAccelerator`. `cmd-or-ctrl` becomes
|
||||
* `CmdOrCtrl`; everything else passes through unchanged.
|
||||
*/
|
||||
const MODIFIER_TO_ELECTRON_TOKEN: Readonly<Record<ShortcutModifier, string>> = {
|
||||
'cmd-or-ctrl': 'CmdOrCtrl',
|
||||
'cmd': 'Cmd',
|
||||
'ctrl': 'Ctrl',
|
||||
'alt': 'Alt',
|
||||
'shift': 'Shift',
|
||||
'super': 'Super',
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-key overrides used when serializing for Electron. Electron's
|
||||
* accelerator format expects literal characters or short names for
|
||||
* many keys (`Up`, `Esc`, `=`, etc.) rather than the W3C codes.
|
||||
*
|
||||
* Letter and digit keys are handled by stripping the `Key`/`Digit`
|
||||
* prefix in `toElectronKey`, so they are not listed here.
|
||||
*/
|
||||
const ELECTRON_KEY_OVERRIDES: Readonly<Record<string, string>> = {
|
||||
ArrowUp: 'Up',
|
||||
ArrowDown: 'Down',
|
||||
ArrowLeft: 'Left',
|
||||
ArrowRight: 'Right',
|
||||
Escape: 'Esc',
|
||||
Backquote: '`',
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: '\'',
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/',
|
||||
}
|
||||
|
||||
const SINGLE_LETTER_RE = /^[A-Z]$/i
|
||||
const SINGLE_DIGIT_RE = /^\d$/
|
||||
|
||||
/**
|
||||
* Normalizes a raw key token to a canonical `ShortcutKey`.
|
||||
*
|
||||
* Before:
|
||||
* - `"K"` / `"k"` / `"KeyK"` / `"Up"` / `"Esc"`
|
||||
*
|
||||
* After:
|
||||
* - `"KeyK"` / `"KeyK"` / `"KeyK"` / `"ArrowUp"` / `"Escape"`
|
||||
*/
|
||||
function normalizeKeyToken(token: string): ShortcutKey {
|
||||
if (KEY_NAMES.has(token))
|
||||
return token
|
||||
|
||||
const aliased = KEY_ALIASES.get(token)
|
||||
if (aliased !== undefined)
|
||||
return aliased
|
||||
|
||||
if (SINGLE_LETTER_RE.test(token)) {
|
||||
const candidate = `Key${token.toUpperCase()}`
|
||||
if (LETTER_KEYS.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
if (SINGLE_DIGIT_RE.test(token)) {
|
||||
const candidate = `Digit${token}`
|
||||
if (DIGIT_KEYS.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
throw new Error(`Invalid accelerator: unknown key "${token}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the canonical modifier for a token, or `undefined` if the
|
||||
* token is not a modifier (i.e. probably a key).
|
||||
*/
|
||||
function lookupModifierToken(token: string): ShortcutModifier | undefined {
|
||||
return MODIFIER_ALIASES.get(token.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string accelerator into its canonical structured form.
|
||||
*
|
||||
* Use when:
|
||||
* - Accepting an accelerator from author code, settings UI, or config
|
||||
* file
|
||||
* - Validating user input
|
||||
*
|
||||
* Expects:
|
||||
* - `input` is a `+`-joined token string. Whitespace around tokens is
|
||||
* tolerated and ignored. Modifier tokens are case-insensitive; key
|
||||
* tokens are matched against `KEY_NAMES` and a small alias map, with
|
||||
* single letters and digits accepted as shorthand.
|
||||
*
|
||||
* Returns:
|
||||
* - A `ShortcutAccelerator` with modifiers in input order and a
|
||||
* canonical key
|
||||
*
|
||||
* Throws:
|
||||
* - `Error` when the input is empty, has empty tokens, names an
|
||||
* unknown key, repeats a modifier, or contains multiple non-modifier
|
||||
* tokens
|
||||
*
|
||||
* @example
|
||||
* parseAccelerator('Mod+Shift+K')
|
||||
* // => { modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' }
|
||||
*
|
||||
* @example
|
||||
* parseAccelerator(' CmdOrCtrl + Shift + KeyK ')
|
||||
* // => same as above; whitespace tolerated
|
||||
*/
|
||||
export function parseAccelerator(input: string): ShortcutAccelerator {
|
||||
const trimmed = input.trim()
|
||||
if (trimmed.length === 0)
|
||||
throw new Error('Invalid accelerator: empty string')
|
||||
|
||||
const rawTokens = trimmed.split('+')
|
||||
const modifiers: ShortcutModifier[] = []
|
||||
let key: ShortcutKey | undefined
|
||||
|
||||
for (const raw of rawTokens) {
|
||||
const token = raw.trim()
|
||||
if (token.length === 0)
|
||||
throw new Error(`Invalid accelerator "${input}": empty token`)
|
||||
|
||||
const modifier = lookupModifierToken(token)
|
||||
if (modifier !== undefined) {
|
||||
if (modifiers.includes(modifier))
|
||||
throw new Error(`Invalid accelerator "${input}": duplicate modifier "${modifier}"`)
|
||||
modifiers.push(modifier)
|
||||
continue
|
||||
}
|
||||
|
||||
if (key !== undefined)
|
||||
throw new Error(`Invalid accelerator "${input}": multiple non-modifier keys ("${key}", "${token}")`)
|
||||
key = normalizeKeyToken(token)
|
||||
}
|
||||
|
||||
if (key === undefined)
|
||||
throw new Error(`Invalid accelerator "${input}": no key token`)
|
||||
|
||||
return { modifiers, key }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests whether `input` is a well-formed accelerator string.
|
||||
*
|
||||
* Use when:
|
||||
* - Gating user input without needing the parsed result
|
||||
*
|
||||
* Returns:
|
||||
* - `true` when `parseAccelerator` would succeed, `false` otherwise
|
||||
*/
|
||||
export function isValidAccelerator(input: string): boolean {
|
||||
try {
|
||||
parseAccelerator(input)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the modifiers of `acc` sorted into canonical order.
|
||||
*/
|
||||
function canonicalModifiers(acc: ShortcutAccelerator): ShortcutModifier[] {
|
||||
return MODIFIER_CANONICAL_ORDER.filter(m => acc.modifiers.includes(m))
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a structured accelerator back to canonical IR string
|
||||
* form.
|
||||
*
|
||||
* Use when:
|
||||
* - Displaying a binding in settings UI
|
||||
* - Round-tripping a binding through a string representation
|
||||
*
|
||||
* Returns:
|
||||
* - A `+`-joined string with modifiers in canonical order followed by
|
||||
* the key. The output round-trips losslessly through
|
||||
* `parseAccelerator`.
|
||||
*
|
||||
* @example
|
||||
* formatAccelerator({ modifiers: ['shift', 'cmd-or-ctrl'], key: 'KeyK' })
|
||||
* // => 'Mod+Shift+KeyK'
|
||||
*/
|
||||
export function formatAccelerator(acc: ShortcutAccelerator): string {
|
||||
const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_IR_TOKEN[m])
|
||||
tokens.push(acc.key)
|
||||
return tokens.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a canonical W3C key name to Electron's accelerator key
|
||||
* token.
|
||||
*
|
||||
* Before:
|
||||
* - `"KeyK"` / `"Digit1"` / `"ArrowUp"` / `"Equal"` / `"F12"`
|
||||
*
|
||||
* After:
|
||||
* - `"K"` / `"1"` / `"Up"` / `"="` / `"F12"`
|
||||
*/
|
||||
function toElectronKey(key: ShortcutKey): string {
|
||||
if (LETTER_KEYS.has(key))
|
||||
return key.slice(3)
|
||||
if (DIGIT_KEYS.has(key))
|
||||
return key.slice(5)
|
||||
if (key in ELECTRON_KEY_OVERRIDES)
|
||||
return ELECTRON_KEY_OVERRIDES[key]
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a structured accelerator to Electron's accelerator string
|
||||
* format, suitable for `globalShortcut.register`.
|
||||
*
|
||||
* Use when:
|
||||
* - Calling Electron's `globalShortcut` API from the main-process
|
||||
* driver
|
||||
*
|
||||
* Returns:
|
||||
* - An Electron-format string with modifiers in canonical order
|
||||
* (`CmdOrCtrl`, `Cmd`, `Ctrl`, `Alt`, `Shift`, `Super`) followed by
|
||||
* Electron's key spelling
|
||||
*
|
||||
* @example
|
||||
* formatElectronAccelerator({ modifiers: ['cmd-or-ctrl', 'shift'], key: 'KeyK' })
|
||||
* // => 'CmdOrCtrl+Shift+K'
|
||||
*/
|
||||
export function formatElectronAccelerator(acc: ShortcutAccelerator): string {
|
||||
const tokens = canonicalModifiers(acc).map(m => MODIFIER_TO_ELECTRON_TOKEN[m])
|
||||
tokens.push(toElectronKey(acc.key))
|
||||
return tokens.join('+')
|
||||
}
|
||||
2
packages/stage-shared/src/global-shortcut/index.ts
Normal file
2
packages/stage-shared/src/global-shortcut/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './accelerators'
|
||||
export * from './types'
|
||||
106
packages/stage-shared/src/global-shortcut/types.ts
Normal file
106
packages/stage-shared/src/global-shortcut/types.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Modifier key understood by accelerator parsing and serialization.
|
||||
*
|
||||
* - `cmd-or-ctrl` — platform meta key. Resolves to Cmd on macOS,
|
||||
* Ctrl on Windows/Linux at the driver boundary.
|
||||
* - `cmd` — literal Command key.
|
||||
* - `ctrl` — literal Control key.
|
||||
* - `alt` — Alt / Option.
|
||||
* - `shift` — Shift.
|
||||
* - `super` — Super / Win / Meta key.
|
||||
*/
|
||||
export type ShortcutModifier
|
||||
= | 'cmd-or-ctrl'
|
||||
| 'cmd'
|
||||
| 'ctrl'
|
||||
| 'alt'
|
||||
| 'shift'
|
||||
| 'super'
|
||||
|
||||
/**
|
||||
* Key identifier following the W3C `KeyboardEvent.code` convention.
|
||||
* Layout-independent; refers to physical key position.
|
||||
*
|
||||
* Examples: `"KeyK"`, `"Digit1"`, `"F12"`, `"ArrowUp"`, `"Space"`,
|
||||
* `"Escape"`. The accepted set is enumerated by `KEY_NAMES` in
|
||||
* `./accelerators`.
|
||||
*/
|
||||
export type ShortcutKey = string
|
||||
|
||||
/**
|
||||
* A keyboard combination: modifiers plus a single key.
|
||||
*
|
||||
* Compare two accelerators structurally; modifier array order is not
|
||||
* significant. Use `formatAccelerator` for a stable canonical string.
|
||||
*/
|
||||
export interface ShortcutAccelerator {
|
||||
modifiers: ShortcutModifier[]
|
||||
key: ShortcutKey
|
||||
}
|
||||
|
||||
/**
|
||||
* When a shortcut is active.
|
||||
*
|
||||
* - `'global'` — fires regardless of which app or window is focused.
|
||||
* - (More will be added if needed)
|
||||
*/
|
||||
export type ShortcutScope = 'global'
|
||||
|
||||
/**
|
||||
* A registered shortcut entry.
|
||||
*
|
||||
* `id` is the stable handle used by (un)registration, and trigger
|
||||
* events; rebinding the accelerator must not change it.
|
||||
*/
|
||||
export interface ShortcutBinding {
|
||||
/** Stable identifier, e.g. `"toggle-main-window"`. */
|
||||
id: string
|
||||
/** Keyboard combination that triggers this shortcut. */
|
||||
accelerator: ShortcutAccelerator
|
||||
/** When the shortcut is active. */
|
||||
scope: ShortcutScope
|
||||
/**
|
||||
* Whether the driver should also emit key-release events.
|
||||
*
|
||||
* Drivers that cannot deliver release events refuse the
|
||||
* registration with `{ ok: false, reason: 'unsupported' }`.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
receiveKeyUps?: boolean
|
||||
/** Human-readable description, surfaced in settings UI. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a registration request.
|
||||
*
|
||||
* `ok: true` means the binding is live. `ok: false` carries `reason`.
|
||||
* `actualAccelerator` is populated when the host had to substitute the
|
||||
* requested accelerator (e.g. user choice via a Wayland portal dialog).
|
||||
*/
|
||||
export interface ShortcutRegistrationResult {
|
||||
id: string
|
||||
ok: boolean
|
||||
/**
|
||||
* The accelerator the host actually bound. Absent when the request
|
||||
* was honoured verbatim.
|
||||
*/
|
||||
actualAccelerator?: ShortcutAccelerator
|
||||
/**
|
||||
* Failure reason. Known values: `'conflict'`, `'denied'`,
|
||||
* `'unsupported'`. Drivers may emit other strings; treat unknown
|
||||
* values as opaque.
|
||||
*/
|
||||
reason?: 'conflict' | 'denied' | 'unsupported' | string
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory shortcut config. Bump `version` on any breaking schema
|
||||
* change; consumers refuse newer versions rather than silently
|
||||
* dropping fields.
|
||||
*/
|
||||
export interface ShortcutConfig {
|
||||
version: 1
|
||||
bindings: ShortcutBinding[]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue