import { useEffect, useRef } from 'react'; import { useEditorStore } from '../store/editorStore'; import { loadBindings } from '../lib/keybindings'; import type { KeyBinding } from '../types/project'; export function useKeyboardShortcuts() { const addCutRange = useEditorStore((s) => s.addCutRange); const markInTime = useEditorStore((s) => s.markInTime); const markOutTime = useEditorStore((s) => s.markOutTime); const setMarkInTime = useEditorStore((s) => s.setMarkInTime); const setMarkOutTime = useEditorStore((s) => s.setMarkOutTime); const clearMarkRange = useEditorStore((s) => s.clearMarkRange); const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices); const words = useEditorStore((s) => s.words); const playbackRateRef = useRef(1); // Read bindings fresh from localStorage on every call to avoid stale closures const getBindings = (): KeyBinding[] => { try { return loadBindings(); } catch { return []; } }; useEffect(() => { const getVideo = (): HTMLVideoElement | null => document.querySelector('video'); const handler = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return; const video = getVideo(); // Build a key string from the event for matching const parts: string[] = []; if (e.ctrlKey || e.metaKey) parts.push('Ctrl'); if (e.shiftKey && !['Shift'].includes(e.key)) parts.push('Shift'); if (e.altKey) parts.push('Alt'); const keyStr = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key; parts.push(keyStr); const combo = parts.join('+'); // Look up binding — fresh read every keystroke so Settings changes take effect immediately const currentBindings = getBindings(); const binding = currentBindings.find((b) => b.keys === combo); if (!binding) return; // Unbound key — ignore e.preventDefault(); switch (binding.id) { case 'undo': useEditorStore.temporal.getState().undo(); return; case 'redo': useEditorStore.temporal.getState().redo(); return; case 'cut': { if (selectedWordIndices.length > 0) { const sorted = [...selectedWordIndices].sort((a, b) => a - b); addCutRange(words[sorted[0]].start, words[sorted[sorted.length - 1]].end); return; } if (markInTime !== null && markOutTime !== null) { const start = Math.min(markInTime, markOutTime); const end = Math.max(markInTime, markOutTime); if (end - start >= 0.01) addCutRange(start, end); clearMarkRange(); } return; } case 'play-pause': if (video) { if (video.paused) video.play(); else video.pause(); } return; case 'slow-down': { if (video) { playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5); if (playbackRateRef.current < 0) video.currentTime = Math.max(0, video.currentTime - 2); else { video.playbackRate = playbackRateRef.current; if (video.paused) video.play(); } } return; } case 'pause': if (video) { video.pause(); playbackRateRef.current = 1; } return; case 'speed-up': { if (video) { playbackRateRef.current = Math.min(4, playbackRateRef.current + 0.5); video.playbackRate = Math.max(0.25, playbackRateRef.current); if (video.paused) video.play(); } return; } case 'rewind': if (video) video.currentTime = Math.max(0, video.currentTime - 5); return; case 'forward': if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5); return; case 'mark-in': if (video) setMarkInTime(video.currentTime); return; case 'mark-out': if (video) setMarkOutTime(video.currentTime); return; case 'save': { const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null; if (saveBtn) saveBtn.click(); else saveProject(); return; } case 'export': { const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement; if (exportBtn) exportBtn.click(); return; } case 'search': { const findBtn = document.querySelector('[title="Find (Ctrl+F)"]') as HTMLButtonElement; if (findBtn) findBtn.click(); return; } case 'help': toggleCheatsheet(currentBindings); return; default: break; } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [addCutRange, markInTime, markOutTime, setMarkInTime, setMarkOutTime, clearMarkRange, selectedWordIndices, words]); } async function saveProject() { const state = useEditorStore.getState(); if (!state.videoPath || state.words.length === 0) return; try { const projectData = state.saveProject(); let outputPath = state.projectFilePath; if (!outputPath) { outputPath = await window.electronAPI?.saveFile({ defaultPath: state.videoPath.replace(/\.[^.]+$/, '.aive'), filters: [{ name: 'TalkEdit Project', extensions: ['aive'] }], }); } if (!outputPath) return; const resolvedPath = outputPath.endsWith('.aive') ? outputPath : `${outputPath}.aive`; if (window.electronAPI?.writeFile) { await window.electronAPI.writeFile(resolvedPath, JSON.stringify(projectData, null, 2)); useEditorStore.getState().setProjectFilePath(resolvedPath); } else { const blob = new Blob([JSON.stringify(projectData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = resolvedPath.split(/[\\/]/).pop() || 'project.aive'; a.click(); URL.revokeObjectURL(url); useEditorStore.getState().setProjectFilePath(resolvedPath); } } catch (err) { console.error('Failed to save project:', err); } } function toggleCheatsheet(bindings: KeyBinding[]) { const existing = document.getElementById('keyboard-cheatsheet'); if (existing) { existing.remove(); return; } const overlay = document.createElement('div'); overlay.id = 'keyboard-cheatsheet'; overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);'; overlay.onclick = () => { overlay.remove(); }; const rows = bindings .map( (b) => `${b.keys}${b.label}${b.category}`, ) .join(''); overlay.innerHTML = `

Keyboard Shortcuts

${rows}

Customize in Settings • Press ? to close

`; document.body.appendChild(overlay); const closeBtn = overlay.querySelector('#cheatsheet-close') as HTMLButtonElement; if (closeBtn) closeBtn.onclick = () => overlay.remove(); const escHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', escHandler); } }; document.addEventListener('keydown', escHandler); }