From 075db64db107dbe88e45d9ce98d9961ba773fcda Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Wed, 20 May 2026 23:28:56 +0700 Subject: [PATCH] refactor(editor): extract project dirty state --- src/components/video-editor/VideoEditor.tsx | 50 +------------- .../video-editor/projectDirtyState.test.ts | 67 +++++++++++++++++++ .../video-editor/projectDirtyState.ts | 53 +++++++++++++++ 3 files changed, 122 insertions(+), 48 deletions(-) create mode 100644 src/components/video-editor/projectDirtyState.test.ts create mode 100644 src/components/video-editor/projectDirtyState.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 30195f00..72acdff0 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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 { - 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], ); diff --git a/src/components/video-editor/projectDirtyState.test.ts b/src/components/video-editor/projectDirtyState.test.ts new file mode 100644 index 00000000..4cfb5257 --- /dev/null +++ b/src/components/video-editor/projectDirtyState.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { hasUnsavedProjectChanges } from "./projectDirtyState"; +import type { EditorProjectData } from "./projectPersistence"; + +function createProjectData(overrides: Partial = {}): 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); + }); +}); diff --git a/src/components/video-editor/projectDirtyState.ts b/src/components/video-editor/projectDirtyState.ts new file mode 100644 index 00000000..78fcee84 --- /dev/null +++ b/src/components/video-editor/projectDirtyState.ts @@ -0,0 +1,53 @@ +import type { EditorProjectData } from "./projectPersistence"; + +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; + } + + 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)), + ); +}