From b97149ce3ef02c26ee597ca70a37480ca8268c75 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Wed, 20 May 2026 23:20:01 +0700 Subject: [PATCH] refactor(editor): extract history state model --- src/components/video-editor/VideoEditor.tsx | 87 +++------- .../video-editor/editorHistory.test.ts | 128 ++++++++++++++ src/components/video-editor/editorHistory.ts | 161 ++++++++++++++++++ 3 files changed, 314 insertions(+), 62 deletions(-) create mode 100644 src/components/video-editor/editorHistory.test.ts create mode 100644 src/components/video-editor/editorHistory.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 589b79bfe..30195f00e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -127,6 +127,14 @@ import { saveEditorPresets, serializeEditorPresetSnapshot, } from "./editorPreferences"; +import { + createEditorHistoryStack, + type EditorHistorySnapshot, + recordEditorHistorySnapshot, + redoEditorHistoryStack, + resetEditorHistoryStack, + undoEditorHistoryStack, +} from "./editorHistory"; import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog"; import { createProjectData, @@ -206,19 +214,6 @@ import { getDisplayedTimelineWindowMs, } from "./videoPlayback/cursorLoopTelemetry"; -type EditorHistorySnapshot = { - zoomRegions: ZoomRegion[]; - clipRegions: ClipRegion[]; - speedRegions: SpeedRegion[]; - annotationRegions: AnnotationRegion[]; - audioRegions: AudioRegion[]; - autoCaptions: CaptionCue[]; - selectedZoomId: string | null; - selectedClipId: string | null; - selectedAnnotationId: string | null; - selectedAudioId: string | null; -}; - type PendingExportSave = { fileName: string; // Exactly one of these is populated. `tempFilePath` is the preferred form @@ -769,9 +764,7 @@ export default function VideoEditor() { const exporterRef = useRef(null); const autoSuggestedVideoPathRef = useRef(null); const pendingFreshRecordingAutoZoomPathRef = useRef(null); - const historyPastRef = useRef([]); - const historyFutureRef = useRef([]); - const historyCurrentRef = useRef(null); + const editorHistoryRef = useRef(createEditorHistoryStack()); const applyingHistoryRef = useRef(false); const pendingExportSaveRef = useRef(null); const pendingTelemetryRetryTimeoutRef = useRef(null); @@ -1513,15 +1506,11 @@ export default function VideoEditor() { void refreshProjectLibrary(); }, [refreshProjectLibrary]); - const canUndo = historyPastRef.current.length > 0; - const canRedo = historyFutureRef.current.length > 0; + const canUndo = editorHistoryRef.current.past.length > 0; + const canRedo = editorHistoryRef.current.future.length > 0; void historyVersion; - const cloneSnapshot = useCallback((snapshot: EditorHistorySnapshot): EditorHistorySnapshot => { - return cloneStructured(snapshot); - }, []); - const gifOutputDimensions = useMemo( () => calculateOutputDimensions( @@ -1970,7 +1959,7 @@ export default function VideoEditor() { const applyHistorySnapshot = useCallback( (snapshot: EditorHistorySnapshot) => { applyingHistoryRef.current = true; - const cloned = cloneSnapshot(snapshot); + const cloned = cloneStructured(snapshot); setZoomRegions(cloned.zoomRegions); setClipRegions(cloned.clipRegions); setSpeedRegions(cloned.speedRegions); @@ -2002,34 +1991,24 @@ export default function VideoEditor() { 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(); + const previous = undoEditorHistoryStack(editorHistoryRef.current, buildHistorySnapshot()); if (!previous) return; - historyFutureRef.current.push(cloneSnapshot(current)); - historyCurrentRef.current = cloneSnapshot(previous); applyHistorySnapshot(previous); syncHistoryButtons(); - }, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot, syncHistoryButtons]); + }, [applyHistorySnapshot, buildHistorySnapshot, syncHistoryButtons]); const handleRedo = useCallback(() => { - if (historyFutureRef.current.length === 0) return; - - const current = historyCurrentRef.current ?? cloneSnapshot(buildHistorySnapshot()); - const next = historyFutureRef.current.pop(); + const next = redoEditorHistoryStack(editorHistoryRef.current, buildHistorySnapshot()); if (!next) return; - historyPastRef.current.push(cloneSnapshot(current)); - historyCurrentRef.current = cloneSnapshot(next); applyHistorySnapshot(next); syncHistoryButtons(); - }, [applyHistorySnapshot, buildHistorySnapshot, cloneSnapshot, syncHistoryButtons]); + }, [applyHistorySnapshot, buildHistorySnapshot, syncHistoryButtons]); const applyLoadedProject = useCallback( async (candidate: unknown, path?: string | null) => { @@ -2169,9 +2148,7 @@ export default function VideoEditor() { 0, ) + 1; - historyPastRef.current = []; - historyFutureRef.current = []; - historyCurrentRef.current = null; + resetEditorHistoryStack(editorHistoryRef.current); applyingHistoryRef.current = false; syncHistoryButtons(); @@ -2285,32 +2262,18 @@ export default function VideoEditor() { useEffect(() => { const snapshot = buildHistorySnapshot(); + const result = recordEditorHistorySnapshot(editorHistoryRef.current, snapshot, { + applyingHistory: applyingHistoryRef.current, + }); - if (!historyCurrentRef.current) { - historyCurrentRef.current = cloneSnapshot(snapshot); - syncHistoryButtons(); - return; - } - - if (applyingHistoryRef.current) { - historyCurrentRef.current = cloneSnapshot(snapshot); + if (result === "applied") { applyingHistoryRef.current = false; - syncHistoryButtons(); - return; } - if (areDeepEqual(historyCurrentRef.current, snapshot)) { - return; - } - - historyPastRef.current.push(cloneSnapshot(historyCurrentRef.current)); - if (historyPastRef.current.length > 100) { - historyPastRef.current.shift(); + if (result !== "unchanged") { + syncHistoryButtons(); } - historyCurrentRef.current = cloneSnapshot(snapshot); - historyFutureRef.current = []; - syncHistoryButtons(); - }, [buildHistorySnapshot, cloneSnapshot, syncHistoryButtons]); + }, [buildHistorySnapshot, syncHistoryButtons]); const hasUnsavedChanges = useMemo( () => diff --git a/src/components/video-editor/editorHistory.test.ts b/src/components/video-editor/editorHistory.test.ts new file mode 100644 index 000000000..858a4b3b9 --- /dev/null +++ b/src/components/video-editor/editorHistory.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { + areEditorHistorySnapshotsEqual, + cloneEditorHistorySnapshot, + createEditorHistoryStack, + type EditorHistorySnapshot, + recordEditorHistorySnapshot, + redoEditorHistoryStack, + resetEditorHistoryStack, + undoEditorHistoryStack, +} from "./editorHistory"; + +function createSnapshot(id: string | null): EditorHistorySnapshot { + return { + zoomRegions: [], + clipRegions: [], + speedRegions: [], + annotationRegions: [], + audioRegions: [], + autoCaptions: [], + selectedZoomId: id, + selectedClipId: id ? `clip-${id}` : null, + selectedAnnotationId: null, + selectedAudioId: null, + }; +} + +describe("editorHistory", () => { + it("initializes without adding undo history", () => { + const stack = createEditorHistoryStack(); + const snapshot = createSnapshot("first"); + + expect(recordEditorHistorySnapshot(stack, snapshot)).toBe("initialized"); + + expect(stack.current).toEqual(snapshot); + expect(stack.past).toEqual([]); + expect(stack.future).toEqual([]); + }); + + it("does not record unchanged snapshots", () => { + const stack = createEditorHistoryStack(); + const snapshot = createSnapshot("same"); + + recordEditorHistorySnapshot(stack, snapshot); + expect(recordEditorHistorySnapshot(stack, createSnapshot("same"))).toBe("unchanged"); + + expect(stack.past).toEqual([]); + expect(stack.future).toEqual([]); + }); + + it("records changes and clears redo history", () => { + const stack = createEditorHistoryStack(); + + recordEditorHistorySnapshot(stack, createSnapshot("first")); + recordEditorHistorySnapshot(stack, createSnapshot("second")); + stack.future.push(createSnapshot("future")); + + expect(recordEditorHistorySnapshot(stack, createSnapshot("third"))).toBe("recorded"); + + expect(stack.past.map((snapshot) => snapshot.selectedZoomId)).toEqual(["first", "second"]); + expect(stack.current?.selectedZoomId).toBe("third"); + expect(stack.future).toEqual([]); + }); + + it("moves snapshots through undo and redo stacks", () => { + const stack = createEditorHistoryStack(); + const first = createSnapshot("first"); + const second = createSnapshot("second"); + + recordEditorHistorySnapshot(stack, first); + recordEditorHistorySnapshot(stack, second); + + expect(undoEditorHistoryStack(stack, second)?.selectedZoomId).toBe("first"); + expect(stack.current?.selectedZoomId).toBe("first"); + expect(stack.future.map((snapshot) => snapshot.selectedZoomId)).toEqual(["second"]); + + expect(redoEditorHistoryStack(stack, first)?.selectedZoomId).toBe("second"); + expect(stack.current?.selectedZoomId).toBe("second"); + expect(stack.past.map((snapshot) => snapshot.selectedZoomId)).toEqual(["first"]); + }); + + it("updates the current snapshot while applying history without recording a new entry", () => { + const stack = createEditorHistoryStack(); + + recordEditorHistorySnapshot(stack, createSnapshot("first")); + expect( + recordEditorHistorySnapshot(stack, createSnapshot("applied"), { + applyingHistory: true, + }), + ).toBe("applied"); + + expect(stack.current?.selectedZoomId).toBe("applied"); + expect(stack.past).toEqual([]); + }); + + it("caps the past stack at the configured history depth", () => { + const stack = createEditorHistoryStack(); + + recordEditorHistorySnapshot(stack, createSnapshot("0")); + recordEditorHistorySnapshot(stack, createSnapshot("1"), { maxEntries: 2 }); + recordEditorHistorySnapshot(stack, createSnapshot("2"), { maxEntries: 2 }); + recordEditorHistorySnapshot(stack, createSnapshot("3"), { maxEntries: 2 }); + + expect(stack.past.map((snapshot) => snapshot.selectedZoomId)).toEqual(["1", "2"]); + }); + + it("resets all history stacks", () => { + const stack = createEditorHistoryStack(); + + recordEditorHistorySnapshot(stack, createSnapshot("first")); + recordEditorHistorySnapshot(stack, createSnapshot("second")); + undoEditorHistoryStack(stack, createSnapshot("second")); + resetEditorHistoryStack(stack); + + expect(stack).toEqual(createEditorHistoryStack()); + }); + + it("clones snapshots before storing them", () => { + const snapshot = createSnapshot("first"); + const cloned = cloneEditorHistorySnapshot(snapshot); + + cloned.selectedZoomId = "changed"; + + expect(snapshot.selectedZoomId).toBe("first"); + expect(areEditorHistorySnapshotsEqual(snapshot, createSnapshot("first"))).toBe(true); + }); +}); diff --git a/src/components/video-editor/editorHistory.ts b/src/components/video-editor/editorHistory.ts new file mode 100644 index 000000000..43b7398b8 --- /dev/null +++ b/src/components/video-editor/editorHistory.ts @@ -0,0 +1,161 @@ +import type { + AnnotationRegion, + AudioRegion, + CaptionCue, + ClipRegion, + SpeedRegion, + ZoomRegion, +} from "./types"; + +export type EditorHistorySnapshot = { + zoomRegions: ZoomRegion[]; + clipRegions: ClipRegion[]; + speedRegions: SpeedRegion[]; + annotationRegions: AnnotationRegion[]; + audioRegions: AudioRegion[]; + autoCaptions: CaptionCue[]; + selectedZoomId: string | null; + selectedClipId: string | null; + selectedAnnotationId: string | null; + selectedAudioId: string | null; +}; + +export type EditorHistoryStack = { + past: EditorHistorySnapshot[]; + current: EditorHistorySnapshot | null; + future: EditorHistorySnapshot[]; +}; + +export type EditorHistoryRecordResult = "initialized" | "applied" | "recorded" | "unchanged"; + +export const MAX_EDITOR_HISTORY_ENTRIES = 100; + +export function createEditorHistoryStack(): EditorHistoryStack { + return { + past: [], + current: null, + future: [], + }; +} + +export function resetEditorHistoryStack(stack: EditorHistoryStack): void { + stack.past = []; + stack.current = null; + stack.future = []; +} + +export function cloneEditorHistorySnapshot( + snapshot: EditorHistorySnapshot, +): EditorHistorySnapshot { + return globalThis.structuredClone(snapshot); +} + +function isComparableObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function areDeepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + + return left.every((value, index) => areDeepEqual(value, right[index])); + } + + if (!isComparableObject(left) || !isComparableObject(right)) { + return false; + } + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) { + return false; + } + + return leftKeys.every((key) => key in right && areDeepEqual(left[key], right[key])); +} + +export function areEditorHistorySnapshotsEqual( + left: EditorHistorySnapshot, + right: EditorHistorySnapshot, +): boolean { + return areDeepEqual(left, right); +} + +export function recordEditorHistorySnapshot( + stack: EditorHistoryStack, + snapshot: EditorHistorySnapshot, + options: { + applyingHistory?: boolean; + maxEntries?: number; + } = {}, +): EditorHistoryRecordResult { + const clonedSnapshot = cloneEditorHistorySnapshot(snapshot); + + if (!stack.current) { + stack.current = clonedSnapshot; + return "initialized"; + } + + if (options.applyingHistory) { + stack.current = clonedSnapshot; + return "applied"; + } + + if (areEditorHistorySnapshotsEqual(stack.current, snapshot)) { + return "unchanged"; + } + + stack.past.push(cloneEditorHistorySnapshot(stack.current)); + const maxEntries = options.maxEntries ?? MAX_EDITOR_HISTORY_ENTRIES; + if (stack.past.length > maxEntries) { + stack.past.shift(); + } + + stack.current = clonedSnapshot; + stack.future = []; + return "recorded"; +} + +export function undoEditorHistoryStack( + stack: EditorHistoryStack, + fallbackCurrent: EditorHistorySnapshot, +): EditorHistorySnapshot | null { + if (stack.past.length === 0) { + return null; + } + + const current = stack.current ?? cloneEditorHistorySnapshot(fallbackCurrent); + const previous = stack.past.pop(); + if (!previous) { + return null; + } + + stack.future.push(cloneEditorHistorySnapshot(current)); + stack.current = cloneEditorHistorySnapshot(previous); + return cloneEditorHistorySnapshot(previous); +} + +export function redoEditorHistoryStack( + stack: EditorHistoryStack, + fallbackCurrent: EditorHistorySnapshot, +): EditorHistorySnapshot | null { + if (stack.future.length === 0) { + return null; + } + + const current = stack.current ?? cloneEditorHistorySnapshot(fallbackCurrent); + const next = stack.future.pop(); + if (!next) { + return null; + } + + stack.past.push(cloneEditorHistorySnapshot(current)); + stack.current = cloneEditorHistorySnapshot(next); + return cloneEditorHistorySnapshot(next); +}