mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-28 08:58:07 +00:00
Merge branch 'main' of https://github.com/storytold/storyteller-bevy into animation-clips-improvements
This commit is contained in:
@@ -35,4 +35,9 @@ describe("deref", () => {
|
|||||||
const v3 = deref((() => {})());
|
const v3 = deref((() => {})());
|
||||||
expect(v3).toBe(undefined);
|
expect(v3).toBe(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return `undefined` when given an empty Ref", () => {
|
||||||
|
const derefed = deref(createRef());
|
||||||
|
expect(derefed).toBe(undefined);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ function isRef<T>(refOrValue: Ref<T> | T): refOrValue is Ref<T> {
|
|||||||
if (!refOrValue || typeof refOrValue !== "object")
|
if (!refOrValue || typeof refOrValue !== "object")
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
if (refOrValue instanceof Element)
|
||||||
|
return false;
|
||||||
|
|
||||||
const keys = Object.keys(refOrValue);
|
const keys = Object.keys(refOrValue);
|
||||||
return keys.length === 1 && keys[0] === "value"
|
return keys.length === 0 || (keys.length === 1 && keys[0] === "value")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { drag, on, provide } from "@storyteller/framework";
|
import { on, provide } from "@storyteller/framework";
|
||||||
import {
|
import {
|
||||||
type SceneSavedEvent,
|
|
||||||
type SceneStateEvent,
|
type SceneStateEvent,
|
||||||
SceneState,
|
SceneState,
|
||||||
StudioMode,
|
StudioMode,
|
||||||
@@ -78,25 +77,6 @@ export class AppElement extends LitElement {
|
|||||||
return this;
|
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")
|
@on("scene-state")
|
||||||
onSceneStateChange({ detail: state }: SceneStateEvent): void {
|
onSceneStateChange({ detail: state }: SceneStateEvent): void {
|
||||||
console.log("scene state change:", SceneState[state]);
|
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/forms/select";
|
||||||
import "@storyteller/studio-ui/toolbar";
|
import "@storyteller/studio-ui/toolbar";
|
||||||
import "./anim-timeline-controls.element";
|
import "./anim-timeline-controls.element";
|
||||||
|
import "./file-menu.element";
|
||||||
|
|
||||||
import "./studio-viewport.element.scss";
|
import "./studio-viewport.element.scss";
|
||||||
import { match } from "@storyteller/utility";
|
import { match } from "@storyteller/utility";
|
||||||
@@ -251,51 +252,7 @@ export class StudioViewportElement extends LitElement {
|
|||||||
})}
|
})}
|
||||||
</sts-studio>
|
</sts-studio>
|
||||||
|
|
||||||
<sts-menu
|
<sts-file-menu .trigger=${this.#fileTriggerRef}></sts-file-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-dialog
|
<sts-dialog
|
||||||
.trigger=${this.#mediaLibraryTriggerRef}
|
.trigger=${this.#mediaLibraryTriggerRef}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@
|
|||||||
|
|
||||||
:host(.disabled),
|
:host(.disabled),
|
||||||
:host(.disabled:hover) {
|
:host(.disabled:hover) {
|
||||||
background: #0003;
|
|
||||||
color: #FFF6;
|
color: #FFF6;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
@@ -58,7 +57,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.keybind {
|
.keybind {
|
||||||
// margin-inline-start: auto;
|
|
||||||
flex: 1 0 0;
|
flex: 1 0 0;
|
||||||
margin-inline-start: 2.25rem;
|
margin-inline-start: 2.25rem;
|
||||||
color: rgba(#FFF, 0.75);
|
color: rgba(#FFF, 0.75);
|
||||||
@@ -67,4 +65,8 @@
|
|||||||
weight: 400;
|
weight: 400;
|
||||||
}
|
}
|
||||||
text-align: end;
|
text-align: end;
|
||||||
|
|
||||||
|
:host(.disabled) & {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,6 +329,24 @@ export class DialogElement extends LitElement {
|
|||||||
|
|
||||||
#overlayHandle?: WeakRef<OverlayHandle>;
|
#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 {
|
open(): void {
|
||||||
if (!this.tag && !this.template)
|
if (!this.tag && !this.template)
|
||||||
return;
|
return;
|
||||||
@@ -348,23 +366,12 @@ export class DialogElement extends LitElement {
|
|||||||
const onBackdropClick = this.onBackdropClick;
|
const onBackdropClick = this.onBackdropClick;
|
||||||
|
|
||||||
if (this.template) {
|
if (this.template) {
|
||||||
const [host, template] = this.template;
|
const [host, template] = this.#overlayTemplate!;
|
||||||
|
|
||||||
this.#overlayHandle = this._overlay.attach(
|
this.#overlayHandle = this._overlay.attach(host, template, {
|
||||||
host,
|
containerStyles,
|
||||||
this.skipWrapper ? template : html`
|
onBackdropClick,
|
||||||
<sts-dialog-base
|
});
|
||||||
role=${ifDefined(this.dialogRole)}
|
|
||||||
@close=${() => this.close()}
|
|
||||||
>
|
|
||||||
${template}
|
|
||||||
</sts-dialog-base>
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
containerStyles,
|
|
||||||
onBackdropClick,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
this.#overlayHandle = this._overlay.attach(this, html`
|
this.#overlayHandle = this._overlay.attach(this, html`
|
||||||
<${unsafeStatic(this.tag!)}
|
<${unsafeStatic(this.tag!)}
|
||||||
@@ -400,7 +407,7 @@ export class DialogElement extends LitElement {
|
|||||||
|
|
||||||
if (changes.has("template")) {
|
if (changes.has("template")) {
|
||||||
if (this.#overlayHandle && this.template)
|
if (this.#overlayHandle && this.template)
|
||||||
this._overlay.reRender(this.#overlayHandle, this.template);
|
this._overlay.reRender(this.#overlayHandle, this.#overlayTemplate!);
|
||||||
}
|
}
|
||||||
|
|
||||||
super.updated(changes);
|
super.updated(changes);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
border-radius: unset;
|
border-radius: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
::slotted(sts-button:hover) {
|
::slotted(sts-button:not(.disabled):hover) {
|
||||||
background: var(--neutral-300);
|
background: var(--neutral-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.base.json",
|
"extends": "../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "ESNext",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
},
|
},
|
||||||
"files": [],
|
"files": [],
|
||||||
"include": [],
|
"include": [],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from "./lib/inspector/entity-inspector.element";
|
|||||||
export * from "./lib/inspector/inspector.types";
|
export * from "./lib/inspector/inspector.types";
|
||||||
export * from "./lib/loading.element";
|
export * from "./lib/loading.element";
|
||||||
export * from "./lib/media-library-dialog.element";
|
export * from "./lib/media-library-dialog.element";
|
||||||
|
export * from "./lib/save-scene";
|
||||||
export * from "./lib/scene-data-provider.element";
|
export * from "./lib/scene-data-provider.element";
|
||||||
export * from "./lib/scene-hierarchy.element";
|
export * from "./lib/scene-hierarchy.element";
|
||||||
export * from "./lib/studio.element";
|
export * from "./lib/studio.element";
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
::slotted([slot="canvas"]) {
|
::slotted([slot="canvas"]) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 * as studio from "@storyteller/studio";
|
||||||
import {
|
import {
|
||||||
SceneElement,
|
SceneElement,
|
||||||
@@ -10,11 +10,15 @@ import {
|
|||||||
} from "@storyteller/studio";
|
} from "@storyteller/studio";
|
||||||
import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree";
|
import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree";
|
||||||
import { match } from "@storyteller/utility";
|
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 { customElement, state } from "lit/decorators.js";
|
||||||
|
|
||||||
import { InspectorComponent } from "./inspector/inspector.types";
|
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> {
|
export interface SceneDataProvider extends DynamicProvider<SceneDataProvider> {
|
||||||
readonly tree: readonly SceneObject[];
|
readonly tree: readonly SceneObject[];
|
||||||
readonly entityMap: ReadonlyMap<string, SceneObject>;
|
readonly entityMap: ReadonlyMap<string, SceneObject>;
|
||||||
@@ -23,7 +27,50 @@ export interface SceneDataProvider extends DynamicProvider<SceneDataProvider> {
|
|||||||
readonly selectionComponents: readonly InspectorComponent[];
|
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"> {
|
export interface SceneObject extends Omit<studio.SceneObject, "children"> {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -32,11 +79,29 @@ export interface SceneObject extends Omit<studio.SceneObject, "children"> {
|
|||||||
|
|
||||||
@customElement("sts-scene-data-provider")
|
@customElement("sts-scene-data-provider")
|
||||||
@provide(SCENE_DATA_PROVIDER)
|
@provide(SCENE_DATA_PROVIDER)
|
||||||
|
@provide(REMOTE_SCENE_MANAGER)
|
||||||
@provide(TREE_SELECTION_PROVIDER)
|
@provide(TREE_SELECTION_PROVIDER)
|
||||||
export class SceneDataProviderElement
|
export class SceneDataProviderElement
|
||||||
extends LitElement
|
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 tree() { return this._tree; }
|
||||||
get entityMap() { return this.#entityMap; }
|
get entityMap() { return this.#entityMap; }
|
||||||
|
|
||||||
@@ -50,6 +115,9 @@ export class SceneDataProviderElement
|
|||||||
|
|
||||||
get selectionComponents() { return this._components; }
|
get selectionComponents() { return this._components; }
|
||||||
|
|
||||||
|
get canSaveInPlace() { return this._canSaveInPlace; }
|
||||||
|
get canSaveAsCopy() { return this._canSaveAsCopy; }
|
||||||
|
|
||||||
@state() _tree: SceneObject[] = [];
|
@state() _tree: SceneObject[] = [];
|
||||||
#sceneObjectsFlat: studio.SceneObject[] = [];
|
#sceneObjectsFlat: studio.SceneObject[] = [];
|
||||||
#entityMap = new Map<string, SceneObject>();
|
#entityMap = new Map<string, SceneObject>();
|
||||||
@@ -57,13 +125,109 @@ export class SceneDataProviderElement
|
|||||||
@state() _selection: string | null = null;
|
@state() _selection: string | null = null;
|
||||||
@state() _components: InspectorComponent[] = [];
|
@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
|
// TODO: This workflow allows dependents of this provider to listen for the
|
||||||
// "property-changes" event and manually trigger updates when relevant
|
// "property-changes" event and manually trigger updates when relevant
|
||||||
// properties change, but it's pretty fiddly to set up. Probably not
|
// properties change, but it's pretty fiddly to set up. Probably not
|
||||||
// worth fixing before we migrate over to Bevy-native UI.
|
// worth fixing before we migrate over to Bevy-native UI.
|
||||||
protected override update(changes: PropertyValues<this>): void {
|
protected override update(changes: PropertyValues<this>): void {
|
||||||
const downstreamChanges: PropertyValues<SceneDataProvider & TreeSelectionProvider>
|
type Interfaces
|
||||||
= new Map();
|
= 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")) {
|
if (changes.has("_tree")) {
|
||||||
downstreamChanges.set("tree", changes.get("_tree"));
|
downstreamChanges.set("tree", changes.get("_tree"));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as studio from "@storyteller/studio";
|
|||||||
export const mockApi = window.location.search.includes("mock-api");
|
export const mockApi = window.location.search.includes("mock-api");
|
||||||
|
|
||||||
interface IStorytellerApi {
|
interface IStorytellerApi {
|
||||||
createScene(file: File): Promise<MediaFileToken>
|
createScene(file: File, title?: string): Promise<MediaFileToken>
|
||||||
session(): Promise<Session>
|
session(): Promise<Session>
|
||||||
listMediaFiles(options: ListMediaFilesOptions): Promise<ListMediaFilesResult>
|
listMediaFiles(options: ListMediaFilesOptions): Promise<ListMediaFilesResult>
|
||||||
uploadMediaFile(
|
uploadMediaFile(
|
||||||
@@ -16,6 +16,7 @@ interface IStorytellerApi {
|
|||||||
renameMediaFile(token: MediaFileToken, name: string): Promise<void>
|
renameMediaFile(token: MediaFileToken, name: string): Promise<void>
|
||||||
convertFbxToGltf(token: MediaFileToken): Promise<void>
|
convertFbxToGltf(token: MediaFileToken): Promise<void>
|
||||||
mediaFile(token: MediaFileToken): Promise<MediaFile>
|
mediaFile(token: MediaFileToken): Promise<MediaFile>
|
||||||
|
writeSceneFile(token: MediaFileToken, file: File): Promise<MediaFileToken>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StorytellerRemoteApi implements IStorytellerApi {
|
export class StorytellerRemoteApi implements IStorytellerApi {
|
||||||
@@ -24,15 +25,16 @@ export class StorytellerRemoteApi implements IStorytellerApi {
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
async createScene(file: File): Promise<MediaFileToken> {
|
async createScene(file: File, title = "Untitled"): Promise<MediaFileToken> {
|
||||||
const response = await this.#api.postForm<MediaUploadResponse>(
|
const response = await this.#api.postForm<MediaUploadResponse>(
|
||||||
"v1/engine/create_scene",
|
"v1/engine/create_scene",
|
||||||
{
|
{
|
||||||
uuid_idempotency_token: uuid(),
|
uuid_idempotency_token: uuid(),
|
||||||
file,
|
file,
|
||||||
|
title,
|
||||||
}, {
|
}, {
|
||||||
headers: { Accept: "text/plain" },
|
headers: { Accept: "text/plain" },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.#validateResponse(response);
|
this.#validateResponse(response);
|
||||||
|
|
||||||
@@ -108,6 +110,23 @@ export class StorytellerRemoteApi implements IStorytellerApi {
|
|||||||
return response.data.media_file;
|
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> {
|
#validateResponse<R extends AxiosResponse>(response: R): asserts response is OkResponse<R> {
|
||||||
if (response.status !== 200)
|
if (response.status !== 200)
|
||||||
throw new Error(`API responded with status ${response.status}: "${response.statusText}"`);
|
throw new Error(`API responded with status ${response.status}: "${response.statusText}"`);
|
||||||
@@ -177,7 +196,7 @@ export class StorytellerLocalApi implements IStorytellerApi {
|
|||||||
return this.database
|
return this.database
|
||||||
}
|
}
|
||||||
|
|
||||||
async createScene(file: File): Promise<string> {
|
async createScene(file: File, title = "Untitled"): Promise<string> {
|
||||||
let token = uuid();
|
let token = uuid();
|
||||||
let original_file_name = file.name;
|
let original_file_name = file.name;
|
||||||
let filename = token + "-" + file.name;
|
let filename = token + "-" + file.name;
|
||||||
@@ -190,7 +209,7 @@ export class StorytellerLocalApi implements IStorytellerApi {
|
|||||||
public_bucket_path: filename,
|
public_bucket_path: filename,
|
||||||
creator_set_visibility: Visibility.Public,
|
creator_set_visibility: Visibility.Public,
|
||||||
is_emulated_media_file: false,
|
is_emulated_media_file: false,
|
||||||
maybe_title: filename,
|
maybe_title: title,
|
||||||
maybe_original_filename: original_file_name,
|
maybe_original_filename: original_file_name,
|
||||||
stats: {
|
stats: {
|
||||||
positive_rating_count: 0,
|
positive_rating_count: 0,
|
||||||
@@ -425,6 +444,10 @@ export class StorytellerLocalApi implements IStorytellerApi {
|
|||||||
return file;
|
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> {
|
async mediaBlob(path: string): Promise<Uint8Array> {
|
||||||
let db = await this.getDatabase();
|
let db = await this.getDatabase();
|
||||||
let file = await db.get('file-blobs', path);
|
let file = await db.get('file-blobs', path);
|
||||||
@@ -436,8 +459,8 @@ export class StorytellerLocalApi implements IStorytellerApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class StorytellerApi implements IStorytellerApi {
|
export class StorytellerApi implements IStorytellerApi {
|
||||||
createScene(file: File): Promise<string> {
|
createScene(file: File, title?: string): Promise<string> {
|
||||||
return this.apiBase.createScene(file)
|
return this.apiBase.createScene(file, title)
|
||||||
}
|
}
|
||||||
session(): Promise<Session> {
|
session(): Promise<Session> {
|
||||||
return this.apiBase.session()
|
return this.apiBase.session()
|
||||||
@@ -457,6 +480,10 @@ export class StorytellerApi implements IStorytellerApi {
|
|||||||
mediaFile(token: string): Promise<MediaFile> {
|
mediaFile(token: string): Promise<MediaFile> {
|
||||||
return this.apiBase.mediaFile(token)
|
return this.apiBase.mediaFile(token)
|
||||||
}
|
}
|
||||||
|
writeSceneFile(token: MediaFileToken, file: File): Promise<MediaFileToken> {
|
||||||
|
return this.apiBase.writeSceneFile(token, file);
|
||||||
|
}
|
||||||
|
|
||||||
apiBase: IStorytellerApi;
|
apiBase: IStorytellerApi;
|
||||||
|
|
||||||
constructor(apiBase: IStorytellerApi) {
|
constructor(apiBase: IStorytellerApi) {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.base.json",
|
"extends": "../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "ESNext",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
},
|
},
|
||||||
"files": [],
|
"files": [],
|
||||||
"include": [],
|
"include": [],
|
||||||
|
|||||||
Reference in New Issue
Block a user