diff --git a/src/components/ImageOverlay.tsx b/src/components/ImageOverlay.tsx index ac928206..463dd2ac 100644 --- a/src/components/ImageOverlay.tsx +++ b/src/components/ImageOverlay.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import { useRef, useState, useEffect } from "react"; import { OverlayPosition } from "@/lib/types"; import { ArrowUpLeft, ArrowUpRight, ArrowDownLeft, ArrowDownRight, Upload, Trash2, FileImage } from "lucide-react"; +import { cn } from "@/lib/utils"; interface ImageOverlayPanelProps { overlayFile: File | null; @@ -12,6 +13,8 @@ interface ImageOverlayPanelProps { setOverlaySize: (v: number) => void; overlayOpacity: number; setOverlayOpacity: (v: number) => void; + isSelected?: boolean; + setIsSelected?: (v: boolean) => void; } const POSITIONS: { value: OverlayPosition; icon: React.ReactNode; label: string }[] = [ @@ -30,6 +33,8 @@ export default function ImageOverlayPanel({ setOverlaySize, overlayOpacity, setOverlayOpacity, + isSelected = false, + setIsSelected, }: ImageOverlayPanelProps) { const inputRef = useRef(null); const [thumbUrl, setThumbUrl] = useState(""); @@ -44,6 +49,18 @@ export default function ImageOverlayPanel({ return () => URL.revokeObjectURL(url); }, [overlayFile]); + useEffect(() => { + return () => { + if (setIsSelected) setIsSelected(false); + }; + }, [setIsSelected]); + + useEffect(() => { + if (!overlayFile && setIsSelected) { + setIsSelected(false); + } + }, [overlayFile, setIsSelected]); + const handleUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) setOverlayFile(file); @@ -98,7 +115,36 @@ export default function ImageOverlayPanel({ {/* Right Side: Horizontal Details Card */} -
+
{ + if (overlayFile && setIsSelected) { + setIsSelected(!isSelected); + } + }} + onFocus={() => { + if (overlayFile && setIsSelected) { + setIsSelected(true); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (overlayFile && setIsSelected) { + setIsSelected(!isSelected); + } + } + }} + tabIndex={overlayFile ? 0 : -1} + className={cn( + "flex-1 min-w-0 h-11 rounded-lg border flex items-center justify-between px-3 gap-2.5 outline-none transition-all duration-200", + overlayFile ? "cursor-pointer" : "", + isSelected && overlayFile + ? "border-film-500 ring-2 ring-film-500/30 bg-film-500/10" + : "border-[var(--border)] bg-[#121d30]/20" + )} + aria-label={overlayFile ? `Image overlay: ${overlayFile.name}. Press Backspace or Delete to remove.` : "No image overlay selected"} + > {overlayFile ? ( <> {/* File Info block */} @@ -116,9 +162,13 @@ export default function ImageOverlayPanel({ diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx index c1cef066..6a5e9322 100644 --- a/src/components/OnboardingTour.tsx +++ b/src/components/OnboardingTour.tsx @@ -354,13 +354,16 @@ export default function OnboardingTour() { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") dismiss(); if (e.key === "ArrowRight" || e.key === "Enter") { - if (stepIndex < TOUR_STEPS.length - 1) setStepIndex((i) => i + 1); - else dismiss(); + setStepIndex((i) => { + if (i < TOUR_STEPS.length - 1) return i + 1; + dismiss(); + return i; + }); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [visible, stepIndex, dismiss]); + }, [visible, dismiss]); if (!visible || !targetRect || !currentStep) return null; @@ -380,8 +383,11 @@ export default function OnboardingTour() { totalSteps={TOUR_STEPS.length} rect={targetRect} onNext={() => { - if (stepIndex < TOUR_STEPS.length - 1) setStepIndex((i) => i + 1); - else dismiss(); + setStepIndex((i) => { + if (i < TOUR_STEPS.length - 1) return i + 1; + dismiss(); + return i; + }); }} onSkip={dismiss} tooltipRef={tooltipRef} diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 1e4e9f0d..e921679a 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -22,7 +22,7 @@ import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; import { Layers, Crop, Scissors, RotateCw, Volume2, Type, - SlidersHorizontal, Zap, AlertTriangle, Github, Copy + SlidersHorizontal, Zap, AlertTriangle, Github, Copy, Undo2, Redo2 } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; @@ -118,41 +118,49 @@ function Kbd({ children }: { children: React.ReactNode }) { } /** Collapsible panel that lists all keyboard shortcuts. */ -function KeyboardShortcutsPanel() { +function KeyboardShortcutsPanel({ onOpenHelpModal }: { onOpenHelpModal: () => void }) { const [open, setOpen] = useState(false); const shortcuts: { keys: React.ReactNode[]; label: string }[] = [ - { - keys: [ - Ctrl, - +, - Shift, - +, - E - ], - label: "Export video", - }, - { - keys: [M], - label: "Toggle audio mute", - }, - { - keys: [R], - label: "Reset all settings", - }, - { - keys: [Esc], - label: "Cancel export", - }, - { - keys: [1, , 9], - label: "Switch preset by index", - }, - { - keys: [?], - label: "Toggle this panel", - }, -]; + { + keys: [Space], + label: "Play / Pause video", + }, + { + keys: [,, /, .], + label: "Frame-by-frame backward / forward", + }, + { + keys: [Ctrl, +, Z], + label: "Undo last change", + }, + { + keys: [Ctrl, +, Y], + label: "Redo change", + }, + { + keys: [ + Ctrl, + +, + Shift, + +, + E + ], + label: "Export video", + }, + { + keys: [M], + label: "Toggle audio mute", + }, + { + keys: [Backspace, /, Del], + label: "Delete selected overlay", + }, + { + keys: [?], + label: "Open full help menu", + }, + ]; return (
@@ -180,23 +188,167 @@ function KeyboardShortcutsPanel() { {open && ( -
    - {shortcuts.map(({ keys, label }) => ( -
  • - {label} - {keys} -
  • - ))} -
+
+
    + {shortcuts.map(({ keys, label }) => ( +
  • + {label} + {keys} +
  • + ))} +
+
+ +
+
)}
); } +interface ShortcutsHelpModalProps { + isOpen: boolean; + onClose: () => void; +} + +function ShortcutsHelpModal({ isOpen, onClose }: ShortcutsHelpModalProps) { + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const sections = [ + { + title: "Playback Controls", + items: [ + { keys: ["Spacebar"], desc: "Play or pause video playback" }, + { keys: [","], desc: "Step backward 1 frame (1/30s)" }, + { keys: ["."], desc: "Step forward 1 frame (1/30s)" }, + ], + }, + { + title: "Editing Operations", + items: [ + { keys: ["Ctrl", "Z"], desc: "Undo last setting or adjustment change" }, + { keys: ["Ctrl", "Shift", "Z"], desc: "Redo last undone action" }, + { keys: ["Ctrl", "Y"], desc: "Redo action (Windows alternative)" }, + { keys: ["M"], desc: "Toggle audio mute (on/off)" }, + { keys: ["Backspace"], desc: "Delete / remove selected image overlay" }, + { keys: ["Delete"], desc: "Delete selected overlay (alternative)" }, + ], + }, + { + title: "System & Export", + items: [ + { keys: ["Ctrl", "Enter"], desc: "Export trimmed & adjusted video" }, + { keys: ["Ctrl", "Shift", "E"], desc: "Export video (alternative)" }, + { keys: ["Escape"], desc: "Cancel active export process" }, + { keys: ["R"], desc: "Reset all current editing settings" }, + { keys: ["?"], desc: "Toggle this help menu overlay" }, + ], + }, + { + title: "Presets & Shortcuts", + items: [ + { keys: ["1"], desc: "Apply preset 1: TikTok/Reels/Shorts Portrait" }, + { keys: ["2"], desc: "Apply preset 2: YouTube Landscape" }, + { keys: ["3"], desc: "Apply preset 3: Instagram Square" }, + { keys: ["4", "–", "9"], desc: "Switch to other layout presets" }, + ], + }, + ]; + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions +
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
e.stopPropagation()} + > + {/* Modal Header */} +
+ + +
+ + {/* Modal Content */} +
+ {sections.map((section) => ( +
+

+ {section.title} +

+
+
    + {section.items.map((item, idx) => ( +
  • + {item.desc} + + {item.keys.map((k, kIdx) => ( + + {kIdx > 0 && +} + + {k} + + + ))} + +
  • + ))} +
+
+ ))} +
+ + {/* Modal Footer */} +
+

+ Press ? at any time to toggle this helper menu. +

+
+
+
+ ); +} + export default function VideoEditor() { + const [isOverlaySelected, setIsOverlaySelected] = useState(false); + const [isShortcutsModalOpen, setIsShortcutsModalOpen] = useState(false); + const { file, duration, recipe, status, progress, result, error, exportStartedAt, updateRecipe, @@ -210,6 +362,10 @@ export default function VideoEditor() { recommendedPreset, currentTime, toggleSound, + undo, + redo, + canUndo, + canRedo, } = useVideoEditor(); useKeyboardShortcuts({ @@ -220,7 +376,16 @@ export default function VideoEditor() { handleExport, status, cancelExport, - onToggleShortcutsModal: () => {}, + onToggleShortcutsModal: () => setIsShortcutsModalOpen((v) => !v), + videoRef, + undo, + redo, + canUndo, + canRedo, + onDeleteSelected: isOverlaySelected && overlayFile ? () => { + setOverlayFile(null); + setIsOverlaySelected(false); + } : undefined, }); const [copied, setCopied] = useState(false); @@ -317,6 +482,7 @@ export default function VideoEditor() { onCancel={cancelExport} /> + setIsShortcutsModalOpen(false)} />
{status === "exporting" && `Exporting video: ${progress}%`} @@ -576,6 +742,8 @@ export default function VideoEditor() { setOverlaySize={setOverlaySize} overlayOpacity={overlayOpacity} setOverlayOpacity={setOverlayOpacity} + isSelected={isOverlaySelected} + setIsSelected={setIsOverlaySelected} />
@@ -662,26 +830,68 @@ export default function VideoEditor() {
-
- - +
+
+ + +
+ +
+ + | + +
- + setIsShortcutsModalOpen(true)} /> {file && (

diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index a21a24b4..677a2668 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -11,6 +11,12 @@ interface UseKeyboardShortcutsProps { status: ExportStatus; cancelExport: () => void; onToggleShortcutsModal: () => void; + videoRef: React.RefObject; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; + onDeleteSelected?: () => void; } export function useKeyboardShortcuts({ @@ -22,28 +28,121 @@ export function useKeyboardShortcuts({ status, cancelExport, onToggleShortcutsModal, + videoRef, + undo, + redo, + canUndo, + canRedo, + onDeleteSelected, }: UseKeyboardShortcutsProps) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; + + // Prevent triggering shortcuts when typing in inputs, textareas, selects, or editable fields if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || target.isContentEditable - ) return; + ) { + return; + } const isMac = navigator.platform.toUpperCase().includes("MAC"); const isCtrlOrCmd = isMac ? e.metaKey : e.ctrlKey; - if (isCtrlOrCmd && e.shiftKey && e.key === "E") { + // 1. Export Shortcut: Ctrl/Cmd + Shift + E OR Ctrl/Cmd + Enter + if ( + (isCtrlOrCmd && e.shiftKey && e.key.toLowerCase() === "e") || + (isCtrlOrCmd && e.key === "Enter") + ) { + e.preventDefault(); + e.stopPropagation(); + if (file && status === "idle") { + handleExport(); + } + return; + } + + // 2. Undo Shortcut: Ctrl/Cmd + Z + if (isCtrlOrCmd && !e.shiftKey && e.key.toLowerCase() === "z") { e.preventDefault(); e.stopPropagation(); - if (file && status === "idle") handleExport(); + if (canUndo) { + undo(); + } + return; + } + + // 3. Redo Shortcut: Ctrl/Cmd + Shift + Z OR Ctrl/Cmd + Y + if ( + (isCtrlOrCmd && e.shiftKey && e.key.toLowerCase() === "z") || + (isCtrlOrCmd && e.key.toLowerCase() === "y") + ) { + e.preventDefault(); + e.stopPropagation(); + if (canRedo) { + redo(); + } + return; + } + + // 4. Delete / Backspace Shortcut + if (e.key === "Delete" || e.key === "Backspace") { + if (onDeleteSelected) { + e.preventDefault(); + e.stopPropagation(); + onDeleteSelected(); + } return; } if (!file) return; + // 5. Spacebar Shortcut (Play/Pause) + if (e.key === " " || e.code === "Space") { + const video = videoRef.current; + if (video) { + e.preventDefault(); + e.stopPropagation(); + if (video.paused) { + video.play().catch(() => {}); + } else { + video.pause(); + } + } + return; + } + + // 6. Frame-by-frame Step Backward: "," (comma) + if (e.key === ",") { + const video = videoRef.current; + if (video) { + e.preventDefault(); + e.stopPropagation(); + video.pause(); + const FRAME_TIME = 1 / 30; // Assuming 30fps standard + video.currentTime = Math.max(0, video.currentTime - FRAME_TIME); + } + return; + } + + // 7. Frame-by-frame Step Forward: "." (period) + if (e.key === ".") { + const video = videoRef.current; + if (video) { + e.preventDefault(); + e.stopPropagation(); + video.pause(); + const FRAME_TIME = 1 / 30; // Assuming 30fps standard + video.currentTime = Math.min(video.duration || 0, video.currentTime + FRAME_TIME); + } + return; + } + + + switch (e.key) { case "m": case "M": @@ -75,5 +174,20 @@ export function useKeyboardShortcuts({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [file, recipe, resetSettings, updateRecipe, handleExport, status, cancelExport, onToggleShortcutsModal]); + }, [ + file, + recipe, + resetSettings, + updateRecipe, + handleExport, + status, + cancelExport, + onToggleShortcutsModal, + videoRef, + undo, + redo, + canUndo, + canRedo, + onDeleteSelected, + ]); } diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index d53006ad..06cd9d88 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -145,15 +145,103 @@ export function useVideoEditor() { const encoded = params.get("settings"); if (encoded) { const decoded = decodeRecipe(encoded); - if (decoded) return { ...DEFAULT_RECIPE, ...decoded }; + if (decoded) return { ...DEFAULT_RECIPE, ...decoded, soundOnCompletion: false }; } return { ...DEFAULT_RECIPE, - soundOnCompletion: - typeof window !== "undefined" && - localStorage.getItem("soundOnCompletion") === "true", + soundOnCompletion: false, }; }); + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const historyRef = useRef<{ list: EditRecipe[]; index: number }>({ list: [], index: -1 }); + const isUndoingOrRedoing = useRef(false); + const debounceTimerRef = useRef(null); + + // Keep historyRef in sync + useEffect(() => { + historyRef.current = { list: history, index: historyIndex }; + }, [history, historyIndex]); + + const pushHistory = useCallback((nextRecipe: EditRecipe) => { + if (isUndoingOrRedoing.current) return; + + const { list, index } = historyRef.current; + + // If the next recipe is identical to the one at the current index, don't push + if (index >= 0 && JSON.stringify(list[index]) === JSON.stringify(nextRecipe)) { + return; + } + + const newList = [...list.slice(0, index + 1), nextRecipe]; + setHistory(newList); + setHistoryIndex(newList.length - 1); + }, []); + + const debouncedPushHistory = useCallback((nextRecipe: EditRecipe) => { + if (isUndoingOrRedoing.current) return; + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + pushHistory(nextRecipe); + }, 400); // 400ms debounce + }, [pushHistory]); + + const initHistory = useCallback((initialRecipe: EditRecipe) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + setHistory([initialRecipe]); + setHistoryIndex(0); + historyRef.current = { list: [initialRecipe], index: 0 }; + }, []); + + const undo = useCallback(() => { + // Flush any pending history changes + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + + const { list, index } = historyRef.current; + if (index >= 0 && JSON.stringify(list[index]) !== JSON.stringify(recipe)) { + const newList = [...list.slice(0, index + 1), recipe]; + historyRef.current = { list: newList, index: newList.length - 1 }; + } + } + + const { list, index } = historyRef.current; + if (index > 0) { + isUndoingOrRedoing.current = true; + const prevRecipe = list[index - 1]; + if (prevRecipe) { + setRecipe(prevRecipe); + setHistoryIndex(index - 1); + } + setTimeout(() => { + isUndoingOrRedoing.current = false; + }, 0); + } + }, [recipe]); + + const redo = useCallback(() => { + const { list, index } = historyRef.current; + if (index < list.length - 1) { + isUndoingOrRedoing.current = true; + const nextRecipe = list[index + 1]; + if (nextRecipe) { + setRecipe(nextRecipe); + setHistoryIndex(index + 1); + } + setTimeout(() => { + isUndoingOrRedoing.current = false; + }, 0); + } + }, []); + const [status, setStatus] = useState("idle"); const [progress, setProgress] = useState(0); const [result, setResult] = useState(null); @@ -181,9 +269,10 @@ export function useVideoEditor() { if (next.format === "gif") { next.keepAudio = false; } + debouncedPushHistory(next); return next; }); -}, []); +}, [debouncedPushHistory]); const isValidValue = (key: keyof EditRecipe, val: any): boolean => { switch (key) { case "preset": @@ -224,6 +313,11 @@ export function useVideoEditor() { const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array; const hasRecipeParams = recipeKeys.some(key => params.has(key)); + let initialSoundOnCompletion = false; + try { + initialSoundOnCompletion = localStorage.getItem("soundOnCompletion") === "true"; + } catch {} + if (hasRecipeParams) { const updatedPatch: Partial = {}; recipeKeys.forEach((key) => { @@ -249,8 +343,14 @@ export function useVideoEditor() { if (Object.keys(updatedPatch).length > 0) { setRecipe(prev => ({ ...prev, + soundOnCompletion: initialSoundOnCompletion, ...updatedPatch })); + } else { + setRecipe(prev => ({ + ...prev, + soundOnCompletion: initialSoundOnCompletion + })); } } else { // Try full recipe restore first (new key) @@ -259,7 +359,10 @@ export function useVideoEditor() { if (raw) { const parsed = JSON.parse(raw); if (isValidRecipe(parsed)) { - setRecipe(parsed); + setRecipe({ + ...parsed, + soundOnCompletion: initialSoundOnCompletion + }); return; } } @@ -277,12 +380,18 @@ export function useVideoEditor() { }; setRecipe(prev => ({ ...prev, + soundOnCompletion: initialSoundOnCompletion, preset: parsed.preset ?? prev.preset, quality: parsed.quality ?? prev.quality, speed: parsed.speed ?? prev.speed, customWidth: sanitizeDimension(parsed.customWidth, prev.customWidth), customHeight: sanitizeDimension(parsed.customHeight, prev.customHeight), })); + } else { + setRecipe(prev => ({ + ...prev, + soundOnCompletion: initialSoundOnCompletion + })); } } } catch (e) { @@ -411,26 +520,27 @@ export function useVideoEditor() { setDuration(dur); setVideoMetadata({ width, height, duration: dur }); setFile(selectedFile); - if (dimensionCheck === "warning") { console.warn(`[Reframe] High resolution video detected (${width}×${height}). Export may be slow.`); } - setRecipe((prev) => { - const suggestedPreset = suggestPreset(width, height); - const shouldApplySuggestion = prev.preset === DEFAULT_RECIPE.preset; - - return { - ...prev, - trimStart: 0, - trimEnd: null, - ...(shouldApplySuggestion ? { preset: suggestedPreset } : {}), - }; - }); + + const suggestedPreset = suggestPreset(width, height); + const shouldApplySuggestion = recipe.preset === DEFAULT_RECIPE.preset; + + const initialRecipe = { + ...recipe, + trimStart: 0, + trimEnd: null, + ...(shouldApplySuggestion ? { preset: suggestedPreset } : {}), + }; + + setRecipe(initialRecipe); + initHistory(initialRecipe); } catch (err) { setError(`Layer 4 Validation Failed: ${err instanceof Error ? err.message : "Unknown error"}`); setStatus("error"); } - }, []); + }, [recipe, initHistory]); const handleExport = useCallback(async () => { if (!file) return; @@ -561,49 +671,7 @@ export function useVideoEditor() { return () => window.removeEventListener("beforeunload", handler); }, [status]); - useEffect(() => { - const handleKeydown = (e: KeyboardEvent) => { - if ( - (e.ctrlKey || e.metaKey) && - e.key === "Enter" && - file && - status !== "loading-engine" && - status !== "exporting" - ) { - handleExport(); - } - }; - - document.addEventListener("keydown", handleKeydown); - return () => { - document.removeEventListener("keydown", handleKeydown); - }; - }, [file, status, handleExport]); - - // M key: toggle audio mute — only when a file is loaded and focus isn't in a text field - useEffect(() => { - if (!file) return; - - const handleMuteShortcut = (e: KeyboardEvent) => { - if (e.key.toLowerCase() !== "m" || e.ctrlKey || e.metaKey || e.altKey) return; - - const target = e.target as HTMLElement; - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { - return; - } - setRecipe((prev) => ({ ...prev, keepAudio: !prev.keepAudio })); - }; - - document.addEventListener("keydown", handleMuteShortcut); - return () => { - document.removeEventListener("keydown", handleMuteShortcut); - }; - }, [file]); useEffect(()=>{ return ()=>{ @@ -616,17 +684,21 @@ export function useVideoEditor() { useEffect(() => { return () => { terminateFFmpeg(); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } }; }, []); const resetSettings = useCallback(() => { setRecipe(DEFAULT_RECIPE); + pushHistory(DEFAULT_RECIPE); try { localStorage.removeItem(STORAGE_KEY); } catch { // ignore } - }, []); + }, [pushHistory]); const cancelExport = useCallback(() => { exportCancelledRef.current = true; @@ -639,13 +711,13 @@ export function useVideoEditor() { setExportStartedAt(null); }, []); - const reset = useCallback(() => { if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setFile(null); setVideoMetadata(null); setDuration(0); setRecipe(DEFAULT_RECIPE); + initHistory(DEFAULT_RECIPE); setStatus("idle"); setProgress(0); setResult(null); @@ -656,12 +728,12 @@ export function useVideoEditor() { } catch { // ignore } - }, [result]); - + }, [result, initHistory]); useEffect(() => { localStorage.setItem("soundOnCompletion", String(recipe.soundOnCompletion)); }, [recipe.soundOnCompletion]); + const seekTo = useCallback((time: number) => { if (videoRef.current) { videoRef.current.currentTime = time; @@ -676,8 +748,11 @@ export function useVideoEditor() { },[]); const toggleSound = useCallback(() => { - updateRecipe({ soundOnCompletion: !recipe.soundOnCompletion }); -}, [recipe.soundOnCompletion, updateRecipe]); + updateRecipe({ soundOnCompletion: !recipe.soundOnCompletion }); + }, [recipe.soundOnCompletion, updateRecipe]); + + const canUndo = historyIndex > 0; + const canRedo = historyIndex < history.length - 1 && historyIndex !== -1; return { file, @@ -716,5 +791,9 @@ export function useVideoEditor() { recommendedPreset, currentTime, toggleSound, + undo, + redo, + canUndo, + canRedo, }; }