export type ViewerShortcutGroupId = 'navigation' | 'display' | 'parts' | 'animation' | 'help'; export type ViewerCommandId = | 'togglePlayback' | 'resetCamera' | 'fitCamera' | 'nextCameraPreset' | 'previousCameraPreset' | 'toggleExplodedView' | 'increaseExplosion' | 'decreaseExplosion' | 'solidMode' | 'wireframeMode' | 'crossSectionMode' | 'toggleAnnotations' | 'togglePartList' | 'toggleDetailDrawer' | 'clearSelection' | 'focusPartSearch' | 'showKeyboardHelp'; export type ShortcutPlatform = 'mac' | 'windows' | 'linux' | 'unknown'; export interface ViewerShortcutChord { readonly key?: string; readonly code?: string; readonly ctrlKey?: boolean; readonly metaKey?: boolean; readonly altKey?: boolean; readonly shiftKey?: boolean; readonly modKey?: boolean; readonly allowRepeat?: boolean; } export interface ViewerShortcutDefinition { readonly command: ViewerCommandId; readonly label: string; readonly description?: string; readonly group?: ViewerShortcutGroupId | string; readonly chords: readonly ViewerShortcutChord[]; readonly preventDefault?: boolean; } export interface KeyboardEventLike { readonly key?: string; readonly code?: string; readonly ctrlKey?: boolean; readonly metaKey?: boolean; readonly altKey?: boolean; readonly shiftKey?: boolean; readonly repeat?: boolean; readonly defaultPrevented?: boolean; readonly target?: EventTarget | null; readonly composedPath?: () => EventTarget[]; } export interface ViewerShortcutMatcherOptions { readonly ignoreEditableTargets?: boolean; readonly ignoreInteractiveTargets?: boolean; } export interface ViewerShortcutMatch { readonly command: ViewerCommandId; readonly definition: ViewerShortcutDefinition; readonly chord: ViewerShortcutChord; } export interface ViewerShortcutGroup { readonly id: ViewerShortcutGroupId | string; readonly label: string; readonly shortcuts: readonly ViewerShortcutDefinition[]; } export const VIEWER_SHORTCUT_GROUP_LABELS: Record = { navigation: 'Navigation', display: 'Display modes', parts: 'Parts and annotations', animation: 'Animation', help: 'Help', }; export const VIEWER_SHORTCUT_GROUP_ORDER: readonly ViewerShortcutGroupId[] = [ 'navigation', 'display', 'parts', 'animation', 'help', ]; export const DEFAULT_VIEWER_SHORTCUTS: readonly ViewerShortcutDefinition[] = [ { command: 'resetCamera', label: 'Reset camera', description: 'Return the camera to the active preset or default isometric view.', group: 'navigation', chords: [{ key: 'r' }], }, { command: 'fitCamera', label: 'Fit model to view', description: 'Frame the complete machine in the viewport.', group: 'navigation', chords: [{ key: 'f' }], }, { command: 'nextCameraPreset', label: 'Next camera preset', description: 'Cycle through saved orthographic-style engineering viewpoints.', group: 'navigation', chords: [{ key: 'c' }], }, { command: 'previousCameraPreset', label: 'Previous camera preset', description: 'Cycle backwards through saved camera presets.', group: 'navigation', chords: [{ key: 'c', shiftKey: true }], }, { command: 'solidMode', label: 'Solid view', description: 'Switch all parts back to physically shaded solid rendering.', group: 'display', chords: [{ key: '1' }], }, { command: 'wireframeMode', label: 'Wireframe view', description: 'Show model topology and hidden construction edges.', group: 'display', chords: [{ key: '2' }, { key: 'w' }], }, { command: 'crossSectionMode', label: 'Cross-section view', description: 'Enable the active clipping plane for internal inspection.', group: 'display', chords: [{ key: '3' }, { key: 'x' }], }, { command: 'toggleExplodedView', label: 'Toggle exploded view', description: 'Separate assemblies along their authored explosion vectors.', group: 'display', chords: [{ key: 'e' }], }, { command: 'decreaseExplosion', label: 'Decrease exploded separation', description: 'Pull exploded parts closer together.', group: 'display', chords: [{ key: '[', allowRepeat: true }], }, { command: 'increaseExplosion', label: 'Increase exploded separation', description: 'Push exploded parts farther apart.', group: 'display', chords: [{ key: ']', allowRepeat: true }], }, { command: 'toggleAnnotations', label: 'Toggle annotations', description: 'Show or hide part labels in the 3D scene.', group: 'parts', chords: [{ key: 'l' }], }, { command: 'togglePartList', label: 'Toggle parts panel', description: 'Open or close the component visibility and opacity controls.', group: 'parts', chords: [{ key: 'p' }], }, { command: 'toggleDetailDrawer', label: 'Toggle detail drawer', description: 'Open or close engineering notes for the selected component.', group: 'parts', chords: [{ key: 'd' }], }, { command: 'clearSelection', label: 'Clear selection', description: 'Deselect the active part and dismiss transient overlays.', group: 'parts', chords: [{ key: 'Escape' }], }, { command: 'focusPartSearch', label: 'Focus part search', description: 'Move keyboard focus to the part filter field when available.', group: 'parts', chords: [{ key: '/' }], }, { command: 'togglePlayback', label: 'Play or pause animation', description: 'Start or pause the current mechanical motion cycle.', group: 'animation', chords: [{ key: ' ' }, { key: 'k' }], }, { command: 'showKeyboardHelp', label: 'Show keyboard shortcuts', description: 'Open the keyboard command reference.', group: 'help', chords: [{ key: '?', shiftKey: true }, { key: 'F1' }], }, ]; const KEY_ALIASES: Readonly> = { ' ': 'space', spacebar: 'space', esc: 'escape', del: 'delete', return: 'enter', up: 'arrowup', down: 'arrowdown', left: 'arrowleft', right: 'arrowright', }; const KEY_LABELS: Readonly> = { space: 'Space', escape: 'Esc', enter: 'Enter', tab: 'Tab', delete: 'Delete', backspace: 'Backspace', arrowup: '↑', arrowdown: '↓', arrowleft: '←', arrowright: '→', pageup: 'Page Up', pagedown: 'Page Down', home: 'Home', end: 'End', }; const CODE_LABELS: Readonly> = { bracketleft: '[', bracketright: ']', slash: '/', backslash: '\\', quote: "'", semicolon: ';', comma: ',', period: '.', minus: '-', equal: '=', space: 'Space', }; const NON_EDITABLE_INPUT_TYPES = new Set([ 'button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit', ]); const EDITABLE_ROLES = new Set([ 'combobox', 'searchbox', 'spinbutton', 'textbox', ]); const INTERACTIVE_ROLES = new Set([ 'button', 'checkbox', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'slider', 'switch', 'tab', 'treeitem', ]); export function normalizeKeyboardKey(key: string | undefined): string { if (!key) { return ''; } const normalized = key.toLowerCase(); return KEY_ALIASES[normalized] ?? normalized; } export function normalizeKeyboardCode(code: string | undefined): string { return code?.toLowerCase() ?? ''; } export function getKeyboardPlatform(source?: string): ShortcutPlatform { const platformSource = source ?? (typeof navigator === 'undefined' ? '' : `${navigator.platform} ${navigator.userAgent}`); if (/mac|iphone|ipad|ipod/i.test(platformSource)) { return 'mac'; } if (/win/i.test(platformSource)) { return 'windows'; } if (/linux|x11|android/i.test(platformSource)) { return 'linux'; } return 'unknown'; } export function formatKeyboardKeyLabel(key: string | undefined): string { const normalized = normalizeKeyboardKey(key); if (!normalized) { return ''; } if (KEY_LABELS[normalized]) { return KEY_LABELS[normalized]; } if (/^f\d{1,2}$/.test(normalized)) { return normalized.toUpperCase(); } if (normalized.length === 1) { return normalized.toUpperCase(); } return toTitleCase(normalized); } export function formatKeyboardCodeLabel(code: string | undefined): string { const normalized = normalizeKeyboardCode(code); if (!normalized) { return ''; } const keyMatch = /^key([a-z])$/i.exec(code ?? ''); if (keyMatch?.[1]) { return keyMatch[1].toUpperCase(); } const digitMatch = /^digit(\d)$/i.exec(code ?? ''); if (digitMatch?.[1]) { return digitMatch[1]; } if (CODE_LABELS[normalized]) { return CODE_LABELS[normalized]; } return toTitleCase(normalized); } export function formatShortcutChord( chord: ViewerShortcutChord, platform: ShortcutPlatform = getKeyboardPlatform(), ): string { const tokens: string[] = []; if (chord.modKey) { tokens.push(platform === 'mac' ? '⌘' : 'Ctrl'); } else { if (chord.ctrlKey) { tokens.push('Ctrl'); } if (chord.metaKey) { tokens.push(platform === 'mac' ? '⌘' : 'Meta'); } } if (chord.altKey) { tokens.push(platform === 'mac' ? 'Option' : 'Alt'); } if (chord.shiftKey) { tokens.push('Shift'); } const keyLabel = chord.key ? formatKeyboardKeyLabel(chord.key) : formatKeyboardCodeLabel(chord.code); if (keyLabel) { tokens.push(keyLabel); } return tokens.join(' + '); } export function describeViewerShortcut( definition: ViewerShortcutDefinition, platform?: ShortcutPlatform, ): string { return definition.chords.map((chord) => formatShortcutChord(chord, platform)).join(' / '); } export function getKeyboardEventTarget(event: KeyboardEventLike): EventTarget | null { const path = typeof event.composedPath === 'function' ? event.composedPath() : undefined; const pathTarget = path?.find((candidate) => candidate instanceof EventTarget); return pathTarget ?? event.target ?? null; } export function isShortcutSuppressedTarget(target: EventTarget | null | undefined): boolean { const element = targetToElement(target); for (let current = element; current; current = current.parentElement) { const override = readShortcutOverride(current); if (override === 'allow' && current === element) { return false; } if (override === 'ignore') { return true; } } return false; } export function isEditableEventTarget(target: EventTarget | null | undefined): boolean { const element = targetToElement(target); for (let current = element; current; current = current.parentElement) { const override = readShortcutOverride(current); if (override === 'ignore') { return true; } if (override === 'allow' && current === element) { return false; } const tagName = current.tagName.toLowerCase(); if (tagName === 'textarea' || tagName === 'select') { return true; } if (tagName === 'input') { return !NON_EDITABLE_INPUT_TYPES.has( (current.getAttribute('type') ?? 'text').trim().toLowerCase(), ); } if ((current as HTMLElement).isContentEditable) { return true; } const role = current.getAttribute('role')?.trim().toLowerCase(); if (role && EDITABLE_ROLES.has(role)) { return true; } } return false; } export function isInteractiveEventTarget(target: EventTarget | null | undefined): boolean { const element = targetToElement(target); for (let current = element; current; current = current.parentElement) { const override = readShortcutOverride(current); if (override === 'ignore') { return true; } if (override === 'allow' && current === element) { return false; } const tagName = current.tagName.toLowerCase(); if ( tagName === 'button' || tagName === 'input' || tagName === 'select' || tagName === 'textarea' || tagName === 'summary' ) { return true; } if (tagName === 'a' && current.hasAttribute('href')) { return true; } const role = current.getAttribute('role')?.trim().toLowerCase(); if (role && INTERACTIVE_ROLES.has(role)) { return true; } } return false; } export function shouldIgnoreViewerKeyboardEvent( event: KeyboardEventLike, options: ViewerShortcutMatcherOptions = {}, ): boolean { if (event.defaultPrevented) { return true; } const target = getKeyboardEventTarget(event); if (isShortcutSuppressedTarget(target)) { return true; } if ((options.ignoreEditableTargets ?? true) && isEditableEventTarget(target)) { return true; } if ((options.ignoreInteractiveTargets ?? true) && isInteractiveEventTarget(target)) { return true; } return false; } export function matchesViewerShortcutChord( event: KeyboardEventLike, chord: ViewerShortcutChord, ): boolean { if (!chord.allowRepeat && event.repeat) { return false; } if (!matchesModifiers(event, chord)) { return false; } if (!chord.key && !chord.code) { return false; } if (chord.key && normalizeKeyboardKey(event.key) !== normalizeKeyboardKey(chord.key)) { return false; } if (chord.code && normalizeKeyboardCode(event.code) !== normalizeKeyboardCode(chord.code)) { return false; } return true; } export function matchViewerShortcut( event: KeyboardEventLike, shortcuts: readonly ViewerShortcutDefinition[] = DEFAULT_VIEWER_SHORTCUTS, options: ViewerShortcutMatcherOptions = {}, ): ViewerShortcutMatch | null { if (shouldIgnoreViewerKeyboardEvent(event, options)) { return null; } for (const definition of shortcuts) { for (const chord of definition.chords) { if (matchesViewerShortcutChord(event, chord)) { return { command: definition.command, definition, chord, }; } } } return null; } export function groupViewerShortcuts( shortcuts: readonly ViewerShortcutDefinition[], ): readonly ViewerShortcutGroup[] { const byGroup = new Map(); for (const shortcut of shortcuts) { const groupId = shortcut.group ?? 'display'; const bucket = byGroup.get(groupId); if (bucket) { bucket.push(shortcut); } else { byGroup.set(groupId, [shortcut]); } } const orderedGroupIds = [ ...VIEWER_SHORTCUT_GROUP_ORDER.filter((groupId) => byGroup.has(groupId)), ...Array.from(byGroup.keys()) .filter((groupId) => !VIEWER_SHORTCUT_GROUP_ORDER.includes(groupId as ViewerShortcutGroupId)) .sort((a, b) => a.localeCompare(b)), ]; return orderedGroupIds.map((groupId) => ({ id: groupId, label: VIEWER_SHORTCUT_GROUP_LABELS[groupId as ViewerShortcutGroupId] ?? toTitleCase(groupId), shortcuts: byGroup.get(groupId) ?? [], })); } function matchesModifiers(event: KeyboardEventLike, chord: ViewerShortcutChord): boolean { if (chord.modKey !== undefined) { const hasPlatformModifier = Boolean(event.ctrlKey || event.metaKey); if (hasPlatformModifier !== chord.modKey) { return false; } } else { if (Boolean(event.ctrlKey) !== Boolean(chord.ctrlKey)) { return false; } if (Boolean(event.metaKey) !== Boolean(chord.metaKey)) { return false; } } if (Boolean(event.altKey) !== Boolean(chord.altKey)) { return false; } if (Boolean(event.shiftKey) !== Boolean(chord.shiftKey)) { return false; } return true; } function targetToElement(target: EventTarget | null | undefined): Element | null { if (!target) { return null; } if (typeof Element !== 'undefined' && target instanceof Element) { return target; } if (typeof Node !== 'undefined' && target instanceof Node) { if (target.nodeType === Node.TEXT_NODE) { return target.parentElement; } return target.parentElement; } return null; } function readShortcutOverride(element: Element): 'allow' | 'ignore' | null { const value = element.getAttribute('data-viewer-shortcuts')?.trim().toLowerCase(); if (value === 'allow' || value === 'ignore') { return value; } return null; } function toTitleCase(value: string): string { return value .replace(/[-_]/g, ' ') .replace(/\s+/g, ' ') .trim() .replace(/\b\w/g, (character) => character.toUpperCase()); }