feat: implement undo/redo functionality in video editor

This commit is contained in:
FabLrc
2026-02-27 15:43:56 +01:00
parent 71bb09c82e
commit 0e85679b14
6 changed files with 279 additions and 190 deletions
@@ -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() {
<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>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Undo</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.undo}</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-400">Redo</span>
<div className="flex items-center gap-1">
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.redo}</kbd>
<span className="text-slate-600 text-[9px]">or</span>
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">{shortcuts.redoAlt}</kbd>
</div>
</div>
</div>
</div>
</div>
@@ -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({
<Slider
value={[shadowIntensity]}
onValueChange={(values) => onShadowChange?.(values[0])}
onValueCommit={() => onShadowCommit?.()}
min={0}
max={1}
step={0.01}
@@ -372,6 +379,7 @@ export function SettingsPanel({
<Slider
value={[borderRadius]}
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
onValueCommit={() => onBorderRadiusCommit?.()}
min={0}
max={16}
step={0.5}
@@ -386,6 +394,7 @@ export function SettingsPanel({
<Slider
value={[padding]}
onValueChange={(values) => onPaddingChange?.(values[0])}
onValueCommit={() => onPaddingCommit?.()}
min={0}
max={100}
step={1}
+116 -175
View File
@@ -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<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [wallpaper, setWallpaper] = useState<string>(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<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);
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [aspectRatio, setAspectRatio] = useState<AspectRatio>('16:9');
const [exportQuality, setExportQuality] = useState<ExportQuality>('good');
const [exportFormat, setExportFormat] = useState<ExportFormat>('mp4');
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(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<VideoExporter | null>(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<AnnotationRegion['style']>) => {
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 })}
/>
</div>
</Panel>
@@ -896,7 +834,7 @@ export default function VideoEditor() {
{/* Right section: settings panel */}
<SettingsPanel
selected={wallpaper}
onWallpaperChange={setWallpaper}
onWallpaperChange={(w) => 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}
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(({
selectedZoomId,
onSelectZoom,
onZoomFocusChange,
onZoomFocusDragEnd,
isPlaying,
showShadow,
shadowIntensity = 0,
@@ -293,6 +295,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
} catch {
}
onZoomFocusDragEnd?.();
};
const handleOverlayPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
@@ -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
+112
View File
@@ -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<EditorState> | ((prev: EditorState) => Partial<EditorState>);
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<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);
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,
};
}