diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx index 8629c354c6..8dd17223e8 100644 --- a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx @@ -108,15 +108,19 @@ function PageSceneEditor() { // onSceneSerialized callback on unmount. Mirrors how Tauri stashes // per-tab scene JSON via useTabStore. // - // Scoped to the no-token playground: when sceneToken is in the URL - // the server-loaded scene is the source of truth, so we neither - // restore from cache (would override the shared scene) nor write the - // session back into it. - const setCacheJsonString = useSceneCacheStore((s) => s.setCacheJsonString); - const cacheJsonString = sceneToken - ? undefined - : useSceneCacheStore.getState().cacheJsonString; - const onSceneSerialized = sceneToken ? undefined : setCacheJsonString; + // Keyed by sceneToken so each scene (and the playground) gets its own + // slot — visiting /edit-3d/A, /edit-3d/B, /edit-3d/A round-trips back + // to A's in-progress state rather than B's or the server's. Cache + // hits short-circuit the lib's loadScene path (Editor.initialize + // prefers cacheJsonString over the sceneToken). + const setCurrent = useSceneCacheStore((s) => s.setCurrent); + const cacheJsonString = useSceneCacheStore + .getState() + .getEntry(sceneToken)?.current; + const onSceneSerialized = useCallback( + (json: string) => setCurrent(sceneToken, json), + [sceneToken, setCurrent], + ); return (
void; - clear: () => void; +// Per-token scratch cache for the 3D editor. +// +// The lib gives the host two hooks: a `cacheJsonString` it'll restore +// from on mount (priority over re-fetching a sceneToken), and an +// `onSceneSerialized` callback it fires whenever the editor has fresh +// JSON to stash. We pair those with sessionStorage so SPA navigation, +// tab reloads, and Cmd+R survive — but a fresh browser session starts +// clean (no cross-user bleed). +// +// Each cache entry holds both the snapshot the host loaded from the +// server (`original`) and the user's latest in-progress state +// (`current`). The destructive "Reset to original" menu item is +// available iff `original` is defined for the active scene, and the +// reset flow just reads `original` and re-applies it via +// `editor.applyJson`. +// +// Playground sessions (no sceneToken in the URL) key on PLAYGROUND_KEY +// and don't track an `original` — there's no "server version" to revert +// to. + +export const PLAYGROUND_KEY = "__playground__"; + +export interface SceneCacheEntry { + original?: string; + current: string; } +interface SceneCacheStore { + entries: Record; + getEntry: (token: string | undefined) => SceneCacheEntry | undefined; + setOriginal: (token: string, original: string) => void; + setCurrent: (token: string | undefined, current: string) => void; + resetCurrentToOriginal: (token: string) => void; + clearToken: (token: string | undefined) => void; +} + +const keyOf = (token: string | undefined) => token ?? PLAYGROUND_KEY; + export const useSceneCacheStore = create()( persist( - (set) => ({ - cacheJsonString: undefined, - setCacheJsonString: (cacheJsonString) => set({ cacheJsonString }), - clear: () => set({ cacheJsonString: undefined }), + (set, get) => ({ + entries: {}, + getEntry: (token) => get().entries[keyOf(token)], + setOriginal: (token, original) => + set((state) => { + const key = keyOf(token); + const existing = state.entries[key]; + return { + entries: { + ...state.entries, + [key]: { + ...existing, + original, + current: existing?.current ?? original, + }, + }, + }; + }), + setCurrent: (token, current) => + set((state) => { + const key = keyOf(token); + const existing = state.entries[key]; + return { + entries: { + ...state.entries, + [key]: { ...existing, current }, + }, + }; + }), + resetCurrentToOriginal: (token) => + set((state) => { + const key = keyOf(token); + const existing = state.entries[key]; + if (!existing?.original) return state; + return { + entries: { + ...state.entries, + [key]: { ...existing, current: existing.original }, + }, + }; + }), + clearToken: (token) => + set((state) => { + const key = keyOf(token); + if (!(key in state.entries)) return state; + const next = { ...state.entries }; + delete next[key]; + return { entries: next }; + }), }), { name: "artcraft-webapp-pagescene-cache", diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx index 0ca2dbb7de..1d517e9c06 100644 --- a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx @@ -19,7 +19,7 @@ import { UsersApi, } from "@storyteller/api"; import type { PageSceneAdapter } from "@storyteller/ui-pagescene"; -import { ToastTypes } from "@storyteller/ui-pagescene"; +import { getActiveEditor, ToastTypes } from "@storyteller/ui-pagescene"; import { UploadModal3D, UploadModalImage, @@ -28,6 +28,7 @@ import { } from "@storyteller/ui-upload-modal"; import { showToast } from "../../components/toast/toast"; import { invalidateSession } from "../../lib/session"; +import { useSceneCacheStore } from "./scene-cache-store"; const apiHost = () => StorytellerApiHostStore.getInstance().getApiSchemeAndHost(); @@ -90,6 +91,16 @@ const loadSceneViaApi = async (token: string): Promise => { const fileResp = await fetch(cdnUrl); if (!fileResp.ok) throw new Error(`Scene fetch HTTP ${fileResp.status}`); const text = await fileResp.text(); + + // Seed the scene cache's `original` snapshot on first fetch so the + // destructive "Reset to original" menu item has something to revert + // to without going back over the network. Cache-hit reloads skip + // this path entirely (Editor.initialize uses cacheJsonString). + const cache = useSceneCacheStore.getState(); + if (!cache.getEntry(token)?.original) { + cache.setOriginal(token, text); + } + return JSON.parse(text); }; @@ -269,6 +280,25 @@ export const useWebAppPageSceneAdapter = ( promptSignup, + // Powers the lib's "Reset to original" menu item. The cache has + // the server's snapshot we seeded on first load; pipe it back + // through `Editor.applyJson` (which clears undo history and + // re-renders) and mirror `current = original` in the cache so a + // pre-serialization reload doesn't drag the edits back. + resetToOriginal: async () => { + if (!initialSceneToken) return; + const editor = getActiveEditor(); + if (!editor) return; + const entry = useSceneCacheStore + .getState() + .getEntry(initialSceneToken); + if (!entry?.original) return; + await editor.applyJson(entry.original); + useSceneCacheStore + .getState() + .resetCurrentToOriginal(initialSceneToken); + }, + performLogout: async () => { try { await new UsersApi().Logout();