diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx
index 3edbd04..308fe65 100644
--- a/src/components/video-editor/KeyboardShortcutsHelp.tsx
+++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx
@@ -6,19 +6,28 @@ export function KeyboardShortcutsHelp() {
const [shortcuts, setShortcuts] = useState({
delete: 'Ctrl + D',
pan: 'Shift + Ctrl + Scroll',
- zoom: 'Ctrl + Scroll'
+ zoom: 'Ctrl + Scroll',
+ undo: 'Ctrl + Z',
+ redo: 'Ctrl + Shift + Z',
+ redoAlt: 'Ctrl + Y',
});
useEffect(() => {
Promise.all([
formatShortcut(['mod', 'D']),
formatShortcut(['shift', 'mod', 'Scroll']),
- formatShortcut(['mod', 'Scroll'])
- ]).then(([deleteKey, panKey, zoomKey]) => {
+ formatShortcut(['mod', 'Scroll']),
+ formatShortcut(['mod', 'Z']),
+ formatShortcut(['shift', 'mod', 'Z']),
+ formatShortcut(['mod', 'Y']),
+ ]).then(([deleteKey, panKey, zoomKey, undoKey, redoKey, redoAltKey]) => {
setShortcuts({
delete: deleteKey,
pan: panKey,
- zoom: zoomKey
+ zoom: zoomKey,
+ undo: undoKey,
+ redo: redoKey,
+ redoAlt: redoAltKey,
});
});
}, []);
@@ -61,6 +70,18 @@ export function KeyboardShortcutsHelp() {
Pause/Play
Space
+
+ Undo
+ {shortcuts.undo}
+
+
+
Redo
+
+ {shortcuts.redo}
+ or
+ {shortcuts.redoAlt}
+
+
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index db5e9d8..93fcd12 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -58,14 +58,17 @@ interface SettingsPanelProps {
onTrimDelete?: (id: string) => void;
shadowIntensity?: number;
onShadowChange?: (intensity: number) => void;
+ onShadowCommit?: () => void;
showBlur?: boolean;
onBlurChange?: (showBlur: boolean) => void;
motionBlurEnabled?: boolean;
onMotionBlurChange?: (enabled: boolean) => void;
borderRadius?: number;
onBorderRadiusChange?: (radius: number) => void;
+ onBorderRadiusCommit?: () => void;
padding?: number;
onPaddingChange?: (padding: number) => void;
+ onPaddingCommit?: () => void;
cropRegion?: CropRegion;
onCropChange?: (region: CropRegion) => void;
aspectRatio: AspectRatio;
@@ -114,14 +117,17 @@ export function SettingsPanel({
onTrimDelete,
shadowIntensity = 0,
onShadowChange,
+ onShadowCommit,
showBlur,
onBlurChange,
motionBlurEnabled = false,
onMotionBlurChange,
borderRadius = 0,
onBorderRadiusChange,
+ onBorderRadiusCommit,
padding = 50,
onPaddingChange,
+ onPaddingCommit,
cropRegion,
onCropChange,
aspectRatio,
@@ -358,6 +364,7 @@ export function SettingsPanel({
onShadowChange?.(values[0])}
+ onValueCommit={() => onShadowCommit?.()}
min={0}
max={1}
step={0.01}
@@ -372,6 +379,7 @@ export function SettingsPanel({
onBorderRadiusChange?.(values[0])}
+ onValueCommit={() => onBorderRadiusCommit?.()}
min={0}
max={16}
step={0.5}
@@ -386,6 +394,7 @@ export function SettingsPanel({
onPaddingChange?.(values[0])}
+ onValueCommit={() => onPaddingCommit?.()}
min={0}
max={100}
step={1}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index d418950..7e93591 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -15,7 +15,6 @@ import type { Span } from "dnd-timeline";
import {
DEFAULT_ZOOM_DEPTH,
clampFocusToDepth,
- DEFAULT_CROP_REGION,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
@@ -26,42 +25,38 @@ import {
type CursorTelemetryPoint,
type TrimRegion,
type AnnotationRegion,
- type CropRegion,
type FigureData,
} from "./types";
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 { getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
-
-const WALLPAPER_COUNT = 18;
-const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
+import { useEditorHistory, INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
export default function VideoEditor() {
+ const { state: editorState, pushState, updateState, commitState, undo, redo } =
+ useEditorHistory(INITIAL_EDITOR_STATE);
+
+ const {
+ zoomRegions, trimRegions, annotationRegions,
+ cropRegion, wallpaper, shadowIntensity, showBlur,
+ motionBlurEnabled, borderRadius, padding, aspectRatio,
+ } = editorState;
+
+ // ── Non-undoable state
const [videoPath, setVideoPath] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
- const [wallpaper, setWallpaper] = useState(WALLPAPER_PATHS[0]);
- const [shadowIntensity, setShadowIntensity] = useState(0);
- const [showBlur, setShowBlur] = useState(false);
- const [motionBlurEnabled, setMotionBlurEnabled] = useState(false);
- const [borderRadius, setBorderRadius] = useState(0);
- const [padding, setPadding] = useState(50);
- const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION);
- const [zoomRegions, setZoomRegions] = useState([]);
const [cursorTelemetry, setCursorTelemetry] = useState([]);
const [selectedZoomId, setSelectedZoomId] = useState(null);
- const [trimRegions, setTrimRegions] = useState([]);
const [selectedTrimId, setSelectedTrimId] = useState(null);
- const [annotationRegions, setAnnotationRegions] = useState([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(null);
const [exportError, setExportError] = useState(null);
const [showExportDialog, setShowExportDialog] = useState(false);
- const [aspectRatio, setAspectRatio] = useState('16:9');
const [exportQuality, setExportQuality] = useState('good');
const [exportFormat, setExportFormat] = useState('mp4');
const [gifFrameRate, setGifFrameRate] = useState(15);
@@ -72,23 +67,12 @@ export default function VideoEditor() {
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const nextAnnotationIdRef = useRef(1);
- const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
+ const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef(null);
- // Helper to convert file path to proper file:// URL
const toFileUrl = (filePath: string): string => {
- // Normalize path separators to forward slashes
const normalized = filePath.replace(/\\/g, '/');
-
- // Check if it's a Windows absolute path (e.g., C:/Users/...)
- if (normalized.match(/^[a-zA-Z]:/)) {
- const fileUrl = `file:///${normalized}`;
- return fileUrl;
- }
-
- // Unix-style absolute path
- const fileUrl = `file://${normalized}`;
- return fileUrl;
+ return normalized.match(/^[a-zA-Z]:/) ? `file:///${normalized}` : `file://${normalized}`;
};
const fromFileUrl = (fileUrl: string): string => {
@@ -158,19 +142,11 @@ export default function VideoEditor() {
// Initialize default wallpaper with resolved asset path
useEffect(() => {
let mounted = true;
- (async () => {
- try {
- const resolvedPath = await getAssetPath('wallpapers/wallpaper1.jpg');
- if (mounted) {
- setWallpaper(resolvedPath);
- }
- } catch (err) {
- // If resolution fails, keep the fallback
- console.warn('Failed to resolve default wallpaper path:', err);
- }
- })();
- return () => { mounted = false };
- }, []);
+ getAssetPath('wallpapers/wallpaper1.jpg')
+ .then((path) => { if (mounted) updateState({ wallpaper: path }); })
+ .catch((err) => console.warn('Failed to resolve default wallpaper path:', err));
+ return () => { mounted = false; };
+ }, [updateState]);
function togglePlayPause() {
const playback = videoPlaybackRef.current;
@@ -220,11 +196,11 @@ export default function VideoEditor() {
depth: DEFAULT_ZOOM_DEPTH,
focus: { cx: 0.5, cy: 0.5 },
};
- setZoomRegions((prev) => [...prev, newRegion]);
+ pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
- }, []);
+ }, [pushState]);
const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => {
const id = `zoom-${nextZoomIdRef.current++}`;
@@ -235,11 +211,11 @@ export default function VideoEditor() {
depth: DEFAULT_ZOOM_DEPTH,
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
};
- setZoomRegions((prev) => [...prev, newRegion]);
+ pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
- }, []);
+ }, [pushState]);
const handleTrimAdded = useCallback((span: Span) => {
const id = `trim-${nextTrimIdRef.current++}`;
@@ -248,85 +224,71 @@ export default function VideoEditor() {
startMs: Math.round(span.start),
endMs: Math.round(span.end),
};
- setTrimRegions((prev) => [...prev, newRegion]);
+ pushState((prev) => ({ trimRegions: [...prev.trimRegions, newRegion] }));
setSelectedTrimId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
- }, []);
+ }, [pushState]);
const handleZoomSpanChange = useCallback((id: string, span: Span) => {
- setZoomRegions((prev) =>
- prev.map((region) =>
+ pushState((prev) => ({
+ zoomRegions: prev.zoomRegions.map((region) =>
region.id === id
- ? {
- ...region,
- startMs: Math.round(span.start),
- endMs: Math.round(span.end),
- }
+ ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
: region,
),
- );
- }, []);
+ }));
+ }, [pushState]);
const handleTrimSpanChange = useCallback((id: string, span: Span) => {
- setTrimRegions((prev) =>
- prev.map((region) =>
+ pushState((prev) => ({
+ trimRegions: prev.trimRegions.map((region) =>
region.id === id
- ? {
- ...region,
- startMs: Math.round(span.start),
- endMs: Math.round(span.end),
- }
+ ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
: region,
),
- );
- }, []);
+ }));
+ }, [pushState]);
+ // Focus drag: updateState for live preview, commitState on pointer-up
const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => {
- setZoomRegions((prev) =>
- prev.map((region) =>
+ updateState((prev) => ({
+ zoomRegions: prev.zoomRegions.map((region) =>
region.id === id
- ? {
- ...region,
- focus: clampFocusToDepth(focus, region.depth),
- }
+ ? { ...region, focus: clampFocusToDepth(focus, region.depth) }
: region,
),
- );
- }, []);
+ }));
+ }, [updateState]);
const handleZoomDepthChange = useCallback((depth: ZoomDepth) => {
if (!selectedZoomId) return;
- setZoomRegions((prev) =>
- prev.map((region) =>
+ pushState((prev) => ({
+ zoomRegions: prev.zoomRegions.map((region) =>
region.id === selectedZoomId
- ? {
- ...region,
- depth,
- focus: clampFocusToDepth(region.focus, depth),
- }
+ ? { ...region, depth, focus: clampFocusToDepth(region.focus, depth) }
: region,
),
- );
- }, [selectedZoomId]);
+ }));
+ }, [selectedZoomId, pushState]);
const handleZoomDelete = useCallback((id: string) => {
- setZoomRegions((prev) => prev.filter((region) => region.id !== id));
+ pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) }));
if (selectedZoomId === id) {
setSelectedZoomId(null);
}
- }, [selectedZoomId]);
+ }, [selectedZoomId, pushState]);
const handleTrimDelete = useCallback((id: string) => {
- setTrimRegions((prev) => prev.filter((region) => region.id !== id));
+ pushState((prev) => ({ trimRegions: prev.trimRegions.filter((r) => r.id !== id) }));
if (selectedTrimId === id) {
setSelectedTrimId(null);
}
- }, [selectedTrimId]);
+ }, [selectedTrimId, pushState]);
const handleAnnotationAdded = useCallback((span: Span) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
- const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
+ const zIndex = nextAnnotationZIndexRef.current++;
const newRegion: AnnotationRegion = {
id,
startMs: Math.round(span.start),
@@ -338,59 +300,48 @@ export default function VideoEditor() {
style: { ...DEFAULT_ANNOTATION_STYLE },
zIndex,
};
- setAnnotationRegions((prev) => [...prev, newRegion]);
+ pushState((prev) => ({ annotationRegions: [...prev.annotationRegions, newRegion] }));
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
- }, []);
+ }, [pushState]);
const handleAnnotationSpanChange = useCallback((id: string, span: Span) => {
- setAnnotationRegions((prev) =>
- prev.map((region) =>
+ pushState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
- ? {
- ...region,
- startMs: Math.round(span.start),
- endMs: Math.round(span.end),
- }
+ ? { ...region, startMs: Math.round(span.start), endMs: Math.round(span.end) }
: region,
),
- );
- }, []);
+ }));
+ }, [pushState]);
const handleAnnotationDelete = useCallback((id: string) => {
- setAnnotationRegions((prev) => prev.filter((region) => region.id !== id));
+ pushState((prev) => ({ annotationRegions: prev.annotationRegions.filter((r) => r.id !== id) }));
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
- }, [selectedAnnotationId]);
+ }, [selectedAnnotationId, pushState]);
const handleAnnotationContentChange = useCallback((id: string, content: string) => {
- setAnnotationRegions((prev) => {
- const updated = prev.map((region) => {
+ pushState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) => {
if (region.id !== id) return region;
-
- // Store content in type-specific fields
if (region.type === 'text') {
return { ...region, content, textContent: content };
} else if (region.type === 'image') {
return { ...region, content, imageContent: content };
- } else {
- return { ...region, content };
}
- });
- return updated;
- });
- }, []);
+ return { ...region, content };
+ }),
+ }));
+ }, [pushState]);
const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => {
- setAnnotationRegions((prev) => {
- const updated = prev.map((region) => {
+ pushState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) => {
if (region.id !== id) return region;
-
const updatedRegion = { ...region, type };
-
- // Restore content from type-specific storage
if (type === 'text') {
updatedRegion.content = region.textContent || 'Enter text...';
} else if (type === 'image') {
@@ -401,85 +352,71 @@ export default function VideoEditor() {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
}
-
return updatedRegion;
- });
- return updated;
- });
- }, []);
+ }),
+ }));
+ }, [pushState]);
const handleAnnotationStyleChange = useCallback((id: string, style: Partial) => {
- setAnnotationRegions((prev) =>
- prev.map((region) =>
+ pushState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) =>
region.id === id
? { ...region, style: { ...region.style, ...style } }
: region,
),
- );
- }, []);
+ }));
+ }, [pushState]);
const handleAnnotationFigureDataChange = useCallback((id: string, figureData: FigureData) => {
- setAnnotationRegions((prev) =>
- prev.map((region) =>
- region.id === id
- ? { ...region, figureData }
- : region,
+ pushState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) =>
+ region.id === id ? { ...region, figureData } : region,
),
- );
- }, []);
+ }));
+ }, [pushState]);
const handleAnnotationPositionChange = useCallback((id: string, position: { x: number; y: number }) => {
- setAnnotationRegions((prev) =>
- prev.map((region) =>
- region.id === id
- ? { ...region, position }
- : region,
+ pushState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) =>
+ region.id === id ? { ...region, position } : region,
),
- );
- }, []);
+ }));
+ }, [pushState]);
const handleAnnotationSizeChange = useCallback((id: string, size: { width: number; height: number }) => {
- setAnnotationRegions((prev) =>
- prev.map((region) =>
- region.id === id
- ? { ...region, size }
- : region,
+ pushState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) =>
+ region.id === id ? { ...region, size } : region,
),
- );
- }, []);
+ }));
+ }, [pushState]);
- // Global Tab prevention
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Tab') {
- // Allow tab only in inputs/textareas
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
- return;
- }
- e.preventDefault();
- }
+ const mod = e.ctrlKey || e.metaKey;
+ const key = e.key.toLowerCase();
- if (e.key === ' ' || e.code === 'Space') {
- // Allow space only in inputs/textareas
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
- return;
- }
+ if (mod && key === 'z' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); undo(); return; }
+ if (mod && (key === 'y' || (key === 'z' && e.shiftKey))) { e.preventDefault(); e.stopPropagation(); redo(); return; }
+
+ const isInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
+
+ if (e.key === 'Tab' && !isInput) { e.preventDefault(); }
+
+ if ((e.key === ' ' || e.code === 'Space') && !isInput) {
e.preventDefault();
-
const playback = videoPlaybackRef.current;
if (playback?.video) {
- if (playback.video.paused) {
- playback.play().catch(console.error);
- } else {
- playback.pause();
- }
+ playback.video.paused
+ ? playback.play().catch(console.error)
+ : playback.pause();
}
}
};
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
- }, []);
+ }, [undo, redo]);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
@@ -822,6 +759,7 @@ export default function VideoEditor() {
selectedZoomId={selectedZoomId}
onSelectZoom={handleSelectZoom}
onZoomFocusChange={handleZoomFocusChange}
+ onZoomFocusDragEnd={commitState}
isPlaying={isPlaying}
showShadow={shadowIntensity > 0}
shadowIntensity={shadowIntensity}
@@ -886,7 +824,7 @@ export default function VideoEditor() {
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
aspectRatio={aspectRatio}
- onAspectRatioChange={setAspectRatio}
+ onAspectRatioChange={(ar) => pushState({ aspectRatio: ar })}
/>
@@ -896,7 +834,7 @@ export default function VideoEditor() {
{/* Right section: settings panel */}
pushState({ wallpaper: w })}
selectedZoomDepth={selectedZoomId ? zoomRegions.find(z => z.id === selectedZoomId)?.depth : null}
onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)}
selectedZoomId={selectedZoomId}
@@ -904,17 +842,20 @@ export default function VideoEditor() {
selectedTrimId={selectedTrimId}
onTrimDelete={handleTrimDelete}
shadowIntensity={shadowIntensity}
- onShadowChange={setShadowIntensity}
+ onShadowChange={(v) => updateState({ shadowIntensity: v })}
+ onShadowCommit={commitState}
showBlur={showBlur}
- onBlurChange={setShowBlur}
+ onBlurChange={(v) => pushState({ showBlur: v })}
motionBlurEnabled={motionBlurEnabled}
- onMotionBlurChange={setMotionBlurEnabled}
+ onMotionBlurChange={(v) => pushState({ motionBlurEnabled: v })}
borderRadius={borderRadius}
- onBorderRadiusChange={setBorderRadius}
+ onBorderRadiusChange={(v) => updateState({ borderRadius: v })}
+ onBorderRadiusCommit={commitState}
padding={padding}
- onPaddingChange={setPadding}
+ onPaddingChange={(v) => updateState({ padding: v })}
+ onPaddingCommit={commitState}
cropRegion={cropRegion}
- onCropChange={setCropRegion}
+ onCropChange={(r) => pushState({ cropRegion: r })}
aspectRatio={aspectRatio}
videoElement={videoPlaybackRef.current?.video || null}
exportQuality={exportQuality}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 4dc491e..a95a301 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -26,6 +26,7 @@ interface VideoPlaybackProps {
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
onZoomFocusChange: (id: string, focus: ZoomFocus) => void;
+ onZoomFocusDragEnd?: () => void;
isPlaying: boolean;
showShadow?: boolean;
shadowIntensity?: number;
@@ -65,6 +66,7 @@ const VideoPlayback = forwardRef(({
selectedZoomId,
onSelectZoom,
onZoomFocusChange,
+ onZoomFocusDragEnd,
isPlaying,
showShadow,
shadowIntensity = 0,
@@ -293,6 +295,7 @@ const VideoPlayback = forwardRef(({
} catch {
}
+ onZoomFocusDragEnd?.();
};
const handleOverlayPointerUp = (event: React.PointerEvent) => {
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 9b15333..2a5f061 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -860,17 +860,20 @@ export default function TimelineEditor({
return;
}
- if (e.key === 'f' || e.key === 'F') {
- addKeyframe();
- }
- if (e.key === 'z' || e.key === 'Z') {
- handleAddZoom();
- }
- if (e.key === 't' || e.key === 'T') {
- handleAddTrim();
- }
- if (e.key === 'a' || e.key === 'A') {
- handleAddAnnotation();
+ // Single-letter shortcuts only when no modifier key is held
+ if (!e.ctrlKey && !e.metaKey && !e.altKey) {
+ if (e.key === 'f' || e.key === 'F') {
+ addKeyframe();
+ }
+ if (e.key === 'z' || e.key === 'Z') {
+ handleAddZoom();
+ }
+ if (e.key === 't' || e.key === 'T') {
+ handleAddTrim();
+ }
+ if (e.key === 'a' || e.key === 'A') {
+ handleAddAnnotation();
+ }
}
// Tab: Cycle through overlapping annotations at current time
diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts
new file mode 100644
index 0000000..d3f686d
--- /dev/null
+++ b/src/hooks/useEditorHistory.ts
@@ -0,0 +1,112 @@
+import { useCallback, useRef, useState } from "react";
+import type { ZoomRegion, TrimRegion, AnnotationRegion, CropRegion } 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[];
+ 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: [],
+ 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 | ((prev: EditorState) => Partial);
+
+interface History {
+ 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 };
+}
+
+function withCheckpoint(history: History, newPresent: EditorState): History {
+ 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({ 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);
+
+ 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 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 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,
+ };
+}