Merge pull request #172 from FabLrc/feature/shortcuts-configuration

Configurable keyboard shortcuts system
This commit is contained in:
Sid
2026-03-01 09:36:25 -08:00
committed by GitHub
12 changed files with 530 additions and 61 deletions
+19 -1
View File
@@ -1,4 +1,4 @@
import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron";
import { ipcMain, screen, BrowserWindow, app, desktopCapturer, shell, dialog, nativeImage, Tray, Menu } from "electron";
import { fileURLToPath } from "node:url";
import path from "node:path";
import fs from "node:fs/promises";
@@ -126,6 +126,7 @@ function createSourceSelectorWindow() {
}
return win;
}
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
let selectedSource = null;
function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) {
ipcMain.handle("get-sources", async (_, opts) => {
@@ -298,6 +299,23 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g
ipcMain.handle("get-platform", () => {
return process.platform;
});
ipcMain.handle("get-shortcuts", async () => {
try {
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
return JSON.parse(data);
} catch {
return null;
}
});
ipcMain.handle("save-shortcuts", async (_, shortcuts) => {
try {
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8");
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
return { success: false, error: String(error) };
}
});
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
+6
View File
@@ -59,5 +59,11 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
},
getPlatform: () => {
return electron.ipcRenderer.invoke("get-platform");
},
getShortcuts: () => {
return electron.ipcRenderer.invoke("get-shortcuts");
},
saveShortcuts: (shortcuts) => {
return electron.ipcRenderer.invoke("save-shortcuts", shortcuts);
}
});
+2
View File
@@ -41,6 +41,8 @@ interface Window {
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
clearCurrentVideoPath: () => Promise<{ success: boolean }>
getPlatform: () => Promise<string>
getShortcuts: () => Promise<Record<string, unknown> | null>
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>
hudOverlayHide: () => void;
hudOverlayClose: () => void;
}
+21
View File
@@ -4,6 +4,8 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import { RECORDINGS_DIR } from '../main'
const SHORTCUTS_FILE = path.join(app.getPath('userData'), 'shortcuts.json')
let selectedSource: any = null
let currentVideoPath: string | null = null
@@ -328,4 +330,23 @@ export function registerIpcHandlers(
ipcMain.handle('get-platform', () => {
return process.platform;
});
ipcMain.handle('get-shortcuts', async () => {
try {
const data = await fs.readFile(SHORTCUTS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
});
ipcMain.handle('save-shortcuts', async (_, shortcuts: unknown) => {
try {
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), 'utf-8');
return { success: true };
} catch (error) {
console.error('Failed to save shortcuts:', error);
return { success: false, error: String(error) };
}
});
}
+6
View File
@@ -66,4 +66,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
getPlatform: () => {
return ipcRenderer.invoke('get-platform')
},
getShortcuts: () => {
return ipcRenderer.invoke('get-shortcuts')
},
saveShortcuts: (shortcuts: unknown) => {
return ipcRenderer.invoke('save-shortcuts', shortcuts)
},
})
+8 -1
View File
@@ -3,6 +3,8 @@ import { LaunchWindow } from "./components/launch/LaunchWindow";
import { SourceSelector } from "./components/launch/SourceSelector";
import VideoEditor from "./components/video-editor/VideoEditor";
import { loadAllCustomFonts } from "./lib/customFonts";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
export default function App() {
const [windowType, setWindowType] = useState('');
@@ -29,7 +31,12 @@ export default function App() {
case 'source-selector':
return <SourceSelector />;
case 'editor':
return <VideoEditor />;
return (
<ShortcutsProvider>
<VideoEditor />
<ShortcutsConfigDialog />
</ShortcutsProvider>
);
default:
return (
<div className="w-full h-full bg-background text-foreground">
@@ -1,65 +1,62 @@
import { HelpCircle } from "lucide-react";
import { HelpCircle, Settings2 } from "lucide-react";
import { useState, useEffect } from "react";
import { formatShortcut } from "@/utils/platformUtils";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
export function KeyboardShortcutsHelp() {
const [shortcuts, setShortcuts] = useState({
delete: 'Ctrl + D',
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
const { shortcuts, isMac, openConfig } = useShortcuts();
const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' });
useEffect(() => {
Promise.all([
formatShortcut(['mod', 'D']),
formatShortcut(['shift', 'mod', 'Scroll']),
formatShortcut(['mod', 'Scroll'])
]).then(([deleteKey, panKey, zoomKey]) => {
setShortcuts({
delete: deleteKey,
pan: panKey,
zoom: zoomKey
});
});
formatShortcut(['mod', 'Scroll']),
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
<div className="text-xs font-semibold text-slate-200 mb-2">Keyboard Shortcuts</div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
<button
type="button"
onClick={openConfig}
title="Customize shortcuts"
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
>
<Settings2 className="w-3 h-3" />
Customize
</button>
</div>
<div className="space-y-1.5 text-[10px]">
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Zoom</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Z</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Annotation</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">A</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Keyframe</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">F</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Add Trim</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">T</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Delete Selected</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.delete}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.pan}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.zoom}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Pause/Play</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Space</kbd>
{SHORTCUT_ACTIONS.map((action) => (
<div key={action} className="flex items-center justify-between">
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{formatBinding(shortcuts[action], isMac)}
</kbd>
</div>
))}
<div className="pt-1 border-t border-white/5 mt-1">
<div className="flex items-center justify-between">
<span className="text-slate-400">Pan Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{scrollLabels.pan}</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Zoom Timeline</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{scrollLabels.zoom}</kbd>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-slate-400">Cycle Annotations</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">Tab</kbd>
</div>
</div>
</div>
</div>
@@ -0,0 +1,223 @@
import { useCallback, useEffect, useState } from 'react';
import { Keyboard, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
DEFAULT_SHORTCUTS,
FIXED_SHORTCUTS,
SHORTCUT_ACTIONS,
SHORTCUT_LABELS,
findConflict,
formatBinding,
type ShortcutAction,
type ShortcutBinding,
type ShortcutConflict,
type ShortcutsConfig,
} from '@/lib/shortcuts';
import { useShortcuts } from '@/contexts/ShortcutsContext';
const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']);
export function ShortcutsConfigDialog() {
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
useShortcuts();
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
const [conflict, setConflict] = useState<{ forAction: ShortcutAction; pending: ShortcutBinding; conflictWith: ShortcutConflict } | null>(null);
useEffect(() => {
if (isConfigOpen) {
setDraft(shortcuts);
setCaptureFor(null);
setConflict(null);
}
}, [isConfigOpen, shortcuts]);
useEffect(() => {
if (!captureFor) return;
const handleCapture = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
setCaptureFor(null);
return;
}
if (MODIFIER_KEYS.has(e.key)) return;
const binding: ShortcutBinding = {
key: e.key.toLowerCase(),
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
...(e.shiftKey ? { shift: true } : {}),
...(e.altKey ? { alt: true } : {}),
};
const found = findConflict(binding, captureFor, draft);
setCaptureFor(null);
if (found?.type === 'fixed') {
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
return;
}
if (found?.type === 'configurable') {
setConflict({ forAction: captureFor, pending: binding, conflictWith: found });
return;
}
setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
};
window.addEventListener('keydown', handleCapture, { capture: true });
return () => window.removeEventListener('keydown', handleCapture, { capture: true });
}, [captureFor]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== 'configurable') return;
const { forAction, pending, conflictWith } = conflict;
setDraft((prev: ShortcutsConfig) => ({
...prev,
[forAction]: pending,
[conflictWith.action]: prev[forAction],
}));
setConflict(null);
}, [conflict]);
const handleCancelConflict = useCallback(() => setConflict(null), []);
const handleSave = useCallback(async () => {
setShortcuts(draft);
await persistShortcuts(draft);
toast.success('Keyboard shortcuts saved');
closeConfig();
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
const handleReset = useCallback(() => {
setDraft({ ...DEFAULT_SHORTCUTS });
toast.info('Reset to default shortcuts — click Save to apply');
}, []);
const handleClose = useCallback(() => {
setCaptureFor(null);
setConflict(null);
closeConfig();
}, [closeConfig]);
return (
<Dialog open={isConfigOpen} onOpenChange={(open: boolean) => { if (!open) handleClose(); }}>
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Keyboard className="w-4 h-4 text-[#34B27B]" />
Keyboard Shortcuts
</DialogTitle>
</DialogHeader>
<div className="space-y-0.5">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Configurable</p>
{SHORTCUT_ACTIONS.map((action) => {
const isCapturing = captureFor === action;
const hasConflict = conflict?.forAction === action;
return (
<div key={action}>
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
<button
type="button"
onClick={() => {
setConflict(null);
setCaptureFor(isCapturing ? null : action);
}}
title={isCapturing ? 'Press Esc to cancel' : 'Click to change'}
className={[
'px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none',
isCapturing
? 'bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse'
: hasConflict
? 'bg-amber-500/10 border-amber-500/50 text-amber-400'
: 'bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer',
].join(' ')}
>
{isCapturing ? 'Press a key…' : formatBinding(draft[action], isMac)}
</button>
</div>
{hasConflict && conflict?.conflictWith.type === 'configurable' && (
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
<span className="text-amber-400">
Already used by <strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={handleSwap}
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
>
Swap
</button>
<button
type="button"
onClick={handleCancelConflict}
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
})}
</div>
<div className="space-y-0.5 mt-2">
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">Fixed</p>
{FIXED_SHORTCUTS.map(({ label, display }) => (
<div
key={label}
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
>
<span className="text-sm text-slate-400">{label}</span>
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
{display}
</kbd>
</div>
))}
</div>
<p className="text-[10px] text-slate-500 mt-1">
Click a shortcut then press the new key combination. Press{' '}
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
</p>
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-white gap-1.5"
onClick={handleReset}
>
<RotateCcw className="w-3 h-3" />
Reset to defaults
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button
size="sm"
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
onClick={handleSave}
>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+6 -2
View File
@@ -32,6 +32,8 @@ import {
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
const WALLPAPER_COUNT = 18;
const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
@@ -71,6 +73,8 @@ export default function VideoEditor() {
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
const exporterRef = useRef<VideoExporter | null>(null);
@@ -459,7 +463,7 @@ export default function VideoEditor() {
e.preventDefault();
}
if (e.key === ' ' || e.code === 'Space') {
if (matchesShortcut(e, shortcuts.playPause, isMac)) {
// Allow space only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
@@ -479,7 +483,7 @@ export default function VideoEditor() {
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, []);
}, [shortcuts, isMac]);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
@@ -20,6 +20,8 @@ import {
import { type AspectRatio, getAspectRatioLabel, ASPECT_RATIOS } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
import { TutorialHelp } from "../TutorialHelp";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
const ZOOM_ROW_ID = "row-zoom";
@@ -558,16 +560,17 @@ export default function TimelineEditor({
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
const [shortcuts, setShortcuts] = useState({
const [scrollLabels, setScrollLabels] = useState({
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
const timelineContainerRef = useRef<HTMLDivElement>(null);
const { shortcuts: keyShortcuts, isMac } = useShortcuts();
useEffect(() => {
formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
formatShortcut(['mod', 'Scroll']).then(zoom => {
setShortcuts({ pan, zoom });
setScrollLabels({ pan, zoom });
});
});
}, []);
@@ -860,16 +863,16 @@ export default function TimelineEditor({
return;
}
if (e.key === 'f' || e.key === 'F') {
if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) {
addKeyframe();
}
if (e.key === 'z' || e.key === 'Z') {
if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) {
handleAddZoom();
}
if (e.key === 't' || e.key === 'T') {
if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) {
handleAddTrim();
}
if (e.key === 'a' || e.key === 'A') {
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
@@ -896,7 +899,7 @@ export default function TimelineEditor({
}
}
// Delete key or Ctrl+D / Cmd+D
if (e.key === 'Delete' || e.key === 'Backspace' || ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))) {
if (e.key === 'Delete' || e.key === 'Backspace' || matchesShortcut(e, keyShortcuts.deleteSelected, isMac)) {
if (selectedKeyframeId) {
deleteSelectedKeyframe();
} else if (selectedZoomId) {
@@ -910,7 +913,7 @@ export default function TimelineEditor({
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]);
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]);
const clampedRange = useMemo<Range>(() => {
if (totalMs === 0) {
@@ -1070,11 +1073,11 @@ export default function TimelineEditor({
<div className="flex-1" />
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.pan}</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{scrollLabels.pan}</kbd>
<span>Pan</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{shortcuts.zoom}</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">{scrollLabels.zoom}</kbd>
<span>Zoom</span>
</span>
</div>
+60
View File
@@ -0,0 +1,60 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from '@/lib/shortcuts';
import { isMac as getIsMac } from '@/utils/platformUtils';
interface ShortcutsContextValue {
shortcuts: ShortcutsConfig;
isMac: boolean;
setShortcuts: (config: ShortcutsConfig) => void;
persistShortcuts: (config?: ShortcutsConfig) => Promise<void>;
isConfigOpen: boolean;
openConfig: () => void;
closeConfig: () => void;
}
const ShortcutsContext = createContext<ShortcutsContextValue | null>(null);
export function useShortcuts(): ShortcutsContextValue {
const ctx = useContext(ShortcutsContext);
if (!ctx) throw new Error('useShortcuts must be used within <ShortcutsProvider>');
return ctx;
}
export function ShortcutsProvider({ children }: { children: ReactNode }) {
const [shortcuts, setShortcuts] = useState<ShortcutsConfig>(DEFAULT_SHORTCUTS);
const [isMac, setIsMac] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
useEffect(() => {
getIsMac().then(setIsMac).catch(() => {});
window.electronAPI.getShortcuts?.()
.then((saved) => {
if (saved) {
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
}
})
.catch(() => {});
}, []);
const persistShortcuts = useCallback(
async (config?: ShortcutsConfig) => {
await window.electronAPI.saveShortcuts?.(config ?? shortcuts);
},
[shortcuts],
);
const openConfig = useCallback(() => setIsConfigOpen(true), []);
const closeConfig = useCallback(() => setIsConfigOpen(false), []);
const value = useMemo<ShortcutsContextValue>(
() => ({ shortcuts, isMac, setShortcuts, persistShortcuts, isConfigOpen, openConfig, closeConfig }),
[shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig],
);
return (
<ShortcutsContext.Provider value={value}>
{children}
</ShortcutsContext.Provider>
);
}
+122
View File
@@ -0,0 +1,122 @@
export const SHORTCUT_ACTIONS = [
'addZoom',
'addTrim',
'addAnnotation',
'addKeyframe',
'deleteSelected',
'playPause',
] as const;
export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number];
export interface ShortcutBinding {
key: string;
/** Maps to Cmd on macOS, Ctrl on Windows/Linux */
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
}
export type ShortcutsConfig = Record<ShortcutAction, ShortcutBinding>;
export interface FixedShortcut {
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ label: 'Cycle Annotations Forward', display: 'Tab', bindings: [{ key: 'tab' }] },
{ label: 'Cycle Annotations Backward', display: 'Shift + Tab', bindings: [{ key: 'tab', shift: true }] },
{ label: 'Delete Selected (alt)', display: 'Del / ⌫', bindings: [{ key: 'delete' }, { key: 'backspace' }] },
{ label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll', bindings: [] },
{ label: 'Zoom Timeline', display: 'Ctrl + Scroll', bindings: [] },
];
export type ShortcutConflict =
| { type: 'configurable'; action: ShortcutAction }
| { type: 'fixed'; label: string };
export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean {
return (
a.key.toLowerCase() === b.key.toLowerCase() &&
!!a.ctrl === !!b.ctrl &&
!!a.shift === !!b.shift &&
!!a.alt === !!b.alt
);
}
export function findConflict(
binding: ShortcutBinding,
forAction: ShortcutAction,
config: ShortcutsConfig,
): ShortcutConflict | null {
for (const fixed of FIXED_SHORTCUTS) {
if (fixed.bindings.some((b) => bindingsEqual(b, binding))) {
return { type: 'fixed', label: fixed.label };
}
}
for (const action of SHORTCUT_ACTIONS) {
if (action !== forAction && bindingsEqual(config[action], binding)) {
return { type: 'configurable', action };
}
}
return null;
}
export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addZoom: { key: 'z' },
addTrim: { key: 't' },
addAnnotation: { key: 'a' },
addKeyframe: { key: 'f' },
deleteSelected: { key: 'd', ctrl: true },
playPause: { key: ' ' },
};
export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
addZoom: 'Add Zoom',
addTrim: 'Add Trim',
addAnnotation: 'Add Annotation',
addKeyframe: 'Add Keyframe',
deleteSelected: 'Delete Selected',
playPause: 'Play / Pause',
};
export function matchesShortcut(
e: KeyboardEvent,
binding: ShortcutBinding,
isMacPlatform: boolean,
): boolean {
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
if (primaryMod !== !!binding.ctrl) return false;
if (e.shiftKey !== !!binding.shift) return false;
if (e.altKey !== !!binding.alt) return false;
return true;
}
const KEY_LABELS: Record<string, string> = {
' ': 'Space', 'delete': 'Del', 'backspace': '⌫', 'escape': 'Esc',
'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→',
};
export function formatBinding(binding: ShortcutBinding, isMac: boolean): string {
const parts: string[] = [];
if (binding.ctrl) parts.push(isMac ? '⌘' : 'Ctrl');
if (binding.shift) parts.push(isMac ? '⇧' : 'Shift');
if (binding.alt) parts.push(isMac ? '⌥' : 'Alt');
parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase());
return parts.join(' + ');
}
export function mergeWithDefaults(partial: Partial<ShortcutsConfig>): ShortcutsConfig {
const merged = { ...DEFAULT_SHORTCUTS };
for (const action of SHORTCUT_ACTIONS) {
if (partial[action]) {
merged[action] = partial[action] as ShortcutBinding;
}
}
return merged;
}