2026-03-03 06:31:04 -05:00
|
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
|
import { useEditorStore } from '../store/editorStore';
|
2026-05-05 10:22:35 -06:00
|
|
|
import { loadBindings } from '../lib/keybindings';
|
|
|
|
|
import type { KeyBinding } from '../types/project';
|
2026-03-03 06:31:04 -05:00
|
|
|
|
|
|
|
|
export function useKeyboardShortcuts() {
|
2026-04-03 11:14:31 -06:00
|
|
|
const addCutRange = useEditorStore((s) => s.addCutRange);
|
2026-04-15 20:57:43 -06:00
|
|
|
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);
|
2026-03-03 06:31:04 -05:00
|
|
|
const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
|
2026-04-03 11:14:31 -06:00
|
|
|
const words = useEditorStore((s) => s.words);
|
2026-03-03 06:31:04 -05:00
|
|
|
const playbackRateRef = useRef(1);
|
|
|
|
|
|
2026-05-05 10:22:35 -06:00
|
|
|
// Read bindings fresh from localStorage on every call to avoid stale closures
|
|
|
|
|
const getBindings = (): KeyBinding[] => {
|
|
|
|
|
try { return loadBindings(); } catch { return []; }
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 06:31:04 -05:00
|
|
|
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();
|
|
|
|
|
|
2026-05-05 10:22:35 -06:00
|
|
|
// 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':
|
2026-03-03 06:31:04 -05:00
|
|
|
useEditorStore.temporal.getState().undo();
|
|
|
|
|
return;
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'redo':
|
|
|
|
|
useEditorStore.temporal.getState().redo();
|
|
|
|
|
return;
|
|
|
|
|
case 'cut': {
|
2026-03-03 06:31:04 -05:00
|
|
|
if (selectedWordIndices.length > 0) {
|
2026-04-03 11:14:31 -06:00
|
|
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
2026-05-05 10:22:35 -06:00
|
|
|
addCutRange(words[sorted[0]].start, words[sorted[sorted.length - 1]].end);
|
2026-04-15 20:57:43 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (markInTime !== null && markOutTime !== null) {
|
|
|
|
|
const start = Math.min(markInTime, markOutTime);
|
|
|
|
|
const end = Math.max(markInTime, markOutTime);
|
2026-05-05 10:22:35 -06:00
|
|
|
if (end - start >= 0.01) addCutRange(start, end);
|
2026-04-15 20:57:43 -06:00
|
|
|
clearMarkRange();
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'play-pause':
|
|
|
|
|
if (video) { if (video.paused) video.play(); else video.pause(); }
|
2026-03-03 06:31:04 -05:00
|
|
|
return;
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'slow-down': {
|
2026-03-03 06:31:04 -05:00
|
|
|
if (video) {
|
|
|
|
|
playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5);
|
2026-05-05 10:22:35 -06:00
|
|
|
if (playbackRateRef.current < 0) video.currentTime = Math.max(0, video.currentTime - 2);
|
|
|
|
|
else { video.playbackRate = playbackRateRef.current; if (video.paused) video.play(); }
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'pause':
|
|
|
|
|
if (video) { video.pause(); playbackRateRef.current = 1; }
|
2026-03-03 06:31:04 -05:00
|
|
|
return;
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'speed-up': {
|
2026-03-03 06:31:04 -05:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'rewind':
|
2026-03-03 06:31:04 -05:00
|
|
|
if (video) video.currentTime = Math.max(0, video.currentTime - 5);
|
|
|
|
|
return;
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'forward':
|
2026-03-03 06:31:04 -05:00
|
|
|
if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
|
|
|
|
return;
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'mark-in':
|
2026-04-15 20:57:43 -06:00
|
|
|
if (video) setMarkInTime(video.currentTime);
|
2026-03-03 06:31:04 -05:00
|
|
|
return;
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'mark-out':
|
2026-04-15 20:57:43 -06:00
|
|
|
if (video) setMarkOutTime(video.currentTime);
|
2026-03-03 06:31:04 -05:00
|
|
|
return;
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'save': {
|
2026-04-15 20:51:24 -06:00
|
|
|
const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null;
|
|
|
|
|
if (saveBtn) saveBtn.click();
|
|
|
|
|
else saveProject();
|
2026-03-03 06:31:04 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'export': {
|
2026-03-03 06:31:04 -05:00
|
|
|
const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement;
|
|
|
|
|
if (exportBtn) exportBtn.click();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'search': {
|
|
|
|
|
const findBtn = document.querySelector('[title="Find (Ctrl+F)"]') as HTMLButtonElement;
|
|
|
|
|
if (findBtn) findBtn.click();
|
2026-03-03 06:31:04 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-05 10:22:35 -06:00
|
|
|
case 'help':
|
|
|
|
|
toggleCheatsheet(currentBindings);
|
|
|
|
|
return;
|
2026-03-03 06:31:04 -05:00
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handler);
|
|
|
|
|
return () => window.removeEventListener('keydown', handler);
|
2026-04-15 20:57:43 -06:00
|
|
|
}, [addCutRange, markInTime, markOutTime, setMarkInTime, setMarkOutTime, clearMarkRange, selectedWordIndices, words]);
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveProject() {
|
|
|
|
|
const state = useEditorStore.getState();
|
|
|
|
|
if (!state.videoPath || state.words.length === 0) return;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-15 20:51:24 -06:00
|
|
|
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'] }],
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-03 06:31:04 -05:00
|
|
|
|
2026-04-15 20:51:24 -06:00
|
|
|
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);
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to save project:', err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 10:22:35 -06:00
|
|
|
function toggleCheatsheet(bindings: KeyBinding[]) {
|
2026-03-03 06:31:04 -05:00
|
|
|
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();
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-05 10:22:35 -06:00
|
|
|
const rows = bindings
|
2026-03-03 06:31:04 -05:00
|
|
|
.map(
|
2026-05-05 10:22:35 -06:00
|
|
|
(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>`,
|
2026-03-03 06:31:04 -05:00
|
|
|
)
|
|
|
|
|
.join('');
|
|
|
|
|
|
2026-05-06 12:15:46 -06:00
|
|
|
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;position:relative;" onclick="event.stopPropagation()">
|
2026-03-03 06:31:04 -05:00
|
|
|
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
|
|
|
|
|
<table style="font-size:13px">${rows}</table>
|
2026-05-05 10:22:35 -06:00
|
|
|
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings • Press ? to close</p>
|
2026-05-06 12:15:46 -06:00
|
|
|
<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;">×</button>
|
2026-03-03 06:31:04 -05:00
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
document.body.appendChild(overlay);
|
2026-05-06 12:15:46 -06:00
|
|
|
|
|
|
|
|
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);
|
2026-03-03 06:31:04 -05:00
|
|
|
}
|