Scene save operations (in-place overwrite vs copy) (#126)

This commit is contained in:
Danny McGee
2024-04-09 15:39:32 -04:00
committed by GitHub
parent 5cd98b4a35
commit 03f90eee47
16 changed files with 418 additions and 105 deletions
+5
View File
@@ -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);
});
});
+4 -1
View File
@@ -11,6 +11,9 @@ function isRef<T>(refOrValue: Ref<T> | T): refOrValue is Ref<T> {
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")
}
+1 -21
View File
@@ -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<void> {
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]);
@@ -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<HTMLElement>();
@observe(["canSaveInPlace", "canSaveAsCopy"])
@inject(REMOTE_SCENE_MANAGER)
_sceneManager!: RemoteSceneManager;
@state() _saveAsCopyTitle = "";
_saveAsCopyFormRef = createRef<HTMLFormElement>();
_saveAsCopyDialogRef = createRef<DialogElement>();
_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`
<sts-dialog-header icon="floppy-disk">
Save Scene
</sts-dialog-header>
<form
${ref(this._saveAsCopyFormRef)}
action=""
@submit=${(event: SubmitEvent) => {
event.preventDefault();
event.stopImmediatePropagation();
this._onSaveAsCopy();
}}
>
<label>
Title
<sts-text-field
autofocus
name="title"
required
.value=${bind(this, "_saveAsCopyTitle")}
@keydown=${(event: KeyboardEvent) => {
if (event.key === "Enter" && this._saveAsCopyTitle)
this._saveAsCopyFormRef.value?.requestSubmit();
}}
></sts-text-field>
</label>
</form>
<sts-dialog-footer>
<sts-button
@click=${() => {
this._saveAsCopyDialogRef.value?.close();
this._saveAsCopyTitle = "";
}}
>
Cancel
</sts-button>
<sts-button
secondary
?disabled=${!this._saveAsCopyTitle}
@click=${() => {
this._saveAsCopyFormRef.value?.requestSubmit();
}}
>
Save
</sts-button>
</sts-dialog-footer>
`;
}
protected override render() {
return html`
<sts-menu
autoClose
.trigger=${this.trigger}
>
<sts-button
role="menuitem"
keybind="Ctrl+N"
disabled
@click=${() => {
// TODO
}}
>
New scene
</sts-button>
<sts-button
role="menuitem"
keybind="Ctrl+Shift+N"
disabled
@click=${() => {
// TODO
}}
>
New scene from template...
</sts-button>
<hr />
<sts-button
role="menuitem"
keybind="Ctrl+S"
?disabled=${!this._sceneManager.canSaveInPlace}
@click=${this._onSaveInPlace}
>
Save scene
</sts-button>
<sts-button
role="menuitem"
keybind="Ctrl+Shift+S"
?disabled=${!this._sceneManager.canSaveAsCopy}
@click=${() => {
this._saveAsCopyDialogRef.value?.open();
}}
>
Save scene as copy...
</sts-button>
</sts-menu>
<sts-dialog
${ref(this._saveAsCopyDialogRef)}
.template=${[this, this._saveAsCopyDialogTemplate] as const}
></sts-dialog>
`;
}
}
@@ -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 {
})}
</sts-studio>
<sts-menu
autoClose
.trigger=${this.#fileTriggerRef}
>
<sts-button
role="menuitem"
keybind="Ctrl+N"
@click=${() => {
// TODO
}}
>
New scene
</sts-button>
<sts-button
role="menuitem"
keybind="Ctrl+Shift+N"
@click=${() => {
// TODO
}}
>
New scene from template...
</sts-button>
<hr />
<sts-button
role="menuitem"
keybind="Ctrl+S"
@click=${() => {
// 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
</sts-button>
<sts-button
role="menuitem"
keybind="Ctrl+Shift+S"
@click=${() => {
// TODO
}}
>
Save scene as copy...
</sts-button>
</sts-menu>
<sts-file-menu .trigger=${this.#fileTriggerRef}></sts-file-menu>
<sts-dialog
.trigger=${this.#mediaLibraryTriggerRef}
+4 -2
View File
@@ -43,7 +43,6 @@
:host(.disabled),
:host(.disabled:hover) {
background: #0003;
color: #FFF6;
cursor: default;
}
@@ -58,7 +57,6 @@
}
.keybind {
// margin-inline-start: auto;
flex: 1 0 0;
margin-inline-start: 2.25rem;
color: rgba(#FFF, 0.75);
@@ -67,4 +65,8 @@
weight: 400;
}
text-align: end;
:host(.disabled) & {
opacity: 0.4;
}
}
+24 -17
View File
@@ -329,6 +329,24 @@ export class DialogElement extends LitElement {
#overlayHandle?: WeakRef<OverlayHandle>;
get #overlayTemplate(): [LitElement, TemplateResult] | undefined {
if (!this.template)
return;
if (this.skipWrapper)
return this.template;
const [host, template] = this.template;
return [host, html`
<sts-dialog-base
role=${ifDefined(this.dialogRole)}
@close=${() => this.close()}
>
${template}
</sts-dialog-base>
`]
}
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`
<sts-dialog-base
role=${ifDefined(this.dialogRole)}
@close=${() => this.close()}
>
${template}
</sts-dialog-base>
`,
{
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);
+1 -1
View File
@@ -14,7 +14,7 @@
border-radius: unset;
}
::slotted(sts-button:hover) {
::slotted(sts-button:not(.disabled):hover) {
background: var(--neutral-300);
}
+4 -2
View File
@@ -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": [],
+1
View File
@@ -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";
@@ -1,3 +1,7 @@
:host {
display: contents;
}
::slotted([slot="canvas"]) {
display: none;
}
+16
View File
@@ -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<string>(resolve => {
window.addEventListener("scene-saved", ({ detail: scene }) => {
resolve(scene);
}, {
once: true,
});
studio.saveScene();
});
}
@@ -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<SceneDataProvider>();
export interface SceneDataProvider extends DynamicProvider<SceneDataProvider> {
readonly tree: readonly SceneObject[];
readonly entityMap: ReadonlyMap<string, SceneObject>;
@@ -23,7 +27,50 @@ export interface SceneDataProvider extends DynamicProvider<SceneDataProvider> {
readonly selectionComponents: readonly InspectorComponent[];
}
export const SCENE_DATA_PROVIDER = UniqueToken.create<SceneDataProvider>();
export const REMOTE_SCENE_MANAGER = UniqueToken.create<RemoteSceneManager>();
export interface RemoteSceneManager extends DynamicProvider<RemoteSceneManager> {
/**
* 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<void>;
/**
* 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<MediaFileToken>;
}
export interface SceneObject extends Omit<studio.SceneObject, "children"> {
icon?: string;
@@ -32,11 +79,29 @@ export interface SceneObject extends Omit<studio.SceneObject, "children"> {
@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<string, SceneObject>();
@@ -57,13 +125,109 @@ export class SceneDataProviderElement
@state() _selection: string | null = null;
@state() _components: InspectorComponent[] = [];
@state() _canSaveInPlace = false;
@state() _canSaveAsCopy = false;
async saveInPlace(): Promise<void> {
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<MediaFileToken> {
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<this>): Promise<void> {
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<this>): void {
const downstreamChanges: PropertyValues<SceneDataProvider & TreeSelectionProvider>
= new Map();
type Interfaces
= SceneDataProvider
& RemoteSceneManager
& TreeSelectionProvider
;
const downstreamChanges: PropertyValues<Interfaces> = 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"));
+35 -8
View File
@@ -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<MediaFileToken>
createScene(file: File, title?: string): Promise<MediaFileToken>
session(): Promise<Session>
listMediaFiles(options: ListMediaFilesOptions): Promise<ListMediaFilesResult>
uploadMediaFile(
@@ -16,6 +16,7 @@ interface IStorytellerApi {
renameMediaFile(token: MediaFileToken, name: string): Promise<void>
convertFbxToGltf(token: MediaFileToken): Promise<void>
mediaFile(token: MediaFileToken): Promise<MediaFile>
writeSceneFile(token: MediaFileToken, file: File): Promise<MediaFileToken>;
}
export class StorytellerRemoteApi implements IStorytellerApi {
@@ -24,15 +25,16 @@ export class StorytellerRemoteApi implements IStorytellerApi {
withCredentials: true,
});
async createScene(file: File): Promise<MediaFileToken> {
async createScene(file: File, title = "Untitled"): Promise<MediaFileToken> {
const response = await this.#api.postForm<MediaUploadResponse>(
"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<MediaFileToken> {
const response = await this.#api.postForm<MediaUploadResponse>(
"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<R extends AxiosResponse>(response: R): asserts response is OkResponse<R> {
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<string> {
async createScene(file: File, title = "Untitled"): Promise<string> {
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<MediaFileToken> {
throw new Error("StorytellerLocalApi.writeSceneFile is not yet implemented");
}
async mediaBlob(path: string): Promise<Uint8Array> {
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<string> {
return this.apiBase.createScene(file)
createScene(file: File, title?: string): Promise<string> {
return this.apiBase.createScene(file, title)
}
session(): Promise<Session> {
return this.apiBase.session()
@@ -457,6 +480,10 @@ export class StorytellerApi implements IStorytellerApi {
mediaFile(token: string): Promise<MediaFile> {
return this.apiBase.mediaFile(token)
}
writeSceneFile(token: MediaFileToken, file: File): Promise<MediaFileToken> {
return this.apiBase.writeSceneFile(token, file);
}
apiBase: IStorytellerApi;
constructor(apiBase: IStorytellerApi) {
+4 -2
View File
@@ -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": [],