verified hotkeys
This commit is contained in:
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@ -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.
|
||||
- **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.
|
||||
- **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)
|
||||
|
||||
|
||||
@ -22,13 +22,13 @@ Features are grouped by priority. Check off items as they are implemented.
|
||||
|
||||
## 🟡 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import AIPanel from './components/AIPanel';
|
||||
import ExportDialog from './components/ExportDialog';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import DevPanel from './components/DevPanel';
|
||||
import MarkersPanel from './components/MarkersPanel';
|
||||
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
||||
import ZoneEditor from './components/ZoneEditor';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
@ -25,11 +26,12 @@ import {
|
||||
FilePlus2,
|
||||
RefreshCw,
|
||||
Grid3x3,
|
||||
MapPin,
|
||||
} from 'lucide-react';
|
||||
|
||||
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() {
|
||||
const {
|
||||
@ -597,6 +599,13 @@ export default function App() {
|
||||
onClick={() => togglePanel('silence')}
|
||||
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">
|
||||
<select
|
||||
value={whisperModel}
|
||||
@ -741,6 +750,7 @@ export default function App() {
|
||||
<ZoneEditor />
|
||||
)}
|
||||
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
||||
{activePanel === 'markers' && <MarkersPanel />}
|
||||
{activePanel === 'ai' && <AIPanel />}
|
||||
{activePanel === 'export' && <ExportDialog />}
|
||||
{activePanel === 'settings' && <SettingsPanel />}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useAIStore } from '../store/aiStore';
|
||||
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 { 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() {
|
||||
const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
|
||||
@ -19,6 +20,51 @@ export default function SettingsPanel() {
|
||||
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 [loadingModels, setLoadingModels] = useState(false);
|
||||
|
||||
@ -112,6 +158,60 @@ export default function SettingsPanel() {
|
||||
</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 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Default AI Provider</label>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
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 COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
||||
@ -239,13 +240,18 @@ export default function WaveformTimeline({
|
||||
const videoPath = useEditorStore((s) => s.videoPath);
|
||||
const backendUrl = useEditorStore((s) => s.backendUrl);
|
||||
const duration = useEditorStore((s) => s.duration);
|
||||
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
||||
const cutRanges = useEditorStore((s) => s.cutRanges);
|
||||
const muteRanges = useEditorStore((s) => s.muteRanges);
|
||||
const gainRanges = useEditorStore((s) => s.gainRanges);
|
||||
const speedRanges = useEditorStore((s) => s.speedRanges);
|
||||
const timelineMarkers = useEditorStore((s) => s.timelineMarkers);
|
||||
const markInTime = useEditorStore((s) => s.markInTime);
|
||||
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 addMuteRange = useEditorStore((s) => s.addMuteRange);
|
||||
const addGainRange = useEditorStore((s) => s.addGainRange);
|
||||
@ -606,6 +612,33 @@ export default function WaveformTimeline({
|
||||
if (markInTime !== null) drawMarkLine(markInTime, 'I');
|
||||
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;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#4a4d5e';
|
||||
@ -1169,6 +1202,26 @@ export default function WaveformTimeline({
|
||||
if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null);
|
||||
}, [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) {
|
||||
return (
|
||||
<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>
|
||||
</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 className="flex-1 relative flex flex-col">
|
||||
{showThumbnails && thumbnailFrames.size > 0 && (
|
||||
<div
|
||||
ref={thumbnailContainerRef}
|
||||
className="h-14 shrink-0 overflow-x-auto border-b border-editor-border/60"
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
<div className="relative h-full" style={{ width: '100%', minHeight: 0 }}>
|
||||
{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>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
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);
|
||||
@ -10,9 +12,13 @@ export function useKeyboardShortcuts() {
|
||||
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');
|
||||
|
||||
@ -22,81 +28,58 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
const video = getVideo();
|
||||
|
||||
switch (true) {
|
||||
// --- Undo / Redo ---
|
||||
case e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey: {
|
||||
e.preventDefault();
|
||||
useEditorStore.temporal.getState().redo();
|
||||
return;
|
||||
}
|
||||
case e.key === 'z' && (e.ctrlKey || e.metaKey): {
|
||||
e.preventDefault();
|
||||
// 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;
|
||||
}
|
||||
|
||||
// --- Delete / Backspace: cut selected words ---
|
||||
case e.key === 'Delete' || e.key === 'Backspace': {
|
||||
case 'redo':
|
||||
useEditorStore.temporal.getState().redo();
|
||||
return;
|
||||
case 'cut': {
|
||||
if (selectedWordIndices.length > 0) {
|
||||
e.preventDefault();
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
const startTime = words[sorted[0]].start;
|
||||
const endTime = words[sorted[sorted.length - 1]].end;
|
||||
addCutRange(startTime, endTime);
|
||||
addCutRange(words[sorted[0]].start, words[sorted[sorted.length - 1]].end);
|
||||
return;
|
||||
}
|
||||
|
||||
if (markInTime !== null && markOutTime !== null) {
|
||||
e.preventDefault();
|
||||
const start = Math.min(markInTime, markOutTime);
|
||||
const end = Math.max(markInTime, markOutTime);
|
||||
if (end - start >= 0.01) {
|
||||
addCutRange(start, end);
|
||||
}
|
||||
if (end - start >= 0.01) addCutRange(start, end);
|
||||
clearMarkRange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Space: play / pause ---
|
||||
case e.key === ' ' && !e.ctrlKey: {
|
||||
e.preventDefault();
|
||||
if (video) {
|
||||
if (video.paused) video.play();
|
||||
else video.pause();
|
||||
}
|
||||
case 'play-pause':
|
||||
if (video) { if (video.paused) video.play(); else video.pause(); }
|
||||
return;
|
||||
}
|
||||
|
||||
// --- J: reverse / slow down ---
|
||||
case e.key === 'j' || e.key === 'J': {
|
||||
e.preventDefault();
|
||||
case 'slow-down': {
|
||||
if (video) {
|
||||
playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5);
|
||||
if (playbackRateRef.current < 0) {
|
||||
// HTML5 video doesn't support negative rates natively; step back
|
||||
video.currentTime = Math.max(0, video.currentTime - 2);
|
||||
} else {
|
||||
video.playbackRate = playbackRateRef.current;
|
||||
if (video.paused) video.play();
|
||||
}
|
||||
if (playbackRateRef.current < 0) video.currentTime = Math.max(0, video.currentTime - 2);
|
||||
else { video.playbackRate = playbackRateRef.current; if (video.paused) video.play(); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- K: pause ---
|
||||
case e.key === 'k' || e.key === 'K': {
|
||||
e.preventDefault();
|
||||
if (video) {
|
||||
video.pause();
|
||||
playbackRateRef.current = 1;
|
||||
}
|
||||
case 'pause':
|
||||
if (video) { video.pause(); playbackRateRef.current = 1; }
|
||||
return;
|
||||
}
|
||||
|
||||
// --- L: forward / speed up ---
|
||||
case e.key === 'l' || e.key === 'L': {
|
||||
e.preventDefault();
|
||||
case 'speed-up': {
|
||||
if (video) {
|
||||
playbackRateRef.current = Math.min(4, playbackRateRef.current + 0.5);
|
||||
video.playbackRate = Math.max(0.25, playbackRateRef.current);
|
||||
@ -104,60 +87,37 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Arrow Left: seek back 5s ---
|
||||
case e.key === 'ArrowLeft' && !e.ctrlKey: {
|
||||
e.preventDefault();
|
||||
case 'rewind':
|
||||
if (video) video.currentTime = Math.max(0, video.currentTime - 5);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Arrow Right: seek forward 5s ---
|
||||
case e.key === 'ArrowRight' && !e.ctrlKey: {
|
||||
e.preventDefault();
|
||||
case 'forward':
|
||||
if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- I: mark in-point ---
|
||||
case e.key === 'i' || e.key === 'I': {
|
||||
e.preventDefault();
|
||||
case 'mark-in':
|
||||
if (video) setMarkInTime(video.currentTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- O: mark out-point ---
|
||||
case e.key === 'o' || e.key === 'O': {
|
||||
e.preventDefault();
|
||||
case 'mark-out':
|
||||
if (video) setMarkOutTime(video.currentTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ctrl+S: save project ---
|
||||
case e.key === 's' && (e.ctrlKey || e.metaKey): {
|
||||
e.preventDefault();
|
||||
case 'save': {
|
||||
const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null;
|
||||
if (saveBtn) saveBtn.click();
|
||||
else saveProject();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ctrl+E: export ---
|
||||
case e.key === 'e' && (e.ctrlKey || e.metaKey): {
|
||||
e.preventDefault();
|
||||
// Trigger export panel via DOM click
|
||||
case 'export': {
|
||||
const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement;
|
||||
if (exportBtn) exportBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- ?: show shortcut cheatsheet ---
|
||||
case e.key === '?' || (e.key === '/' && e.shiftKey): {
|
||||
e.preventDefault();
|
||||
toggleCheatsheet();
|
||||
case 'search': {
|
||||
const findBtn = document.querySelector('[title="Find (Ctrl+F)"]') as HTMLButtonElement;
|
||||
if (findBtn) findBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'help':
|
||||
toggleCheatsheet(currentBindings);
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -205,7 +165,7 @@ async function saveProject() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCheatsheet() {
|
||||
function toggleCheatsheet(bindings: KeyBinding[]) {
|
||||
const existing = document.getElementById('keyboard-cheatsheet');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
@ -220,32 +180,17 @@ function toggleCheatsheet() {
|
||||
overlay.remove();
|
||||
};
|
||||
|
||||
const shortcuts = [
|
||||
['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
|
||||
const rows = bindings
|
||||
.map(
|
||||
([key, desc]) =>
|
||||
`<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>`,
|
||||
(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: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>
|
||||
<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 • Press ? to close</p>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
@ -12,6 +12,8 @@ import type {
|
||||
SilenceDetectionRange,
|
||||
SilenceTrimSettings,
|
||||
SilenceTrimGroup,
|
||||
TimelineMarker,
|
||||
Chapter,
|
||||
} from '../types/project';
|
||||
|
||||
interface EditorState {
|
||||
@ -27,6 +29,7 @@ interface EditorState {
|
||||
speedRanges: SpeedRange[];
|
||||
globalGainDb: number;
|
||||
silenceTrimGroups: SilenceTrimGroup[];
|
||||
timelineMarkers: TimelineMarker[];
|
||||
transcriptionModel: string | null;
|
||||
language: string;
|
||||
|
||||
@ -89,6 +92,10 @@ interface EditorActions {
|
||||
settings: SilenceTrimSettings;
|
||||
}) => { groupId: string; appliedCount: number };
|
||||
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;
|
||||
setExporting: (active: boolean, progress?: number) => void;
|
||||
setZonePreviewPaddingSeconds: (seconds: number) => void;
|
||||
@ -122,6 +129,7 @@ const initialState: EditorState = {
|
||||
speedRanges: [],
|
||||
globalGainDb: 0,
|
||||
silenceTrimGroups: [],
|
||||
timelineMarkers: [],
|
||||
transcriptionModel: null,
|
||||
language: '',
|
||||
currentTime: 0,
|
||||
@ -182,7 +190,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||
|
||||
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');
|
||||
const now = new Date().toISOString();
|
||||
// Strip globalStartIndex (runtime-only field) before persisting.
|
||||
@ -204,6 +212,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
speedRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
timelineMarkers,
|
||||
language,
|
||||
createdAt: now, // will be overwritten if we track original creation time later
|
||||
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) =>
|
||||
set({
|
||||
isTranscribing: active,
|
||||
@ -587,6 +630,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
speedRanges: data.speedRanges || [],
|
||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||
timelineMarkers: data.timelineMarkers || [],
|
||||
transcriptionModel: data.transcriptionModel ?? null,
|
||||
language: data.language || '',
|
||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||
|
||||
@ -72,6 +72,7 @@ export interface ProjectFile {
|
||||
speedRanges?: SpeedRange[];
|
||||
globalGainDb?: number;
|
||||
silenceTrimGroups?: SilenceTrimGroup[];
|
||||
timelineMarkers?: TimelineMarker[];
|
||||
language: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
@ -93,6 +94,28 @@ export interface ExportOptions {
|
||||
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 {
|
||||
fontName: string;
|
||||
fontSize: number;
|
||||
|
||||
@ -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"}
|
||||
Reference in New Issue
Block a user