Merge pull request #544 from webadderallorg/refactor/editor-history-model-slice

refactor(editor): extract history state model
This commit is contained in:
Phạm Thị Minh Hồng
2026-05-20 23:47:11 +07:00
committed by GitHub
3 changed files with 315 additions and 63 deletions
+26 -63
View File
@@ -125,6 +125,14 @@ import {
saveEditorPresets,
serializeEditorPresetSnapshot,
} from "./editorPreferences";
import {
createEditorHistoryStack,
type EditorHistorySnapshot,
recordEditorHistorySnapshot,
redoEditorHistoryStack,
resetEditorHistoryStack,
undoEditorHistoryStack,
} from "./editorHistory";
import ProjectBrowserDialog, { type ProjectLibraryEntry } from "./ProjectBrowserDialog";
import {
createProjectData,
@@ -205,19 +213,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
@@ -640,9 +635,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);
@@ -1384,15 +1377,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(
@@ -1841,7 +1830,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);
@@ -1873,34 +1862,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) => {
@@ -2040,9 +2019,7 @@ export default function VideoEditor() {
0,
) + 1;
historyPastRef.current = [];
historyFutureRef.current = [];
historyCurrentRef.current = null;
resetEditorHistoryStack(editorHistoryRef.current);
applyingHistoryRef.current = false;
syncHistoryButtons();
@@ -2156,32 +2133,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;
}
if (result !== "unchanged") {
syncHistoryButtons();
return;
}
if (areDeepEqual(historyCurrentRef.current, snapshot)) {
return;
}
historyPastRef.current.push(cloneSnapshot(historyCurrentRef.current));
if (historyPastRef.current.length > 100) {
historyPastRef.current.shift();
}
historyCurrentRef.current = cloneSnapshot(snapshot);
historyFutureRef.current = [];
syncHistoryButtons();
}, [buildHistorySnapshot, cloneSnapshot, syncHistoryButtons]);
}, [buildHistorySnapshot, syncHistoryButtons]);
const hasUnsavedChanges = useMemo(
() =>
@@ -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);
});
});
@@ -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<string, unknown> {
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);
}