Add tests and enhance local draft storage handling

This commit is contained in:
2026-03-31 01:50:29 +02:00
parent e23bcac025
commit 4df1773c95
7 changed files with 296 additions and 43 deletions

View File

@@ -9,6 +9,7 @@ import { useEditorStoreState } from "./use-editor-store";
interface AppProps {
store: EditorStore;
initialStatusMessage?: string;
}
function describeSelection(selectionKind: EditorSelection["kind"]): string {
@@ -34,10 +35,10 @@ function getErrorMessage(error: unknown): string {
return "An unexpected error occurred.";
}
export function App({ store }: AppProps) {
export function App({ store, initialStatusMessage }: AppProps) {
const editorState = useEditorStoreState(store);
const [sceneNameDraft, setSceneNameDraft] = useState(editorState.document.name);
const [statusMessage, setStatusMessage] = useState("Viewport shell ready.");
const [statusMessage, setStatusMessage] = useState(initialStatusMessage ?? "Viewport shell ready.");
const importInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
@@ -57,17 +58,13 @@ export function App({ store }: AppProps) {
};
const handleSaveDraft = () => {
const didSave = store.saveDraft();
setStatusMessage(didSave ? "Local draft saved." : "Local draft storage is unavailable.");
const result = store.saveDraft();
setStatusMessage(result.message);
};
const handleLoadDraft = () => {
try {
const didLoad = store.loadDraft();
setStatusMessage(didLoad ? "Local draft loaded." : "No local draft was found.");
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
const result = store.loadDraft();
setStatusMessage(result.message);
};
const handleExportJson = () => {

View File

@@ -5,8 +5,10 @@ import type { ToolMode } from "../core/tool-mode";
import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document";
import {
DEFAULT_SCENE_DRAFT_STORAGE_KEY,
type LoadSceneDocumentDraftResult,
loadSceneDocumentDraft,
type KeyValueStorage,
type SaveSceneDocumentDraftResult,
saveSceneDocumentDraft
} from "../serialization/local-draft-storage";
import { parseSceneDocumentJson, serializeSceneDocument } from "../serialization/scene-document-json";
@@ -28,6 +30,9 @@ interface EditorStoreOptions {
type EditorStoreListener = () => void;
export type EditorDraftSaveResult = SaveSceneDocumentDraftResult;
export type EditorDraftLoadResult = LoadSceneDocumentDraftResult;
export class EditorStore {
private document: SceneDocument;
private selection: EditorSelection = { kind: "none" };
@@ -128,28 +133,33 @@ export class EditorStore {
this.emit();
}
saveDraft(): boolean {
saveDraft(): EditorDraftSaveResult {
if (this.storage === null) {
return false;
return {
status: "error",
message: "Browser local storage is unavailable."
};
}
saveSceneDocumentDraft(this.storage, this.document, this.storageKey);
return true;
return saveSceneDocumentDraft(this.storage, this.document, this.storageKey);
}
loadDraft(): boolean {
loadDraft(): EditorDraftLoadResult {
if (this.storage === null) {
return false;
return {
status: "error",
message: "Browser local storage is unavailable."
};
}
const document = loadSceneDocumentDraft(this.storage, this.storageKey);
const draftResult = loadSceneDocumentDraft(this.storage, this.storageKey);
if (document === null) {
return false;
if (draftResult.status !== "loaded") {
return draftResult;
}
this.replaceDocument(document);
return true;
this.replaceDocument(draftResult.document);
return draftResult;
}
exportDocumentJson(): string {

View File

@@ -4,7 +4,7 @@ import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import "./app/app.css";
import { createEditorStore } from "./app/editor-store";
import { getBrowserStorage, loadOrCreateSceneDocument } from "./serialization/local-draft-storage";
import { getBrowserStorageAccess, loadOrCreateSceneDocument } from "./serialization/local-draft-storage";
const rootElement = document.getElementById("root");
@@ -12,14 +12,16 @@ if (rootElement === null) {
throw new Error("Expected #root element to bootstrap the editor.");
}
const storage = getBrowserStorage();
const storageAccess = getBrowserStorageAccess();
const bootstrapResult = loadOrCreateSceneDocument(storageAccess.storage);
const editorStore = createEditorStore({
initialDocument: loadOrCreateSceneDocument(storage),
storage
initialDocument: bootstrapResult.document,
storage: storageAccess.storage
});
const initialStatusMessage = [storageAccess.diagnostic, bootstrapResult.diagnostic].filter(Boolean).join(" ") || undefined;
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App store={editorStore} />
<App store={editorStore} initialStatusMessage={initialStatusMessage} />
</React.StrictMode>
);

View File

@@ -8,38 +8,139 @@ export interface KeyValueStorage {
removeItem(key: string): void;
}
export interface BrowserStorageAccessResult {
storage: KeyValueStorage | null;
diagnostic: string | null;
}
export type SaveSceneDocumentDraftResult =
| { status: "saved"; message: string }
| { status: "error"; message: string };
export type LoadSceneDocumentDraftResult =
| { status: "loaded"; document: SceneDocument; message: string }
| { status: "missing"; message: string }
| { status: "error"; message: string };
export interface LoadOrCreateSceneDocumentResult {
document: SceneDocument;
diagnostic: string | null;
}
export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft";
export function getBrowserStorage(): KeyValueStorage | null {
if (typeof window === "undefined") {
return null;
function getErrorDetail(error: unknown): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message.trim();
}
return window.localStorage;
return "Unknown error.";
}
function formatStorageDiagnostic(prefix: string, error: unknown): string {
return `${prefix} ${getErrorDetail(error)}`;
}
export function getBrowserStorageAccess(): BrowserStorageAccessResult {
if (typeof window === "undefined") {
return {
storage: null,
diagnostic: null
};
}
try {
return {
storage: window.localStorage,
diagnostic: null
};
} catch (error) {
return {
storage: null,
diagnostic: formatStorageDiagnostic("Browser local storage is unavailable.", error)
};
}
}
export function getBrowserStorage(): KeyValueStorage | null {
return getBrowserStorageAccess().storage;
}
export function saveSceneDocumentDraft(
storage: KeyValueStorage,
document: SceneDocument,
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
) {
storage.setItem(key, serializeSceneDocument(document));
}
): SaveSceneDocumentDraftResult {
try {
storage.setItem(key, serializeSceneDocument(document));
export function loadSceneDocumentDraft(storage: KeyValueStorage, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument | null {
const rawDocument = storage.getItem(key);
if (rawDocument === null) {
return null;
return {
status: "saved",
message: "Local draft saved."
};
} catch (error) {
return {
status: "error",
message: formatStorageDiagnostic("Local draft could not be saved.", error)
};
}
return parseSceneDocumentJson(rawDocument);
}
export function loadOrCreateSceneDocument(storage: KeyValueStorage | null, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY): SceneDocument {
export function loadSceneDocumentDraft(
storage: KeyValueStorage,
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
): LoadSceneDocumentDraftResult {
try {
const rawDocument = storage.getItem(key);
if (rawDocument === null) {
return {
status: "missing",
message: "No local draft was found."
};
}
return {
status: "loaded",
document: parseSceneDocumentJson(rawDocument),
message: "Local draft loaded."
};
} catch (error) {
return {
status: "error",
message: formatStorageDiagnostic("Stored local draft could not be loaded.", error)
};
}
}
export function loadOrCreateSceneDocument(
storage: KeyValueStorage | null,
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
): LoadOrCreateSceneDocumentResult {
if (storage === null) {
return createEmptySceneDocument();
return {
document: createEmptySceneDocument(),
diagnostic: null
};
}
return loadSceneDocumentDraft(storage, key) ?? createEmptySceneDocument();
const draftResult = loadSceneDocumentDraft(storage, key);
switch (draftResult.status) {
case "loaded":
return {
document: draftResult.document,
diagnostic: null
};
case "missing":
return {
document: createEmptySceneDocument(),
diagnostic: null
};
case "error":
return {
document: createEmptySceneDocument(),
diagnostic: `${draftResult.message} Starting with a fresh empty document.`
};
}
}