export type ShortcutModifier = 'ctrl' | 'alt' | 'shift' | 'meta'; export type ShortcutPlatform = 'mac' | 'windows' | 'linux'; export type ShortcutTargetPolicy = 'allow' | 'ignore'; export type ShortcutScope = | 'global' | 'catalogue' | 'viewer' | 'viewer-panel' | 'modal' | (string & {}); export interface KeyboardShortcut { key: string; modifiers?: readonly ShortcutModifier[]; } export interface NormalizedKeyboardShortcut { key: string; modifiers: readonly ShortcutModifier[]; } export interface KeyboardShortcutEventLike { key: string; code?: string; ctrlKey?: boolean; altKey?: boolean; shiftKey?: boolean; metaKey?: boolean; repeat?: boolean; target?: EventTarget | null; preventDefault?: () => void; getModifierState?: (keyArg: string) => boolean; } export interface ShortcutTargetOptions { allowInEditable?: boolean | ((event: KeyboardShortcutEventLike) => boolean); allowInInteractive?: boolean | ((event: KeyboardShortcutEventLike) => boolean); } export interface ShortcutMatchOptions extends ShortcutTargetOptions { allowRepeat?: boolean; preventDefault?: boolean; } export interface KeyboardShortcutDefinition { id: string; scope: ShortcutScope; description: string; category?: string; shortcut: KeyboardShortcut | string; preventDefault?: boolean; allowRepeat?: boolean; allowInEditable?: boolean | ((event: KeyboardShortcutEventLike) => boolean); allowInInteractive?: boolean | ((event: KeyboardShortcutEventLike) => boolean); } export interface ShortcutConflict { signature: string; shortcut: NormalizedKeyboardShortcut; definitions: readonly KeyboardShortcutDefinition[]; scopes: readonly ShortcutScope[]; } export const SHORTCUT_MODIFIER_ORDER: readonly ShortcutModifier[] = [ 'ctrl', 'alt', 'shift', 'meta', ]; const MODIFIER_ALIASES: Readonly> = { alt: 'alt', option: 'alt', opt: 'alt', ctrl: 'ctrl', control: 'ctrl', ctl: 'ctrl', meta: 'meta', cmd: 'meta', command: 'meta', super: 'meta', win: 'meta', windows: 'meta', shift: 'shift', }; const KEY_ALIASES: Readonly> = { ' ': 'Space', space: 'Space', spacebar: 'Space', esc: 'Escape', escape: 'Escape', enter: 'Enter', return: 'Enter', tab: 'Tab', backspace: 'Backspace', delete: 'Delete', del: 'Delete', insert: 'Insert', ins: 'Insert', home: 'Home', end: 'End', pageup: 'PageUp', pgup: 'PageUp', pagedown: 'PageDown', pgdown: 'PageDown', arrowleft: 'ArrowLeft', left: 'ArrowLeft', arrowright: 'ArrowRight', right: 'ArrowRight', arrowup: 'ArrowUp', up: 'ArrowUp', arrowdown: 'ArrowDown', down: 'ArrowDown', plus: '+', minus: '-', equals: '=', equal: '=', comma: ',', period: '.', dot: '.', slash: '/', forwardslash: '/', backslash: '\\', bracketleft: '[', openbracket: '[', bracketright: ']', closebracket: ']', semicolon: ';', quote: "'", apostrophe: "'", backquote: '`', grave: '`', question: '?', questionmark: '?', }; const SHIFTED_PRINTABLE_KEYS: Readonly> = { '~': '`', '!': '1', '@': '2', '#': '3', $: '4', '%': '5', '^': '6', '&': '7', '*': '8', '(': '9', ')': '0', _: '-', '+': '=', '{': '[', '}': ']', '|': '\\', ':': ';', '"': "'", '<': ',', '>': '.', '?': '/', }; const KEY_LABELS: Readonly> = { Space: 'Space', Escape: 'Esc', Enter: 'Enter', Tab: 'Tab', Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert', Home: 'Home', End: 'End', PageUp: 'Page Up', PageDown: 'Page Down', ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓', }; const MAC_MODIFIER_LABELS: Readonly> = { ctrl: '⌃', alt: '⌥', shift: '⇧', meta: '⌘', }; const TEXT_MODIFIER_LABELS: Readonly< Record, Readonly>> > = { windows: { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Win', }, linux: { ctrl: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Meta', }, }; const EDITABLE_TAGS = new Set(['input', 'select', 'textarea']); const EDITABLE_ROLES = new Set(['combobox', 'searchbox', 'spinbutton', 'textbox']); const INTERACTIVE_TAGS = new Set(['button', 'summary']); const INTERACTIVE_ROLES = new Set([ 'button', 'checkbox', 'grid', 'gridcell', 'link', 'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'slider', 'switch', 'tab', 'tree', 'treeitem', ]); interface ShortcutTargetNode { tagName?: string; isContentEditable?: boolean; parentElement?: ShortcutTargetNode | null; parentNode?: ShortcutTargetNode | null; getAttribute?: (name: string) => string | null; } export function normalizeModifier(modifier: string): ShortcutModifier { const compactModifier = compactToken(modifier); const normalized = MODIFIER_ALIASES[compactModifier]; if (!normalized) { throw new Error(`Unknown keyboard shortcut modifier "${modifier}".`); } return normalized; } export function normalizeModifiers(modifiers: readonly string[] = []): readonly ShortcutModifier[] { const seen = new Set(); for (const modifier of modifiers) { seen.add(normalizeModifier(modifier)); } return SHORTCUT_MODIFIER_ORDER.filter((modifier) => seen.has(modifier)); } export function normalizeKey(rawKey: string): string { if (rawKey === ' ') { return 'Space'; } const trimmedKey = String(rawKey).trim(); if (!trimmedKey) { throw new Error('Keyboard shortcut key cannot be empty.'); } const compactKey = compactToken(trimmedKey); const aliasedKey = KEY_ALIASES[compactKey]; if (aliasedKey) { return aliasedKey; } if (/^f([1-9]|1\d|2[0-4])$/i.test(trimmedKey)) { return trimmedKey.toUpperCase(); } if (trimmedKey.length === 1) { return trimmedKey.toLowerCase(); } return trimmedKey; } export function parseShortcut(input: string): NormalizedKeyboardShortcut { const tokens = tokenizeShortcut(input); const modifiers: ShortcutModifier[] = []; let keyToken: string | undefined; for (const token of tokens) { const modifier = getModifierAlias(token); if (modifier) { modifiers.push(modifier); continue; } if (keyToken) { throw new Error( `Keyboard shortcut "${input}" has multiple non-modifier keys: "${keyToken}" and "${token}".`, ); } keyToken = token; } if (!keyToken) { throw new Error(`Keyboard shortcut "${input}" is missing a non-modifier key.`); } return normalizeParsedShortcut(keyToken, modifiers); } export function normalizeShortcut(shortcut: KeyboardShortcut | string): NormalizedKeyboardShortcut { if (typeof shortcut === 'string') { return parseShortcut(shortcut); } return normalizeShortcutObject(shortcut); } export function shortcutSignature(shortcut: KeyboardShortcut | string): string { const normalized = normalizeShortcut(shortcut); return [...normalized.modifiers, normalized.key].join('+'); } export function eventToShortcut(event: KeyboardShortcutEventLike): NormalizedKeyboardShortcut { let key = normalizeKey(event.key); if (event.shiftKey && SHIFTED_PRINTABLE_KEYS[key]) { key = SHIFTED_PRINTABLE_KEYS[key]; } const modifiers: ShortcutModifier[] = []; if (event.ctrlKey) { modifiers.push('ctrl'); } if (event.altKey) { modifiers.push('alt'); } if (event.shiftKey) { modifiers.push('shift'); } if (event.metaKey) { modifiers.push('meta'); } return normalizeShortcutObject({ key, modifiers }); } export function getShortcutPlatform(): ShortcutPlatform { if (typeof navigator === 'undefined') { return 'windows'; } const nav = navigator as Navigator & { userAgentData?: { platform?: string } }; const platform = [ nav.userAgentData?.platform ?? '', nav.platform ?? '', nav.userAgent ?? '', ] .join(' ') .toLowerCase(); if (/(mac|iphone|ipad|ipod)/.test(platform)) { return 'mac'; } if (/(linux|x11)/.test(platform)) { return 'linux'; } return 'windows'; } export function shortcutToLabel( shortcut: KeyboardShortcut | string, platform: ShortcutPlatform = getShortcutPlatform(), ): string { const normalized = normalizeShortcut(shortcut); const modifierLabels = platform === 'mac' ? MAC_MODIFIER_LABELS : TEXT_MODIFIER_LABELS[platform]; const parts = [ ...normalized.modifiers.map((modifier) => modifierLabels[modifier]), KEY_LABELS[normalized.key] ?? (normalized.key.length === 1 ? normalized.key.toUpperCase() : normalized.key), ]; return platform === 'mac' ? parts.join('') : parts.join(' + '); } export function getShortcutTargetPolicy( target: EventTarget | null | undefined, ): ShortcutTargetPolicy | undefined { let current = asShortcutTargetNode(target); const visited = new Set(); while (current && !visited.has(current)) { visited.add(current); const policy = readShortcutPolicy(current); if (policy) { return policy; } current = getParentNode(current); } return undefined; } export function isEditableTarget(target: EventTarget | null | undefined): boolean { return hasTargetMatching(target, (node) => { const tagName = getTagName(node); if (EDITABLE_TAGS.has(tagName)) { return true; } if (node.isContentEditable === true) { return true; } const contentEditable = getAttributeValue(node, 'contenteditable')?.toLowerCase(); if ( contentEditable === '' || contentEditable === 'true' || contentEditable === 'plaintext-only' ) { return true; } const role = getPrimaryRole(node); return role ? EDITABLE_ROLES.has(role) : false; }); } export function isInteractiveTarget(target: EventTarget | null | undefined): boolean { if (isEditableTarget(target)) { return true; } return hasTargetMatching(target, (node) => { const tagName = getTagName(node); if (INTERACTIVE_TAGS.has(tagName)) { return true; } if (tagName === 'a' && getAttributeValue(node, 'href') !== null) { return true; } const role = getPrimaryRole(node); return role ? INTERACTIVE_ROLES.has(role) : false; }); } export function shouldIgnoreKeyboardEvent( event: KeyboardShortcutEventLike, options: ShortcutTargetOptions = {}, ): boolean { const targetPolicy = getShortcutTargetPolicy(event.target); if (targetPolicy === 'ignore') { return true; } if (targetPolicy === 'allow') { return false; } if (isEditableTarget(event.target)) { return !resolveTargetAllowance(options.allowInEditable, event); } if (isInteractiveTarget(event.target)) { return !resolveTargetAllowance(options.allowInInteractive, event); } return false; } export function isAltGraphEvent(event: KeyboardShortcutEventLike): boolean { if (typeof event.getModifierState !== 'function') { return false; } try { return event.getModifierState('AltGraph'); } catch { return false; } } export function matchesShortcut( event: KeyboardShortcutEventLike, shortcut: KeyboardShortcut | string, options: ShortcutMatchOptions = {}, ): boolean { if (typeof event.key !== 'string' || !event.key) { return false; } if (isAltGraphEvent(event)) { return false; } if (event.repeat && options.allowRepeat !== true) { return false; } if (shouldIgnoreKeyboardEvent(event, options)) { return false; } let matched = false; try { matched = shortcutSignature(eventToShortcut(event)) === shortcutSignature(shortcut); } catch { return false; } if (matched && options.preventDefault) { event.preventDefault?.(); } return matched; } export function findShortcutConflicts( definitions: readonly KeyboardShortcutDefinition[], ): readonly ShortcutConflict[] { const normalizedByDefinition = new Map< KeyboardShortcutDefinition, NormalizedKeyboardShortcut >(); const conflictSets = new Map>(); const getNormalizedShortcut = ( definition: KeyboardShortcutDefinition, ): NormalizedKeyboardShortcut => { const existing = normalizedByDefinition.get(definition); if (existing) { return existing; } const normalized = normalizeShortcut(definition.shortcut); normalizedByDefinition.set(definition, normalized); return normalized; }; for (let aIndex = 0; aIndex < definitions.length; aIndex += 1) { const definitionA = definitions[aIndex]; if (!definitionA) { continue; } for (let bIndex = aIndex + 1; bIndex < definitions.length; bIndex += 1) { const definitionB = definitions[bIndex]; if (!definitionB || !scopesOverlap(definitionA.scope, definitionB.scope)) { continue; } const shortcutA = getNormalizedShortcut(definitionA); const shortcutB = getNormalizedShortcut(definitionB); const signature = shortcutSignature(shortcutA); if (signature !== shortcutSignature(shortcutB)) { continue; } const conflictSet = conflictSets.get(signature) ?? new Set(); conflictSet.add(definitionA); conflictSet.add(definitionB); conflictSets.set(signature, conflictSet); } } const conflicts: ShortcutConflict[] = []; for (const [signature, definitionSet] of conflictSets) { const conflictDefinitions = [...definitionSet]; const firstDefinition = conflictDefinitions[0]; if (!firstDefinition) { continue; } conflicts.push({ signature, shortcut: normalizeShortcut(firstDefinition.shortcut), definitions: conflictDefinitions, scopes: Array.from(new Set(conflictDefinitions.map((definition) => definition.scope))).sort( (scopeA, scopeB) => String(scopeA).localeCompare(String(scopeB)), ), }); } return conflicts.sort((conflictA, conflictB) => conflictA.signature.localeCompare(conflictB.signature), ); } export const DEFAULT_MECHANICA_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [ { id: 'general.open-help', scope: 'global', category: 'General', description: 'Open the keyboard shortcuts reference.', shortcut: '?', preventDefault: true, }, { id: 'general.dismiss', scope: 'global', category: 'General', description: 'Dismiss the active popover, drawer, or modal.', shortcut: 'Escape', preventDefault: true, allowInEditable: true, allowInInteractive: true, }, { id: 'catalogue.focus-search', scope: 'catalogue', category: 'Catalogue', description: 'Move focus to the catalogue search input.', shortcut: '/', preventDefault: true, }, { id: 'catalogue.toggle-filters', scope: 'catalogue', category: 'Catalogue', description: 'Toggle the advanced catalogue filter panel.', shortcut: 'Shift+F', preventDefault: true, }, { id: 'viewer.play-pause', scope: 'viewer', category: 'Animation', description: 'Play or pause the active working animation.', shortcut: 'Space', preventDefault: true, }, { id: 'viewer.step-backward', scope: 'viewer', category: 'Animation', description: 'Step the working animation backward by one increment.', shortcut: 'ArrowLeft', preventDefault: true, allowRepeat: true, }, { id: 'viewer.step-forward', scope: 'viewer', category: 'Animation', description: 'Step the working animation forward by one increment.', shortcut: 'ArrowRight', preventDefault: true, allowRepeat: true, }, { id: 'viewer.decrease-speed', scope: 'viewer', category: 'Animation', description: 'Decrease animation playback speed.', shortcut: '-', preventDefault: true, allowRepeat: true, }, { id: 'viewer.increase-speed', scope: 'viewer', category: 'Animation', description: 'Increase animation playback speed.', shortcut: '=', preventDefault: true, allowRepeat: true, }, { id: 'viewer.toggle-exploded', scope: 'viewer', category: 'View', description: 'Toggle exploded assembly view.', shortcut: 'E', preventDefault: true, }, { id: 'viewer.decrease-explode', scope: 'viewer', category: 'View', description: 'Decrease exploded view separation.', shortcut: '[', preventDefault: true, allowRepeat: true, }, { id: 'viewer.increase-explode', scope: 'viewer', category: 'View', description: 'Increase exploded view separation.', shortcut: ']', preventDefault: true, allowRepeat: true, }, { id: 'viewer.solid-mode', scope: 'viewer', category: 'Render Modes', description: 'Switch the viewer to solid render mode.', shortcut: 'S', preventDefault: true, }, { id: 'viewer.wireframe-mode', scope: 'viewer', category: 'Render Modes', description: 'Switch the viewer to wireframe render mode.', shortcut: 'W', preventDefault: true, }, { id: 'viewer.cross-section-mode', scope: 'viewer', category: 'Render Modes', description: 'Toggle cross-section inspection mode.', shortcut: 'X', preventDefault: true, }, { id: 'viewer.reset-camera', scope: 'viewer', category: 'Camera', description: 'Reset the camera to the default saved view.', shortcut: 'R', preventDefault: true, }, { id: 'viewer.frame-selection', scope: 'viewer', category: 'Camera', description: 'Frame the currently selected component or full machine.', shortcut: 'F', preventDefault: true, }, { id: 'viewer.camera-isometric', scope: 'viewer', category: 'Camera', description: 'Jump to the isometric camera preset.', shortcut: '1', preventDefault: true, }, { id: 'viewer.camera-front', scope: 'viewer', category: 'Camera', description: 'Jump to the front camera preset.', shortcut: '2', preventDefault: true, }, { id: 'viewer.camera-side', scope: 'viewer', category: 'Camera', description: 'Jump to the side camera preset.', shortcut: '3', preventDefault: true, }, { id: 'viewer.camera-top', scope: 'viewer', category: 'Camera', description: 'Jump to the top camera preset.', shortcut: '4', preventDefault: true, }, { id: 'viewer.toggle-components', scope: 'viewer', category: 'Panels', description: 'Toggle the component visibility and opacity panel.', shortcut: 'C', preventDefault: true, }, ]; export const DEFAULT_GLOBAL_SHORTCUTS: readonly KeyboardShortcutDefinition[] = DEFAULT_MECHANICA_SHORTCUTS.filter((shortcut) => shortcut.scope === 'global'); export const DEFAULT_CATALOGUE_SHORTCUTS: readonly KeyboardShortcutDefinition[] = DEFAULT_MECHANICA_SHORTCUTS.filter((shortcut) => shortcut.scope === 'catalogue'); export const DEFAULT_VIEWER_SHORTCUTS: readonly KeyboardShortcutDefinition[] = DEFAULT_MECHANICA_SHORTCUTS.filter((shortcut) => shortcut.scope === 'viewer'); function compactToken(token: string): string { return token.trim().toLowerCase().replace(/\s+/g, ''); } function getModifierAlias(token: string): ShortcutModifier | undefined { return MODIFIER_ALIASES[compactToken(token)]; } function tokenizeShortcut(input: string): readonly string[] { const trimmedInput = input.trim(); if (!trimmedInput) { throw new Error('Keyboard shortcut cannot be empty.'); } if (trimmedInput === '+') { return ['+']; } const tokens = trimmedInput .split('+') .map((token) => token.trim()) .filter(Boolean); if (trimmedInput.endsWith('+') && tokens[tokens.length - 1] !== '+') { tokens.push('+'); } if (tokens.length === 0) { throw new Error(`Keyboard shortcut "${input}" could not be parsed.`); } return tokens; } function normalizeShortcutObject(shortcut: KeyboardShortcut): NormalizedKeyboardShortcut { return { key: normalizeKey(shortcut.key), modifiers: normalizeModifiers(shortcut.modifiers ?? []), }; } function normalizeParsedShortcut( key: string, modifiers: readonly ShortcutModifier[], ): NormalizedKeyboardShortcut { const normalized = normalizeShortcutObject({ key, modifiers }); const unshiftedKey = SHIFTED_PRINTABLE_KEYS[normalized.key]; if (!unshiftedKey) { return normalized; } return normalizeShortcutObject({ key: unshiftedKey, modifiers: [...normalized.modifiers, 'shift'], }); } function asShortcutTargetNode( target: EventTarget | null | undefined, ): ShortcutTargetNode | null { if (!target || typeof target !== 'object') { return null; } return target as ShortcutTargetNode; } function getParentNode(node: ShortcutTargetNode): ShortcutTargetNode | null { const parent = node.parentElement ?? node.parentNode ?? null; return parent && typeof parent === 'object' ? parent : null; } function getAttributeValue(node: ShortcutTargetNode, attribute: string): string | null { try { return node.getAttribute?.(attribute) ?? null; } catch { return null; } } function readShortcutPolicy(node: ShortcutTargetNode): ShortcutTargetPolicy | undefined { const policy = ( getAttributeValue(node, 'data-mechanica-shortcuts') ?? getAttributeValue(node, 'data-keyboard-shortcuts') )?.toLowerCase(); return policy === 'allow' || policy === 'ignore' ? policy : undefined; } function hasTargetMatching( target: EventTarget | null | undefined, predicate: (node: ShortcutTargetNode) => boolean, ): boolean { let current = asShortcutTargetNode(target); const visited = new Set(); while (current && !visited.has(current)) { visited.add(current); if (predicate(current)) { return true; } current = getParentNode(current); } return false; } function getTagName(node: ShortcutTargetNode): string { return typeof node.tagName === 'string' ? node.tagName.toLowerCase() : ''; } function getPrimaryRole(node: ShortcutTargetNode): string | null { const role = getAttributeValue(node, 'role'); return role?.trim().split(/\s+/)[0]?.toLowerCase() ?? null; } function resolveTargetAllowance( allowance: boolean | ((event: KeyboardShortcutEventLike) => boolean) | undefined, event: KeyboardShortcutEventLike, ): boolean { if (typeof allowance === 'function') { try { return allowance(event); } catch { return false; } } return allowance === true; } function scopesOverlap(scopeA: ShortcutScope, scopeB: ShortcutScope): boolean { return scopeA === scopeB || scopeA === 'global' || scopeB === 'global'; }