Files
flatrender/src/hooks/useImageProjectPersistence.ts
T

141 lines
4.3 KiB
TypeScript
Raw Normal View History

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { fetchProject, patchProjectSceneData } from "@/lib/project-api";
import { isDevProjectId } from "@/lib/project-ids";
import { isImageSceneDataEmpty } from "@/lib/image-scene-data";
import { useImageEditorStore } from "@/lib/image-editor-store";
import {
PROJECT_SAVE_DEBOUNCE_MS,
PROJECT_SAVED_DISPLAY_MS,
type ProjectSaveStatus,
} from "@/lib/project-save-status";
export interface UseImageProjectPersistenceResult {
projectName: string;
setProjectName: (name: string) => void;
saveStatus: ProjectSaveStatus;
retrySave: () => void;
}
export function useImageProjectPersistence(
projectId: string | undefined
): UseImageProjectPersistenceResult {
const layers = useImageEditorStore((state) => state.layers);
const canvasWidth = useImageEditorStore((state) => state.canvasWidth);
const canvasHeight = useImageEditorStore((state) => state.canvasHeight);
const adjustments = useImageEditorStore((state) => state.adjustments);
const activeFilterPreset = useImageEditorStore(
(state) => state.activeFilterPreset
);
const hydrateFromSceneData = useImageEditorStore(
(state) => state.hydrateFromSceneData
);
const getSceneDataForSave = useImageEditorStore(
(state) => state.getSceneDataForSave
);
const [projectName, setProjectName] = useState("Untitled image");
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("idle");
const skipSaveRef = useRef(true);
const lastSavedPayloadRef = useRef<string | null>(null);
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const persistPayload = useMemo(
() => JSON.stringify(getSceneDataForSave()),
[layers, canvasWidth, canvasHeight, adjustments, activeFilterPreset]
);
const debouncedPayload = useDebouncedValue(
persistPayload,
PROJECT_SAVE_DEBOUNCE_MS
);
const clearSavedTimer = useCallback(() => {
if (savedTimerRef.current) {
clearTimeout(savedTimerRef.current);
savedTimerRef.current = null;
}
}, []);
const performSave = useCallback(
async (payloadJson: string) => {
if (!projectId || isDevProjectId(projectId)) return;
setSaveStatus("saving");
try {
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
await patchProjectSceneData(projectId, scene_data);
lastSavedPayloadRef.current = payloadJson;
setSaveStatus("saved");
clearSavedTimer();
savedTimerRef.current = setTimeout(() => {
setSaveStatus((current) => (current === "saved" ? "idle" : current));
}, PROJECT_SAVED_DISPLAY_MS);
} catch {
setSaveStatus("error");
}
},
[projectId, clearSavedTimer]
);
const retrySave = useCallback(() => {
void performSave(persistPayload);
}, [performSave, persistPayload]);
useEffect(() => {
if (!projectId || isDevProjectId(projectId)) return;
let cancelled = false;
skipSaveRef.current = true;
void (async () => {
try {
const { project } = await fetchProject(projectId);
if (cancelled) return;
setProjectName(project.name);
if (!isImageSceneDataEmpty(project.scene_data)) {
hydrateFromSceneData(project.scene_data);
}
lastSavedPayloadRef.current = JSON.stringify(
useImageEditorStore.getState().getSceneDataForSave()
);
} catch {
if (!cancelled) {
setSaveStatus("error");
}
} finally {
if (!cancelled) {
skipSaveRef.current = false;
}
}
})();
return () => {
cancelled = true;
};
}, [projectId, hydrateFromSceneData]);
useEffect(() => {
if (!projectId || isDevProjectId(projectId) || skipSaveRef.current) return;
if (persistPayload === lastSavedPayloadRef.current) return;
setSaveStatus("pending");
}, [projectId, persistPayload]);
useEffect(() => {
if (!projectId || isDevProjectId(projectId) || skipSaveRef.current) return;
if (debouncedPayload === lastSavedPayloadRef.current) return;
void performSave(debouncedPayload);
}, [projectId, debouncedPayload, performSave]);
useEffect(() => clearSavedTimer, [clearSavedTimer]);
return { projectName, setProjectName, saveStatus, retrySave };
}