// Keyboard Shortcuts system — action registry, keybinding types, preset layouts // ── Action IDs ── // Every bindable action in the editor export type ActionId = // Tools | 'tool.select' | 'tool.blade' | 'tool.ripple' | 'tool.roll' | 'tool.slide' | 'tool.slip' | 'tool.trackForward' // Transport | 'transport.playPause' | 'transport.stop' | 'transport.shuttleReverse' // J | 'transport.shuttleStop' // K | 'transport.shuttleForward' // L | 'transport.stepBackward' // Left arrow | 'transport.stepForward' // Right arrow | 'transport.jumpBackward' // Shift+Left | 'transport.jumpForward' // Shift+Right | 'transport.goToStart' | 'transport.goToEnd' | 'transport.goToIn' | 'transport.goToOut' // Editing | 'edit.undo' | 'edit.redo' | 'edit.cut' | 'edit.copy' | 'edit.paste' | 'edit.delete' | 'edit.selectAll' | 'edit.deselect' // Marking | 'mark.setIn' | 'mark.setOut' | 'mark.clearIn' | 'mark.clearOut' | 'mark.clearInOut' // 3-Point Editing | 'edit.insertEdit' | 'edit.overwriteEdit' | 'edit.matchFrame' // Timeline | 'timeline.zoomIn' | 'timeline.zoomOut' | 'timeline.fitToView' | 'timeline.toggleSnap' // Navigation | 'nav.prevEdit' | 'nav.nextEdit' // View | 'view.fullscreen' // ── Key Combo ── export interface KeyCombo { key: string // e.g. 'b', ' ', 'arrowleft', 'delete', ',' ctrl?: boolean shift?: boolean alt?: boolean meta?: boolean // Cmd on Mac } // ── Action Definition ── export interface ActionDefinition { id: ActionId label: string category: 'Tools' | 'Transport' | 'Editing' | 'Marking' | 'Timeline' description?: string } // ── Full Registry ── export const ACTION_REGISTRY: ActionDefinition[] = [ // Tools { id: 'tool.select', label: 'Selection Tool', category: 'Tools' }, { id: 'tool.blade', label: 'Blade / Razor Tool', category: 'Tools' }, { id: 'tool.ripple', label: 'Ripple Edit Tool', category: 'Tools' }, { id: 'tool.roll', label: 'Roll Edit Tool', category: 'Tools' }, { id: 'tool.slide', label: 'Slide Tool', category: 'Tools' }, { id: 'tool.slip', label: 'Slip Tool', category: 'Tools' }, { id: 'tool.trackForward', label: 'Track Select Forward', category: 'Tools' }, // Transport { id: 'transport.playPause', label: 'Play / Pause', category: 'Transport' }, { id: 'transport.stop', label: 'Stop', category: 'Transport' }, { id: 'transport.shuttleReverse', label: 'Shuttle Reverse (J)', category: 'Transport' }, { id: 'transport.shuttleStop', label: 'Shuttle Stop (K)', category: 'Transport' }, { id: 'transport.shuttleForward', label: 'Shuttle Forward (L)', category: 'Transport' }, { id: 'transport.stepBackward', label: 'Step Backward (1 frame)', category: 'Transport' }, { id: 'transport.stepForward', label: 'Step Forward (1 frame)', category: 'Transport' }, { id: 'transport.jumpBackward', label: 'Jump Backward (1 sec)', category: 'Transport' }, { id: 'transport.jumpForward', label: 'Jump Forward (1 sec)', category: 'Transport' }, { id: 'transport.goToStart', label: 'Go to Start', category: 'Transport' }, { id: 'transport.goToEnd', label: 'Go to End', category: 'Transport' }, { id: 'transport.goToIn', label: 'Go to In Point', category: 'Transport' }, { id: 'transport.goToOut', label: 'Go to Out Point', category: 'Transport' }, // Editing { id: 'edit.undo', label: 'Undo', category: 'Editing' }, { id: 'edit.redo', label: 'Redo', category: 'Editing' }, { id: 'edit.cut', label: 'Cut', category: 'Editing' }, { id: 'edit.copy', label: 'Copy', category: 'Editing' }, { id: 'edit.paste', label: 'Paste', category: 'Editing' }, { id: 'edit.delete', label: 'Delete', category: 'Editing' }, { id: 'edit.selectAll', label: 'Select All', category: 'Editing' }, { id: 'edit.deselect', label: 'Deselect All', category: 'Editing' }, { id: 'edit.insertEdit', label: 'Insert Edit', category: 'Editing' }, { id: 'edit.overwriteEdit', label: 'Overwrite Edit', category: 'Editing' }, { id: 'edit.matchFrame', label: 'Match Frame', category: 'Editing', description: 'Load the clip under the playhead into the source monitor at the matching frame' }, // Marking { id: 'mark.setIn', label: 'Set In Point', category: 'Marking' }, { id: 'mark.setOut', label: 'Set Out Point', category: 'Marking' }, { id: 'mark.clearIn', label: 'Clear In Point', category: 'Marking' }, { id: 'mark.clearOut', label: 'Clear Out Point', category: 'Marking' }, { id: 'mark.clearInOut', label: 'Clear In / Out', category: 'Marking' }, // Timeline { id: 'timeline.zoomIn', label: 'Zoom In', category: 'Timeline' }, { id: 'timeline.zoomOut', label: 'Zoom Out', category: 'Timeline' }, { id: 'timeline.fitToView', label: 'Fit Timeline to View', category: 'Timeline' }, { id: 'timeline.toggleSnap', label: 'Toggle Snap', category: 'Timeline' }, // Navigation { id: 'nav.prevEdit', label: 'Go to Previous Edit Point', category: 'Transport', description: 'Jump playhead to previous cut on timeline' }, { id: 'nav.nextEdit', label: 'Go to Next Edit Point', category: 'Transport', description: 'Jump playhead to next cut on timeline' }, { id: 'view.fullscreen', label: 'Fullscreen Preview', category: 'Timeline' }, ] // ── Keyboard Layout (mapping from ActionId to one or more key combos) ── export type KeyboardLayout = Partial> // ── Named Preset ── export interface KeyboardPreset { id: string name: string description: string layout: KeyboardLayout builtIn: boolean // true for factory presets, false for user-created } // Helper to create key combos concisely function k(key: string, mods?: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean }): KeyCombo { return { key, ...mods } } // ═══════════════════════════════════════════ // ── PRESET: LTX Default ── // ═══════════════════════════════════════════ export const LTX_DEFAULT_LAYOUT: KeyboardLayout = { // Tools 'tool.select': [k('v')], 'tool.blade': [k('b')], 'tool.ripple': [k('r')], 'tool.roll': [k('n')], 'tool.slide': [k('u')], 'tool.slip': [k('y')], 'tool.trackForward': [k('a')], // Transport 'transport.playPause': [k(' ')], 'transport.shuttleReverse': [k('j')], 'transport.shuttleStop': [k('k')], 'transport.shuttleForward': [k('l')], 'transport.stepBackward': [k('arrowleft')], 'transport.stepForward': [k('arrowright')], 'transport.jumpBackward': [k('arrowleft', { shift: true })], 'transport.jumpForward': [k('arrowright', { shift: true })], 'transport.goToStart': [k('home')], 'transport.goToEnd': [k('end')], 'transport.goToIn': [k('i', { shift: true })], 'transport.goToOut': [k('o', { shift: true })], // Editing 'edit.undo': [k('z', { ctrl: true })], 'edit.redo': [k('z', { ctrl: true, shift: true }), k('y', { ctrl: true })], 'edit.cut': [k('x', { ctrl: true })], 'edit.copy': [k('c', { ctrl: true })], 'edit.paste': [k('v', { ctrl: true })], 'edit.delete': [k('delete'), k('backspace')], 'edit.selectAll': [k('a', { ctrl: true })], 'edit.deselect': [k('escape')], 'edit.insertEdit': [k(',')], 'edit.overwriteEdit': [k('.')], 'edit.matchFrame': [k('f')], // Marking 'mark.setIn': [k('i')], 'mark.setOut': [k('o')], 'mark.clearIn': [k('i', { alt: true })], 'mark.clearOut': [k('o', { alt: true })], 'mark.clearInOut': [k('x', { alt: true })], // Timeline 'timeline.zoomIn': [k('='), k('+')], 'timeline.zoomOut': [k('-')], 'timeline.fitToView': [k('0', { ctrl: true })], 'timeline.toggleSnap': [k('s')], // Navigation 'nav.prevEdit': [k('arrowup')], 'nav.nextEdit': [k('arrowdown')], 'view.fullscreen': [k('`'), k('f11')], } // ═══════════════════════════════════════════ // ── PRESET: Adobe Premiere Pro ── // ═══════════════════════════════════════════ export const PREMIERE_LAYOUT: KeyboardLayout = { // Tools (Premiere defaults) 'tool.select': [k('v')], 'tool.blade': [k('c')], // Premiere uses C for razor 'tool.ripple': [k('b')], // Premiere: B = ripple edit 'tool.roll': [k('n')], 'tool.slide': [k('u')], 'tool.slip': [k('y')], 'tool.trackForward': [k('a')], // Transport (same JKL) 'transport.playPause': [k(' ')], 'transport.shuttleReverse': [k('j')], 'transport.shuttleStop': [k('k')], 'transport.shuttleForward': [k('l')], 'transport.stepBackward': [k('arrowleft')], 'transport.stepForward': [k('arrowright')], 'transport.jumpBackward': [k('arrowleft', { shift: true })], 'transport.jumpForward': [k('arrowright', { shift: true })], 'transport.goToStart': [k('home')], 'transport.goToEnd': [k('end')], 'transport.goToIn': [k('i', { shift: true })], 'transport.goToOut': [k('o', { shift: true })], // Editing 'edit.undo': [k('z', { ctrl: true })], 'edit.redo': [k('z', { ctrl: true, shift: true })], 'edit.cut': [k('x', { ctrl: true })], 'edit.copy': [k('c', { ctrl: true })], 'edit.paste': [k('v', { ctrl: true })], 'edit.delete': [k('delete'), k('backspace')], 'edit.selectAll': [k('a', { ctrl: true })], 'edit.deselect': [k('escape')], 'edit.insertEdit': [k(',')], // Premiere: , = insert 'edit.overwriteEdit': [k('.')], // Premiere: . = overwrite 'edit.matchFrame': [k('f')], // Premiere: F = match frame // Marking (same as Premiere) 'mark.setIn': [k('i')], 'mark.setOut': [k('o')], 'mark.clearIn': [k('i', { alt: true })], // Premiere: Alt+I / Option+I 'mark.clearOut': [k('o', { alt: true })], // Premiere: Alt+O / Option+O 'mark.clearInOut': [k('x', { alt: true })], // Premiere: Alt+X or Option+X // Timeline 'timeline.zoomIn': [k('=')], 'timeline.zoomOut': [k('-')], 'timeline.fitToView': [k('\\')], // Premiere: backslash fits timeline 'timeline.toggleSnap': [k('s')], // Premiere: S = snap // Navigation (Premiere: Up/Down = previous/next edit) 'nav.prevEdit': [k('arrowup')], 'nav.nextEdit': [k('arrowdown')], 'view.fullscreen': [k('`')], // Premiere: ` = fullscreen } // ═══════════════════════════════════════════ // ── PRESET: DaVinci Resolve ── // ═══════════════════════════════════════════ export const DAVINCI_LAYOUT: KeyboardLayout = { // Tools 'tool.select': [k('a')], // DaVinci: A = selection 'tool.blade': [k('b')], // DaVinci: B = blade 'tool.ripple': [k('t')], // DaVinci: T = trim (contextual ripple/roll) 'tool.roll': [k('g')], // DaVinci has no separate roll key; G assigned to avoid conflict 'tool.slide': [k('u')], 'tool.slip': [k('s')], // DaVinci: S = slip (not snap) 'tool.trackForward': [k('y')], // Transport 'transport.playPause': [k(' ')], 'transport.shuttleReverse': [k('j')], 'transport.shuttleStop': [k('k')], 'transport.shuttleForward': [k('l')], 'transport.stepBackward': [k('arrowleft')], 'transport.stepForward': [k('arrowright')], 'transport.jumpBackward': [k('arrowleft', { shift: true })], 'transport.jumpForward': [k('arrowright', { shift: true })], 'transport.goToStart': [k('home')], 'transport.goToEnd': [k('end')], 'transport.goToIn': [k('i', { shift: true })], 'transport.goToOut': [k('o', { shift: true })], // Editing 'edit.undo': [k('z', { ctrl: true })], 'edit.redo': [k('z', { ctrl: true, shift: true })], 'edit.cut': [k('x', { ctrl: true })], 'edit.copy': [k('c', { ctrl: true })], 'edit.paste': [k('v', { ctrl: true })], 'edit.delete': [k('delete'), k('backspace')], 'edit.selectAll': [k('a', { ctrl: true })], 'edit.deselect': [k('escape')], 'edit.insertEdit': [k('f9')], // DaVinci: F9 = insert 'edit.overwriteEdit': [k('f10')], // DaVinci: F10 = overwrite 'edit.matchFrame': [k('f')], // DaVinci: F = match frame // Marking 'mark.setIn': [k('i')], 'mark.setOut': [k('o')], 'mark.clearIn': [k('i', { alt: true })], 'mark.clearOut': [k('o', { alt: true })], 'mark.clearInOut': [k('x', { alt: true })], // Timeline 'timeline.zoomIn': [k('=', { ctrl: true })], 'timeline.zoomOut': [k('-', { ctrl: true })], 'timeline.fitToView': [k('z', { shift: true })], // DaVinci: Shift+Z = fit 'timeline.toggleSnap': [k('n')], // DaVinci: N = snap // Navigation (DaVinci: Up/Down = previous/next edit) 'nav.prevEdit': [k('arrowup')], 'nav.nextEdit': [k('arrowdown')], 'view.fullscreen': [k('p', { ctrl: true, shift: true })], // DaVinci: Ctrl+Shift+P } // ═══════════════════════════════════════════ // ── PRESET: Avid Media Composer ── // ═══════════════════════════════════════════ export const AVID_LAYOUT: KeyboardLayout = { // Tools 'tool.select': [k('v')], 'tool.blade': [k('/')], // Avid uses different paradigm but closest 'tool.ripple': [k('r')], 'tool.roll': [k('n')], 'tool.slide': [k('u')], 'tool.slip': [k('y')], 'tool.trackForward': [k('a')], // Transport — Avid uses different keys 'transport.playPause': [k(' '), k('5')], // Avid: 5 or Space 'transport.shuttleReverse': [k('j')], 'transport.shuttleStop': [k('k')], 'transport.shuttleForward': [k('l')], 'transport.stepBackward': [k('3')], // Avid: 3 = step back 'transport.stepForward': [k('4')], // Avid: 4 = step forward 'transport.jumpBackward': [k('1')], // Avid: 1 = fast reverse 'transport.jumpForward': [k('2')], // Avid: 2 = fast forward 'transport.goToStart': [k('home')], 'transport.goToEnd': [k('end')], 'transport.goToIn': [k('i', { shift: true })], 'transport.goToOut': [k('o', { shift: true })], // Editing 'edit.undo': [k('z', { ctrl: true })], 'edit.redo': [k('z', { ctrl: true, shift: true })], 'edit.cut': [k('x', { ctrl: true })], 'edit.copy': [k('c', { ctrl: true })], 'edit.paste': [k('v', { ctrl: true })], 'edit.delete': [k('delete'), k('backspace')], 'edit.selectAll': [k('a', { ctrl: true })], 'edit.deselect': [k('escape')], 'edit.insertEdit': [k('v', { shift: true })], // Avid: Shift+V to avoid conflict with tool.select 'edit.overwriteEdit': [k('b')], // Avid: B = overwrite 'edit.matchFrame': [k('f')], // Avid: match frame // Marking — Avid classic: I/O or E/R 'mark.setIn': [k('i'), k('e')], // Avid: E = mark in 'mark.setOut': [k('o')], // Avid: O = mark out (R removed to avoid conflict with tool.ripple) 'mark.clearIn': [k('i', { alt: true })], 'mark.clearOut': [k('o', { alt: true })], 'mark.clearInOut': [k('g')], // Avid: G = clear both marks // Timeline 'timeline.zoomIn': [k('=', { ctrl: true })], 'timeline.zoomOut': [k('-', { ctrl: true })], 'timeline.fitToView': [k('0', { ctrl: true })], 'timeline.toggleSnap': [k('s')], // Navigation (Avid: similar to A/S or arrow keys) 'nav.prevEdit': [k('arrowup')], 'nav.nextEdit': [k('arrowdown')], 'view.fullscreen': [k('`'), k('f11')], } // ── All Built-in Presets ── export const BUILT_IN_PRESETS: KeyboardPreset[] = [ { id: 'ltx-default', name: 'LTX Default', description: 'Default keyboard layout for LTX Desktop', layout: LTX_DEFAULT_LAYOUT, builtIn: true, }, { id: 'premiere', name: 'Adobe Premiere Pro', description: 'Keyboard layout matching Premiere Pro defaults', layout: PREMIERE_LAYOUT, builtIn: true, }, { id: 'davinci', name: 'DaVinci Resolve', description: 'Keyboard layout matching DaVinci Resolve defaults', layout: DAVINCI_LAYOUT, builtIn: true, }, { id: 'avid', name: 'Avid Media Composer', description: 'Keyboard layout matching Avid Media Composer defaults', layout: AVID_LAYOUT, builtIn: true, }, ] // ── Utilities ── /** Format a KeyCombo into a human-readable string like "Ctrl+Shift+B" */ export function formatKeyCombo(combo: KeyCombo): string { const parts: string[] = [] if (combo.ctrl || combo.meta) parts.push('Ctrl') if (combo.shift) parts.push('Shift') if (combo.alt) parts.push('Alt') // Pretty-print special keys const keyMap: Record = { ' ': 'Space', 'arrowleft': '\u2190', 'arrowright': '\u2192', 'arrowup': '\u2191', 'arrowdown': '\u2193', 'delete': 'Del', 'backspace': 'Bksp', 'escape': 'Esc', 'enter': 'Enter', ',': ',', '.': '.', '/': '/', '\\': '\\', '=': '=', '+': '+', '-': '-', 'home': 'Home', 'end': 'End', 'f9': 'F9', 'f10': 'F10', } parts.push(keyMap[combo.key] || combo.key.toUpperCase()) return parts.join('+') } /** Map a physical KeyboardEvent.code to a logical key string matching KeyCombo.key */ function codeToKey(code: string): string { if (code.startsWith('Key')) return code.slice(3).toLowerCase() if (code.startsWith('Digit')) return code.slice(5) return code.toLowerCase() } /** Check if a keyboard event matches a key combo */ export function eventMatchesCombo(e: KeyboardEvent, combo: KeyCombo): boolean { // On Mac, Option (alt) remaps e.key to special characters (e.g. Option+I → 'Dead', Option+O → 'ø'). // Use the physical key code instead when alt is involved so shortcuts still resolve correctly. const key = (combo.alt && e.altKey) ? codeToKey(e.code) : e.key.toLowerCase() if (key !== combo.key) return false if (!!combo.ctrl !== (e.ctrlKey || e.metaKey)) return false if (!!combo.shift !== e.shiftKey) return false if (!!combo.alt !== e.altKey) return false return true } /** Given a layout and an event, return the matching ActionId (or null) */ export function resolveAction(layout: KeyboardLayout, e: KeyboardEvent): ActionId | null { for (const [actionId, combos] of Object.entries(layout)) { if (!combos) continue for (const combo of combos) { if (eventMatchesCombo(e, combo)) { return actionId as ActionId } } } return null } /** Find conflicts: actions that share the same key combo */ export function findConflicts(layout: KeyboardLayout): Map { const comboMap = new Map() for (const [actionId, combos] of Object.entries(layout)) { if (!combos) continue for (const combo of combos) { const key = formatKeyCombo(combo) const existing = comboMap.get(key) || [] existing.push(actionId as ActionId) comboMap.set(key, existing) } } // Only return entries with conflicts (more than 1 action) const conflicts = new Map() for (const [key, actions] of comboMap) { if (actions.length > 1) conflicts.set(key, actions) } return conflicts } /** Deep clone a layout for mutation */ export function cloneLayout(layout: KeyboardLayout): KeyboardLayout { const result: KeyboardLayout = {} for (const [key, combos] of Object.entries(layout)) { if (combos) { result[key as ActionId] = combos.map(c => ({ ...c })) } } return result }