mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-09 00:09:55 +00:00
Single-canvas juggling (#15)
This PR adds a `CanvasProviderElement` to `@storyteller/studio-web` to manage the complexity of maintaining a single concrete `HTMLCanvasElement` for `@storyteller/studio`, while still being able to treat components like the `ViewerElement` as self-contained "views" that behave as expected with regards to CSS styling, DOM placement, etc. Consuming applications should wrap the new `<sts-canvas-provider />` element around some common ancestor of all views into the `@storyteller/studio` app, similar to a React context provider. The `react-frontend` application has been updated to take advantage of the new `CanvasProviderElement` by demonstrating a more realistic use case for the `ViewerElement`. The new demo presents a grid of "thumbnails," one for each object in our hard-coded "object library." Clicking on one of these thumbnails will render a `ViewerElement` for that object in a modal dialog.
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export * from "./lib/decorators/on";
|
||||
export * from "./lib/decorators";
|
||||
export * from "./lib/di";
|
||||
export { default as spread } from "./lib/directives/spread";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./on";
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Method } from "@storyteller/utility";
|
||||
import { LitElement } from "lit";
|
||||
|
||||
/**
|
||||
* Modifies the prototype function at `proto[key]` by adding an invocation of
|
||||
* `callback` to the end of the method body.
|
||||
*/
|
||||
export function appendRoutine<
|
||||
E extends LitElement,
|
||||
M extends Method<E>,
|
||||
>(proto: E, key: string | number | symbol, callback: M): void {
|
||||
const original = getMethodDescriptor(proto, key)?.value;
|
||||
if (!original || typeof original !== "function")
|
||||
return;
|
||||
|
||||
Object.defineProperty(proto, key, {
|
||||
value(this: E): void {
|
||||
original.call(this);
|
||||
callback.call(this);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the prototype function at `proto[key]` by adding an invocation of
|
||||
* `callback` to the start of the method body.
|
||||
*/
|
||||
export function prependRoutine<
|
||||
E extends LitElement,
|
||||
M extends Method<E>,
|
||||
>(proto: E, key: string | number | symbol, callback: M): void {
|
||||
const original = getMethodDescriptor(proto, key)?.value;
|
||||
if (!original || typeof original !== "function")
|
||||
return;
|
||||
|
||||
Object.defineProperty(proto, key, {
|
||||
value(this: E): void {
|
||||
callback.call(this);
|
||||
original.call(this);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks for a member in the prototype chain named `key`, starting at `proto`
|
||||
* and recursing up the inheritance tree. If it reaches `HTMLElement` without
|
||||
* finding a match, it stops and returns `undefined` -- otherwise, it returns
|
||||
* the matching property descriptor.
|
||||
*/
|
||||
export function getMethodDescriptor<E extends LitElement>(proto: E, key: string | number | symbol)
|
||||
: TypedPropertyDescriptor<Method<E, [], void>> | undefined
|
||||
{
|
||||
let result = Object.getOwnPropertyDescriptor(proto, key);
|
||||
while (!result) {
|
||||
proto = Object.getPrototypeOf(proto);
|
||||
if (!proto || proto === HTMLElement.prototype)
|
||||
break;
|
||||
|
||||
result = Object.getOwnPropertyDescriptor(proto, key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { match } from "@storyteller/utility";
|
||||
import type { LitElement } from "lit";
|
||||
import { getMethodDescriptor, prependRoutine } from "./internal";
|
||||
|
||||
/**
|
||||
* Adds the decorated method as an event listener for the given event name, and
|
||||
@@ -21,29 +22,16 @@ export function on(eventSelector: string) {
|
||||
});
|
||||
|
||||
return <T extends LitElement>(proto: T, propName: keyof T, desc: PropertyDescriptor) => {
|
||||
const handler = desc?.value ?? proto[propName] ?? (() => {});
|
||||
const handler = getMethodDescriptor(proto, propName)?.value ?? (() => {});
|
||||
|
||||
const connectedCb = proto.connectedCallback;
|
||||
const disconnectedCb = proto.disconnectedCallback;
|
||||
|
||||
Object.defineProperty(proto, "connectedCallback", {
|
||||
value: function (this: HTMLElement) {
|
||||
target ??= this;
|
||||
target.addEventListener(eventName, handler.bind(this));
|
||||
connectedCb.call(this);
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
prependRoutine(proto, "connectedCallback", function (this: T) {
|
||||
target ??= this;
|
||||
target.addEventListener(eventName, handler.bind(this));
|
||||
});
|
||||
|
||||
Object.defineProperty(proto, "disconnectedCallback", {
|
||||
value: function (this: HTMLElement) {
|
||||
target ??= this;
|
||||
target.removeEventListener(eventName, handler.bind(this));
|
||||
disconnectedCb.call(this);
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
prependRoutine(proto, "disconnectedCallback", function (this: T) {
|
||||
target ??= this;
|
||||
target.removeEventListener(eventName, handler.bind(this));
|
||||
});
|
||||
|
||||
return desc;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
This module is a simple implementation of hierarchical [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection),
|
||||
inspired by [Angular](https://angular.io/guide/hierarchical-dependency-injection),
|
||||
based on [this talk](https://www.youtube.com/watch?v=6o5zaKHedTE) by Polymer/Lit
|
||||
engineer Justin Fagnani, and adapted from [this StackBlitz experiment](https://stackblitz.com/edit/vitejs-vite-6tenu8?file=src%2Fapp.ts).
|
||||
|
||||
### Design Goals
|
||||
|
||||
* Enable [dependency inversion](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
|
||||
by declaring dependencies as abstract interfaces which can be swapped out with
|
||||
different concrete implementations without necessitating downstream code
|
||||
changes
|
||||
* Enable bottom-up _injection_ of dependencies (i.e., descendants can inject
|
||||
dependencies provided by ancestors)
|
||||
* Enable top-down _querying_ for dependencies (i.e., ancestors can query for
|
||||
dependencies provided by descendants)
|
||||
* Enable these features through [water-tight](https://en.wikipedia.org/wiki/Leaky_abstraction),
|
||||
intuitive abstractions
|
||||
|
||||
### Core Concepts
|
||||
|
||||
* Abstract dependencies are represented by a "token," which serves as a runtime
|
||||
representation of a dependency described by a TypeScript interface
|
||||
* Dependency tokens are "provided" by custom elements annotated with a `provide`
|
||||
decorator
|
||||
* Dependencies can be "injected" by descendants of a provider (using the
|
||||
`inject` decorator to annotate a custom element property) or "queried" by
|
||||
ancestors of a provider (using the `queryProviders` decorator)
|
||||
|
||||
### Implementation
|
||||
|
||||
These features are implemented through an exchange of [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent)
|
||||
extensions.
|
||||
|
||||
* Providers dispatch a `DependencyProvision` event just before their
|
||||
`connectedCallback` method is invoked, and a `ProviderRemoval` event just
|
||||
after `disconnectedCallback`.
|
||||
* Elements with `queryProviders`-decorated properties listen for
|
||||
`DependencyProvision` and `ProviderRemoval` events, updating the decorated
|
||||
property's array accordingly.
|
||||
* Elements with `inject`-decorated properties dispatch an `InjectionRequest`
|
||||
event just before their `connectedCallback` method is invoked. Providers
|
||||
listen for this event. If they provide a token matching the one specified
|
||||
by the `inject` decorator, the provider will:
|
||||
|
||||
- Mutate the event's `detail` property to insert the provided `value`
|
||||
- `stopImmediatePropagation` of the event to prevent it from bubbling further
|
||||
up the DOM tree
|
||||
|
||||
Because JavaScript event bubbling and event listener invocations happen
|
||||
synchronously, the injector can check the `detail` property of the dispatched
|
||||
event immediately after dispatching it to retrieve the injected value. This
|
||||
value is then assigned to a field returned by the decorated property's `get`
|
||||
accessor.
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Ctor, Fn, Method } from "@storyteller/utility";
|
||||
import { LitElement } from "lit";
|
||||
|
||||
import { appendRoutine, prependRoutine } from "../decorators/internal";
|
||||
import { DIEvent, DependencyProvision, InjectionRequest, ProviderRemoval } from "./events";
|
||||
import { Token } from "./types";
|
||||
|
||||
const $injector = Symbol("injector");
|
||||
const $$injector = Symbol("#injector");
|
||||
|
||||
export type ProviderOptions<T, E extends LitElement>
|
||||
= Token<T>
|
||||
| CustomProvider<T, E>;
|
||||
|
||||
export interface CustomProvider<T, E extends LitElement> {
|
||||
token: Token<T>;
|
||||
/**
|
||||
* A method which is invoked just before `connectedCallback` to resolve the
|
||||
* provided value.
|
||||
*/
|
||||
provide(this: E): T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the decorated custom element as a provider of the given `token`.
|
||||
* Descendants who `inject` the `token`, and ancestors who `queryProviders` for
|
||||
* `token`, will receive a reference to the instance of this element.
|
||||
*/
|
||||
export function provide<T, E extends LitElement>(token: Token<T>): Fn<[Ctor<E>], void>;
|
||||
|
||||
/**
|
||||
* Registers the decorated custom element as a custom provider for the
|
||||
* configured `token`. Descendants who `inject` the `token`, and ancestors who
|
||||
* `queryProviders` for `token`, will receive the return value of the configured
|
||||
* `provide` method, which is invoked just before this element's
|
||||
* `connectedCallback`.
|
||||
*/
|
||||
export function provide<T, E extends LitElement>(config: CustomProvider<T, E>): Fn<[Ctor<E>], void>;
|
||||
|
||||
export function provide<T, E extends LitElement>(options: ProviderOptions<T, E>) {
|
||||
type Decorated = E & {
|
||||
[$$injector]?: Map<Token<any>, any> | undefined;
|
||||
readonly [$injector]: Map<Token<any>, any>;
|
||||
}
|
||||
|
||||
return (Target: Ctor<E>) => {
|
||||
const proto = Target.prototype;
|
||||
assertType<Decorated>(proto);
|
||||
|
||||
let descriptor = Object.getOwnPropertyDescriptor(proto, $injector);
|
||||
let needsInit = false;
|
||||
if (!($injector in proto) || !descriptor) {
|
||||
needsInit = true;
|
||||
Object.defineProperty(proto, $injector, {
|
||||
get(this: Decorated) { return this[$$injector] ??= new Map() },
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function fulfillInjectionRequest<T>(this: Decorated, event: InjectionRequest<T>): void {
|
||||
if (this[$injector].has(event.detail.token)) {
|
||||
event.detail.value = this[$injector].get(event.detail.token);
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
prependRoutine(proto, "connectedCallback", function (this: Decorated): void {
|
||||
const { token, value } = "token" in options
|
||||
? { token: options.token, value: options.provide.call(this) }
|
||||
: { token: options, value: this as T };
|
||||
|
||||
this[$injector].set(token, value);
|
||||
this.dispatchEvent(new DependencyProvision(token, value));
|
||||
|
||||
if (needsInit) {
|
||||
const fulfill = fulfillInjectionRequest.bind(this);
|
||||
this.addEventListener(DIEvent.InjectionRequest, fulfill as Method<HTMLElement>);
|
||||
}
|
||||
});
|
||||
|
||||
appendRoutine(proto, "disconnectedCallback", function (this: Decorated): void {
|
||||
const token = "token" in options ? options.token : options;
|
||||
const value = this[$injector].get(token);
|
||||
|
||||
this.dispatchEvent(new ProviderRemoval(token, value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the decorated property just before `connectedCallback` by looking
|
||||
* up the nearest ancestor element configured to `provide` the given `token`.
|
||||
*
|
||||
* @note If no provider for the given `token` is found among this element's
|
||||
* ancestors, the decorated property will remain `undefined` unless initialized
|
||||
* with a default value.
|
||||
*/
|
||||
export function inject<T, E extends LitElement>(token: Token<T>) {
|
||||
return <K extends keyof E>(proto: E, key: K) => {
|
||||
const $field = Symbol(String(key));
|
||||
const initial = proto[key];
|
||||
|
||||
type Decorated = E & {
|
||||
[$field]?: E[K] | undefined;
|
||||
}
|
||||
|
||||
assertType<Decorated>(proto);
|
||||
|
||||
Object.defineProperty(proto, key, {
|
||||
get(this: Decorated): E[K] {
|
||||
return this[$field] ??= initial;
|
||||
},
|
||||
set(this: Decorated, value: E[K]): void {
|
||||
this[$field] = value;
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
prependRoutine(proto, "connectedCallback", function (this: E): void {
|
||||
const event = new InjectionRequest(token);
|
||||
this.dispatchEvent(event);
|
||||
|
||||
if (event.detail.value != null) {
|
||||
this[key] = event.detail.value as E[K];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the decorated property just after `connectedCallback` by looking
|
||||
* for descendants configured to `provide` the given `token`.
|
||||
*
|
||||
* @note It's strongly recommended to default-initialize the decorated property
|
||||
* to an empty array. The decorated property will be immutably updated at
|
||||
* runtime (via `Array.prototype.concat` and `Array.prototype.filter`) when
|
||||
* providers are added to or removed from the DOM sub-tree.
|
||||
*/
|
||||
export function queryProviders<T, E extends LitElement>(token: Token<T>) {
|
||||
return <K extends keyof E>(proto: E, key: K) => {
|
||||
function onProvided(this: E, event: DependencyProvision<T>): void {
|
||||
const array = this[key] ?? [];
|
||||
assertType<E[K] & T[]>(array);
|
||||
|
||||
if (event.detail.token === token)
|
||||
this[key] = array.concat(event.detail.value) as E[K];
|
||||
}
|
||||
|
||||
function onRemoved(this: E, event: ProviderRemoval<T>): void {
|
||||
const array = this[key] ?? [];
|
||||
assertType<E[K] & T[]>(array);
|
||||
|
||||
if (event.detail.token === token)
|
||||
this[key] = array.filter(el => el !== event.detail.value) as E[K];
|
||||
}
|
||||
|
||||
appendRoutine(proto, "connectedCallback", function (this: E): void {
|
||||
this[key] = [] as E[K];
|
||||
this.addEventListener(
|
||||
DIEvent.DependencyProvision,
|
||||
onProvided.bind(this) as Method<HTMLElement>,
|
||||
);
|
||||
this.addEventListener(
|
||||
DIEvent.ProviderRemoval,
|
||||
onRemoved.bind(this) as Method<HTMLElement>,
|
||||
);
|
||||
});
|
||||
|
||||
appendRoutine(proto, "disconnectedCallback", function (this: E): void {
|
||||
this.removeEventListener(
|
||||
DIEvent.DependencyProvision,
|
||||
onProvided.bind(this) as Method<HTMLElement>,
|
||||
);
|
||||
this.removeEventListener(
|
||||
DIEvent.ProviderRemoval,
|
||||
onRemoved.bind(this) as Method<HTMLElement>,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertType<T>(value: unknown): asserts value is T {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { WithOpt } from "@storyteller/utility";
|
||||
import { Provider, Token } from "./types";
|
||||
|
||||
export enum DIEvent {
|
||||
InjectionRequest = "di-inject",
|
||||
DependencyProvision = "di-provide",
|
||||
ProviderRemoval = "di-remove",
|
||||
}
|
||||
|
||||
type PendingProvider<T> = WithOpt<Provider<T>, "value">;
|
||||
|
||||
/**
|
||||
* An event that can be dispatched to inject a dependency provided by an
|
||||
* ancestor node.
|
||||
*
|
||||
* Providers should listen for this event. If they provide a match for the
|
||||
* event's `detail.token`, they should populate its `detail.value` property and
|
||||
* call `stopPropagation()` to prevent further bubbling.
|
||||
*/
|
||||
export class InjectionRequest<T> extends CustomEvent<PendingProvider<T>> {
|
||||
declare readonly type: DIEvent.InjectionRequest;
|
||||
|
||||
constructor (token: Token<T>) {
|
||||
super(DIEvent.InjectionRequest, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
detail: { token },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event that can be dispatched to inform ancestors that this node provides a
|
||||
* dependency.
|
||||
*/
|
||||
export class DependencyProvision<T> extends CustomEvent<Provider<T>> {
|
||||
declare readonly type: DIEvent.DependencyProvision;
|
||||
|
||||
constructor (token: Token<T>, value: T) {
|
||||
super(DIEvent.DependencyProvision, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
detail: { token, value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event that can be dispatched to inform ancestors when a node providing a
|
||||
* dependency is removed from the tree.
|
||||
*/
|
||||
export class ProviderRemoval<T> extends CustomEvent<Provider<T>> {
|
||||
declare readonly type: DIEvent.ProviderRemoval;
|
||||
|
||||
constructor (token: Token<T>, value: T) {
|
||||
super(DIEvent.ProviderRemoval, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
detail: { token, value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
[DIEvent.InjectionRequest]: InjectionRequest<any>;
|
||||
[DIEvent.DependencyProvision]: DependencyProvision<any>;
|
||||
[DIEvent.ProviderRemoval]: ProviderRemoval<any>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./decorators";
|
||||
export * from "./events";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AbstractCtor, Ctor } from "@storyteller/utility";
|
||||
|
||||
export class UniqueToken {
|
||||
get id() { return this.#id; }
|
||||
readonly #id: symbol;
|
||||
|
||||
constructor (name: string) {
|
||||
this.#id = Symbol(name);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `UniqueToken(${String(this.#id)})`;
|
||||
}
|
||||
}
|
||||
|
||||
export type Token<T>
|
||||
= UniqueToken
|
||||
| Ctor<T>
|
||||
| AbstractCtor<T>
|
||||
;
|
||||
|
||||
export interface Provider<T> {
|
||||
token: Token<T>;
|
||||
value: T;
|
||||
}
|
||||
@@ -1,5 +1,58 @@
|
||||
.root {
|
||||
position: relative;
|
||||
padding: 64px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
flex: 0 0 352px;
|
||||
width: 352px;
|
||||
height: 352px;
|
||||
background: #444;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #2222227F;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
top: calc(50vh - 360px);
|
||||
left: calc(50vw - 640px);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 0 16px rgba(0,0,0,0.17),
|
||||
0 16px 48px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #222;
|
||||
pointer-events: none;
|
||||
transition: opacity 66.666ms linear;
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -34,13 +87,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
.modalClose {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #222;
|
||||
pointer-events: none;
|
||||
transition: opacity 66.666ms linear;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
border-radius: 50%;
|
||||
appearance: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #2222227F;
|
||||
backdrop-filter: blur(16px);
|
||||
font: unset {
|
||||
family: inherit;
|
||||
weight: 400;
|
||||
size: 40px;
|
||||
}
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #222222AA;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type SceneStateEvent, SceneState } from "@storyteller/studio";
|
||||
import { FormEvent, useState } from "react";
|
||||
|
||||
import StudioViewer from "./studio-viewer";
|
||||
import StudioCanvasProvider from "./studio-canvas-provider";
|
||||
|
||||
import style from "./app.module.scss";
|
||||
|
||||
@@ -79,14 +80,11 @@ const SKYBOXES = [
|
||||
];
|
||||
|
||||
export default () => {
|
||||
const [objectId, setObjectId] = useState("desk");
|
||||
const [skyboxId, setSkyboxId] = useState("test_scene");
|
||||
const [objectId, setObjectId] = useState<string|null>(null);
|
||||
const [skyboxId, setSkyboxId] = useState("meadow_4k");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const onObjectSelect = (event: FormEvent<HTMLSelectElement>) => {
|
||||
setObjectId((event.target as HTMLSelectElement).value)
|
||||
}
|
||||
|
||||
const onSkyboxSelect = (event: FormEvent<HTMLSelectElement>) => {
|
||||
setSkyboxId((event.target as HTMLSelectElement).value)
|
||||
}
|
||||
@@ -96,62 +94,75 @@ export default () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.root}>
|
||||
<StudioViewer
|
||||
objectId={objectId}
|
||||
skyboxId={skyboxId}
|
||||
onSceneStateChange={onSceneStateChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={style.loading}
|
||||
style={{
|
||||
opacity: loading ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<h1>Loading...</h1>
|
||||
<StudioCanvasProvider>
|
||||
<div className={style.root}>
|
||||
{OBJECTS.map(({ id, label }) => (
|
||||
<div
|
||||
className={style.thumbnail}
|
||||
key={id}
|
||||
onClick={() => {
|
||||
if (id !== objectId) {
|
||||
setLoading(true);
|
||||
setObjectId(id);
|
||||
}
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<h3>{label}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={style.controls}>
|
||||
<label className={style.control}>
|
||||
<span className={style.label}>Object</span>
|
||||
<select
|
||||
className={style.input}
|
||||
name="object"
|
||||
value={objectId}
|
||||
disabled={loading}
|
||||
onChange={onObjectSelect}
|
||||
{modalOpen && !!objectId && <>
|
||||
<div
|
||||
className={style.modalOverlay}
|
||||
onClick={() => setModalOpen(false)}
|
||||
/>
|
||||
<div className={style.modal}>
|
||||
<StudioViewer
|
||||
objectId={objectId}
|
||||
skyboxId={skyboxId}
|
||||
onSceneStateChange={onSceneStateChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={style.loading}
|
||||
style={{
|
||||
opacity: loading ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{OBJECTS.map(({ id, label }) => (
|
||||
<option
|
||||
key={id}
|
||||
value={id}
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
|
||||
<div className={style.controls}>
|
||||
<label className={style.control}>
|
||||
<span className={style.label}>Skybox</span>
|
||||
<select
|
||||
className={style.input}
|
||||
name="skybox"
|
||||
value={skyboxId}
|
||||
disabled={loading}
|
||||
onChange={onSkyboxSelect}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={style.control}>
|
||||
<span className={style.label}>Skybox</span>
|
||||
<select
|
||||
className={style.input}
|
||||
name="skybox"
|
||||
value={skyboxId}
|
||||
disabled={loading}
|
||||
onChange={onSkyboxSelect}
|
||||
{SKYBOXES.map(({ id, label }) => (
|
||||
<option
|
||||
key={id}
|
||||
value={id}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button className={style.modalClose}
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
{SKYBOXES.map(({ id, label }) => (
|
||||
<option
|
||||
key={id}
|
||||
value={id}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</>}
|
||||
</StudioCanvasProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { CanvasProviderElement } from "@storyteller/studio-web/canvas-provider";
|
||||
import "@storyteller/studio-web/canvas-provider";
|
||||
import React, { ReactNode, forwardRef } from "react";
|
||||
|
||||
export interface Props extends React.HTMLProps<CanvasProviderElement> {
|
||||
children?: ReactNode | ReactNode[];
|
||||
}
|
||||
|
||||
export default forwardRef<CanvasProviderElement, Props>(
|
||||
({ children, ...props }, ref) => (
|
||||
<sts-canvas-provider {...props} ref={ref}>
|
||||
{children}
|
||||
</sts-canvas-provider>
|
||||
)
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { SceneStateEvent } from "@storyteller/studio";
|
||||
import { ViewerElement } from "@storyteller/studio-web/viewer";
|
||||
import "@storyteller/studio-web/viewer";
|
||||
import { MutableRefObject, forwardRef, useEffect, useRef } from "react";
|
||||
import React, { MutableRefObject, forwardRef, useEffect, useRef } from "react";
|
||||
|
||||
export interface Props {
|
||||
export interface Props extends React.HTMLProps<ViewerElement> {
|
||||
objectId: string;
|
||||
skyboxId: string;
|
||||
onSceneStateChange?(event: SceneStateEvent): void;
|
||||
@@ -13,6 +13,7 @@ export default forwardRef<ViewerElement, Props>(({
|
||||
objectId,
|
||||
skyboxId,
|
||||
onSceneStateChange,
|
||||
...props
|
||||
}, ref) => {
|
||||
const viewerRef = useRef<ViewerElement>(null);
|
||||
|
||||
@@ -38,6 +39,7 @@ export default forwardRef<ViewerElement, Props>(({
|
||||
|
||||
return (
|
||||
<sts-viewer
|
||||
{...props}
|
||||
ref={refCallback}
|
||||
objectId={objectId}
|
||||
skyboxId={skyboxId}
|
||||
|
||||
@@ -2,7 +2,6 @@ html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -1,4 +1,5 @@
|
||||
import { ViewerElement } from "@storyteller/studio-web";
|
||||
import { CanvasProviderElement } from "@storyteller/studio-web/canvas-provider";
|
||||
|
||||
declare global {
|
||||
export namespace JSX {
|
||||
@@ -9,6 +10,10 @@ declare global {
|
||||
React.HTMLAttributes<ViewerElement>,
|
||||
ViewerElement,
|
||||
>;
|
||||
"sts-canvas-provider": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<CanvasProviderElement>,
|
||||
CanvasProviderElement,
|
||||
>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,14 @@
|
||||
font-size: 16px;
|
||||
padding: 10px 12px;
|
||||
pointer-events: auto;
|
||||
outline: 0 solid transparent;
|
||||
|
||||
// TODO: Copypasta
|
||||
background: #0006;
|
||||
color: #FFFC;
|
||||
backdrop-filter: blur(16px);
|
||||
transition: {
|
||||
property: background, color;
|
||||
property: background, color, outline-width, outline-color;
|
||||
duration: 0.1s;
|
||||
timing-function: linear;
|
||||
}
|
||||
@@ -62,6 +63,11 @@
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
&:has(:focus-visible) {
|
||||
z-index: 1;
|
||||
outline: 4px solid #2F83FFBF;
|
||||
}
|
||||
|
||||
&:has(:checked) {
|
||||
background: #7A2C34E3;
|
||||
color: #FFF;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"module": "./index.mjs",
|
||||
"typings": "./index.d.ts",
|
||||
"exports": {
|
||||
"./canvas-provider": "./canvas-provider.element.js",
|
||||
"./viewer": "./viewer.element.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
::slotted([slot="canvas"]) {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Token, UniqueToken, provide } from "@storyteller/framework";
|
||||
import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
import styles from "./canvas-provider.element.scss?inline";
|
||||
|
||||
export interface CanvasProvider {
|
||||
readonly host: HTMLElement;
|
||||
readonly canvasId: string;
|
||||
readonly canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
/**
|
||||
* Remove the canvas element from the provider host and return it. This can
|
||||
* be used to temporarily move the canvas element to a different location in
|
||||
* the DOM tree (e.g., for displaying in a modal dialog).
|
||||
*
|
||||
* `returnCanvas` must be called to restore the canvas element to its
|
||||
* original location before the canvas's new parent element is removed from
|
||||
* the document. If the canvas element is entirely absent from the document
|
||||
* at any point after initialization (i.e., if
|
||||
* `document.getElementById(canvasId)` returns `null`), the
|
||||
* `@storyteller/studio` app will panic.
|
||||
*/
|
||||
takeCanvas(): HTMLCanvasElement | undefined;
|
||||
|
||||
/**
|
||||
* Return the canvas element to the provider after removing it via
|
||||
* `takeCanvas`.
|
||||
*
|
||||
* When `takeCanvas` is used to move the canvas element to a new location,
|
||||
* this method must be called before the canvas's new parent is removed from
|
||||
* the DOM tree, to ensure that the canvas element is always accessible to
|
||||
* `@storyteller/studio`.
|
||||
*/
|
||||
returnCanvas(canvas: HTMLCanvasElement): void;
|
||||
}
|
||||
|
||||
export const CANVAS_PROVIDER: Token<CanvasProvider> = new UniqueToken("CanvasProvider");
|
||||
|
||||
@customElement("sts-canvas-provider")
|
||||
@provide(CANVAS_PROVIDER)
|
||||
export class CanvasProviderElement
|
||||
extends LitElement
|
||||
implements CanvasProvider
|
||||
{
|
||||
static override styles = unsafeCSS(styles);
|
||||
|
||||
get host() { return this; }
|
||||
get canvasId() { return this.#canvasId; }
|
||||
get canvas(): HTMLCanvasElement | undefined { return this.#canvas; }
|
||||
|
||||
#canvasId = "studio_canvas";
|
||||
#canvas?: HTMLCanvasElement;
|
||||
|
||||
takeCanvas(): HTMLCanvasElement | undefined {
|
||||
if (!this.#canvas) return;
|
||||
|
||||
const canvas = this.removeChild(this.#canvas);
|
||||
this.#canvas = undefined;
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
returnCanvas(canvas: HTMLCanvasElement) {
|
||||
this.appendChild(canvas);
|
||||
this.#canvas = canvas;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
this.#canvas ??= this.#appendLightDomCanvas();
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<slot></slot>
|
||||
<slot name="canvas"></slot>
|
||||
`;
|
||||
|
||||
#appendLightDomCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.id = this.#canvasId;
|
||||
canvas.slot = "canvas";
|
||||
|
||||
this.appendChild(canvas);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface HTMLElementTagNameMap {
|
||||
"sts-canvas-provider": CanvasProviderElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
::slotted([slot="canvas"]) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import * as studio from "@storyteller/studio";
|
||||
import { ViewportSize } from "@storyteller/studio";
|
||||
import { inject } from "@storyteller/framework";
|
||||
import { LitElement, PropertyValues, html, unsafeCSS } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { CANVAS_PROVIDER, CanvasProvider } from "./canvas-provider.element";
|
||||
|
||||
import styles from "./viewer.element.scss?inline";
|
||||
import { ViewportSize } from "@storyteller/studio";
|
||||
|
||||
@customElement("sts-viewer")
|
||||
export class ViewerElement extends LitElement {
|
||||
@@ -12,8 +15,32 @@ export class ViewerElement extends LitElement {
|
||||
@property() objectId?: string;
|
||||
@property() skyboxId?: string;
|
||||
|
||||
protected override createRenderRoot(): Element | ShadowRoot {
|
||||
return this;
|
||||
@inject(CANVAS_PROVIDER)
|
||||
_canvasProvider?: CanvasProvider;
|
||||
|
||||
#canvas?: HTMLCanvasElement;
|
||||
#resizeObserver?: ResizeObserver;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.#resizeObserver = new ResizeObserver(this.#onResize.bind(this));
|
||||
this.#resizeObserver.observe(this);
|
||||
|
||||
if (!this._canvasProvider)
|
||||
throw new Error(`No provider found for token: ${CANVAS_PROVIDER}`);
|
||||
|
||||
if ((this.#canvas = this._canvasProvider.takeCanvas()))
|
||||
this.appendChild(this.#canvas);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
if (this.#canvas)
|
||||
this._canvasProvider?.returnCanvas(this.removeChild(this.#canvas));
|
||||
|
||||
this.#resizeObserver?.disconnect();
|
||||
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected override updated(changes: PropertyValues<this>): void {
|
||||
@@ -28,17 +55,26 @@ export class ViewerElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<slot name="canvas"></slot>
|
||||
`;
|
||||
|
||||
#onResize(): void {
|
||||
const { width, height } = this.getBoundingClientRect();
|
||||
studio.resize(new ViewportSize(width, height));
|
||||
}
|
||||
|
||||
#startViewer(): void {
|
||||
if (!this.#canvas) return;
|
||||
if (!this.objectId) return;
|
||||
if (!this.skyboxId) return;
|
||||
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const { width, height } = this.getBoundingClientRect();
|
||||
try {
|
||||
studio.startViewer(
|
||||
this.objectId,
|
||||
this.skyboxId,
|
||||
"#studio_canvas",
|
||||
`#${this._canvasProvider!.canvasId}`,
|
||||
"Storyteller Studio",
|
||||
new ViewportSize(width, height),
|
||||
);
|
||||
@@ -47,10 +83,6 @@ export class ViewerElement extends LitElement {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected override render = () => html`
|
||||
<canvas id="studio_canvas" alt="Storyteller Studio"></canvas>
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
+14
-1
@@ -1,4 +1,5 @@
|
||||
use bevy::{
|
||||
asset::AssetPath,
|
||||
core_pipeline::{
|
||||
bloom::{BloomCompositeMode, BloomPrefilterSettings, BloomSettings},
|
||||
fxaa::Fxaa,
|
||||
@@ -143,12 +144,24 @@ fn initialize(mut cmd: Commands) {
|
||||
fn init_scene_loading(
|
||||
mut cmd: Commands,
|
||||
assets: Res<AssetServer>,
|
||||
r_active_scene: Option<Res<ActiveScene>>,
|
||||
mut er: EventReader<LoadSceneEvent>,
|
||||
mut st_next: ResMut<NextState<SceneState>>,
|
||||
) {
|
||||
if let Some(id) = er.read().last() {
|
||||
let name = &**id;
|
||||
cmd.insert_resource(PendingScene(assets.load(format!("gltf/{name}.gltf"))));
|
||||
let path = format!("gltf/{name}.gltf");
|
||||
|
||||
// Avoid trying to re-load this scene if it's already active
|
||||
if let Some(active_scene_path) =
|
||||
r_active_scene.and_then(|scene| scene.source.path().cloned())
|
||||
{
|
||||
if active_scene_path == AssetPath::parse(&path) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cmd.insert_resource(PendingScene(assets.load(path)));
|
||||
st_next.set(SceneState::Loading);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@storyteller/framework": ["framework/src/index.ts"],
|
||||
"@storyteller/studio": ["dist/studio"],
|
||||
"@storyteller/studio-web": ["studio-web/src/index.ts"],
|
||||
"@storyteller/studio-web/canvas-provider": ["studio-web/src/lib/canvas-provider.element.ts"],
|
||||
"@storyteller/studio-web/viewer": ["studio-web/src/lib/viewer.element.ts"],
|
||||
"@storyteller/utility": ["utility/src/index.ts"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
export interface Ctor<T, Args extends any[] = any[]> {
|
||||
new (...args: Args): T;
|
||||
prototype: T;
|
||||
}
|
||||
|
||||
export type AbstractCtor<T, Args extends any[] = any[]>
|
||||
= (abstract new (...args: Args) => T)
|
||||
& { prototype: T }
|
||||
;
|
||||
|
||||
export interface Fn<Args extends any[] = [], R = void> {
|
||||
(...args: Args): R;
|
||||
}
|
||||
|
||||
export interface Method<T, Args extends any[] = any[], R = any> {
|
||||
(this: T, ...args: Args): R;
|
||||
}
|
||||
|
||||
export type Opt<T> = T | null | undefined;
|
||||
|
||||
export type WithOpt<T, K extends keyof T>
|
||||
= Omit<T, K>
|
||||
& Partial<Pick<T, K>>
|
||||
|
||||
Reference in New Issue
Block a user