From 0e85679b141aec0362ff9518ad97c352f628c448 Mon Sep 17 00:00:00 2001 From: FabLrc Date: Fri, 27 Feb 2026 15:43:56 +0100 Subject: [PATCH] feat: implement undo/redo functionality in video editor --- .../video-editor/KeyboardShortcutsHelp.tsx | 29 +- src/components/video-editor/SettingsPanel.tsx | 9 + src/components/video-editor/VideoEditor.tsx | 291 +++++++----------- src/components/video-editor/VideoPlayback.tsx | 3 + .../video-editor/timeline/TimelineEditor.tsx | 25 +- src/hooks/useEditorHistory.ts | 112 +++++++ 6 files changed, 279 insertions(+), 190 deletions(-) create mode 100644 src/hooks/useEditorHistory.ts 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, + }; +}