Merge branch 'main' into feature/speed-option
This commit is contained in:
+19
-1
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+9
@@ -32,6 +32,7 @@ interface Window {
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
|
||||
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>
|
||||
setRecordingState: (recording: boolean) => Promise<void>
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; message?: string; error?: string }>
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; cancelled?: boolean }>
|
||||
@@ -40,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;
|
||||
}
|
||||
@@ -52,3 +55,9 @@ interface ProcessedDesktopSource {
|
||||
thumbnail: string | null
|
||||
appIcon: string | null
|
||||
}
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number
|
||||
cx: number
|
||||
cy: number
|
||||
}
|
||||
|
||||
+135
-3
@@ -1,10 +1,64 @@
|
||||
import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog } from 'electron'
|
||||
import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog, screen } from 'electron'
|
||||
|
||||
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
|
||||
|
||||
const CURSOR_TELEMETRY_VERSION = 1
|
||||
const CURSOR_SAMPLE_INTERVAL_MS = 100
|
||||
const MAX_CURSOR_SAMPLES = 60 * 60 * 10 // 1 hour @ 10Hz
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number
|
||||
cx: number
|
||||
cy: number
|
||||
}
|
||||
|
||||
let cursorCaptureInterval: NodeJS.Timeout | null = null
|
||||
let cursorCaptureStartTimeMs = 0
|
||||
let activeCursorSamples: CursorTelemetryPoint[] = []
|
||||
let pendingCursorSamples: CursorTelemetryPoint[] = []
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function stopCursorCapture() {
|
||||
if (cursorCaptureInterval) {
|
||||
clearInterval(cursorCaptureInterval)
|
||||
cursorCaptureInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
function sampleCursorPoint() {
|
||||
const cursor = screen.getCursorScreenPoint()
|
||||
const sourceDisplayId = Number(selectedSource?.display_id)
|
||||
const sourceDisplay = Number.isFinite(sourceDisplayId)
|
||||
? screen.getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null
|
||||
: null
|
||||
const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor)
|
||||
const bounds = display.bounds
|
||||
const width = Math.max(1, bounds.width)
|
||||
const height = Math.max(1, bounds.height)
|
||||
|
||||
const cx = clamp((cursor.x - bounds.x) / width, 0, 1)
|
||||
const cy = clamp((cursor.y - bounds.y) / height, 0, 1)
|
||||
|
||||
activeCursorSamples.push({
|
||||
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
|
||||
cx,
|
||||
cy,
|
||||
})
|
||||
|
||||
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
|
||||
activeCursorSamples.shift()
|
||||
}
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
createEditorWindow: () => void,
|
||||
@@ -61,6 +115,17 @@ export function registerIpcHandlers(
|
||||
const videoPath = path.join(RECORDINGS_DIR, fileName)
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData))
|
||||
currentVideoPath = videoPath;
|
||||
|
||||
const telemetryPath = `${videoPath}.cursor.json`
|
||||
if (pendingCursorSamples.length > 0) {
|
||||
await fs.writeFile(
|
||||
telemetryPath,
|
||||
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
|
||||
'utf-8'
|
||||
)
|
||||
}
|
||||
pendingCursorSamples = []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: videoPath,
|
||||
@@ -98,12 +163,62 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
ipcMain.handle('set-recording-state', (_, recording: boolean) => {
|
||||
if (recording) {
|
||||
stopCursorCapture()
|
||||
activeCursorSamples = []
|
||||
pendingCursorSamples = []
|
||||
cursorCaptureStartTimeMs = Date.now()
|
||||
sampleCursorPoint()
|
||||
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS)
|
||||
} else {
|
||||
stopCursorCapture()
|
||||
pendingCursorSamples = [...activeCursorSamples]
|
||||
activeCursorSamples = []
|
||||
}
|
||||
|
||||
const source = selectedSource || { name: 'Screen' }
|
||||
if (onRecordingStateChange) {
|
||||
onRecordingStateChange(recording, source.name)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('get-cursor-telemetry', async (_, videoPath?: string) => {
|
||||
const targetVideoPath = videoPath ?? currentVideoPath
|
||||
if (!targetVideoPath) {
|
||||
return { success: true, samples: [] }
|
||||
}
|
||||
|
||||
const telemetryPath = `${targetVideoPath}.cursor.json`
|
||||
try {
|
||||
const content = await fs.readFile(telemetryPath, 'utf-8')
|
||||
const parsed = JSON.parse(content)
|
||||
const rawSamples = Array.isArray(parsed)
|
||||
? parsed
|
||||
: (Array.isArray(parsed?.samples) ? parsed.samples : [])
|
||||
|
||||
const samples: CursorTelemetryPoint[] = rawSamples
|
||||
.filter((sample: unknown) => Boolean(sample && typeof sample === 'object'))
|
||||
.map((sample: unknown) => {
|
||||
const point = sample as Partial<CursorTelemetryPoint>
|
||||
return {
|
||||
timeMs: typeof point.timeMs === 'number' && Number.isFinite(point.timeMs) ? Math.max(0, point.timeMs) : 0,
|
||||
cx: typeof point.cx === 'number' && Number.isFinite(point.cx) ? clamp(point.cx, 0, 1) : 0.5,
|
||||
cy: typeof point.cy === 'number' && Number.isFinite(point.cy) ? clamp(point.cy, 0, 1) : 0.5,
|
||||
}
|
||||
})
|
||||
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs)
|
||||
|
||||
return { success: true, samples }
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException
|
||||
if (nodeError.code === 'ENOENT') {
|
||||
return { success: true, samples: [] }
|
||||
}
|
||||
console.error('Failed to load cursor telemetry:', error)
|
||||
return { success: false, message: 'Failed to load cursor telemetry', error: String(error), samples: [] }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
ipcMain.handle('open-external-url', async (_, url: string) => {
|
||||
try {
|
||||
@@ -198,8 +313,6 @@ export function registerIpcHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
let currentVideoPath: string | null = null;
|
||||
|
||||
ipcMain.handle('set-current-video-path', (_, path: string) => {
|
||||
currentVideoPath = path;
|
||||
return { success: true };
|
||||
@@ -217,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) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
setRecordingState: (recording: boolean) => {
|
||||
return ipcRenderer.invoke('set-recording-state', recording)
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke('get-cursor-telemetry', videoPath)
|
||||
},
|
||||
onStopRecordingFromTray: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('stop-recording-from-tray', listener)
|
||||
@@ -63,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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
type CursorTelemetryPoint,
|
||||
type TrimRegion,
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
@@ -34,6 +35,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`);
|
||||
@@ -53,6 +56,7 @@ export default function VideoEditor() {
|
||||
const [padding, setPadding] = useState(50);
|
||||
const [cropRegion, setCropRegion] = useState<CropRegion>(DEFAULT_CROP_REGION);
|
||||
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
|
||||
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
@@ -75,6 +79,8 @@ export default function VideoEditor() {
|
||||
const nextZoomIdRef = useRef(1);
|
||||
const nextTrimIdRef = useRef(1);
|
||||
const nextSpeedIdRef = 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);
|
||||
@@ -95,6 +101,19 @@ export default function VideoEditor() {
|
||||
return fileUrl;
|
||||
};
|
||||
|
||||
const fromFileUrl = (fileUrl: string): string => {
|
||||
if (!fileUrl.startsWith('file://')) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(fileUrl);
|
||||
return decodeURIComponent(url.pathname);
|
||||
} catch {
|
||||
return fileUrl.replace(/^file:\/\//, '');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadVideo() {
|
||||
try {
|
||||
@@ -115,6 +134,37 @@ export default function VideoEditor() {
|
||||
loadVideo();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadCursorTelemetry() {
|
||||
if (!videoPath) {
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.getCursorTelemetry(fromFileUrl(videoPath));
|
||||
if (mounted) {
|
||||
setCursorTelemetry(result.success ? result.samples : []);
|
||||
}
|
||||
} catch (telemetryError) {
|
||||
console.warn('Unable to load cursor telemetry:', telemetryError);
|
||||
if (mounted) {
|
||||
setCursorTelemetry([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCursorTelemetry();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [videoPath]);
|
||||
|
||||
// Initialize default wallpaper with resolved asset path
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -186,6 +236,21 @@ export default function VideoEditor() {
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
|
||||
const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => {
|
||||
const id = `zoom-${nextZoomIdRef.current++}`;
|
||||
const newRegion: ZoomRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
depth: DEFAULT_ZOOM_DEPTH,
|
||||
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
|
||||
};
|
||||
setZoomRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedZoomId(id);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
|
||||
const handleTrimAdded = useCallback((span: Span) => {
|
||||
const id = `trim-${nextTrimIdRef.current++}`;
|
||||
const newRegion: TrimRegion = {
|
||||
@@ -458,7 +523,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;
|
||||
@@ -478,7 +543,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)) {
|
||||
@@ -873,8 +938,10 @@ export default function VideoEditor() {
|
||||
videoDuration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={handleSeek}
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
zoomRegions={zoomRegions}
|
||||
onZoomAdded={handleZoomAdded}
|
||||
onZoomSuggested={handleZoomSuggested}
|
||||
onZoomSpanChange={handleZoomSpanChange}
|
||||
onZoomDelete={handleZoomDelete}
|
||||
selectedZoomId={selectedZoomId}
|
||||
@@ -973,4 +1040,4 @@ export default function VideoEditor() {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge } from "lucide-react";
|
||||
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge, WandSparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TimelineWrapper from "./TimelineWrapper";
|
||||
@@ -9,7 +9,7 @@ import Row from "./Row";
|
||||
import Item from "./Item";
|
||||
import KeyframeMarkers from "./KeyframeMarkers";
|
||||
import type { Range, Span } from "dnd-timeline";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion } from "../types";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,6 +20,9 @@ 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";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
@@ -27,13 +30,16 @@ const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const SPEED_ROW_ID = "row-speed";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
const SUGGESTION_SPACING_MS = 1800;
|
||||
|
||||
interface TimelineEditorProps {
|
||||
videoDuration: number;
|
||||
currentTime: number;
|
||||
onSeek?: (time: number) => void;
|
||||
cursorTelemetry?: CursorTelemetryPoint[];
|
||||
zoomRegions: ZoomRegion[];
|
||||
onZoomAdded: (span: Span) => void;
|
||||
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
|
||||
onZoomSpanChange: (id: string, span: Span) => void;
|
||||
onZoomDelete: (id: string) => void;
|
||||
selectedZoomId: string | null;
|
||||
@@ -551,8 +557,10 @@ export default function TimelineEditor({
|
||||
videoDuration,
|
||||
currentTime,
|
||||
onSeek,
|
||||
cursorTelemetry = [],
|
||||
zoomRegions,
|
||||
onZoomAdded,
|
||||
onZoomSuggested,
|
||||
onZoomSpanChange,
|
||||
onZoomDelete,
|
||||
selectedZoomId,
|
||||
@@ -589,16 +597,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 });
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
@@ -778,6 +787,91 @@ export default function TimelineEditor({
|
||||
onZoomAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleSuggestZooms = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onZoomSuggested) {
|
||||
toast.error("Zoom suggestion handler unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursorTelemetry.length < 2) {
|
||||
toast.info("No cursor telemetry available", {
|
||||
description: "Record a screencast first to generate cursor-based suggestions.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reservedSpans = [...zoomRegions]
|
||||
.map((region) => ({ start: region.startMs, end: region.endMs }))
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
|
||||
|
||||
if (normalizedSamples.length < 2) {
|
||||
toast.info("No usable cursor telemetry", {
|
||||
description: "The recording does not include enough cursor movement data.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dwellCandidates = detectZoomDwellCandidates(normalizedSamples);
|
||||
|
||||
if (dwellCandidates.length === 0) {
|
||||
toast.info("No clear cursor dwell moments found", {
|
||||
description: "Try a recording with slower cursor pauses on important actions.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedCandidates = [...dwellCandidates].sort((a, b) => b.strength - a.strength);
|
||||
const acceptedCenters: number[] = [];
|
||||
|
||||
let addedCount = 0;
|
||||
|
||||
sortedCandidates.forEach((candidate) => {
|
||||
const tooCloseToAccepted = acceptedCenters.some(
|
||||
(center) => Math.abs(center - candidate.centerTimeMs) < SUGGESTION_SPACING_MS,
|
||||
);
|
||||
|
||||
if (tooCloseToAccepted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2);
|
||||
const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration));
|
||||
const candidateEnd = candidateStart + defaultDuration;
|
||||
const hasOverlap = reservedSpans.some(
|
||||
(span) => candidateEnd > span.start && candidateStart < span.end,
|
||||
);
|
||||
|
||||
if (hasOverlap) {
|
||||
return;
|
||||
}
|
||||
|
||||
reservedSpans.push({ start: candidateStart, end: candidateEnd });
|
||||
acceptedCenters.push(candidate.centerTimeMs);
|
||||
onZoomSuggested({ start: candidateStart, end: candidateEnd }, candidate.focus);
|
||||
addedCount += 1;
|
||||
});
|
||||
|
||||
if (addedCount === 0) {
|
||||
toast.info("No auto-zoom slots available", {
|
||||
description: "Detected dwell points overlap existing zoom regions.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(`Added ${addedCount} cursor-based zoom suggestion${addedCount === 1 ? "" : "s"}`);
|
||||
}, [videoDuration, totalMs, defaultRegionDurationMs, zoomRegions, onZoomSuggested, cursorTelemetry]);
|
||||
|
||||
const handleAddTrim = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
|
||||
return;
|
||||
@@ -861,16 +955,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();
|
||||
}
|
||||
if (e.key === 's' || e.key === 'S') {
|
||||
@@ -900,7 +994,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) {
|
||||
@@ -916,7 +1010,7 @@ export default function TimelineEditor({
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation]);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]);
|
||||
|
||||
const clampedRange = useMemo<Range>(() => {
|
||||
if (totalMs === 0) {
|
||||
@@ -1029,6 +1123,15 @@ export default function TimelineEditor({
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSuggestZooms}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
|
||||
title="Suggest Zooms from Cursor"
|
||||
>
|
||||
<WandSparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddTrim}
|
||||
variant="ghost"
|
||||
@@ -1088,11 +1191,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>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
|
||||
export const MIN_DWELL_DURATION_MS = 450;
|
||||
export const MAX_DWELL_DURATION_MS = 2600;
|
||||
export const DWELL_MOVE_THRESHOLD = 0.02;
|
||||
|
||||
export interface ZoomDwellCandidate {
|
||||
centerTimeMs: number;
|
||||
focus: ZoomFocus;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
function normalizeTelemetrySample(sample: CursorTelemetryPoint, totalMs: number): CursorTelemetryPoint {
|
||||
return {
|
||||
timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)),
|
||||
cx: Math.max(0, Math.min(sample.cx, 1)),
|
||||
cy: Math.max(0, Math.min(sample.cy, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCursorTelemetry(
|
||||
telemetry: CursorTelemetryPoint[],
|
||||
totalMs: number,
|
||||
): CursorTelemetryPoint[] {
|
||||
return [...telemetry]
|
||||
.filter((sample) => Number.isFinite(sample.timeMs) && Number.isFinite(sample.cx) && Number.isFinite(sample.cy))
|
||||
.sort((a, b) => a.timeMs - b.timeMs)
|
||||
.map((sample) => normalizeTelemetrySample(sample, totalMs));
|
||||
}
|
||||
|
||||
export function detectZoomDwellCandidates(samples: CursorTelemetryPoint[]): ZoomDwellCandidate[] {
|
||||
if (samples.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dwellCandidates: ZoomDwellCandidate[] = [];
|
||||
let runStart = 0;
|
||||
|
||||
const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => {
|
||||
if (endIndexExclusive - startIndex < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = samples[startIndex];
|
||||
const end = samples[endIndexExclusive - 1];
|
||||
const runDuration = end.timeMs - start.timeMs;
|
||||
if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runSamples = samples.slice(startIndex, endIndexExclusive);
|
||||
const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length;
|
||||
const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length;
|
||||
|
||||
dwellCandidates.push({
|
||||
centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2),
|
||||
focus: { cx: avgCx, cy: avgCy },
|
||||
strength: runDuration,
|
||||
});
|
||||
};
|
||||
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const prev = samples[index - 1];
|
||||
const curr = samples[index];
|
||||
const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy);
|
||||
|
||||
if (distance > DWELL_MOVE_THRESHOLD) {
|
||||
pushRunIfDwell(runStart, index);
|
||||
runStart = index;
|
||||
}
|
||||
}
|
||||
pushRunIfDwell(runStart, samples.length);
|
||||
|
||||
return dwellCandidates;
|
||||
}
|
||||
@@ -13,6 +13,12 @@ export interface ZoomRegion {
|
||||
focus: ZoomFocus;
|
||||
}
|
||||
|
||||
export interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
export interface TrimRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,36 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
||||
|
||||
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
const MIN_FRAME_RATE = 30;
|
||||
const TARGET_WIDTH = 3840;
|
||||
const TARGET_HEIGHT = 2160;
|
||||
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
|
||||
const QHD_WIDTH = 2560;
|
||||
const QHD_HEIGHT = 1440;
|
||||
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
|
||||
|
||||
// Bitrates (bits per second) per resolution tier
|
||||
const BITRATE_4K = 45_000_000;
|
||||
const BITRATE_QHD = 28_000_000;
|
||||
const BITRATE_BASE = 18_000_000;
|
||||
const HIGH_FRAME_RATE_THRESHOLD = 60;
|
||||
const HIGH_FRAME_RATE_BOOST = 1.7;
|
||||
|
||||
// Fallback track settings when the driver reports nothing
|
||||
const DEFAULT_WIDTH = 1920;
|
||||
const DEFAULT_HEIGHT = 1080;
|
||||
|
||||
// Codec alignment: VP9/AV1 require dimensions divisible by 2
|
||||
const CODEC_ALIGNMENT = 2;
|
||||
|
||||
const RECORDER_TIMESLICE_MS = 1000;
|
||||
const BITS_PER_MEGABIT = 1_000_000;
|
||||
const CHROME_MEDIA_SOURCE = "desktop";
|
||||
const RECORDING_FILE_PREFIX = "recording-";
|
||||
const VIDEO_FILE_EXTENSION = ".webm";
|
||||
|
||||
type UseScreenRecorderReturn = {
|
||||
recording: boolean;
|
||||
toggleRecording: () => void;
|
||||
@@ -13,11 +43,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
const chunks = useRef<Blob[]>([]);
|
||||
const startTime = useRef<number>(0);
|
||||
|
||||
// Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
|
||||
const TARGET_FRAME_RATE = 60;
|
||||
const TARGET_WIDTH = 3840;
|
||||
const TARGET_HEIGHT = 2160;
|
||||
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
|
||||
const selectMimeType = () => {
|
||||
const preferred = [
|
||||
"video/webm;codecs=av1",
|
||||
@@ -32,17 +57,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
|
||||
const computeBitrate = (width: number, height: number) => {
|
||||
const pixels = width * height;
|
||||
const highFrameRateBoost = TARGET_FRAME_RATE >= 60 ? 1.7 : 1;
|
||||
const highFrameRateBoost = TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
|
||||
|
||||
if (pixels >= FOUR_K_PIXELS) {
|
||||
return Math.round(45_000_000 * highFrameRateBoost);
|
||||
return Math.round(BITRATE_4K * highFrameRateBoost);
|
||||
}
|
||||
|
||||
if (pixels >= 2560 * 1440) {
|
||||
return Math.round(28_000_000 * highFrameRateBoost);
|
||||
if (pixels >= QHD_PIXELS) {
|
||||
return Math.round(BITRATE_QHD * highFrameRateBoost);
|
||||
}
|
||||
|
||||
return Math.round(18_000_000 * highFrameRateBoost);
|
||||
return Math.round(BITRATE_BASE * highFrameRateBoost);
|
||||
};
|
||||
|
||||
const stopRecording = useRef(() => {
|
||||
@@ -91,12 +116,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: "desktop",
|
||||
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
maxWidth: TARGET_WIDTH,
|
||||
maxHeight: TARGET_HEIGHT,
|
||||
maxFrameRate: TARGET_FRAME_RATE,
|
||||
minFrameRate: 30,
|
||||
minFrameRate: MIN_FRAME_RATE,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -115,18 +140,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
console.warn("Unable to lock 4K/60fps constraints, using best available track settings.", error);
|
||||
}
|
||||
|
||||
let { width = 1920, height = 1080, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings();
|
||||
let { width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings();
|
||||
|
||||
// Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
|
||||
width = Math.floor(width / 2) * 2;
|
||||
height = Math.floor(height / 2) * 2;
|
||||
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
||||
height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
||||
|
||||
const videoBitsPerSecond = computeBitrate(width, height);
|
||||
const mimeType = selectMimeType();
|
||||
|
||||
console.log(
|
||||
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
|
||||
videoBitsPerSecond / 1_000_000
|
||||
videoBitsPerSecond / BITS_PER_MEGABIT
|
||||
)} Mbps`
|
||||
);
|
||||
|
||||
@@ -148,7 +173,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
// Clear chunks early to free memory immediately after blob creation
|
||||
chunks.current = [];
|
||||
const timestamp = Date.now();
|
||||
const videoFileName = `recording-${timestamp}.webm`;
|
||||
const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`;
|
||||
|
||||
try {
|
||||
const videoBlob = await fixWebmDuration(buggyBlob, duration);
|
||||
@@ -169,7 +194,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
|
||||
}
|
||||
};
|
||||
recorder.onerror = () => setRecording(false);
|
||||
recorder.start(1000);
|
||||
recorder.start(RECORDER_TIMESLICE_MS);
|
||||
startTime.current = Date.now();
|
||||
setRecording(true);
|
||||
window.electronAPI?.setRecordingState(true);
|
||||
|
||||
@@ -138,7 +138,12 @@ function renderText(
|
||||
const style = annotation.style;
|
||||
|
||||
ctx.save();
|
||||
|
||||
|
||||
// Clip text to annotation box bounds (matches editor's overflow: hidden)
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
ctx.clip();
|
||||
|
||||
const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal';
|
||||
const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal';
|
||||
const scaledFontSize = style.fontSize * scaleFactor;
|
||||
@@ -161,7 +166,27 @@ function renderText(
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
const lines = annotation.content.split('\n');
|
||||
const availableWidth = width - containerPadding * 2;
|
||||
const rawLines = annotation.content.split('\n');
|
||||
const lines: string[] = [];
|
||||
for (const rawLine of rawLines) {
|
||||
if (!rawLine) {
|
||||
lines.push('');
|
||||
continue;
|
||||
}
|
||||
const words = rawLine.split(/(\s+)/);
|
||||
let current = '';
|
||||
for (const word of words) {
|
||||
const test = current + word;
|
||||
if (current && ctx.measureText(test).width > availableWidth) {
|
||||
lines.push(current);
|
||||
current = word.trimStart();
|
||||
} else {
|
||||
current = test;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
}
|
||||
const lineHeight = scaledFontSize * 1.4;
|
||||
|
||||
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
|
||||
|
||||
@@ -386,7 +386,7 @@ export class FrameRenderer {
|
||||
|
||||
private clampFocusToStage(focus: { cx: number; cy: number }, depth: number): { cx: number; cy: number } {
|
||||
if (!this.layoutCache) return focus;
|
||||
return clampFocusToStageUtil(focus, depth as any, this.layoutCache);
|
||||
return clampFocusToStageUtil(focus, depth as any, this.layoutCache.stageSize);
|
||||
}
|
||||
|
||||
private updateAnimationState(timeMs: number): number {
|
||||
|
||||
@@ -128,8 +128,8 @@ export class VideoExporter {
|
||||
});
|
||||
|
||||
// Check encoder queue before encoding to keep it full
|
||||
while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
while (this.encoder && this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
@@ -253,7 +253,7 @@ export class VideoExporter {
|
||||
height: this.config.height,
|
||||
bitrate: this.config.bitrate,
|
||||
framerate: this.config.frameRate,
|
||||
latencyMode: 'realtime',
|
||||
latencyMode: 'quality', // Changed from 'realtime' to 'quality' for better throughput
|
||||
bitrateMode: 'variable',
|
||||
hardwareAcceleration: 'prefer-hardware',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+12
@@ -9,6 +9,12 @@ interface ProcessedDesktopSource {
|
||||
appIcon: string | null;
|
||||
}
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>
|
||||
@@ -30,6 +36,12 @@ interface Window {
|
||||
}>
|
||||
getAssetBasePath: () => Promise<string | null>
|
||||
setRecordingState: (recording: boolean) => Promise<void>
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean
|
||||
samples: CursorTelemetryPoint[]
|
||||
message?: string
|
||||
error?: string
|
||||
}>
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
|
||||
|
||||
Reference in New Issue
Block a user