fix: stabilize lint/typecheck and shortcut typing

This commit is contained in:
FabLrc
2026-03-13 11:24:54 +01:00
parent 0a6895e89f
commit 4b79909116
19 changed files with 3585 additions and 3151 deletions
+3 -2
View File
@@ -27,8 +27,9 @@ interface Window {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: any) => Promise<any>;
getSelectedSource: () => Promise<any>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
getAssetBasePath: () => Promise<string | null>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,
+1 -1
View File
@@ -20,7 +20,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
openSourceSelector: () => {
return ipcRenderer.invoke("open-source-selector");
},
selectSource: (source: any) => {
selectSource: (source: ProcessedDesktopSource) => {
return ipcRenderer.invoke("select-source", source);
},
getSelectedSource: () => {
+3 -1
View File
@@ -108,7 +108,9 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
if (isDragging) {
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
} catch {
// Pointer may already be released; ignore.
}
}
setIsDragging(null);
};
@@ -1,50 +1,55 @@
import { HelpCircle, Settings2 } from "lucide-react";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS, FIXED_SHORTCUTS } from "@/lib/shortcuts";
import { FIXED_SHORTCUTS, formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
export function KeyboardShortcutsHelp() {
const { shortcuts, isMac, openConfig } = useShortcuts();
const { shortcuts, isMac, openConfig } = useShortcuts();
return (
<div className="relative group">
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
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="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="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="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]">
{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="space-y-1.5 text-[10px]">
{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 space-y-1.5">
{FIXED_SHORTCUTS.map((fixed) => (
<div key={fixed.label} className="flex items-center justify-between">
<span className="text-slate-400">{fixed.label}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{isMac ? fixed.display.replace(/Ctrl/g, '⌘').replace(/Shift/g, '⇧').replace(/Alt/g, '⌥') : fixed.display}
</kbd>
</div>
))}
</div>
</div>
</div>
</div>
);
<div className="pt-1 border-t border-white/5 mt-1 space-y-1.5">
{FIXED_SHORTCUTS.map((fixed) => (
<div key={fixed.label} className="flex items-center justify-between">
<span className="text-slate-400">{fixed.label}</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
{isMac
? fixed.display
.replace(/Ctrl/g, "⌘")
.replace(/Shift/g, "⇧")
.replace(/Alt/g, "⌥")
: fixed.display}
</kbd>
</div>
))}
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -84,7 +84,7 @@ export function ShortcutsConfigDialog() {
window.addEventListener("keydown", handleCapture, { capture: true });
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
}, [captureFor]);
}, [captureFor, draft]);
const handleSwap = useCallback(() => {
if (!conflict || conflict.conflictWith.type !== "configurable") return;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -187,12 +187,12 @@ export default function TimelineWrapper({
// Drag/resize tooltip (direct DOM updates, no re-renders)
const tooltipRef = useRef<HTMLDivElement>(null);
const formatTooltipMs = (ms: number) => {
const formatTooltipMs = useCallback((ms: number) => {
const s = ms / 1000;
const min = Math.floor(s / 60);
const sec = s % 60;
return min > 0 ? `${min}:${sec.toFixed(1).padStart(4, "0")}` : `${sec.toFixed(1)}s`;
};
}, []);
const showTooltip = useCallback(
(span: { start: number; end: number } | null, screenX?: number) => {
@@ -213,7 +213,7 @@ export default function TimelineWrapper({
}
}
},
[],
[formatTooltipMs],
);
const onDragStart = useCallback(
+6 -2
View File
@@ -36,7 +36,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
getIsMac()
.then(setIsMac)
.catch(() => {});
.catch(() => {
// Keep default non-mac fallback if detection fails.
});
window.electronAPI
.getShortcuts?.()
@@ -45,7 +47,9 @@ export function ShortcutsProvider({ children }: { children: ReactNode }) {
setShortcuts(mergeWithDefaults(saved as Partial<ShortcutsConfig>));
}
})
.catch(() => {});
.catch(() => {
// Keep default shortcuts if persisted settings can't be loaded.
});
}, []);
const persistShortcuts = useCallback(
+18 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
export interface AudioLevelMeterOptions {
enabled: boolean;
@@ -13,6 +13,22 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
const streamRef = useRef<MediaStream | null>(null);
const animationFrameRef = useRef<number | null>(null);
const cleanup = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
}, []);
useEffect(() => {
if (!options.enabled) {
cleanup();
@@ -85,23 +101,7 @@ export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
mounted = false;
cleanup();
};
}, [options.enabled, options.deviceId, options.smoothingFactor]);
const cleanup = () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
if (audioContextRef.current) {
audioContextRef.current.close();
audioContextRef.current = null;
}
analyserRef.current = null;
};
}, [options.enabled, options.deviceId, options.smoothingFactor, cleanup]);
return { level };
}
+90 -80
View File
@@ -1,114 +1,124 @@
import { useCallback, useRef, useState } from "react";
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, CropRegion } from "@/components/video-editor/types";
import type {
AnnotationRegion,
CropRegion,
SpeedRegion,
TrimRegion,
ZoomRegion,
} from "@/components/video-editor/types";
import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
// Undoable state — selection IDs are intentionally excluded (undoing a
// selection change would feel surprising to the user).
export interface EditorState {
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
cropRegion: CropRegion;
wallpaper: string;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled: boolean;
borderRadius: number;
padding: number;
aspectRatio: AspectRatio;
zoomRegions: ZoomRegion[];
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
cropRegion: CropRegion;
wallpaper: string;
shadowIntensity: number;
showBlur: boolean;
motionBlurEnabled: boolean;
borderRadius: number;
padding: number;
aspectRatio: AspectRatio;
}
export const INITIAL_EDITOR_STATE: EditorState = {
zoomRegions: [],
trimRegions: [],
speedRegions: [],
annotationRegions: [],
cropRegion: DEFAULT_CROP_REGION,
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurEnabled: false,
borderRadius: 0,
padding: 50,
aspectRatio: "16:9",
zoomRegions: [],
trimRegions: [],
speedRegions: [],
annotationRegions: [],
cropRegion: DEFAULT_CROP_REGION,
wallpaper: "/wallpapers/wallpaper1.jpg",
shadowIntensity: 0,
showBlur: false,
motionBlurEnabled: false,
borderRadius: 0,
padding: 50,
aspectRatio: "16:9",
};
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
interface History {
past: EditorState[];
present: EditorState;
future: EditorState[];
past: EditorState[];
present: EditorState;
future: EditorState[];
}
const MAX_HISTORY = 80;
function resolve(present: EditorState, update: StateUpdate): EditorState {
const partial = typeof update === "function" ? update(present) : update;
return { ...present, ...partial };
const partial = typeof update === "function" ? update(present) : update;
return { ...present, ...partial };
}
function withCheckpoint(history: History, newPresent: EditorState): History {
return {
past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present],
present: newPresent,
future: [],
};
return {
past: [...history.past.slice(-(MAX_HISTORY - 1)), history.present],
present: newPresent,
future: [],
};
}
export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) {
const [history, setHistory] = useState<History>({ past: [], present: initial, future: [] });
const [history, setHistory] = useState<History>({ past: [], present: initial, future: [] });
// Tracks whether a live-update series (e.g. slider drag) is in progress.
// The first updateState call saves the pre-interaction state as a checkpoint.
const dirtyRef = useRef(false);
// Tracks whether a live-update series (e.g. slider drag) is in progress.
// The first updateState call saves the pre-interaction state as a checkpoint.
const dirtyRef = useRef(false);
const pushState = useCallback((update: StateUpdate) => {
setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update)));
dirtyRef.current = false;
}, []);
const pushState = useCallback((update: StateUpdate) => {
setHistory((prev) => withCheckpoint(prev, resolve(prev.present, update)));
dirtyRef.current = false;
}, []);
const updateState = useCallback((update: StateUpdate) => {
const isFirst = !dirtyRef.current;
dirtyRef.current = true;
setHistory((prev) => {
const next = resolve(prev.present, update);
return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next };
});
}, []);
const updateState = useCallback((update: StateUpdate) => {
const isFirst = !dirtyRef.current;
dirtyRef.current = true;
setHistory((prev) => {
const next = resolve(prev.present, update);
return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next };
});
}, []);
const commitState = useCallback(() => {
dirtyRef.current = false;
}, []);
const commitState = useCallback(() => {
dirtyRef.current = false;
}, []);
const undo = useCallback(() => {
setHistory((prev) => {
if (!prev.past.length) return prev;
const previous = prev.past[prev.past.length - 1];
return { past: prev.past.slice(0, -1), present: previous, future: [prev.present, ...prev.future] };
});
dirtyRef.current = false;
}, []);
const undo = useCallback(() => {
setHistory((prev) => {
if (!prev.past.length) return prev;
const previous = prev.past[prev.past.length - 1];
return {
past: prev.past.slice(0, -1),
present: previous,
future: [prev.present, ...prev.future],
};
});
dirtyRef.current = false;
}, []);
const redo = useCallback(() => {
setHistory((prev) => {
if (!prev.future.length) return prev;
const [next, ...remainingFuture] = prev.future;
return { past: [...prev.past, prev.present], present: next, future: remainingFuture };
});
dirtyRef.current = false;
}, []);
const redo = useCallback(() => {
setHistory((prev) => {
if (!prev.future.length) return prev;
const [next, ...remainingFuture] = prev.future;
return { past: [...prev.past, prev.present], present: next, future: remainingFuture };
});
dirtyRef.current = false;
}, []);
return {
state: history.present,
pushState,
updateState,
commitState,
undo,
redo,
canUndo: history.past.length > 0,
canRedo: history.future.length > 0,
};
return {
state: history.present,
pushState,
updateState,
commitState,
undo,
redo,
canUndo: history.past.length > 0,
canRedo: history.future.length > 0,
};
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function useMicrophoneDevices(enabled: boolean = true) {
mounted = false;
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
};
}, [enabled]);
}, [enabled, selectedDeviceId]);
return {
devices,
+15 -9
View File
@@ -104,7 +104,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current.close().catch(() => {
// Ignore close errors during recorder teardown.
});
mixingContext.current = null;
}
mediaRecorder.current.stop();
@@ -142,7 +144,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current.close().catch(() => {
// Ignore close errors during cleanup.
});
mixingContext.current = null;
}
};
@@ -171,7 +175,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (systemAudioEnabled) {
try {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
screenMediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
@@ -179,20 +183,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
},
},
video: videoConstraints,
});
} as unknown as MediaStreamConstraints);
} catch (audioErr) {
console.warn("System audio capture failed, falling back to video-only:", audioErr);
toast.error("System audio not available. Recording without system audio.");
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
screenMediaStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: videoConstraints,
});
} as unknown as MediaStreamConstraints);
}
} else {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
screenMediaStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: videoConstraints,
});
} as unknown as MediaStreamConstraints);
}
screenStream.current = screenMediaStream;
@@ -354,7 +358,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
microphoneStream.current = null;
}
if (mixingContext.current) {
mixingContext.current.close().catch(() => {});
mixingContext.current.close().catch(() => {
// Ignore close errors during error recovery.
});
mixingContext.current = null;
}
}
+2 -5
View File
@@ -10,11 +10,8 @@ export async function getAssetPath(relativePath: string): Promise<string> {
return `/${relativePath.replace(/^\//, "")}`;
}
if (
(window as any).electronAPI &&
typeof (window as any).electronAPI.getAssetBasePath === "function"
) {
const base = await (window as any).electronAPI.getAssetBasePath();
if (window.electronAPI && typeof window.electronAPI.getAssetBasePath === "function") {
const base = await window.electronAPI.getAssetBasePath();
if (base) {
const normalized = base.replace(/\\/g, "/");
return `file://${normalized}/${relativePath}`;
+33 -11
View File
@@ -1,8 +1,17 @@
import { Application, BlurFilter, Container, Graphics, Sprite, Texture } from "pixi.js";
import {
Application,
BlurFilter,
Container,
Graphics,
Sprite,
Texture,
type TextureSourceLike,
} from "pixi.js";
import type {
AnnotationRegion,
CropRegion,
SpeedRegion,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
@@ -42,6 +51,14 @@ interface AnimationState {
focusY: number;
}
interface LayoutCache {
stageSize: { width: number; height: number };
videoSize: { width: number; height: number };
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
}
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
export class FrameRenderer {
@@ -49,7 +66,7 @@ export class FrameRenderer {
private cameraContainer: Container | null = null;
private videoContainer: Container | null = null;
private videoSprite: Sprite | null = null;
private backgroundSprite: Sprite | null = null;
private backgroundSprite: HTMLCanvasElement | null = null;
private maskGraphics: Graphics | null = null;
private blurFilter: BlurFilter | null = null;
private shadowCanvas: HTMLCanvasElement | null = null;
@@ -58,7 +75,7 @@ export class FrameRenderer {
private compositeCtx: CanvasRenderingContext2D | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
private layoutCache: any = null;
private layoutCache: LayoutCache | null = null;
private currentVideoTime = 0;
constructor(config: FrameRenderConfig) {
@@ -263,7 +280,7 @@ export class FrameRenderer {
}
// Store the background canvas for compositing
this.backgroundSprite = bgCanvas as any;
this.backgroundSprite = bgCanvas;
}
async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> {
@@ -275,13 +292,13 @@ export class FrameRenderer {
// Create or update video sprite from VideoFrame
if (!this.videoSprite) {
const texture = Texture.from(videoFrame as any);
const texture = Texture.from(videoFrame as unknown as TextureSourceLike);
this.videoSprite = new Sprite(texture);
this.videoContainer.addChild(this.videoSprite);
} else {
// Destroy old texture to avoid memory leaks, then create new one
const oldTexture = this.videoSprite.texture;
const newTexture = Texture.from(videoFrame as any);
const newTexture = Texture.from(videoFrame as unknown as TextureSourceLike);
this.videoSprite.texture = newTexture;
oldTexture.destroy(true);
}
@@ -298,12 +315,17 @@ export class FrameRenderer {
maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity);
}
const layoutCache = this.layoutCache;
if (!layoutCache) {
throw new Error("Layout cache not initialized");
}
// Apply transform once with maximum motion intensity from all ticks
applyZoomTransform({
cameraContainer: this.cameraContainer,
blurFilter: this.blurFilter,
stageSize: this.layoutCache.stageSize,
baseMask: this.layoutCache.maskRect,
stageSize: layoutCache.stageSize,
baseMask: layoutCache.maskRect,
zoomScale: this.animationState.scale,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
@@ -411,10 +433,10 @@ export class FrameRenderer {
private clampFocusToStage(
focus: { cx: number; cy: number },
depth: number,
depth: ZoomDepth,
): { cx: number; cy: number } {
if (!this.layoutCache) return focus;
return clampFocusToStageUtil(focus, depth as any, this.layoutCache.stageSize);
return clampFocusToStageUtil(focus, depth, this.layoutCache.stageSize);
}
private updateAnimationState(timeMs: number): number {
@@ -493,7 +515,7 @@ export class FrameRenderer {
// Step 1: Draw background layer (with optional blur, not affected by zoom)
if (this.backgroundSprite) {
const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement;
const bgCanvas = this.backgroundSprite;
if (this.config.showBlur) {
ctx.save();
+5 -1
View File
@@ -219,7 +219,11 @@ export class VideoExporter {
// Capture decoder config metadata from encoder output
if (meta?.decoderConfig?.description && !videoDescription) {
const desc = meta.decoderConfig.description;
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
if (desc instanceof ArrayBuffer || desc instanceof SharedArrayBuffer) {
videoDescription = new Uint8Array(desc);
} else if (ArrayBuffer.isView(desc)) {
videoDescription = new Uint8Array(desc.buffer, desc.byteOffset, desc.byteLength);
}
this.videoDescription = videoDescription;
}
// Capture colorSpace from encoder metadata if provided
+103 -82
View File
@@ -1,127 +1,148 @@
export const SHORTCUT_ACTIONS = [
'addZoom',
'addTrim',
'addSpeed',
'addAnnotation',
'addKeyframe',
'deleteSelected',
'playPause',
"addZoom",
"addTrim",
"addSpeed",
"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;
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[];
label: string;
display: string;
bindings: ShortcutBinding[];
}
export const FIXED_SHORTCUTS: FixedShortcut[] = [
{ label: 'Undo', display: 'Ctrl + Z', bindings: [{ key: 'z', ctrl: true }] },
{ label: 'Redo', display: 'Ctrl + Shift + Z / Ctrl + Y', bindings: [{ key: 'z', ctrl: true, shift: true }, { key: 'y', ctrl: true }] },
{ 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: [] },
{ label: "Undo", display: "Ctrl + Z", bindings: [{ key: "z", ctrl: true }] },
{
label: "Redo",
display: "Ctrl + Shift + Z / Ctrl + Y",
bindings: [
{ key: "z", ctrl: true, shift: true },
{ key: "y", ctrl: true },
],
},
{ 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 };
| { 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
);
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,
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;
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' },
addSpeed: { key: 's' },
addAnnotation: { key: 'a' },
addKeyframe: { key: 'f' },
deleteSelected: { key: 'd', ctrl: true },
playPause: { key: ' ' },
addZoom: { key: "z" },
addTrim: { key: "t" },
addSpeed: { key: "s" },
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',
addSpeed: 'Add Speed',
addAnnotation: 'Add Annotation',
addKeyframe: 'Add Keyframe',
deleteSelected: 'Delete Selected',
playPause: 'Play / Pause',
addZoom: "Add Zoom",
addTrim: "Add Trim",
addSpeed: "Add Speed",
addAnnotation: "Add Annotation",
addKeyframe: "Add Keyframe",
deleteSelected: "Delete Selected",
playPause: "Play / Pause",
};
export function matchesShortcut(
e: KeyboardEvent,
binding: ShortcutBinding,
isMacPlatform: boolean,
e: KeyboardEvent,
binding: ShortcutBinding,
isMacPlatform: boolean,
): boolean {
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
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;
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;
return true;
}
const KEY_LABELS: Record<string, string> = {
' ': 'Space', 'delete': 'Del', 'backspace': '⌫', 'escape': 'Esc',
'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→',
" ": "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(' + ');
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;
const merged = { ...DEFAULT_SHORTCUTS };
for (const action of SHORTCUT_ACTIONS) {
if (partial[action]) {
merged[action] = partial[action] as ShortcutBinding;
}
}
return merged;
}
+2 -2
View File
@@ -20,8 +20,8 @@ interface Window {
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: any) => Promise<any>;
getSelectedSource: () => Promise<any>;
selectSource: (source: ProcessedDesktopSource) => Promise<ProcessedDesktopSource | null>;
getSelectedSource: () => Promise<ProcessedDesktopSource | null>;
storeRecordedVideo: (
videoData: ArrayBuffer,
fileName: string,