Files
TalkEdit/frontend/src/hooks/useKeyboardShortcuts.ts
2026-05-06 13:18:53 -06:00

213 lines
8.4 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useEditorStore } from '../store/editorStore';
import { loadBindings, DEFAULT_PRESETS } 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 presetName = JSON.stringify(bindings) === JSON.stringify(DEFAULT_PRESETS['left-hand']) ? 'Left-Hand Preset' : 'Standard Preset';
const rows = bindings
.map(
(b) =>
`<tr><td style="padding:6px 16px 6px 0;font-family:monospace;color:#818cf8;font-weight:600;white-space:nowrap">${b.keys}</td><td style="padding:6px 0;color:#e2e8f0">${b.label}</td><td style="padding:6px 0 6px 12px;font-size:10px;color:#94a3b8">${b.category}</td></tr>`,
)
.join('');
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;position:relative;" onclick="event.stopPropagation()">
<div style="font-size:11px;color:#94a3b8;margin-bottom:12px">Active preset: <span style="color:#818cf8;font-weight:500">${presetName}</span></div>
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
<table style="font-size:13px">${rows}</table>
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings &bull; Press ? to close</p>
<button id="cheatsheet-close" style="position:absolute;top:12px;right:16px;background:none;border:none;color:#94a3b8;font-size:18px;cursor:pointer;line-height:1;padding:4px;">&times;</button>
</div>`;
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);
}