229 lines
7.6 KiB
TypeScript
229 lines
7.6 KiB
TypeScript
'use client'
|
|
|
|
import { useScene } from '@pascal-app/core'
|
|
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
|
import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
|
|
|
|
const AUTOSAVE_DEBOUNCE_MS = 1000
|
|
|
|
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
|
|
|
interface UseAutoSaveOptions {
|
|
onSave?: (scene: SceneGraph, options?: { keepalive?: boolean }) => Promise<void>
|
|
onDirty?: () => void
|
|
onSaveStatusChange?: (status: SaveStatus) => void
|
|
isVersionPreviewMode?: boolean
|
|
}
|
|
|
|
/**
|
|
* Generic autosave hook. Subscribes to the scene store and debounces saves.
|
|
* Falls back to localStorage when no `onSave` is provided.
|
|
*
|
|
* ⚠️ Mount in exactly ONE component (the Editor).
|
|
*/
|
|
export function useAutoSave({
|
|
onSave,
|
|
onDirty,
|
|
onSaveStatusChange,
|
|
isVersionPreviewMode = false,
|
|
}: UseAutoSaveOptions): { isLoadingSceneRef: MutableRefObject<boolean> } {
|
|
const saveTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
|
|
const isSavingRef = useRef(false)
|
|
const isLoadingSceneRef = useRef(false)
|
|
const pendingSaveRef = useRef(false)
|
|
const executeSaveRef = useRef<(() => Promise<void>) | null>(null)
|
|
const hasDirtyChangesRef = useRef(false)
|
|
|
|
// Keep latest callback/value refs so the stable subscription always uses current values
|
|
const onSaveRef = useRef(onSave)
|
|
const onDirtyRef = useRef(onDirty)
|
|
const onSaveStatusChangeRef = useRef(onSaveStatusChange)
|
|
const isVersionPreviewModeRef = useRef(isVersionPreviewMode)
|
|
|
|
useEffect(() => {
|
|
onSaveRef.current = onSave
|
|
}, [onSave])
|
|
useEffect(() => {
|
|
onDirtyRef.current = onDirty
|
|
}, [onDirty])
|
|
useEffect(() => {
|
|
onSaveStatusChangeRef.current = onSaveStatusChange
|
|
}, [onSaveStatusChange])
|
|
useEffect(() => {
|
|
isVersionPreviewModeRef.current = isVersionPreviewMode
|
|
}, [isVersionPreviewMode])
|
|
|
|
const setSaveStatus = useCallback((status: SaveStatus) => {
|
|
onSaveStatusChangeRef.current?.(status)
|
|
}, [])
|
|
|
|
// Stable subscription to scene changes
|
|
useEffect(() => {
|
|
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
|
|
let lastNodeCount = Object.keys(useScene.getState().nodes).length
|
|
// Collections + scene materials are document-level state that persists with
|
|
// the graph but lives outside `nodes`. Track them by reference (zustand
|
|
// hands out a new object on every mutation) so a material edit or a
|
|
// collection change still triggers a save.
|
|
let lastCollectionsRef = useScene.getState().collections
|
|
let lastMaterialsRef = useScene.getState().materials
|
|
|
|
async function executeSave() {
|
|
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
|
|
pendingSaveRef.current = true
|
|
setSaveStatus('paused')
|
|
return
|
|
}
|
|
|
|
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
|
|
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
|
|
|
|
// Guard: refuse to autosave if the scene went from populated to nearly empty.
|
|
// This catches accidental full deletions before they're persisted.
|
|
const currentNodeCount = Object.keys(nodes).length
|
|
const STRUCTURAL_NODE_COUNT = 4 // site + building + levels (empty scene skeleton)
|
|
if (lastNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT) {
|
|
console.warn(
|
|
`[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
|
)
|
|
setSaveStatus('error')
|
|
return
|
|
}
|
|
lastNodeCount = currentNodeCount
|
|
|
|
isSavingRef.current = true
|
|
pendingSaveRef.current = false
|
|
setSaveStatus('saving')
|
|
|
|
try {
|
|
if (onSaveRef.current) {
|
|
await onSaveRef.current(sceneGraph)
|
|
} else {
|
|
saveSceneToLocalStorage(sceneGraph)
|
|
}
|
|
hasDirtyChangesRef.current = false
|
|
setSaveStatus('saved')
|
|
} catch {
|
|
setSaveStatus('error')
|
|
} finally {
|
|
isSavingRef.current = false
|
|
|
|
if (pendingSaveRef.current) {
|
|
pendingSaveRef.current = false
|
|
setSaveStatus('pending')
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
saveTimeoutRef.current = undefined
|
|
executeSave()
|
|
}, AUTOSAVE_DEBOUNCE_MS)
|
|
}
|
|
}
|
|
}
|
|
|
|
executeSaveRef.current = executeSave
|
|
|
|
const unsubscribe = useScene.subscribe((state) => {
|
|
if (isLoadingSceneRef.current) {
|
|
lastNodesSnapshot = JSON.stringify(state.nodes)
|
|
lastCollectionsRef = state.collections
|
|
lastMaterialsRef = state.materials
|
|
return
|
|
}
|
|
|
|
if (isVersionPreviewModeRef.current) {
|
|
setSaveStatus('paused')
|
|
lastNodesSnapshot = JSON.stringify(state.nodes)
|
|
lastCollectionsRef = state.collections
|
|
lastMaterialsRef = state.materials
|
|
return
|
|
}
|
|
|
|
const currentNodesSnapshot = JSON.stringify(state.nodes)
|
|
const changed =
|
|
currentNodesSnapshot !== lastNodesSnapshot ||
|
|
state.collections !== lastCollectionsRef ||
|
|
state.materials !== lastMaterialsRef
|
|
if (!changed) return
|
|
|
|
lastNodesSnapshot = currentNodesSnapshot
|
|
lastCollectionsRef = state.collections
|
|
lastMaterialsRef = state.materials
|
|
hasDirtyChangesRef.current = true
|
|
onDirtyRef.current?.()
|
|
setSaveStatus('pending')
|
|
|
|
if (isSavingRef.current) {
|
|
pendingSaveRef.current = true
|
|
return
|
|
}
|
|
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
saveTimeoutRef.current = undefined
|
|
executeSave()
|
|
}, AUTOSAVE_DEBOUNCE_MS)
|
|
})
|
|
|
|
// Flush any unsaved change while the page is going away. The network
|
|
// save MUST set `keepalive` — a normal fetch is cancelled by the browser
|
|
// the moment the page unloads, so a quick refresh right after an edit
|
|
// would otherwise drop the change entirely. `pagehide` fires in cases
|
|
// (mobile Safari, bfcache) where `beforeunload` does not.
|
|
function flushOnExit() {
|
|
if (!hasDirtyChangesRef.current) return
|
|
hasDirtyChangesRef.current = false
|
|
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
|
|
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
|
|
if (onSaveRef.current) {
|
|
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
|
|
} else {
|
|
saveSceneToLocalStorage(sceneGraph)
|
|
}
|
|
}
|
|
|
|
window.addEventListener('beforeunload', flushOnExit)
|
|
window.addEventListener('pagehide', flushOnExit)
|
|
|
|
return () => {
|
|
executeSaveRef.current = null
|
|
window.removeEventListener('beforeunload', flushOnExit)
|
|
window.removeEventListener('pagehide', flushOnExit)
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
flushOnExit()
|
|
unsubscribe()
|
|
}
|
|
}, [setSaveStatus])
|
|
|
|
// Handle version preview mode transitions
|
|
useEffect(() => {
|
|
if (isVersionPreviewMode) {
|
|
if (saveTimeoutRef.current) {
|
|
clearTimeout(saveTimeoutRef.current)
|
|
saveTimeoutRef.current = undefined
|
|
}
|
|
if (hasDirtyChangesRef.current) {
|
|
pendingSaveRef.current = true
|
|
}
|
|
setSaveStatus('paused')
|
|
return
|
|
}
|
|
|
|
if (isSavingRef.current) return
|
|
|
|
if (hasDirtyChangesRef.current) {
|
|
setSaveStatus('pending')
|
|
if (!saveTimeoutRef.current) {
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
saveTimeoutRef.current = undefined
|
|
executeSaveRef.current?.()
|
|
}, AUTOSAVE_DEBOUNCE_MS)
|
|
}
|
|
return
|
|
}
|
|
|
|
setSaveStatus('saved')
|
|
}, [isVersionPreviewMode, setSaveStatus])
|
|
|
|
return { isLoadingSceneRef }
|
|
}
|