From 03f90eee47b586ae762ba1a33adeb41857ef4811 Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Tue, 9 Apr 2024 15:39:32 -0400 Subject: [PATCH] Scene save operations (in-place overwrite vs copy) (#126) --- framework/src/lib/deref.spec.ts | 5 + framework/src/lib/deref.ts | 5 +- studio-frontend/src/app/app.element.ts | 22 +-- .../src/app/file-menu.element.scss | 0 studio-frontend/src/app/file-menu.element.ts | 143 ++++++++++++++ .../src/app/studio-viewport.element.ts | 47 +---- studio-ui/src/lib/button.element.scss | 6 +- studio-ui/src/lib/dialog/dialog.element.ts | 41 ++-- studio-ui/src/lib/menu.element.scss | 2 +- studio-ui/tsconfig.json | 6 +- studio-web/src/index.ts | 1 + .../src/lib/canvas-provider.element.scss | 4 + studio-web/src/lib/save-scene.ts | 16 ++ .../src/lib/scene-data-provider.element.ts | 176 +++++++++++++++++- studio-web/src/lib/storyteller-api.ts | 43 ++++- studio-web/tsconfig.json | 6 +- 16 files changed, 418 insertions(+), 105 deletions(-) create mode 100644 studio-frontend/src/app/file-menu.element.scss create mode 100644 studio-frontend/src/app/file-menu.element.ts create mode 100644 studio-web/src/lib/save-scene.ts diff --git a/framework/src/lib/deref.spec.ts b/framework/src/lib/deref.spec.ts index 90b7655..9296963 100644 --- a/framework/src/lib/deref.spec.ts +++ b/framework/src/lib/deref.spec.ts @@ -35,4 +35,9 @@ describe("deref", () => { const v3 = deref((() => {})()); expect(v3).toBe(undefined); }); + + it("should return `undefined` when given an empty Ref", () => { + const derefed = deref(createRef()); + expect(derefed).toBe(undefined); + }); }); diff --git a/framework/src/lib/deref.ts b/framework/src/lib/deref.ts index ba10bb7..308abb7 100644 --- a/framework/src/lib/deref.ts +++ b/framework/src/lib/deref.ts @@ -11,6 +11,9 @@ function isRef(refOrValue: Ref | T): refOrValue is Ref { if (!refOrValue || typeof refOrValue !== "object") return false; + if (refOrValue instanceof Element) + return false; + const keys = Object.keys(refOrValue); - return keys.length === 1 && keys[0] === "value" + return keys.length === 0 || (keys.length === 1 && keys[0] === "value") } diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 2bc75af..86b838a 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -1,6 +1,5 @@ -import { drag, on, provide } from "@storyteller/framework"; +import { on, provide } from "@storyteller/framework"; import { - type SceneSavedEvent, type SceneStateEvent, SceneState, StudioMode, @@ -78,25 +77,6 @@ export class AppElement extends LitElement { return this; } - @on("scene-saved") - async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise { - try { - const file = new File([scene], "scene.scn.ron", { type: "text/ron" }); - - const token = await this.api.createScene(file); - - this.dispatchEvent(new CustomEvent("scene-uploaded", { - detail: token, - bubbles: true, - composed: true, - })); - - } - catch (err) { - console.error(err); - } - } - @on("scene-state") onSceneStateChange({ detail: state }: SceneStateEvent): void { console.log("scene state change:", SceneState[state]); diff --git a/studio-frontend/src/app/file-menu.element.scss b/studio-frontend/src/app/file-menu.element.scss new file mode 100644 index 0000000..e69de29 diff --git a/studio-frontend/src/app/file-menu.element.ts b/studio-frontend/src/app/file-menu.element.ts new file mode 100644 index 0000000..a06ec85 --- /dev/null +++ b/studio-frontend/src/app/file-menu.element.ts @@ -0,0 +1,143 @@ +import { bind, inject, observe } from "@storyteller/framework"; +import { DialogElement } from "@storyteller/studio-ui"; +import { REMOTE_SCENE_MANAGER, type RemoteSceneManager } from "@storyteller/studio-web"; +import { LitElement, TemplateResult, html, unsafeCSS } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; + +import styles from "./file-menu.element.scss?inline"; + +@customElement("sts-file-menu") +export class FileMenuElement extends LitElement { + static override styles = unsafeCSS(styles); + + @property({ attribute: false }) + trigger = createRef(); + + @observe(["canSaveInPlace", "canSaveAsCopy"]) + @inject(REMOTE_SCENE_MANAGER) + _sceneManager!: RemoteSceneManager; + + @state() _saveAsCopyTitle = ""; + + _saveAsCopyFormRef = createRef(); + _saveAsCopyDialogRef = createRef(); + + _onSaveInPlace(): void { + this._sceneManager.saveInPlace().catch(console.error); + } + + _onSaveAsCopy(): void { + const title = this._saveAsCopyTitle; + this._saveAsCopyDialogRef.value?.close(); + this._saveAsCopyTitle = ""; + + this._sceneManager.saveAsCopy(title).catch(console.error); + } + + get _saveAsCopyDialogTemplate(): TemplateResult { + return html` + + Save Scene + + +
{ + event.preventDefault(); + event.stopImmediatePropagation(); + this._onSaveAsCopy(); + }} + > + +
+ + + { + this._saveAsCopyDialogRef.value?.close(); + this._saveAsCopyTitle = ""; + }} + > + Cancel + + { + this._saveAsCopyFormRef.value?.requestSubmit(); + }} + > + Save + + + `; + } + + protected override render() { + return html` + + { + // TODO + }} + > + New scene + + { + // TODO + }} + > + New scene from template... + +
+ + Save scene + + { + this._saveAsCopyDialogRef.value?.open(); + }} + > + Save scene as copy... + +
+ + + `; + } +} diff --git a/studio-frontend/src/app/studio-viewport.element.ts b/studio-frontend/src/app/studio-viewport.element.ts index 44c70ad..599646f 100644 --- a/studio-frontend/src/app/studio-viewport.element.ts +++ b/studio-frontend/src/app/studio-viewport.element.ts @@ -23,6 +23,7 @@ import "@storyteller/studio-ui/forms/radio"; import "@storyteller/studio-ui/forms/select"; import "@storyteller/studio-ui/toolbar"; import "./anim-timeline-controls.element"; +import "./file-menu.element"; import "./studio-viewport.element.scss"; import { match } from "@storyteller/utility"; @@ -251,51 +252,7 @@ export class StudioViewportElement extends LitElement { })} - - { - // TODO - }} - > - New scene - - { - // TODO - }} - > - New scene from template... - -
- { - // TODO: Should we show a spinner here while we're waiting? - // Toast message for success/failure? - // TODO: Need to disable this when the scene is not ours - studio.saveScene(); - }} - > - Save scene - - { - // TODO - }} - > - Save scene as copy... - -
+ ; + get #overlayTemplate(): [LitElement, TemplateResult] | undefined { + if (!this.template) + return; + + if (this.skipWrapper) + return this.template; + + const [host, template] = this.template; + return [host, html` + this.close()} + > + ${template} + + `] + } + open(): void { if (!this.tag && !this.template) return; @@ -348,23 +366,12 @@ export class DialogElement extends LitElement { const onBackdropClick = this.onBackdropClick; if (this.template) { - const [host, template] = this.template; + const [host, template] = this.#overlayTemplate!; - this.#overlayHandle = this._overlay.attach( - host, - this.skipWrapper ? template : html` - this.close()} - > - ${template} - - `, - { - containerStyles, - onBackdropClick, - }, - ); + this.#overlayHandle = this._overlay.attach(host, template, { + containerStyles, + onBackdropClick, + }); } else { this.#overlayHandle = this._overlay.attach(this, html` <${unsafeStatic(this.tag!)} @@ -400,7 +407,7 @@ export class DialogElement extends LitElement { if (changes.has("template")) { if (this.#overlayHandle && this.template) - this._overlay.reRender(this.#overlayHandle, this.template); + this._overlay.reRender(this.#overlayHandle, this.#overlayTemplate!); } super.updated(changes); diff --git a/studio-ui/src/lib/menu.element.scss b/studio-ui/src/lib/menu.element.scss index 2a882df..d4e8442 100644 --- a/studio-ui/src/lib/menu.element.scss +++ b/studio-ui/src/lib/menu.element.scss @@ -14,7 +14,7 @@ border-radius: unset; } -::slotted(sts-button:hover) { +::slotted(sts-button:not(.disabled):hover) { background: var(--neutral-300); } diff --git a/studio-ui/tsconfig.json b/studio-ui/tsconfig.json index 451a604..0da4bef 100644 --- a/studio-ui/tsconfig.json +++ b/studio-ui/tsconfig.json @@ -1,13 +1,15 @@ { "extends": "../tsconfig.base.json", "compilerOptions": { - "module": "commonjs", + "module": "ESNext", "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "emitDecoratorMetadata": true, }, "files": [], "include": [], diff --git a/studio-web/src/index.ts b/studio-web/src/index.ts index 84c1d19..795cdc7 100644 --- a/studio-web/src/index.ts +++ b/studio-web/src/index.ts @@ -7,6 +7,7 @@ export * from "./lib/inspector/entity-inspector.element"; export * from "./lib/inspector/inspector.types"; export * from "./lib/loading.element"; export * from "./lib/media-library-dialog.element"; +export * from "./lib/save-scene"; export * from "./lib/scene-data-provider.element"; export * from "./lib/scene-hierarchy.element"; export * from "./lib/studio.element"; diff --git a/studio-web/src/lib/canvas-provider.element.scss b/studio-web/src/lib/canvas-provider.element.scss index 7e928ca..5033eac 100644 --- a/studio-web/src/lib/canvas-provider.element.scss +++ b/studio-web/src/lib/canvas-provider.element.scss @@ -1,3 +1,7 @@ +:host { + display: contents; +} + ::slotted([slot="canvas"]) { display: none; } diff --git a/studio-web/src/lib/save-scene.ts b/studio-web/src/lib/save-scene.ts new file mode 100644 index 0000000..5bb57ff --- /dev/null +++ b/studio-web/src/lib/save-scene.ts @@ -0,0 +1,16 @@ +import * as studio from "@storyteller/studio"; + +/** + * A simple wrapper around {@linkcode studio.saveScene} that returns a Promise. + */ +export function saveScene() { + return new Promise(resolve => { + window.addEventListener("scene-saved", ({ detail: scene }) => { + resolve(scene); + }, { + once: true, + }); + + studio.saveScene(); + }); +} diff --git a/studio-web/src/lib/scene-data-provider.element.ts b/studio-web/src/lib/scene-data-provider.element.ts index 1b5e034..66a56b8 100644 --- a/studio-web/src/lib/scene-data-provider.element.ts +++ b/studio-web/src/lib/scene-data-provider.element.ts @@ -1,4 +1,4 @@ -import { DynamicProvider, UniqueToken, on, provide } from "@storyteller/framework"; +import { DynamicProvider, UniqueToken, inject, on, provide } from "@storyteller/framework"; import * as studio from "@storyteller/studio"; import { SceneElement, @@ -10,11 +10,15 @@ import { } from "@storyteller/studio"; import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree"; import { match } from "@storyteller/utility"; -import { LitElement, type PropertyValues, html } from "lit"; +import { LitElement, type PropertyValues, html, css } from "lit"; import { customElement, state } from "lit/decorators.js"; import { InspectorComponent } from "./inspector/inspector.types"; +import { type MediaFile, type MediaFileToken, StorytellerApi } from "./storyteller-api"; +import { STUDIO_PARAMS, type StudioParams } from "./url-params"; +import { saveScene } from "./save-scene"; +export const SCENE_DATA_PROVIDER = UniqueToken.create(); export interface SceneDataProvider extends DynamicProvider { readonly tree: readonly SceneObject[]; readonly entityMap: ReadonlyMap; @@ -23,7 +27,50 @@ export interface SceneDataProvider extends DynamicProvider { readonly selectionComponents: readonly InspectorComponent[]; } -export const SCENE_DATA_PROVIDER = UniqueToken.create(); +export const REMOTE_SCENE_MANAGER = UniqueToken.create(); +export interface RemoteSceneManager extends DynamicProvider { + /** + * The media token for the current scene. Can be `null` if: + * + * - This is a new scene that hasn't been saved yet + * - We're in a `StudioMode.Viewer` instance for some other asset type, like + * an FBX/glTF import or an animation + * - We're in a dev instance loading assets from the local `assets` directory + * - Probably some other scenarios I'm not considering + */ + readonly sceneToken: MediaFileToken | null; + + /** + * Indicates whether the user is permitted to save the current scene by + * overwriting the remote media file. + */ + readonly canSaveInPlace: boolean; + + /** + * Indicates whether the user is permitted to save the current scene as a + * copy to their account. (Presumably this should only be `false` if the user + * is not logged in.) + */ + readonly canSaveAsCopy: boolean; + + /** + * Save the current scene to the user's account by overwriting the existing + * media file. + * + * @returns a rejected Promise if there is no active {@linkcode sceneToken} + * or if {@linkcode canSaveInPlace} is false. + */ + saveInPlace(): Promise; + + /** + * Save the current scene to a new {@linkcode MediaFile} in the user's + * account. + * + * @returns the new scene token. + * @returns a rejected Promise if {@linkcode canSaveAsCopy} is false. + */ + saveAsCopy(title: string): Promise; +} export interface SceneObject extends Omit { icon?: string; @@ -32,11 +79,29 @@ export interface SceneObject extends Omit { @customElement("sts-scene-data-provider") @provide(SCENE_DATA_PROVIDER) +@provide(REMOTE_SCENE_MANAGER) @provide(TREE_SELECTION_PROVIDER) export class SceneDataProviderElement extends LitElement - implements SceneDataProvider, TreeSelectionProvider + implements + SceneDataProvider, + RemoteSceneManager, + TreeSelectionProvider { + static override styles = css` + :host { + display: contents; + } + `; + + @inject(STUDIO_PARAMS) + _params!: StudioParams; + + @inject(StorytellerApi) + _api!: StorytellerApi; + + @state() sceneToken: MediaFileToken | null = null; + get tree() { return this._tree; } get entityMap() { return this.#entityMap; } @@ -50,6 +115,9 @@ export class SceneDataProviderElement get selectionComponents() { return this._components; } + get canSaveInPlace() { return this._canSaveInPlace; } + get canSaveAsCopy() { return this._canSaveAsCopy; } + @state() _tree: SceneObject[] = []; #sceneObjectsFlat: studio.SceneObject[] = []; #entityMap = new Map(); @@ -57,13 +125,109 @@ export class SceneDataProviderElement @state() _selection: string | null = null; @state() _components: InspectorComponent[] = []; + @state() _canSaveInPlace = false; + @state() _canSaveAsCopy = false; + + async saveInPlace(): Promise { + if (!this.canSaveInPlace) + return Promise.reject(new Error("User not permitted to overwrite the current scene")); + + try { + const scene = await saveScene(); + const file = new File([scene], "scene.scn.ron", { type: "text/plain" }); + + if (!this.sceneToken) + return Promise.reject(new Error("Unable to save scene in-place: no active scene token")); + + await this._api.writeSceneFile(this.sceneToken, file); + } + catch (err) { + return Promise.reject(err); + } + } + + async saveAsCopy(title: string): Promise { + if (!this.canSaveAsCopy) + return Promise.reject(new Error("User not permitted to save the scene as a copy")) + + try { + const scene = await saveScene(); + const file = new File([scene], "scene.scn.ron", { type: "text/plain" }); + + this.sceneToken = await this._api.createScene(file, title); + this.dispatchEvent(new CustomEvent("scene-uploaded", { + detail: this.sceneToken, + bubbles: true, + composed: true, + })); + + return this.sceneToken; + } + catch (err) { + return Promise.reject(err); + } + } + + override connectedCallback(): void { + if (this._params.scene != null) { + this.sceneToken = this._params.scene + .match(/^remote:\/\/([a-zA-Z0-9_]+)\.scn\.ron/) + ?.[1] + ?? null; + } + + super.connectedCallback(); + } + + protected override async willUpdate(changes: PropertyValues): Promise { + if (changes.has("sceneToken")) { + if (!this.sceneToken) + this._canSaveInPlace = false; + + try { + const session = await this._api.session(); + if (session.logged_in) + this._canSaveAsCopy = true; + + if (this.sceneToken != null) { + const sceneFile = await this._api.mediaFile(this.sceneToken); + if ( + session.user?.user_token != null + && sceneFile.maybe_creator_user?.user_token === session.user.user_token + ) { + this._canSaveInPlace = true; + } + } + } + catch (err) { + console.error(err); + } + } + + super.willUpdate(changes); + } + // TODO: This workflow allows dependents of this provider to listen for the // "property-changes" event and manually trigger updates when relevant // properties change, but it's pretty fiddly to set up. Probably not // worth fixing before we migrate over to Bevy-native UI. protected override update(changes: PropertyValues): void { - const downstreamChanges: PropertyValues - = new Map(); + type Interfaces + = SceneDataProvider + & RemoteSceneManager + & TreeSelectionProvider + ; + + const downstreamChanges: PropertyValues = new Map(); + + if (changes.has("sceneToken")) + downstreamChanges.set("sceneToken", changes.get("sceneToken")); + + if (changes.has("_canSaveInPlace")) + downstreamChanges.set("canSaveInPlace", changes.get("_canSaveInPlace")); + + if (changes.has("_canSaveAsCopy")) + downstreamChanges.set("canSaveAsCopy", changes.get("_canSaveAsCopy")); if (changes.has("_tree")) { downstreamChanges.set("tree", changes.get("_tree")); diff --git a/studio-web/src/lib/storyteller-api.ts b/studio-web/src/lib/storyteller-api.ts index 980bb3e..4c27746 100644 --- a/studio-web/src/lib/storyteller-api.ts +++ b/studio-web/src/lib/storyteller-api.ts @@ -6,7 +6,7 @@ import * as studio from "@storyteller/studio"; export const mockApi = window.location.search.includes("mock-api"); interface IStorytellerApi { - createScene(file: File): Promise + createScene(file: File, title?: string): Promise session(): Promise listMediaFiles(options: ListMediaFilesOptions): Promise uploadMediaFile( @@ -16,6 +16,7 @@ interface IStorytellerApi { renameMediaFile(token: MediaFileToken, name: string): Promise convertFbxToGltf(token: MediaFileToken): Promise mediaFile(token: MediaFileToken): Promise + writeSceneFile(token: MediaFileToken, file: File): Promise; } export class StorytellerRemoteApi implements IStorytellerApi { @@ -24,15 +25,16 @@ export class StorytellerRemoteApi implements IStorytellerApi { withCredentials: true, }); - async createScene(file: File): Promise { + async createScene(file: File, title = "Untitled"): Promise { const response = await this.#api.postForm( "v1/engine/create_scene", { uuid_idempotency_token: uuid(), file, + title, }, { - headers: { Accept: "text/plain" }, - }, + headers: { Accept: "text/plain" }, + }, ); this.#validateResponse(response); @@ -108,6 +110,23 @@ export class StorytellerRemoteApi implements IStorytellerApi { return response.data.media_file; } + async writeSceneFile(token: MediaFileToken, file: File): Promise { + const response = await this.#api.postForm( + "v1/media_files/write/scene_file", + { + uuid_idempotency_token: uuid(), + media_file_token: token, + file, + }, + { + headers: { Accept: "text/plain" }, + }, + ); + this.#validateResponse(response); + + return response.data.media_file_token; + } + #validateResponse(response: R): asserts response is OkResponse { if (response.status !== 200) throw new Error(`API responded with status ${response.status}: "${response.statusText}"`); @@ -177,7 +196,7 @@ export class StorytellerLocalApi implements IStorytellerApi { return this.database } - async createScene(file: File): Promise { + async createScene(file: File, title = "Untitled"): Promise { let token = uuid(); let original_file_name = file.name; let filename = token + "-" + file.name; @@ -190,7 +209,7 @@ export class StorytellerLocalApi implements IStorytellerApi { public_bucket_path: filename, creator_set_visibility: Visibility.Public, is_emulated_media_file: false, - maybe_title: filename, + maybe_title: title, maybe_original_filename: original_file_name, stats: { positive_rating_count: 0, @@ -425,6 +444,10 @@ export class StorytellerLocalApi implements IStorytellerApi { return file; } + async writeSceneFile(token: MediaFileToken, file: File): Promise { + throw new Error("StorytellerLocalApi.writeSceneFile is not yet implemented"); + } + async mediaBlob(path: string): Promise { let db = await this.getDatabase(); let file = await db.get('file-blobs', path); @@ -436,8 +459,8 @@ export class StorytellerLocalApi implements IStorytellerApi { } export class StorytellerApi implements IStorytellerApi { - createScene(file: File): Promise { - return this.apiBase.createScene(file) + createScene(file: File, title?: string): Promise { + return this.apiBase.createScene(file, title) } session(): Promise { return this.apiBase.session() @@ -457,6 +480,10 @@ export class StorytellerApi implements IStorytellerApi { mediaFile(token: string): Promise { return this.apiBase.mediaFile(token) } + writeSceneFile(token: MediaFileToken, file: File): Promise { + return this.apiBase.writeSceneFile(token, file); + } + apiBase: IStorytellerApi; constructor(apiBase: IStorytellerApi) { diff --git a/studio-web/tsconfig.json b/studio-web/tsconfig.json index 451a604..0da4bef 100644 --- a/studio-web/tsconfig.json +++ b/studio-web/tsconfig.json @@ -1,13 +1,15 @@ { "extends": "../tsconfig.base.json", "compilerOptions": { - "module": "commonjs", + "module": "ESNext", "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "emitDecoratorMetadata": true, }, "files": [], "include": [],