Add autosave functionality and update project package handling

This commit is contained in:
2026-04-10 12:23:11 +02:00
parent 72ca505a61
commit 65bf168d3a
2 changed files with 87 additions and 7 deletions

View File

@@ -201,7 +201,13 @@ import type { FirstPersonTelemetry } from "../runtime-three/navigation-controlle
import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system";
import { buildRuntimeSceneFromDocument, type RuntimeNavigationMode, type RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build";
import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation";
import { EditorAutosaveController } from "../serialization/editor-autosave";
import { Panel } from "../shared-ui/Panel";
import {
loadProjectPackage,
PROJECT_PACKAGE_FILE_EXTENSION,
saveProjectPackage
} from "../serialization/project-package";
import { HierarchicalMenu, type HierarchicalMenuItem, type HierarchicalMenuPosition } from "../shared-ui/HierarchicalMenu";
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
import { ViewportPanel } from "../viewport-three/ViewportPanel";
@@ -942,6 +948,16 @@ function formatBoxVolumeRenderPathLabel(path: BoxVolumeRenderPath): string {
}
}
function createProjectDownloadName(documentName: string): string {
const slug = documentName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "");
return `${slug.length > 0 ? slug : "scene"}${PROJECT_PACKAGE_FILE_EXTENSION}`;
}
export function App({ store, initialStatusMessage }: AppProps) {
const editorState = useEditorStoreState(store);
const brushList = Object.values(editorState.document.brushes);
@@ -1127,7 +1143,7 @@ export function App({ store, initialStatusMessage }: AppProps) {
kind: "none"
}
});
const importInputRef = useRef<HTMLInputElement | null>(null);
const importProjectInputRef = useRef<HTMLInputElement | null>(null);
const importModelInputRef = useRef<HTMLInputElement | null>(null);
const importBackgroundImageInputRef = useRef<HTMLInputElement | null>(null);
const importAudioInputRef = useRef<HTMLInputElement | null>(null);
@@ -1135,6 +1151,8 @@ export function App({ store, initialStatusMessage }: AppProps) {
const loadedModelAssetsRef = useRef<Record<string, LoadedModelAsset>>({});
const loadedImageAssetsRef = useRef<Record<string, LoadedImageAsset>>({});
const loadedAudioAssetsRef = useRef<Record<string, LoadedAudioAsset>>({});
const autosaveControllerRef = useRef<EditorAutosaveController | null>(null);
const lastAutosaveErrorRef = useRef<string | null>(null);
const viewportQuadSplitRef = useRef(editorState.viewportQuadSplit);
const lastPointerPositionRef = useRef<HierarchicalMenuPosition>({
x: Math.round(window.innerWidth * 0.5),
@@ -1394,6 +1412,68 @@ export function App({ store, initialStatusMessage }: AppProps) {
};
}, []);
useEffect(() => {
autosaveControllerRef.current = new EditorAutosaveController({
saveDraft: () => store.saveDraft(),
onComplete: (result) => {
if (result.status === "error") {
if (lastAutosaveErrorRef.current !== result.message) {
lastAutosaveErrorRef.current = result.message;
setStatusMessage(result.message);
}
return;
}
lastAutosaveErrorRef.current = null;
}
});
return () => {
autosaveControllerRef.current?.dispose();
autosaveControllerRef.current = null;
};
}, [store]);
useEffect(() => {
if (!editorState.storageAvailable) {
return;
}
autosaveControllerRef.current?.schedule();
}, [
editorState.activeViewportPanelId,
editorState.document,
editorState.storageAvailable,
editorState.viewportLayoutMode,
editorState.viewportPanels,
editorState.viewportQuadSplit
]);
useEffect(() => {
if (!editorState.storageAvailable) {
return;
}
const flushAutosave = () => {
autosaveControllerRef.current?.flush();
};
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
flushAutosave();
}
};
window.addEventListener("beforeunload", flushAutosave);
window.addEventListener("pagehide", flushAutosave);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("beforeunload", flushAutosave);
window.removeEventListener("pagehide", flushAutosave);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [editorState.storageAvailable]);
useEffect(() => {
if (!projectAssetStorageReady) {
return;

View File

@@ -206,12 +206,12 @@ export function saveSceneDocumentDraft(
return {
status: "saved",
message: "Local draft saved."
message: "Autosave updated."
};
} catch (error) {
return {
status: "error",
message: formatStorageDiagnostic("Local draft could not be saved.", error)
message: formatStorageDiagnostic("Autosave could not be saved.", error)
};
}
}
@@ -226,7 +226,7 @@ export function loadSceneDocumentDraft(
if (rawDocument === null) {
return {
status: "missing",
message: "No local draft was found."
message: "No autosave was found."
};
}
@@ -237,7 +237,7 @@ export function loadSceneDocumentDraft(
status: "loaded",
document: parseSceneDocumentJson(JSON.stringify(parsedDraft.document)),
viewportLayoutState: parseViewportLayoutState(parsedDraft.viewportLayoutState ?? null),
message: "Local draft loaded."
message: "Recovered latest autosave."
};
}
@@ -245,12 +245,12 @@ export function loadSceneDocumentDraft(
status: "loaded",
document: parseSceneDocumentJson(rawDocument),
viewportLayoutState: null,
message: "Local draft loaded."
message: "Recovered latest autosave."
};
} catch (error) {
return {
status: "error",
message: formatStorageDiagnostic("Stored local draft could not be loaded.", error)
message: formatStorageDiagnostic("Stored autosave could not be loaded.", error)
};
}
}