From 15dae9deae5beab7ccefdeb2c31c05276ab833f0 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 14 Mar 2026 16:50:12 +1100 Subject: [PATCH] feat(editor): add minimal keyboard undo/redo without UI rewrite Implements Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z (plus Ctrl+Y on Windows/Linux) using local history snapshots in VideoEditor only. This avoids importing upstream UI/layout/button changes while preserving the same undo/redo idea. --- src/components/video-editor/VideoEditor.tsx | 157 +++++++++++++++++++- 1 file changed, 154 insertions(+), 3 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 722c04ac..cc930fe1 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -54,6 +54,17 @@ import { useShortcuts } from "@/contexts/ShortcutsContext"; import { matchesShortcut } from "@/lib/shortcuts"; import { detectInteractionCandidates, normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils"; +type EditorHistorySnapshot = { + zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; + speedRegions: SpeedRegion[]; + annotationRegions: AnnotationRegion[]; + selectedZoomId: string | null; + selectedTrimId: string | null; + selectedSpeedId: string | null; + selectedAnnotationId: string | null; +}; + export default function VideoEditor() { const [videoPath, setVideoPath] = useState(null); const [videoSourcePath, setVideoSourcePath] = useState(null); @@ -108,6 +119,89 @@ export default function VideoEditor() { const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); const autoSuggestedVideoPathRef = useRef(null); + const historyPastRef = useRef([]); + const historyFutureRef = useRef([]); + const historyCurrentRef = useRef(null); + const applyingHistoryRef = useRef(false); + + const cloneSnapshot = useCallback((snapshot: EditorHistorySnapshot): EditorHistorySnapshot => { + return { + zoomRegions: JSON.parse(JSON.stringify(snapshot.zoomRegions)), + trimRegions: JSON.parse(JSON.stringify(snapshot.trimRegions)), + speedRegions: JSON.parse(JSON.stringify(snapshot.speedRegions)), + annotationRegions: JSON.parse(JSON.stringify(snapshot.annotationRegions)), + selectedZoomId: snapshot.selectedZoomId, + selectedTrimId: snapshot.selectedTrimId, + selectedSpeedId: snapshot.selectedSpeedId, + selectedAnnotationId: snapshot.selectedAnnotationId, + }; + }, []); + + const buildHistorySnapshot = useCallback((): EditorHistorySnapshot => { + return { + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + selectedZoomId, + selectedTrimId, + selectedSpeedId, + selectedAnnotationId, + }; + }, [ + zoomRegions, + trimRegions, + speedRegions, + annotationRegions, + selectedZoomId, + selectedTrimId, + selectedSpeedId, + selectedAnnotationId, + ]); + + const applyHistorySnapshot = useCallback((snapshot: EditorHistorySnapshot) => { + applyingHistoryRef.current = true; + const cloned = cloneSnapshot(snapshot); + setZoomRegions(cloned.zoomRegions); + setTrimRegions(cloned.trimRegions); + setSpeedRegions(cloned.speedRegions); + setAnnotationRegions(cloned.annotationRegions); + setSelectedZoomId(cloned.selectedZoomId); + setSelectedTrimId(cloned.selectedTrimId); + setSelectedSpeedId(cloned.selectedSpeedId); + setSelectedAnnotationId(cloned.selectedAnnotationId); + + nextZoomIdRef.current = deriveNextId("zoom", cloned.zoomRegions.map((region) => region.id)); + nextTrimIdRef.current = deriveNextId("trim", cloned.trimRegions.map((region) => region.id)); + nextSpeedIdRef.current = deriveNextId("speed", cloned.speedRegions.map((region) => region.id)); + nextAnnotationIdRef.current = deriveNextId("annotation", cloned.annotationRegions.map((region) => region.id)); + nextAnnotationZIndexRef.current = + cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1; + }, [cloneSnapshot]); + + const handleUndo = useCallback(() => { + if (historyPastRef.current.length === 0) return; + + const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); + const previous = historyPastRef.current.pop(); + if (!previous) return; + + historyFutureRef.current.push(cloneSnapshot(current)); + historyCurrentRef.current = cloneSnapshot(previous); + applyHistorySnapshot(previous); + }, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot]); + + const handleRedo = useCallback(() => { + if (historyFutureRef.current.length === 0) return; + + const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); + const next = historyFutureRef.current.pop(); + if (!next) return; + + historyPastRef.current.push(cloneSnapshot(current)); + historyCurrentRef.current = cloneSnapshot(next); + applyHistorySnapshot(next); + }, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot]); const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => { if (!validateProjectData(candidate)) { @@ -235,6 +329,34 @@ export default function VideoEditor() { gifSizePreset, ]); + useEffect(() => { + const snapshot = cloneSnapshot(buildHistorySnapshot()); + + if (!historyCurrentRef.current) { + historyCurrentRef.current = snapshot; + return; + } + + if (applyingHistoryRef.current) { + historyCurrentRef.current = snapshot; + applyingHistoryRef.current = false; + return; + } + + const currentSerialized = JSON.stringify(historyCurrentRef.current); + const nextSerialized = JSON.stringify(snapshot); + if (currentSerialized === nextSerialized) { + return; + } + + historyPastRef.current.push(cloneSnapshot(historyCurrentRef.current)); + if (historyPastRef.current.length > 100) { + historyPastRef.current.shift(); + } + historyCurrentRef.current = snapshot; + historyFutureRef.current = []; + }, [buildHistorySnapshot, cloneSnapshot]); + const hasUnsavedChanges = Boolean( currentProjectPath && currentProjectSnapshot && @@ -898,9 +1020,38 @@ export default function VideoEditor() { // Global Tab prevention useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const isEditableTarget = + target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || target?.isContentEditable; + + const usesPrimaryModifier = isMac ? e.metaKey : e.ctrlKey; + const key = e.key.toLowerCase(); + + if (usesPrimaryModifier && !e.altKey && key === 'z') { + if (!isEditableTarget) { + e.preventDefault(); + if (e.shiftKey) { + handleRedo(); + } else { + handleUndo(); + } + } + return; + } + + if (!isMac && e.ctrlKey && !e.metaKey && !e.altKey && key === 'y') { + if (!isEditableTarget) { + e.preventDefault(); + handleRedo(); + } + return; + } + if (e.key === 'Tab') { // Allow tab only in inputs/textareas - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + if (isEditableTarget) { return; } e.preventDefault(); @@ -908,7 +1059,7 @@ export default function VideoEditor() { if (matchesShortcut(e, shortcuts.playPause, isMac)) { // Allow space only in inputs/textareas - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + if (isEditableTarget) { return; } e.preventDefault(); @@ -926,7 +1077,7 @@ export default function VideoEditor() { window.addEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); - }, [shortcuts, isMac]); + }, [shortcuts, isMac, handleUndo, handleRedo]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {