From 84e3f4e0e5c68c17f3a3e69f0b0a08970607b990 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Fri, 3 Apr 2026 01:36:37 +0200 Subject: [PATCH] Update local draft storage to include viewport layout state --- src/serialization/local-draft-storage.ts | 149 ++++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/src/serialization/local-draft-storage.ts b/src/serialization/local-draft-storage.ts index 04e9de4e..0ec2adef 100644 --- a/src/serialization/local-draft-storage.ts +++ b/src/serialization/local-draft-storage.ts @@ -1,4 +1,12 @@ import { createEmptySceneDocument, type SceneDocument } from "../document/scene-document"; +import { + VIEWPORT_PANEL_IDS, + cloneViewportLayoutState, + createDefaultViewportLayoutState, + type ViewportLayoutMode, + type ViewportLayoutState, + type ViewportPanelId +} from "../viewport-three/viewport-layout"; import { parseSceneDocumentJson, serializeSceneDocument } from "./scene-document-json"; @@ -18,16 +26,24 @@ export type SaveSceneDocumentDraftResult = | { status: "error"; message: string }; export type LoadSceneDocumentDraftResult = - | { status: "loaded"; document: SceneDocument; message: string } + | { status: "loaded"; document: SceneDocument; viewportLayoutState: ViewportLayoutState | null; message: string } | { status: "missing"; message: string } | { status: "error"; message: string }; export interface LoadOrCreateSceneDocumentResult { document: SceneDocument; + viewportLayoutState: ViewportLayoutState | null; diagnostic: string | null; } export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft"; +const EDITOR_DRAFT_ENVELOPE_FORMAT = "webeditor3d.editor-draft.v1"; + +interface StoredEditorDraftEnvelope { + format: typeof EDITOR_DRAFT_ENVELOPE_FORMAT; + document: unknown; + viewportLayoutState?: unknown; +} function getErrorDetail(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { @@ -41,6 +57,111 @@ function formatStorageDiagnostic(prefix: string, error: unknown): string { return `${prefix} ${getErrorDetail(error)}`; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function parseViewportLayoutMode(value: unknown): ViewportLayoutMode | null { + return value === "single" || value === "quad" ? value : null; +} + +function parseViewportPanelId(value: unknown): ViewportPanelId | null { + return typeof value === "string" && (VIEWPORT_PANEL_IDS as readonly string[]).includes(value) ? (value as ViewportPanelId) : null; +} + +function parseViewportLayoutState(value: unknown): ViewportLayoutState | null { + if (!isRecord(value)) { + return null; + } + + const layoutMode = parseViewportLayoutMode(value.layoutMode); + const activePanelId = parseViewportPanelId(value.activePanelId); + const viewportQuadSplit = isRecord(value.viewportQuadSplit) ? value.viewportQuadSplit : null; + const panels = isRecord(value.panels) ? value.panels : null; + + if (layoutMode === null || activePanelId === null || viewportQuadSplit === null || panels === null) { + return null; + } + + if (!isFiniteNumber(viewportQuadSplit.x) || !isFiniteNumber(viewportQuadSplit.y)) { + return null; + } + + const defaultLayoutState = createDefaultViewportLayoutState(); + const nextLayoutState = cloneViewportLayoutState(defaultLayoutState); + + nextLayoutState.layoutMode = layoutMode; + nextLayoutState.activePanelId = activePanelId; + nextLayoutState.viewportQuadSplit = { + x: viewportQuadSplit.x, + y: viewportQuadSplit.y + }; + + for (const panelId of VIEWPORT_PANEL_IDS) { + const storedPanel = panels[panelId]; + + if (!isRecord(storedPanel)) { + return null; + } + + const storedViewMode = storedPanel.viewMode; + const storedDisplayMode = storedPanel.displayMode; + const storedCameraState = isRecord(storedPanel.cameraState) ? storedPanel.cameraState : null; + const storedPerspectiveOrbit = storedCameraState !== null && isRecord(storedCameraState.perspectiveOrbit) ? storedCameraState.perspectiveOrbit : null; + const storedTarget = storedCameraState !== null && isRecord(storedCameraState.target) ? storedCameraState.target : null; + + if ( + (storedViewMode !== "perspective" && storedViewMode !== "top" && storedViewMode !== "front" && storedViewMode !== "side") || + (storedDisplayMode !== "normal" && storedDisplayMode !== "authoring") || + storedCameraState === null || + storedPerspectiveOrbit === null || + storedTarget === null + ) { + return null; + } + + if ( + !isFiniteNumber(storedTarget.x) || + !isFiniteNumber(storedTarget.y) || + !isFiniteNumber(storedTarget.z) || + !isFiniteNumber(storedPerspectiveOrbit.radius) || + !isFiniteNumber(storedPerspectiveOrbit.theta) || + !isFiniteNumber(storedPerspectiveOrbit.phi) || + !isFiniteNumber(storedCameraState.orthographicZoom) + ) { + return null; + } + + nextLayoutState.panels[panelId] = { + viewMode: storedViewMode, + displayMode: storedDisplayMode, + cameraState: { + target: { + x: storedTarget.x, + y: storedTarget.y, + z: storedTarget.z + }, + perspectiveOrbit: { + radius: storedPerspectiveOrbit.radius, + theta: storedPerspectiveOrbit.theta, + phi: storedPerspectiveOrbit.phi + }, + orthographicZoom: storedCameraState.orthographicZoom + } + }; + } + + return nextLayoutState; +} + +function isStoredEditorDraftEnvelope(value: unknown): value is StoredEditorDraftEnvelope { + return isRecord(value) && value.format === EDITOR_DRAFT_ENVELOPE_FORMAT && "document" in value; +} + export function getBrowserStorageAccess(): BrowserStorageAccessResult { if (typeof window === "undefined") { return { @@ -69,10 +190,19 @@ export function getBrowserStorage(): KeyValueStorage | null { export function saveSceneDocumentDraft( storage: KeyValueStorage, document: SceneDocument, + viewportLayoutState: ViewportLayoutState | null = null, key = DEFAULT_SCENE_DRAFT_STORAGE_KEY ): SaveSceneDocumentDraftResult { try { - storage.setItem(key, serializeSceneDocument(document)); + const rawDocument = serializeSceneDocument(document); + storage.setItem( + key, + JSON.stringify({ + format: EDITOR_DRAFT_ENVELOPE_FORMAT, + document: JSON.parse(rawDocument), + viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState) + } satisfies StoredEditorDraftEnvelope) + ); return { status: "saved", @@ -100,9 +230,21 @@ export function loadSceneDocumentDraft( }; } + const parsedDraft = JSON.parse(rawDocument) as unknown; + + if (isStoredEditorDraftEnvelope(parsedDraft)) { + return { + status: "loaded", + document: parseSceneDocumentJson(JSON.stringify(parsedDraft.document)), + viewportLayoutState: parseViewportLayoutState(parsedDraft.viewportLayoutState ?? null), + message: "Local draft loaded." + }; + } + return { status: "loaded", document: parseSceneDocumentJson(rawDocument), + viewportLayoutState: null, message: "Local draft loaded." }; } catch (error) { @@ -130,16 +272,19 @@ export function loadOrCreateSceneDocument( case "loaded": return { document: draftResult.document, + viewportLayoutState: draftResult.viewportLayoutState, diagnostic: null }; case "missing": return { document: createEmptySceneDocument(), + viewportLayoutState: null, diagnostic: null }; case "error": return { document: createEmptySceneDocument(), + viewportLayoutState: null, diagnostic: `${draftResult.message} Starting with a fresh empty document.` }; }