/** * Hooks for project model (scene) loading and auto-saving */ 'use client' import { useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef } from 'react' import useEditor from '@/store/use-editor' import { useProjectStore } from '../projects/store' import { getProjectModel, saveProjectModel, type SceneGraph } from './actions' /** Debounce interval for cloud auto-save (ms). */ const AUTOSAVE_DEBOUNCE_MS = 1_000 function syncEditorSelectionFromCurrentScene() { const sceneNodes = useScene.getState().nodes as Record const sceneRootIds = useScene.getState().rootNodeIds const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null const resolve = (child: any) => typeof child === 'string' ? sceneNodes[child] : child const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building') const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level') if (firstBuilding && firstLevel) { useViewer.getState().setSelection({ buildingId: firstBuilding.id, levelId: firstLevel.id, selectedIds: [], zoneId: null, }) useEditor.getState().setPhase('structure') useEditor.getState().setStructureLayer('elements') // Auto-select the wall tool if the level is empty (e.g., brand new project) if (!firstLevel.children || firstLevel.children.length === 0) { useEditor.getState().setMode('build') useEditor.getState().setTool('wall') } } else { useEditor.getState().setPhase('site') useViewer.getState().setSelection({ buildingId: null, levelId: null, selectedIds: [], zoneId: null, }) } } export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { if (sceneGraph?.nodes && sceneGraph.rootNodeIds) { const { nodes, rootNodeIds } = sceneGraph useScene.getState().setScene(nodes, rootNodeIds) } else { useScene.getState().clearScene() } syncEditorSelectionFromCurrentScene() } /** * Load the scene when a project becomes active. * Saves changes automatically with debouncing. * * ⚠️ This hook must be mounted in exactly ONE component (the Editor). * Mounting it in multiple components causes duplicate save calls. */ export function useProjectScene() { // Subscribe to project store const activeProject = useProjectStore((state) => state.activeProject) const isLoadingProject = useProjectStore((state) => state.isLoading) const isVersionPreviewMode = useProjectStore((state) => state.isVersionPreviewMode) const setAutosaveStatus = useProjectStore((state) => state.setAutosaveStatus) const lastProjectIdRef = useRef(null) const saveTimeoutRef = useRef(undefined) const isSavingRef = useRef(false) const currentProjectIdRef = useRef(null) // Track whether the scene was just loaded from the server so we can skip // the first store update (which is the load itself, not a user edit). const isLoadingSceneRef = useRef(false) // Track whether there are pending changes that arrived while a save was // in-flight so we can coalesce them into one follow-up save. const pendingSaveRef = useRef(false) const executeSaveRef = useRef<(() => Promise) | null>(null) // Extract project ID for dependency tracking const projectId = activeProject?.id ?? null // Load scene when active project changes useEffect(() => { if (isLoadingProject) { return } if (!projectId) { useProjectStore.getState().setIsVersionPreviewMode(false) setAutosaveStatus('idle') return } // Skip if same project if (lastProjectIdRef.current === projectId) { return } lastProjectIdRef.current = projectId // Load the project's scene async function loadScene() { // Suppress auto-save for the store update caused by setScene/clearScene isLoadingSceneRef.current = true useProjectStore.getState().setIsVersionPreviewMode(false) setAutosaveStatus('idle') useProjectStore.getState().setIsSceneLoading(true) try { const result = await getProjectModel(projectId || '') applySceneGraphToEditor(result.success ? result.data?.model?.scene_graph ?? null : null) } catch (error) { // Fall back to an empty scene while preserving editor selection sync. applySceneGraphToEditor(null) } finally { useProjectStore.getState().setIsSceneLoading(false) } // Allow auto-save again after a tick (let the store update propagate) requestAnimationFrame(() => { isLoadingSceneRef.current = false setAutosaveStatus('saved') }) } loadScene() }, [projectId, isLoadingProject, setAutosaveStatus]) // Track whether there are unsaved changes (dirty flag for flush-on-exit). const hasDirtyChangesRef = useRef(false) // Auto-save scene changes with debouncing useEffect(() => { if (!projectId) { currentProjectIdRef.current = null executeSaveRef.current = null setAutosaveStatus('idle') return } currentProjectIdRef.current = projectId // Use JSON stringification to detect node changes, not just count let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes) const unsubscribe = useScene.subscribe((state) => { // Skip saves triggered by loading a scene from the server if (isLoadingSceneRef.current) { // Update the snapshot so the next real edit is compared correctly lastNodesSnapshot = JSON.stringify(state.nodes) return } if (useProjectStore.getState().isVersionPreviewMode) { // Do not autosave preview scenes. Keep snapshot aligned so returning to // latest does not schedule a false-positive save. setAutosaveStatus('paused') lastNodesSnapshot = JSON.stringify(state.nodes) return } const currentNodesSnapshot = JSON.stringify(state.nodes) // Only trigger save if nodes actually changed if (currentNodesSnapshot === lastNodesSnapshot) { return } lastNodesSnapshot = currentNodesSnapshot hasDirtyChangesRef.current = true setAutosaveStatus('pending') // If a save is in-flight, mark pending so we do one follow-up save // instead of queuing unlimited concurrent saves. if (isSavingRef.current) { pendingSaveRef.current = true return } // Clear existing timeout (debounce reset) if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) } // Debounce save saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = undefined executeSave() }, AUTOSAVE_DEBOUNCE_MS) }) async function executeSave() { const currentProjectId = currentProjectIdRef.current if (!currentProjectId) return if (isLoadingSceneRef.current || useProjectStore.getState().isVersionPreviewMode) { // Save is paused while previewing older versions. pendingSaveRef.current = true setAutosaveStatus('paused') return } const { nodes, rootNodeIds } = useScene.getState() const sceneGraph = { nodes, rootNodeIds } isSavingRef.current = true pendingSaveRef.current = false setAutosaveStatus('saving') try { await saveProjectModel(currentProjectId, sceneGraph) hasDirtyChangesRef.current = false setAutosaveStatus('saved') } finally { isSavingRef.current = false // If changes arrived while we were saving, schedule one more save if (pendingSaveRef.current) { pendingSaveRef.current = false setAutosaveStatus('pending') saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = undefined executeSave() }, AUTOSAVE_DEBOUNCE_MS) } } } executeSaveRef.current = executeSave // Flush unsaved changes when the user leaves the page / closes the tab. // Uses sendBeacon via keepalive fetch so the request survives page unload. function flushOnExit() { if (!hasDirtyChangesRef.current || !currentProjectIdRef.current) return const { nodes, rootNodeIds } = useScene.getState() const sceneGraph = { nodes, rootNodeIds } // Best-effort fire-and-forget save. We use the server action directly // (it's just a POST to a Next.js endpoint). If the browser kills it, // localStorage still has the data and will sync on next load. saveProjectModel(currentProjectIdRef.current, sceneGraph).catch(() => { // Swallow — nothing we can do during unload }) hasDirtyChangesRef.current = false } window.addEventListener('beforeunload', flushOnExit) return () => { executeSaveRef.current = null window.removeEventListener('beforeunload', flushOnExit) if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) } // Flush on unmount (e.g. navigating away within the SPA) flushOnExit() unsubscribe() } }, [projectId, setAutosaveStatus]) useEffect(() => { if (!projectId) return if (isVersionPreviewMode) { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) saveTimeoutRef.current = undefined } if (hasDirtyChangesRef.current) { pendingSaveRef.current = true } setAutosaveStatus('paused') return } if (isSavingRef.current) { return } if (hasDirtyChangesRef.current) { setAutosaveStatus('pending') if (!saveTimeoutRef.current) { saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = undefined executeSaveRef.current?.() }, AUTOSAVE_DEBOUNCE_MS) } return } setAutosaveStatus('saved') }, [isVersionPreviewMode, projectId, setAutosaveStatus]) }