diff --git a/framework/src/index.ts b/framework/src/index.ts index 49b1fa1..6b91947 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -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"; diff --git a/framework/src/lib/decorators/index.ts b/framework/src/lib/decorators/index.ts new file mode 100644 index 0000000..66c74fd --- /dev/null +++ b/framework/src/lib/decorators/index.ts @@ -0,0 +1 @@ +export * from "./on"; diff --git a/framework/src/lib/decorators/internal.ts b/framework/src/lib/decorators/internal.ts new file mode 100644 index 0000000..026fe5e --- /dev/null +++ b/framework/src/lib/decorators/internal.ts @@ -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, +>(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, +>(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(proto: E, key: string | number | symbol) + : TypedPropertyDescriptor> | 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; +} diff --git a/framework/src/lib/decorators/on.ts b/framework/src/lib/decorators/on.ts index e15ad57..dbd4f5b 100644 --- a/framework/src/lib/decorators/on.ts +++ b/framework/src/lib/decorators/on.ts @@ -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 (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; diff --git a/framework/src/lib/di/README.md b/framework/src/lib/di/README.md new file mode 100644 index 0000000..5d6b2d5 --- /dev/null +++ b/framework/src/lib/di/README.md @@ -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. diff --git a/framework/src/lib/di/decorators.ts b/framework/src/lib/di/decorators.ts new file mode 100644 index 0000000..6674c96 --- /dev/null +++ b/framework/src/lib/di/decorators.ts @@ -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 + = Token + | CustomProvider; + +export interface CustomProvider { + token: Token; + /** + * 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(token: Token): Fn<[Ctor], 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(config: CustomProvider): Fn<[Ctor], void>; + +export function provide(options: ProviderOptions) { + type Decorated = E & { + [$$injector]?: Map, any> | undefined; + readonly [$injector]: Map, any>; + } + + return (Target: Ctor) => { + const proto = Target.prototype; + assertType(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(this: Decorated, event: InjectionRequest): 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); + } + }); + + 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(token: Token) { + return (proto: E, key: K) => { + const $field = Symbol(String(key)); + const initial = proto[key]; + + type Decorated = E & { + [$field]?: E[K] | undefined; + } + + assertType(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(token: Token) { + return (proto: E, key: K) => { + function onProvided(this: E, event: DependencyProvision): void { + const array = this[key] ?? []; + assertType(array); + + if (event.detail.token === token) + this[key] = array.concat(event.detail.value) as E[K]; + } + + function onRemoved(this: E, event: ProviderRemoval): void { + const array = this[key] ?? []; + assertType(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, + ); + this.addEventListener( + DIEvent.ProviderRemoval, + onRemoved.bind(this) as Method, + ); + }); + + appendRoutine(proto, "disconnectedCallback", function (this: E): void { + this.removeEventListener( + DIEvent.DependencyProvision, + onProvided.bind(this) as Method, + ); + this.removeEventListener( + DIEvent.ProviderRemoval, + onRemoved.bind(this) as Method, + ); + }); + } +} + +function assertType(value: unknown): asserts value is T {} diff --git a/framework/src/lib/di/events.ts b/framework/src/lib/di/events.ts new file mode 100644 index 0000000..629d5c8 --- /dev/null +++ b/framework/src/lib/di/events.ts @@ -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 = WithOpt, "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 extends CustomEvent> { + declare readonly type: DIEvent.InjectionRequest; + + constructor (token: Token) { + 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 extends CustomEvent> { + declare readonly type: DIEvent.DependencyProvision; + + constructor (token: Token, 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 extends CustomEvent> { + declare readonly type: DIEvent.ProviderRemoval; + + constructor (token: Token, value: T) { + super(DIEvent.ProviderRemoval, { + bubbles: true, + cancelable: true, + composed: true, + detail: { token, value }, + }); + } +} + +declare global { + interface HTMLElementEventMap { + [DIEvent.InjectionRequest]: InjectionRequest; + [DIEvent.DependencyProvision]: DependencyProvision; + [DIEvent.ProviderRemoval]: ProviderRemoval; + } +} diff --git a/framework/src/lib/di/index.ts b/framework/src/lib/di/index.ts new file mode 100644 index 0000000..ce72d0c --- /dev/null +++ b/framework/src/lib/di/index.ts @@ -0,0 +1,3 @@ +export * from "./decorators"; +export * from "./events"; +export * from "./types"; diff --git a/framework/src/lib/di/types.ts b/framework/src/lib/di/types.ts new file mode 100644 index 0000000..1e9c965 --- /dev/null +++ b/framework/src/lib/di/types.ts @@ -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 + = UniqueToken + | Ctor + | AbstractCtor + ; + +export interface Provider { + token: Token; + value: T; +} diff --git a/react-frontend/src/app/app.module.scss b/react-frontend/src/app/app.module.scss index d53b537..bdda68f 100644 --- a/react-frontend/src/app/app.module.scss +++ b/react-frontend/src/app/app.module.scss @@ -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; + } } diff --git a/react-frontend/src/app/app.tsx b/react-frontend/src/app/app.tsx index c0cb471..5af94b3 100644 --- a/react-frontend/src/app/app.tsx +++ b/react-frontend/src/app/app.tsx @@ -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(null); + const [skyboxId, setSkyboxId] = useState("meadow_4k"); + const [modalOpen, setModalOpen] = useState(false); const [loading, setLoading] = useState(true); - const onObjectSelect = (event: FormEvent) => { - setObjectId((event.target as HTMLSelectElement).value) - } - const onSkyboxSelect = (event: FormEvent) => { setSkyboxId((event.target as HTMLSelectElement).value) } @@ -96,62 +94,75 @@ export default () => { } return ( -
- - -
-

Loading...

+ +
+ {OBJECTS.map(({ id, label }) => ( +
{ + if (id !== objectId) { + setLoading(true); + setObjectId(id); + } + setModalOpen(true); + }} + > +

{label}

+
+ ))}
-
- - +
+ +
-
+ × + + + } + ); } diff --git a/react-frontend/src/app/studio-canvas-provider.tsx b/react-frontend/src/app/studio-canvas-provider.tsx new file mode 100644 index 0000000..290c762 --- /dev/null +++ b/react-frontend/src/app/studio-canvas-provider.tsx @@ -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 { + children?: ReactNode | ReactNode[]; +} + +export default forwardRef( + ({ children, ...props }, ref) => ( + + {children} + + ) +); diff --git a/react-frontend/src/app/studio-viewer.tsx b/react-frontend/src/app/studio-viewer.tsx index e92a328..a5a2ea5 100644 --- a/react-frontend/src/app/studio-viewer.tsx +++ b/react-frontend/src/app/studio-viewer.tsx @@ -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 { objectId: string; skyboxId: string; onSceneStateChange?(event: SceneStateEvent): void; @@ -13,6 +13,7 @@ export default forwardRef(({ objectId, skyboxId, onSceneStateChange, + ...props }, ref) => { const viewerRef = useRef(null); @@ -38,6 +39,7 @@ export default forwardRef(({ return ( , ViewerElement, >; + "sts-canvas-provider": React.DetailedHTMLProps< + React.HTMLAttributes, + CanvasProviderElement, + > } } } diff --git a/studio-frontend/src/app/app.element.scss b/studio-frontend/src/app/app.element.scss index d6c1b7f..7d60320 100644 --- a/studio-frontend/src/app/app.element.scss +++ b/studio-frontend/src/app/app.element.scss @@ -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; diff --git a/studio-web/package.json b/studio-web/package.json index 1602a83..c9dd07d 100644 --- a/studio-web/package.json +++ b/studio-web/package.json @@ -6,6 +6,7 @@ "module": "./index.mjs", "typings": "./index.d.ts", "exports": { + "./canvas-provider": "./canvas-provider.element.js", "./viewer": "./viewer.element.js" } } diff --git a/studio-web/src/lib/canvas-provider.element.scss b/studio-web/src/lib/canvas-provider.element.scss new file mode 100644 index 0000000..7e928ca --- /dev/null +++ b/studio-web/src/lib/canvas-provider.element.scss @@ -0,0 +1,3 @@ +::slotted([slot="canvas"]) { + display: none; +} diff --git a/studio-web/src/lib/canvas-provider.element.ts b/studio-web/src/lib/canvas-provider.element.ts new file mode 100644 index 0000000..9a56fed --- /dev/null +++ b/studio-web/src/lib/canvas-provider.element.ts @@ -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 = 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` + + + `; + + #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; + } +} diff --git a/studio-web/src/lib/viewer.element.scss b/studio-web/src/lib/viewer.element.scss index e69de29..e9de20f 100644 --- a/studio-web/src/lib/viewer.element.scss +++ b/studio-web/src/lib/viewer.element.scss @@ -0,0 +1,13 @@ +:host { + display: block; + position: relative; + width: 100%; + height: 100%; +} + +::slotted([slot="canvas"]) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} diff --git a/studio-web/src/lib/viewer.element.ts b/studio-web/src/lib/viewer.element.ts index e83ea3b..d81cd7d 100644 --- a/studio-web/src/lib/viewer.element.ts +++ b/studio-web/src/lib/viewer.element.ts @@ -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): void { @@ -28,17 +55,26 @@ export class ViewerElement extends LitElement { } } + protected override render = () => html` + + `; + + #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` - - `; } declare global { diff --git a/studio/src/scene.rs b/studio/src/scene.rs index e9ef739..b4cc5c0 100644 --- a/studio/src/scene.rs +++ b/studio/src/scene.rs @@ -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, + r_active_scene: Option>, mut er: EventReader, mut st_next: ResMut>, ) { 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); } } diff --git a/tsconfig.base.json b/tsconfig.base.json index ffb5726..f6337bf 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"] } diff --git a/utility/src/lib/types.ts b/utility/src/lib/types.ts index 34b592b..c2182aa 100644 --- a/utility/src/lib/types.ts +++ b/utility/src/lib/types.ts @@ -1,7 +1,23 @@ +export interface Ctor { + new (...args: Args): T; + prototype: T; +} + +export type AbstractCtor + = (abstract new (...args: Args) => T) + & { prototype: T } + ; + export interface Fn { (...args: Args): R; } +export interface Method { + (this: T, ...args: Args): R; +} + +export type Opt = T | null | undefined; + export type WithOpt = Omit & Partial>