Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 25 additions & 62 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -769,9 +764,7 @@ export default function VideoEditor() {
const exporterRef = useRef<CancelableExporter | null>(null);
const autoSuggestedVideoPathRef = useRef<string | null>(null);
const pendingFreshRecordingAutoZoomPathRef = useRef<string | null>(null);
const historyPastRef = useRef<EditorHistorySnapshot[]>([]);
const historyFutureRef = useRef<EditorHistorySnapshot[]>([]);
const historyCurrentRef = useRef<EditorHistorySnapshot | null>(null);
const editorHistoryRef = useRef(createEditorHistoryStack());
const applyingHistoryRef = useRef(false);
const pendingExportSaveRef = useRef<PendingExportSave | null>(null);
const pendingTelemetryRetryTimeoutRef = useRef<number | null>(null);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -2169,9 +2148,7 @@ export default function VideoEditor() {
0,
) + 1;

historyPastRef.current = [];
historyFutureRef.current = [];
historyCurrentRef.current = null;
resetEditorHistoryStack(editorHistoryRef.current);
applyingHistoryRef.current = false;
syncHistoryButtons();

Expand Down Expand Up @@ -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(
() =>
Expand Down
128 changes: 128 additions & 0 deletions src/components/video-editor/editorHistory.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading