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:
Danny McGee
2023-12-19 16:11:25 -05:00
committed by GitHub
parent cba70a62ae
commit 0a0acacfdb
24 changed files with 774 additions and 102 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./on";
+65
View File
@@ -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;
}
+8 -20
View File
@@ -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;
+53
View File
@@ -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.
+184
View File
@@ -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 {}
+73
View File
@@ -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>;
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./decorators";
export * from "./events";
export * from "./types";
+25
View File
@@ -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;
}