mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
Merge pull request #544 from webadderallorg/refactor/editor-history-model-slice
refactor(editor): extract history state model
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user