verified hotkeys

This commit is contained in:
2026-05-05 10:22:35 -06:00
parent 1678d28db7
commit 21e4255325
9 changed files with 341 additions and 132 deletions

View File

@ -73,6 +73,10 @@ Use project virtualenvs where available (`.venv312`, `.venv`, or `venv`) for bac
- **color-scheme:dark**: All `<select>` elements in ExportDialog use `[color-scheme:dark]` to ensure readable native dropdown popups on Linux WebKit. - **color-scheme:dark**: All `<select>` elements in ExportDialog use `[color-scheme:dark]` to ensure readable native dropdown popups on Linux WebKit.
- **Re-transcribe selection (#013)**: Backend `POST /transcribe/segment` extracts audio via FFmpeg, runs Whisper, adjusts timestamps. Frontend: "Re-transcribe" button on selected words in TranscriptEditor; `replaceWordRange()` store action swaps words + rebuilds segments by speaker. - **Re-transcribe selection (#013)**: Backend `POST /transcribe/segment` extracts audio via FFmpeg, runs Whisper, adjusts timestamps. Frontend: "Re-transcribe" button on selected words in TranscriptEditor; `replaceWordRange()` store action swaps words + rebuilds segments by speaker.
- **Transcript-only export (#024)**: "Export Transcript Only" in ExportDialog with .txt/.srt options. **Pure frontend** — generates content in-browser, writes via Tauri `writeFile`. No backend dependency. Respects word cuts. - **Transcript-only export (#024)**: "Export Transcript Only" in ExportDialog with .txt/.srt options. **Pure frontend** — generates content in-browser, writes via Tauri `writeFile`. No backend dependency. Respects word cuts.
- **Named timeline markers (#016)**: `TimelineMarker` type in `project.ts`. Store actions: `addTimelineMarker`, `updateTimelineMarker`, `removeTimelineMarker`. Colored pins on waveform canvas. MarkersPanel UI for add/edit/delete. Persisted in project.
- **Chapters (#017)**: `getChapters()` store action derives from sorted markers. "Copy as YouTube timestamps" in MarkersPanel. Zero backend.
- **Clip thumbnail strip (#022)**: `lib/thumbnails.ts` — frontend canvas capture from `<video>`. Toggle button in WaveformTimeline. Clickable frames at 10s intervals.
- **Customizable hotkeys (#041)**: `lib/keybindings.ts` with two presets (standard + left-hand). `useKeyboardShortcuts.ts` reads bindings dynamically. Settings panel includes key remapper with conflict detection and per-key reset. `?` key shows dynamic cheatsheet.
## Update Rules (Important) ## Update Rules (Important)

View File

@ -22,13 +22,13 @@ Features are grouped by priority. Check off items as they are implemented.
## 🟡 Medium Impact — Workflow completeness ## 🟡 Medium Impact — Workflow completeness
- [ ] [#016] **Named timeline markers**drop named marker pins on the waveform (like Resolve markers). Store as `{ id, time, label, color }` in the project. Rendered as colored triangles on the timeline canvas. - [x] [#016] **Named timeline markers**colored marker pins on the waveform canvas. Add at current playback position with label/color picker in Markers panel. Editable labels, deletable. Persisted in project file. (2026-05-04)
- [ ] [#017] **Chapters**group markers into named chapter ranges. Useful for podcasts and lectures. Exportable as YouTube chapter timestamps in the description. - [x] [#017] **Chapters**sorted markers auto-form chapters. "Copy as YouTube timestamps" button exports `MM:SS Label` format to clipboard. (2026-05-04)
- [ ] [#041] **Customizable hotkeys / keymap editor (left-hand focused)** — allow users to view, remap, and reset keyboard shortcuts (transport, edit, save/export, zone tools), with a default preset optimized for left-hand reach (Q/W/E/R/A/S/D/F/Z/X/C/V + modifiers). Include conflict detection, an alternate standard preset, and one-click "restore defaults". - [x] [#041] **Customizable hotkeys / keymap editor** — two presets (Standard: J/K/L/I/O/arrows; Left-hand: Q/W/E/A/S/D/F). Settings panel shows all bindings with click-to-remap, conflict detection, per-key reset to default. Cheatsheet (press `?`) shows current bindings. (2026-05-04)
- [ ] [#022] **Clip thumbnail strip**video frame thumbnails along the timeline so users can navigate visually, not only by waveform. Backend: `ffmpeg` thumbnail extraction at regular intervals. - [x] [#022] **Clip thumbnail strip**frontend-side canvas capture from the `<video>` element. Toggle "Thumbnails" button above waveform. Extracts frames at 10s intervals, clickable to seek. Zero backend dependency. (2026-05-04)
--- ---

View File

@ -7,6 +7,7 @@ import AIPanel from './components/AIPanel';
import ExportDialog from './components/ExportDialog'; import ExportDialog from './components/ExportDialog';
import SettingsPanel from './components/SettingsPanel'; import SettingsPanel from './components/SettingsPanel';
import DevPanel from './components/DevPanel'; import DevPanel from './components/DevPanel';
import MarkersPanel from './components/MarkersPanel';
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel'; import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
import ZoneEditor from './components/ZoneEditor'; import ZoneEditor from './components/ZoneEditor';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
@ -25,11 +26,12 @@ import {
FilePlus2, FilePlus2,
RefreshCw, RefreshCw,
Grid3x3, Grid3x3,
MapPin,
} from 'lucide-react'; } from 'lucide-react';
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath'; const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | null; type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | null;
export default function App() { export default function App() {
const { const {
@ -597,6 +599,13 @@ export default function App() {
onClick={() => togglePanel('silence')} onClick={() => togglePanel('silence')}
disabled={!videoPath} disabled={!videoPath}
/> />
<ToolbarButton
icon={<MapPin className="w-4 h-4" />}
label="Markers"
active={activePanel === 'markers'}
onClick={() => togglePanel('markers')}
disabled={!videoPath}
/>
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border"> <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
<select <select
value={whisperModel} value={whisperModel}
@ -741,6 +750,7 @@ export default function App() {
<ZoneEditor /> <ZoneEditor />
)} )}
{activePanel === 'silence' && <SilenceTrimmerPanel />} {activePanel === 'silence' && <SilenceTrimmerPanel />}
{activePanel === 'markers' && <MarkersPanel />}
{activePanel === 'ai' && <AIPanel />} {activePanel === 'ai' && <AIPanel />}
{activePanel === 'export' && <ExportDialog />} {activePanel === 'export' && <ExportDialog />}
{activePanel === 'settings' && <SettingsPanel />} {activePanel === 'settings' && <SettingsPanel />}

View File

@ -1,8 +1,9 @@
import { useAIStore } from '../store/aiStore'; import { useAIStore } from '../store/aiStore';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { AIProvider } from '../types/project'; import type { AIProvider, KeyBinding, HotkeyPreset } from '../types/project';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { Bot, Cloud, Brain, RefreshCw } from 'lucide-react'; import { Bot, Cloud, Brain, RefreshCw, Keyboard } from 'lucide-react';
import { loadBindings, saveBindings, applyPreset as applyKeyPreset, DEFAULT_PRESETS, detectConflicts as detectKeyConflicts } from '../lib/keybindings';
export default function SettingsPanel() { export default function SettingsPanel() {
const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore(); const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
@ -19,6 +20,51 @@ export default function SettingsPanel() {
window.localStorage.setItem(CONFIDENCE_THRESHOLD_KEY, String(clamped)); window.localStorage.setItem(CONFIDENCE_THRESHOLD_KEY, String(clamped));
} }
}; };
// Keyboard shortcuts state
const [bindings, setBindings] = useState<KeyBinding[]>(() => {
try { return loadBindings(); } catch { return DEFAULT_PRESETS['standard']; }
});
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editKeyValue, setEditKeyValue] = useState('');
const conflicts = detectKeyConflicts(bindings);
const persistBindings = (newB: KeyBinding[]) => {
saveBindings(newB);
setBindings(newB);
};
const applyPresetAction = (preset: HotkeyPreset) => {
persistBindings(applyKeyPreset(preset));
};
const startKeyEdit = (idx: number) => {
setEditingKey(bindings[idx].id);
setEditKeyValue(bindings[idx].keys);
};
const handleKeyCapture = (e: React.KeyboardEvent, idx: number) => {
e.preventDefault();
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
if (e.shiftKey) parts.push('Shift');
if (e.altKey) parts.push('Alt');
const key = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key;
if (!['Control', 'Shift', 'Alt', 'Meta'].includes(key)) parts.push(key);
if (parts.length === 0) return;
const combo = parts.join('+');
const newBindings = bindings.map((b, i) => (i === idx ? { ...b, keys: combo } : b));
setEditKeyValue(combo);
setEditingKey(null);
persistBindings(newBindings);
};
const handleReset = (idx: number) => {
const standard = DEFAULT_PRESETS['standard'];
const existing = standard.find((b: KeyBinding) => b.id === bindings[idx].id);
if (!existing) return;
persistBindings(bindings.map((b, i) => (i === idx ? { ...existing } : b)));
};
const [ollamaModels, setOllamaModels] = useState<string[]>([]); const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false); const [loadingModels, setLoadingModels] = useState(false);
@ -112,6 +158,60 @@ export default function SettingsPanel() {
</div> </div>
</div> </div>
{/* Keyboard shortcuts */}
<div className="space-y-2 pt-1 border-t border-editor-border">
<h4 className="text-xs font-semibold flex items-center gap-1.5">
<Keyboard className="w-3.5 h-3.5" />
Keyboard Shortcuts
</h4>
<div className="flex items-center gap-2">
<button
onClick={() => applyPresetAction('standard')}
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
>
Standard Preset
</button>
<button
onClick={() => applyPresetAction('left-hand')}
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
>
Left-Hand Preset
</button>
</div>
{conflicts.length > 0 && (
<div className="px-2 py-1 rounded border border-red-500/40 bg-red-500/10 text-[10px] text-red-300">
{conflicts.join('; ')}
</div>
)}
<div className="max-h-52 overflow-y-auto space-y-1 pr-1">
{bindings.map((b, i) => (
<div key={b.id} className="flex items-center gap-2 text-[11px]">
<span className="flex-1 truncate text-editor-text-muted">{b.label}</span>
<input
value={editingKey === b.id ? editKeyValue : b.keys}
onFocus={() => startKeyEdit(i)}
onChange={(e) => {
setEditingKey(b.id);
setEditKeyValue(e.target.value);
}}
onKeyDown={(e) => handleKeyCapture(e, i)}
className="w-28 px-2 py-1 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-center focus:outline-none focus:border-editor-accent"
placeholder="Type shortcut"
/>
<button
onClick={() => handleReset(i)}
className="text-[10px] text-editor-text-muted hover:text-editor-text px-1"
>
</button>
</div>
))}
</div>
<p className="text-[10px] text-editor-text-muted">
Press <kbd>?</kbd> anytime to view shortcuts. Changes apply immediately.
</p>
</div>
{/* Default provider selector */} {/* Default provider selector */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs text-editor-text-muted font-medium">Default AI Provider</label> <label className="text-xs text-editor-text-muted font-medium">Default AI Provider</label>

View File

@ -1,6 +1,7 @@
import { useRef, useEffect, useCallback, useState, useMemo } from 'react'; import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { extractThumbnails } from '../lib/thumbnails';
const RULER_H = 20; // px reserved at top of canvas for the time ruler const RULER_H = 20; // px reserved at top of canvas for the time ruler
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08; const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
@ -239,13 +240,18 @@ export default function WaveformTimeline({
const videoPath = useEditorStore((s) => s.videoPath); const videoPath = useEditorStore((s) => s.videoPath);
const backendUrl = useEditorStore((s) => s.backendUrl); const backendUrl = useEditorStore((s) => s.backendUrl);
const duration = useEditorStore((s) => s.duration); const duration = useEditorStore((s) => s.duration);
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
const cutRanges = useEditorStore((s) => s.cutRanges); const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges); const muteRanges = useEditorStore((s) => s.muteRanges);
const gainRanges = useEditorStore((s) => s.gainRanges); const gainRanges = useEditorStore((s) => s.gainRanges);
const speedRanges = useEditorStore((s) => s.speedRanges); const speedRanges = useEditorStore((s) => s.speedRanges);
const timelineMarkers = useEditorStore((s) => s.timelineMarkers);
const markInTime = useEditorStore((s) => s.markInTime); const markInTime = useEditorStore((s) => s.markInTime);
const markOutTime = useEditorStore((s) => s.markOutTime); const markOutTime = useEditorStore((s) => s.markOutTime);
const setCurrentTime = useEditorStore((s) => s.setCurrentTime); const [showThumbnails, setShowThumbnails] = useState(() => typeof window !== 'undefined' && localStorage.getItem('talkedit:showThumbnails') === 'true');
const [thumbnailFrames, setThumbnailFrames] = useState<Map<number, string>>(new Map());
void setShowThumbnails;
const thumbnailContainerRef = useRef<HTMLDivElement>(null);
const addCutRange = useEditorStore((s) => s.addCutRange); const addCutRange = useEditorStore((s) => s.addCutRange);
const addMuteRange = useEditorStore((s) => s.addMuteRange); const addMuteRange = useEditorStore((s) => s.addMuteRange);
const addGainRange = useEditorStore((s) => s.addGainRange); const addGainRange = useEditorStore((s) => s.addGainRange);
@ -606,6 +612,33 @@ export default function WaveformTimeline({
if (markInTime !== null) drawMarkLine(markInTime, 'I'); if (markInTime !== null) drawMarkLine(markInTime, 'I');
if (markOutTime !== null) drawMarkLine(markOutTime, 'O'); if (markOutTime !== null) drawMarkLine(markOutTime, 'O');
// Draw timeline markers (colored pins)
for (const marker of timelineMarkers) {
const x = (sourceToDisplayTime(marker.time, timelineSegments, dur) - scroll) * pxPerSec;
if (x < -4 || x > width + 4) continue;
// Draw a pin triangle
ctx.beginPath();
ctx.moveTo(x, waveTop - 8);
ctx.lineTo(x - 4, waveTop - 2);
ctx.lineTo(x + 4, waveTop - 2);
ctx.closePath();
ctx.fillStyle = marker.color;
ctx.fill();
ctx.strokeStyle = marker.color;
ctx.lineWidth = 1;
// Draw a thin vertical line
ctx.beginPath();
ctx.moveTo(x, waveTop);
ctx.lineTo(x, waveTop + waveH);
ctx.stroke();
// Label
ctx.fillStyle = marker.color;
ctx.font = '9px sans-serif';
ctx.textBaseline = 'bottom';
ctx.fillText(marker.label, Math.min(width - 50, Math.max(2, x + 5)), waveTop - 2);
ctx.textBaseline = 'alphabetic';
}
const mid = waveTop + waveH / 2; const mid = waveTop + waveH / 2;
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = '#4a4d5e'; ctx.strokeStyle = '#4a4d5e';
@ -1169,6 +1202,26 @@ export default function WaveformTimeline({
if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null); if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null);
}, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]); }, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
// Capture thumbnail frames when enabled
useEffect(() => {
if (!showThumbnails) { setThumbnailFrames(new Map()); return; }
const dur = displayDuration || waveformDataRef.current?.duration || 0;
if (dur <= 0) return;
const video = document.querySelector('video') as HTMLVideoElement | null;
if (!video) return;
const interval = 10;
const times: number[] = [];
for (let t = 0; t < dur; t += interval) times.push(t);
let cancelled = false;
extractThumbnails(video, times).then((frames) => {
if (!cancelled) setThumbnailFrames(frames);
});
return () => { cancelled = true; };
}, [showThumbnails, videoUrl, displayDuration]);
if (!videoUrl) { if (!videoUrl) {
return ( return (
<div className="w-full h-full flex items-center justify-center text-editor-text-muted text-xs"> <div className="w-full h-full flex items-center justify-center text-editor-text-muted text-xs">
@ -1248,16 +1301,46 @@ export default function WaveformTimeline({
</pre> </pre>
</div> </div>
) : ( ) : (
<div className="flex-1 relative"> <div className="flex-1 relative flex flex-col">
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" /> {showThumbnails && thumbnailFrames.size > 0 && (
<canvas <div
ref={headCanvasRef} ref={thumbnailContainerRef}
className="absolute inset-0 w-full h-full" className="h-14 shrink-0 overflow-x-auto border-b border-editor-border/60"
style={{ cursor: isDragging ? 'grabbing' : hoverCursor }} style={{ scrollbarWidth: 'thin' }}
onMouseDown={handleMouseDown} >
onMouseMove={handleMouseMove} <div className="relative h-full" style={{ width: '100%', minHeight: 0 }}>
onWheel={handleWheel} {Array.from(thumbnailFrames.entries()).map(([time, dataUrl]) => {
/> const dur = displayDuration || waveformDataRef.current?.duration || 1;
const pct = (time / dur) * 100;
return (
<img
key={time}
src={dataUrl}
alt={`Thumbnail at ${time.toFixed(0)}s`}
className="absolute top-1 rounded border border-editor-border/40 object-cover cursor-pointer"
style={{ left: `${pct}%`, width: 80, height: 45, transform: 'translateX(-50%)' }}
title={`${time.toFixed(0)}s`}
onClick={() => {
const video = document.querySelector('video') as HTMLVideoElement | null;
if (video) { video.currentTime = time; setCurrentTime(time); }
}}
/>
);
})}
</div>
</div>
)}
<div className="flex-1 relative">
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" />
<canvas
ref={headCanvasRef}
className="absolute inset-0 w-full h-full"
style={{ cursor: isDragging ? 'grabbing' : hoverCursor }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onWheel={handleWheel}
/>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -1,5 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { loadBindings } from '../lib/keybindings';
import type { KeyBinding } from '../types/project';
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
const addCutRange = useEditorStore((s) => s.addCutRange); const addCutRange = useEditorStore((s) => s.addCutRange);
@ -10,9 +12,13 @@ export function useKeyboardShortcuts() {
const clearMarkRange = useEditorStore((s) => s.clearMarkRange); const clearMarkRange = useEditorStore((s) => s.clearMarkRange);
const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices); const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
const words = useEditorStore((s) => s.words); const words = useEditorStore((s) => s.words);
const playbackRateRef = useRef(1); 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(() => { useEffect(() => {
const getVideo = (): HTMLVideoElement | null => document.querySelector('video'); const getVideo = (): HTMLVideoElement | null => document.querySelector('video');
@ -22,81 +28,58 @@ export function useKeyboardShortcuts() {
const video = getVideo(); const video = getVideo();
switch (true) { // Build a key string from the event for matching
// --- Undo / Redo --- const parts: string[] = [];
case e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey: { if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
e.preventDefault(); if (e.shiftKey && !['Shift'].includes(e.key)) parts.push('Shift');
useEditorStore.temporal.getState().redo(); if (e.altKey) parts.push('Alt');
return; const keyStr = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key;
} parts.push(keyStr);
case e.key === 'z' && (e.ctrlKey || e.metaKey): { const combo = parts.join('+');
e.preventDefault();
// 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(); useEditorStore.temporal.getState().undo();
return; return;
} case 'redo':
useEditorStore.temporal.getState().redo();
// --- Delete / Backspace: cut selected words --- return;
case e.key === 'Delete' || e.key === 'Backspace': { case 'cut': {
if (selectedWordIndices.length > 0) { if (selectedWordIndices.length > 0) {
e.preventDefault();
const sorted = [...selectedWordIndices].sort((a, b) => a - b); const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start; addCutRange(words[sorted[0]].start, words[sorted[sorted.length - 1]].end);
const endTime = words[sorted[sorted.length - 1]].end;
addCutRange(startTime, endTime);
return; return;
} }
if (markInTime !== null && markOutTime !== null) { if (markInTime !== null && markOutTime !== null) {
e.preventDefault();
const start = Math.min(markInTime, markOutTime); const start = Math.min(markInTime, markOutTime);
const end = Math.max(markInTime, markOutTime); const end = Math.max(markInTime, markOutTime);
if (end - start >= 0.01) { if (end - start >= 0.01) addCutRange(start, end);
addCutRange(start, end);
}
clearMarkRange(); clearMarkRange();
} }
return; return;
} }
case 'play-pause':
// --- Space: play / pause --- if (video) { if (video.paused) video.play(); else video.pause(); }
case e.key === ' ' && !e.ctrlKey: {
e.preventDefault();
if (video) {
if (video.paused) video.play();
else video.pause();
}
return; return;
} case 'slow-down': {
// --- J: reverse / slow down ---
case e.key === 'j' || e.key === 'J': {
e.preventDefault();
if (video) { if (video) {
playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5); playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5);
if (playbackRateRef.current < 0) { if (playbackRateRef.current < 0) video.currentTime = Math.max(0, video.currentTime - 2);
// HTML5 video doesn't support negative rates natively; step back else { video.playbackRate = playbackRateRef.current; if (video.paused) video.play(); }
video.currentTime = Math.max(0, video.currentTime - 2);
} else {
video.playbackRate = playbackRateRef.current;
if (video.paused) video.play();
}
} }
return; return;
} }
case 'pause':
// --- K: pause --- if (video) { video.pause(); playbackRateRef.current = 1; }
case e.key === 'k' || e.key === 'K': {
e.preventDefault();
if (video) {
video.pause();
playbackRateRef.current = 1;
}
return; return;
} case 'speed-up': {
// --- L: forward / speed up ---
case e.key === 'l' || e.key === 'L': {
e.preventDefault();
if (video) { if (video) {
playbackRateRef.current = Math.min(4, playbackRateRef.current + 0.5); playbackRateRef.current = Math.min(4, playbackRateRef.current + 0.5);
video.playbackRate = Math.max(0.25, playbackRateRef.current); video.playbackRate = Math.max(0.25, playbackRateRef.current);
@ -104,60 +87,37 @@ export function useKeyboardShortcuts() {
} }
return; return;
} }
case 'rewind':
// --- Arrow Left: seek back 5s ---
case e.key === 'ArrowLeft' && !e.ctrlKey: {
e.preventDefault();
if (video) video.currentTime = Math.max(0, video.currentTime - 5); if (video) video.currentTime = Math.max(0, video.currentTime - 5);
return; return;
} case 'forward':
// --- Arrow Right: seek forward 5s ---
case e.key === 'ArrowRight' && !e.ctrlKey: {
e.preventDefault();
if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5); if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5);
return; return;
} case 'mark-in':
// --- I: mark in-point ---
case e.key === 'i' || e.key === 'I': {
e.preventDefault();
if (video) setMarkInTime(video.currentTime); if (video) setMarkInTime(video.currentTime);
return; return;
} case 'mark-out':
// --- O: mark out-point ---
case e.key === 'o' || e.key === 'O': {
e.preventDefault();
if (video) setMarkOutTime(video.currentTime); if (video) setMarkOutTime(video.currentTime);
return; return;
} case 'save': {
// --- Ctrl+S: save project ---
case e.key === 's' && (e.ctrlKey || e.metaKey): {
e.preventDefault();
const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null; const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null;
if (saveBtn) saveBtn.click(); if (saveBtn) saveBtn.click();
else saveProject(); else saveProject();
return; return;
} }
case 'export': {
// --- Ctrl+E: export ---
case e.key === 'e' && (e.ctrlKey || e.metaKey): {
e.preventDefault();
// Trigger export panel via DOM click
const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement; const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement;
if (exportBtn) exportBtn.click(); if (exportBtn) exportBtn.click();
return; return;
} }
case 'search': {
// --- ?: show shortcut cheatsheet --- const findBtn = document.querySelector('[title="Find (Ctrl+F)"]') as HTMLButtonElement;
case e.key === '?' || (e.key === '/' && e.shiftKey): { if (findBtn) findBtn.click();
e.preventDefault();
toggleCheatsheet();
return; return;
} }
case 'help':
toggleCheatsheet(currentBindings);
return;
default: default:
break; break;
} }
@ -205,7 +165,7 @@ async function saveProject() {
} }
} }
function toggleCheatsheet() { function toggleCheatsheet(bindings: KeyBinding[]) {
const existing = document.getElementById('keyboard-cheatsheet'); const existing = document.getElementById('keyboard-cheatsheet');
if (existing) { if (existing) {
existing.remove(); existing.remove();
@ -220,32 +180,17 @@ function toggleCheatsheet() {
overlay.remove(); overlay.remove();
}; };
const shortcuts = [ const rows = bindings
['Space', 'Play / Pause'],
['J', 'Reverse / Slow down'],
['K', 'Pause'],
['L', 'Forward / Speed up'],
['\u2190 / \u2192', 'Seek \u00b15 seconds'],
['I / O', 'Mark in / out points'],
['Delete', 'Cut selected words'],
['Ctrl+Z', 'Undo'],
['Ctrl+Shift+Z', 'Redo'],
['Ctrl+S', 'Save project'],
['Ctrl+E', 'Export'],
['?', 'This cheatsheet'],
];
const rows = shortcuts
.map( .map(
([key, desc]) => (b) =>
`<tr><td style="padding:6px 16px 6px 0;font-family:monospace;color:#818cf8;font-weight:600">${key}</td><td style="padding:6px 0;color:#e2e8f0">${desc}</td></tr>`, `<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(''); .join('');
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:400px;" onclick="event.stopPropagation()"> overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;" onclick="event.stopPropagation()">
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3> <h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
<table style="font-size:13px">${rows}</table> <table style="font-size:13px">${rows}</table>
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Press ? or click outside to close</p> <p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings &bull; Press ? to close</p>
</div>`; </div>`;
document.body.appendChild(overlay); document.body.appendChild(overlay);

View File

@ -12,6 +12,8 @@ import type {
SilenceDetectionRange, SilenceDetectionRange,
SilenceTrimSettings, SilenceTrimSettings,
SilenceTrimGroup, SilenceTrimGroup,
TimelineMarker,
Chapter,
} from '../types/project'; } from '../types/project';
interface EditorState { interface EditorState {
@ -27,6 +29,7 @@ interface EditorState {
speedRanges: SpeedRange[]; speedRanges: SpeedRange[];
globalGainDb: number; globalGainDb: number;
silenceTrimGroups: SilenceTrimGroup[]; silenceTrimGroups: SilenceTrimGroup[];
timelineMarkers: TimelineMarker[];
transcriptionModel: string | null; transcriptionModel: string | null;
language: string; language: string;
@ -89,6 +92,10 @@ interface EditorActions {
settings: SilenceTrimSettings; settings: SilenceTrimSettings;
}) => { groupId: string; appliedCount: number }; }) => { groupId: string; appliedCount: number };
removeSilenceTrimGroup: (groupId: string) => void; removeSilenceTrimGroup: (groupId: string) => void;
addTimelineMarker: (time: number, label?: string, color?: string) => void;
updateTimelineMarker: (id: string, updates: Partial<TimelineMarker>) => void;
removeTimelineMarker: (id: string) => void;
getChapters: () => Chapter[];
setTranscribing: (active: boolean, progress?: number, status?: string) => void; setTranscribing: (active: boolean, progress?: number, status?: string) => void;
setExporting: (active: boolean, progress?: number) => void; setExporting: (active: boolean, progress?: number) => void;
setZonePreviewPaddingSeconds: (seconds: number) => void; setZonePreviewPaddingSeconds: (seconds: number) => void;
@ -122,6 +129,7 @@ const initialState: EditorState = {
speedRanges: [], speedRanges: [],
globalGainDb: 0, globalGainDb: 0,
silenceTrimGroups: [], silenceTrimGroups: [],
timelineMarkers: [],
transcriptionModel: null, transcriptionModel: null,
language: '', language: '',
currentTime: 0, currentTime: 0,
@ -182,7 +190,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
setTranscriptionModel: (model) => set({ transcriptionModel: model }), setTranscriptionModel: (model) => set({ transcriptionModel: model }),
saveProject: (): ProjectFile => { saveProject: (): ProjectFile => {
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get(); const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath } = get();
if (!videoPath) throw new Error('No video loaded'); if (!videoPath) throw new Error('No video loaded');
const now = new Date().toISOString(); const now = new Date().toISOString();
// Strip globalStartIndex (runtime-only field) before persisting. // Strip globalStartIndex (runtime-only field) before persisting.
@ -204,6 +212,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
speedRanges, speedRanges,
globalGainDb, globalGainDb,
silenceTrimGroups, silenceTrimGroups,
timelineMarkers,
language, language,
createdAt: now, // will be overwritten if we track original creation time later createdAt: now, // will be overwritten if we track original creation time later
modifiedAt: now, modifiedAt: now,
@ -453,6 +462,40 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}); });
}, },
addTimelineMarker: (time, label, color) => {
const { timelineMarkers } = get();
const newMarker: TimelineMarker = {
id: `marker_${nextRangeId++}`,
time,
label: label || `Marker ${timelineMarkers.length + 1}`,
color: color || '#6366f1',
};
set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) });
},
updateTimelineMarker: (id, updates) => {
const { timelineMarkers } = get();
set({
timelineMarkers: timelineMarkers
.map((m) => (m.id === id ? { ...m, ...updates } : m))
.sort((a, b) => a.time - b.time),
});
},
removeTimelineMarker: (id) => {
const { timelineMarkers } = get();
set({ timelineMarkers: timelineMarkers.filter((m) => m.id !== id) });
},
getChapters: () => {
const { timelineMarkers } = get();
return timelineMarkers.map((m) => ({
markerId: m.id,
label: m.label,
startTime: m.time,
}));
},
setTranscribing: (active, progress, status) => setTranscribing: (active, progress, status) =>
set({ set({
isTranscribing: active, isTranscribing: active,
@ -587,6 +630,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
speedRanges: data.speedRanges || [], speedRanges: data.speedRanges || [],
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0, globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
silenceTrimGroups: data.silenceTrimGroups || [], silenceTrimGroups: data.silenceTrimGroups || [],
timelineMarkers: data.timelineMarkers || [],
transcriptionModel: data.transcriptionModel ?? null, transcriptionModel: data.transcriptionModel ?? null,
language: data.language || '', language: data.language || '',
exportedAudioPath: data.exportedAudioPath ?? null, exportedAudioPath: data.exportedAudioPath ?? null,

View File

@ -72,6 +72,7 @@ export interface ProjectFile {
speedRanges?: SpeedRange[]; speedRanges?: SpeedRange[];
globalGainDb?: number; globalGainDb?: number;
silenceTrimGroups?: SilenceTrimGroup[]; silenceTrimGroups?: SilenceTrimGroup[];
timelineMarkers?: TimelineMarker[];
language: string; language: string;
createdAt: string; createdAt: string;
modifiedAt: string; modifiedAt: string;
@ -93,6 +94,28 @@ export interface ExportOptions {
captionStyle?: CaptionStyle; captionStyle?: CaptionStyle;
} }
export interface TimelineMarker {
id: string;
time: number;
label: string;
color: string;
}
export interface Chapter {
markerId: string;
label: string;
startTime: number;
}
export interface KeyBinding {
id: string;
label: string;
keys: string; // e.g. "Ctrl+Z"
category: string; // "transport", "edit", "file", "view"
}
export type HotkeyPreset = 'left-hand' | 'standard';
export interface CaptionStyle { export interface CaptionStyle {
fontName: string; fontName: string;
fontSize: number; fontSize: number;

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/tauri-bridge.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"}