refactor(editor): extract project dirty state

This commit is contained in:
wiiiii123 committed 2026-05-20 23:33:34 +07:00
1 parent fd2879af04
commit 0b577fb84d
3 files changed
+122 -48

No files matched your search

+2 -48
View File
@@ -147,6 +147,7 @@ import {
toFileUrl,
validateProjectData,
} from "./projectPersistence";
import { hasUnsavedProjectChanges } from "./projectDirtyState";
import { SettingsPanel } from "./SettingsPanel";
import { useVideoEditorAudio } from "./audio/useVideoEditorAudio";
import {
@@ -463,48 +464,6 @@ function getDevOpenRecordingConfig(search: string): DevOpenRecordingConfig {
};
}
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;
}
for (let index = 0; index < left.length; index += 1) {
if (!areDeepEqual(left[index], right[index])) {
return false;
}
}
return true;
}
if (!isComparableObject(left) || !isComparableObject(right)) {
return false;
}
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (const key of leftKeys) {
if (!(key in right) || !areDeepEqual(left[key], right[key])) {
return false;
}
}
return true;
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
@@ -2276,12 +2235,7 @@ export default function VideoEditor() {
}, [buildHistorySnapshot, syncHistoryButtons]);
const hasUnsavedChanges = useMemo(
() =>
Boolean(
currentProjectSnapshot &&
(!lastSavedSnapshot ||
!areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)),
),
() => hasUnsavedProjectChanges(currentProjectSnapshot, lastSavedSnapshot),
[currentProjectSnapshot, lastSavedSnapshot],
);
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { hasUnsavedProjectChanges } from "./projectDirtyState";
import type { EditorProjectData } from "./projectPersistence";
function createProjectData(overrides: Partial<EditorProjectData> = {}): EditorProjectData {
return {
version: 1,
projectId: "project-1",
videoPath: "file:///recording.mp4",
editor: {
wallpaper: "#ffffff",
zoomRegions: [],
trimRegions: [],
clipRegions: [],
speedRegions: [],
annotationRegions: [],
audioRegions: [],
autoCaptions: [],
},
...overrides,
};
}
describe("hasUnsavedProjectChanges", () => {
it("does not report changes before a project snapshot exists", () => {
expect(hasUnsavedProjectChanges(null, createProjectData())).toBe(false);
});
it("reports changes when there is no saved baseline", () => {
expect(hasUnsavedProjectChanges(createProjectData(), null)).toBe(true);
});
it("treats deeply equal snapshots as unchanged", () => {
expect(hasUnsavedProjectChanges(createProjectData(), createProjectData())).toBe(false);
});
it("detects nested editor changes", () => {
const current = createProjectData({
editor: {
...createProjectData().editor,
zoomRegions: [
{
id: "zoom-1",
startMs: 100,
endMs: 500,
focus: { cx: 0.4, cy: 0.6 },
depth: 2,
},
],
},
});
expect(hasUnsavedProjectChanges(current, createProjectData())).toBe(true);
});
it("detects array length changes", () => {
const current = createProjectData({
editor: {
...createProjectData().editor,
autoCaptions: [{ id: "caption-1", startMs: 0, endMs: 1_000, text: "hello" }],
},
});
expect(hasUnsavedProjectChanges(current, createProjectData())).toBe(true);
});
});
@@ -0,0 +1,53 @@
import type { EditorProjectData } from "./projectPersistence";
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;
}
for (let index = 0; index < left.length; index += 1) {
if (!areDeepEqual(left[index], right[index])) {
return false;
}
}
return true;
}
if (!isComparableObject(left) || !isComparableObject(right)) {
return false;
}
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (const key of leftKeys) {
if (!(key in right) || !areDeepEqual(left[key], right[key])) {
return false;
}
}
return true;
}
export function hasUnsavedProjectChanges(
currentProjectSnapshot: EditorProjectData | null,
lastSavedSnapshot: EditorProjectData | null,
): boolean {
return Boolean(
currentProjectSnapshot &&
(!lastSavedSnapshot || !areDeepEqual(currentProjectSnapshot, lastSavedSnapshot)),
);
}