Inspector frontend UI (#68)

This PR begins fleshing out the front-end UI for inspecting the selected
entity's components.

### Implemented
* Support for the `Name` component
* Placeholder/WIP support for the `Transform` component

### To-do
* Support for sending changes made in the inspector back to Bevy
* A slider/field combo widget for numeric values (can be adapted from
  [this StackBlitz](https://stackblitz.com/edit/angular-wfikuy?file=src%2Ffloat-input%2Ffloat-input.component.ts))
* Quat -> Euler angles conversion ([example math here](https://github.com/dannymcgee/3DGameMath/blob/5cb7a8ff9310565329e57751cd8b9c33e9bd4cb7/libs/Math/include/math/quat.inl.hpp#L56-L78),
  or use a 3rd-party math library)

### Known issues
* Selecting entities from the hierarchy doesn't always update the
  components list. I suspect this is the same issue as #39
This commit is contained in:
Danny McGee
2024-03-04 15:13:03 -05:00
committed by GitHub
parent 9875ea7cca
commit c9904a5293
26 changed files with 1093 additions and 174 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
export * from "./lib/decorators";
export * from "./lib/di";
export { default as spread } from "./lib/directives/spread";
export { default as bind, type Props as BindProps } from "./lib/directives/bind";
export { default as drag, type Props as DragProps } from "./lib/directives/drag";
export { default as spread } from "./lib/directives/spread";
+19 -2
View File
@@ -1,13 +1,18 @@
import { AbstractCtor, Ctor } from "@storyteller/utility";
import { type AbstractCtor, type Ctor, type Fn } from "@storyteller/utility";
import { type PropertyValues } from "lit";
export class UniqueToken {
get id() { return this.#id; }
readonly #id: symbol;
constructor (name: string) {
private constructor (name: string) {
this.#id = Symbol(name);
}
static create<T>(name?: string): Token<T> {
return new UniqueToken(name ?? crypto.randomUUID());
}
toString(): string {
return `UniqueToken(${String(this.#id)})`;
}
@@ -23,3 +28,15 @@ export interface Provider<T> {
token: Token<T>;
value: T;
}
export interface DynamicProvider<T> {
addEventListener(
type: "property-changes",
listener: Fn<[CustomEvent<PropertyValues<T>>], any>,
): void;
removeEventListener(
type: "property-changes",
listener: Fn<[CustomEvent<PropertyValues<T>>], any>,
): void;
}
+146
View File
@@ -0,0 +1,146 @@
import { match } from "@storyteller/utility";
import { AttributePart, LitElement } from "lit";
import {
Directive,
DirectiveParameters,
PartInfo,
PartType,
directive,
} from "lit/directive.js";
export type Props<T extends LitElement, K extends keyof T> = [T, K];
class BindDirective<T extends LitElement, K extends keyof T> extends Directive {
#host?: HTMLElement;
#hostKey: string;
#listener?: (this: T, event: CustomEvent<T[K]>) => void;
constructor (info: PartInfo) {
super(info);
if (
info.type !== PartType.ATTRIBUTE
&& info.type !== PartType.BOOLEAN_ATTRIBUTE
&& info.type !== PartType.PROPERTY
) {
throw new Error(`The 'bind' directive must be used on an attribute`);
}
this.#hostKey = info.name;
}
override render([receiver, key]: Props<T, K>) {
return receiver[key];
}
override update(part: AttributePart, [[receiver, key]]: DirectiveParameters<this>) {
this.#host ??= part.element;
if (!this.#listener)
this.#addListener(part.element, receiver, key);
return this.render([receiver, key]);
}
#addListener(host: HTMLElement, receiver: T, key: K): void {
const proto = receiver.constructor.prototype;
const descriptor = Object.getOwnPropertyDescriptor(proto, key);
if (!descriptor || !descriptor.set) {
const className = receiver.constructor.name;
const printableKey = match (typeof key, {
"string": () => `"${key as string}"`,
"number": () => `${key as number}`,
"symbol": () => `Symbol(${String(key)})`,
_: () => {
throw new Error("Unreachable");
}
});
throw new Error(
`'bind' directive expected ${className}[${printableKey}] to be a \`@state()\` property`,
);
}
this.#listener = function (this: T, event: CustomEvent<T[K]>) {
descriptor.set!.call(this, event.detail);
}
host.addEventListener(`${this.#hostKey}-change`, this.#listener.bind(receiver) as any);
}
}
/**
* Configures two-way binding for a property.
*
* @example
* ```
* \@customElement("sts-receiver")
* export class ReceiverElement extends LitElement {
* \@state() receiverValue = "";
*
* render = () => html`
* <sts-host .hostValue=${bind(this, "receiverValue")}></sts-host>
* `;
* }
*
* \@customElement("sts-host")
* export class HostElement extends LitElement {
* \@property() hostValue = "";
*
* emitNewValue(event: InputEvent) {
* this.dispatchEvent(new CustomEvent("hostValue-change", {
* detail: event.target.value
* }));
* }
*
* render = () => html`
* <input
* type="text"
* .value=${this.hostValue}
* \@input=${this.emitNewValue}
* />
* `;
* }
* ```
*
* @note
* Two-way binding works as follows:
*
* - The property bound in the "receiver" element (`ReceiverElement` in the
* example above) must be configured with `get` and `set` accessors. This is
* done automatically when using the `@state()` decorator to annotate the
* property.
*
* - The "host" element (`HostElement` in the example above) should emit a
* `CustomEvent` named "&lt;property&gt;-change" whenever the property value
* should change. (The new value should be emitted as the event `detail`.)
*
* - "&lt;property&gt;-change" events emitting from the host element will
* propagate up to the receiver, and changes to the value of the receiver
* property will propagate down to the host element as normal.
*
* A simpler way to explain all this is to say that the following example is
* functionally equivalent to the example above:
*
* ```ts
* \@customElement("sts-receiver")
* export class ReceiverElement extends LitElement {
* \@state() receiverValue = "";
*
* onHostValueChange(event: CustomEvent<string>) {
* this.receiverValue = event.detail;
* }
*
* render = () => html`
* <sts-host
* .hostValue=${this.receiverValue)}
* \@hostValue-change=${this.onHostValueChange}
* ></sts-host>
* `;
* }
* ```
*/
export default function <T extends LitElement, K extends keyof T>(receiver: T, key: K) {
return directive(BindDirective<T, K>)([receiver, key]);
}
+7 -30
View File
@@ -1,7 +1,7 @@
import * as studio from "@storyteller/studio";
import { type CameraKeyframesEvent } from "@storyteller/studio";
import { drag, on } from "@storyteller/framework";
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
import { bind, drag, on } from "@storyteller/framework";
import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { styleMap } from "lit/directives/style-map.js";
@@ -81,23 +81,7 @@ export class AnimEditorElement extends LitElement {
this.playHead = this.clipEnd;
}
#onTimelineStartChange(event: CustomEvent<number>): void {
this.timelineStart = event.detail;
}
#onTimelineEndChange(event: CustomEvent<number>): void {
this.timelineEnd = event.detail;
}
#onPlayHeadChange(event: CustomEvent<number>): void {
this.playHead = event.detail;
}
#onKeyframesChange(event: CustomEvent<Record<string, number>>): void {
this.keyframes = event.detail;
}
protected override render = () => html`
protected override render = (): TemplateResult => html`
<div class="sidebar"
style=${styleMap({
width: `${this.sidebarWidth}px`,
@@ -165,19 +149,12 @@ export class AnimEditorElement extends LitElement {
></div>
<sts-anim-timeline class="timeline"
.rangeStart=${this.timelineStart}
@rangeStartChange=${this.#onTimelineStartChange}
.rangeEnd=${this.timelineEnd}
@rangeEndChange=${this.#onTimelineEndChange}
.rangeStart=${bind(this, "timelineStart")}
.rangeEnd=${bind(this, "timelineEnd")}
.clipStart=${this.clipStart}
.clipEnd=${this.clipEnd}
.playHead=${this.playHead}
@playHeadChange=${this.#onPlayHeadChange}
.keyframes=${this.keyframes}
@keyframesChange=${this.#onKeyframesChange}
.playHead=${bind(this, "playHead")}
.keyframes=${bind(this, "keyframes")}
></sts-anim-timeline>
`;
}
@@ -141,7 +141,7 @@ export class AnimTimelineElement extends LitElement {
if (Array.from(Object.keys(this.keyframes)).includes(id)) {
this._selectedKeyframe = id;
this.dispatchEvent(new CustomEvent("playHeadChange", { detail: this.keyframes[id]! }));
this.dispatchEvent(new CustomEvent("playHead-change", { detail: this.keyframes[id]! }));
} else {
this._selectedKeyframe = null;
}
@@ -165,8 +165,8 @@ export class AnimTimelineElement extends LitElement {
const rangeStart = this.rangeStart - deltaRange * t;
const rangeEnd = this.rangeEnd + deltaRange * (1 - t);
this.dispatchEvent(new CustomEvent("rangeStartChange", { detail: rangeStart }));
this.dispatchEvent(new CustomEvent("rangeEndChange", { detail: rangeEnd }));
this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart }));
this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd }));
}
#onDragStart(): void {
@@ -188,15 +188,15 @@ export class AnimTimelineElement extends LitElement {
const rangeStart = this.rangeStart - deltaPx * msPerPx;
const rangeEnd = this.rangeEnd - deltaPx * msPerPx;
this.dispatchEvent(new CustomEvent("rangeStartChange", { detail: rangeStart }));
this.dispatchEvent(new CustomEvent("rangeEndChange", { detail: rangeEnd }));
this.dispatchEvent(new CustomEvent("rangeStart-change", { detail: rangeStart }));
this.dispatchEvent(new CustomEvent("rangeEnd-change", { detail: rangeEnd }));
}
#onPlayheadDrag(event: PointerEvent): void {
const deltaMs = event.movementX * this.msPerPx.get();
const updated = this.playHead + deltaMs;
this.dispatchEvent(new CustomEvent("playHeadChange", { detail: updated }));
this.dispatchEvent(new CustomEvent("playHead-change", { detail: updated }));
}
#onKeyframeDrag(id: string, event: PointerEvent): void {
@@ -213,7 +213,7 @@ export class AnimTimelineElement extends LitElement {
: entry
)
);
this.dispatchEvent(new CustomEvent("keyframesChange", { detail: updatedKeyframes }));
this.dispatchEvent(new CustomEvent("keyframes-change", { detail: updatedKeyframes }));
}
#updateRenderWidth(): void {
+134 -8
View File
@@ -1,3 +1,5 @@
@use "sass:math";
$hierarchy-width: 290px;
.app-root {
@@ -20,22 +22,146 @@ $hierarchy-width: 290px;
}
.h-start { left: 16px }
.h-end { right: 16px }
.h-before-hierarchy {
right: $hierarchy-width + 32px;
}
.h-center {
left: 50%;
transform: translateX(-50%);
}
.studio-scene.block-pointer-events::part(canvas) {
pointer-events: none;
}
sts-button.active {
background: #7A2C34E3;
color: #FFF;
}
// Hierarchy (TODO: Probably move this)
.hierarchy {
width: $hierarchy-width;
max-height: calc(100vh - 32px);
pointer-events: auto;
// TODO: Probably move this
.inspector-open {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border-radius: 24px;
appearance: none;
border: none;
background: #0006;
backdrop-filter: blur(16px);
color: #FFFC;
transition: {
property: background-color, color;
duration: 100ms;
timing-function: linear;
}
cursor: pointer;
font-size: 20px;
&:hover {
background: #0008;
color: #FFFF;
}
}
.inspector {
display: flex;
flex-direction: column;
border-radius: 0.5rem;
border: 0.5rem solid #0004 {
block-start: none;
inline-start: none;
}
padding: {
block-start: 2rem;
inline-start: 0.5rem;
}
background: #0006;
backdrop-filter: blur(16px);
&__hierarchy {
flex: 0 0 calc(50% - 0.25rem);
}
&__entity {
flex: 1 0 0;
height: 0;
}
.resize-handle {
background: #0004;
transition: 66.666ms background linear;
&:hover {
background: #FFF1;
}
&--h-start {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 0.5rem;
cursor: ew-resize;
}
&--v-center {
height: 0.5rem;
cursor: ns-resize
}
}
}
.inspector-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 2rem;
padding: 0 {
inline-start: 0.75rem;
}
position: absolute;
top: 0;
right: 0;
left: 0;
background: #0004;
&__label {
margin: 0;
color: #FFF9;
font: {
family: "Fira Sans", sans-serif;
size: math.div(15, 16) * 1rem;
weight: 500;
}
}
&__minimize {
width: math.div(24, 16) * 1rem;
height: math.div(24, 16) * 1rem;
border-radius: math.div(12, 16) * 1rem;
margin: 0;
padding: 0;
appearance: none;
background: transparent;
border: none;
color: #FFFC;
transition: {
property: color, background-color;
duration: 66.666ms;
timing-function: linear;
}
font-size: 1rem;
&:hover {
background: #FFF1;
color: #FFFF;
}
}
}
+132 -38
View File
@@ -1,7 +1,6 @@
import { on } from "@storyteller/framework";
import { drag, on } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import {
type EntitySelectEvent,
type SceneSavedEvent,
type SceneStateEvent,
SceneState,
@@ -9,15 +8,14 @@ import {
} from "@storyteller/studio";
import {
SceneHierarchyElement,
type SceneObject,
StudioURLParams,
} from "@storyteller/studio-web";
import { CanvasProvider } from "@storyteller/studio-web/canvas-provider";
import { match } from "@storyteller/utility";
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit-html/directives/class-map.js";
import { Ref, createRef, ref } from "lit-html/directives/ref.js";
import { classMap } from "lit/directives/class-map.js";
import { createRef, ref } from "lit/directives/ref.js";
import { styleMap } from "lit/directives/style-map.js";
import { v4 as uuid } from "uuid";
import "@storyteller/studio-ui/button";
@@ -25,6 +23,7 @@ import "@storyteller/studio-ui/icon";
import "@storyteller/studio-ui/toolbar";
import "@storyteller/studio-ui/tree";
import "@storyteller/studio-web/canvas-provider";
import "@storyteller/studio-web/entity-inspector";
import "@storyteller/studio-web/loading";
import "@storyteller/studio-web/scene-hierarchy";
import "@storyteller/studio-web/studio";
@@ -39,25 +38,24 @@ import "./app.element.scss";
@customElement("app-root")
export class AppElement extends LitElement {
@state() showHelp = false;
@state() showInspector = false;
@state() loading = true;
@state() selection?: SceneObject;
@state() resizingPanel = false;
@state() inspectorWidth = 320;
@state() hierarchyHeight?: number;
#enableAnimTimeline = false; // TODO
#inspectorRef = createRef<HTMLDivElement>();
#hierarchyRef = createRef<SceneHierarchyElement>();
#hierarchy = createRef<SceneHierarchyElement>();
#params = new StudioURLParams();
#canvasProvider: Ref<CanvasProvider> = createRef();
protected override createRenderRoot(): Element | ShadowRoot {
return this;
}
@on("inspector-changed")
onInspectorChanged(event: studio.InspectorChanged): void {
const data = event.detail ? JSON.parse(event.detail) : null;
console.log("Inspector changed:", data);
}
@on("scene-saved")
async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise<void> {
const file = new File([scene], "scene.scn.ron", { type: "text/ron" });
@@ -90,13 +88,41 @@ export class AppElement extends LitElement {
});
}
@on("entity-select")
onEntitySelected({ detail: id }: EntitySelectEvent): void {
if (!id) {
this.selection = undefined;
} else {
this.selection = this.#hierarchy.value?.entityMap.get(id);
}
onPanelResizeStart(): void {
this.resizingPanel = true;
}
onPanelResizeEnd(): void {
this.resizingPanel = false;
}
resizeInspectorWidth(event: PointerEvent): void {
const updated = this.inspectorWidth - event.movementX;
this.inspectorWidth = Math.max(Math.min(updated, 1024), 128);
}
resizeHierarchyHeight(event: PointerEvent): void {
let availableHeight = this.#inspectorRef.value?.clientHeight;
if (availableHeight == null) return;
// Account for:
// - `section.inspector` padding (2rem ~= 32px)
// - resize-handle (0.5rem ~= 8px)
// - minimal space for `sts-entity-inspector` (2rem ~= 32px)
availableHeight -= 72;
const height = this.hierarchyHeight ?? this.#hierarchyRef.value?.offsetHeight;
if (height == null) return;
const updated = height + event.movementY;
this.hierarchyHeight = Math.max(Math.min(updated, availableHeight), 8);
}
minimizeInspector(): void {
this.showInspector = false;
}
openInspector(): void {
this.showInspector = true;
}
toggleHelp(): void {
@@ -104,12 +130,14 @@ export class AppElement extends LitElement {
}
protected override render = () => html`
<sts-canvas-provider
class="app-root"
${ref(this.#canvasProvider as any)}
>
<sts-canvas-provider class="app-root">
<sts-scene-data-provider>
<!-- Scene view -->
<sts-studio
class=${classMap({
"studio-scene": true,
"block-pointer-events": this.resizingPanel,
})}
.objectId=${this.#params.objectId}
.bvhPath=${this.#params.bvh}
.mixamoPath=${this.#params.mixamo}
@@ -142,18 +170,83 @@ export class AppElement extends LitElement {
<!-- Studio-mode-only elements -->
${this.#params.mode === StudioMode.Editor ? html`
<sts-transform-toolbar
class="v-start h-center"
></sts-transform-toolbar>
<sts-scene-hierarchy
class="hierarchy v-start h-end"
${ref(this.#hierarchy)}
></sts-scene-hierarchy>
<sts-anim-editor
class="timeline v-end h-start h-before-hierarchy"
></sts-anim-editor>
<sts-blocking></sts-blocking>
<sts-transform-toolbar class="v-start h-center"></sts-transform-toolbar>
${this.showInspector ? html`
<section
${ref(this.#inspectorRef)}
class="inspector h-end v-start v-end"
style=${styleMap({
width: `${this.inspectorWidth}px`,
})}
aria-labelledby="inspector-heading"
>
<header class="inspector-header">
<h3 class="inspector-header__label"
id="inspector-heading"
>
Inspector
</h3>
<button class="inspector-header__minimize"
aria-label="Minimize inspector"
title="Minimize inspector"
@click=${this.minimizeInspector}
>
<sts-icon icon="minus"></sts-icon>
</button>
</header>
<sts-scene-hierarchy
${ref(this.#hierarchyRef)}
class="inspector__hierarchy"
style=${styleMap({
flex: this.hierarchyHeight != null
? `0 0 ${this.hierarchyHeight}px`
: null,
})}
></sts-scene-hierarchy>
<div
class="resize-handle resize-handle--v-center"
${drag({
start: this.onPanelResizeStart,
end: this.onPanelResizeEnd,
move: this.resizeHierarchyHeight,
})}
></div>
<sts-entity-inspector class="inspector__entity"></sts-entity-inspector>
<div
class="resize-handle resize-handle--h-start"
${drag({
start: this.onPanelResizeStart,
end: this.onPanelResizeEnd,
move: this.resizeInspectorWidth,
})}
></div>
</section>
` : html`
<button
class="inspector-open h-end v-start"
aria-label="Open scene inspector"
title="Open scene inspector"
@click=${this.openInspector}
>
<sts-icon icon="bars"></sts-icon>
</button>
`}
${this.#enableAnimTimeline ? html`
<sts-anim-editor
class="timeline v-end h-start"
style=${styleMap({
right: `${this.inspectorWidth + 32}px`
})}
></sts-anim-editor>
` : nothing}
` : nothing}
<!-- Dialogs and modals -->
@@ -164,6 +257,7 @@ export class AppElement extends LitElement {
${this.loading ? html`
<sts-loading></sts-loading>
` : nothing}
</sts-scene-data-provider>
</sts-canvas-provider>
`;
}
@@ -0,0 +1,37 @@
import { LitElement } from "lit";
import { property } from "lit/decorators.js";
export abstract class FormFieldBaseElement extends LitElement {
static formAssociated = true;
@property({ type: Boolean }) disabled = false;
@property({ type: Boolean }) readonly = false;
@property({ type: Number }) override tabIndex = 0;
get form() { return this.internals.form; }
get name() { return this.getAttribute("name"); }
get type() { return this.localName; }
get validity() { return this.internals.validity; }
get validationMessage() { return this.internals.validationMessage; }
get willValidate() { return this.internals.willValidate; }
protected internals: ElementInternals;
constructor () {
super();
this.internals = this.attachInternals();
}
checkValidity(): boolean {
return this.internals.checkValidity();
}
reportValidity(): boolean {
return this.internals.reportValidity();
}
formDisabledCallback(disabled: boolean): void {
this.disabled = disabled;
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./text-field.element";
@@ -0,0 +1,40 @@
@use "sass:math";
:host {
box-sizing: border-box;
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
background: #0004;
border: 1px solid #FFF2;
color: #FFFC;
transition: {
property: background-color, border-color, color;
duration: 33.333ms;
timing-function: linear;
}
font: {
family: "Fira Sans", sans-serif;
size: math.div(15, 16) * 1rem;
}
}
:host(:focus) {
outline: none;
border-color: #FFF4;
background: #0006;
color: #FFFF;
}
.input {
width: 100%;
appearance: none;
background: transparent;
border: none;
font: inherit;
color: inherit;
outline: none !important;
}
@@ -0,0 +1,41 @@
import { on } from "@storyteller/framework";
import { html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { FormFieldBaseElement } from "./form-field-base.element";
import styles from "./text-field.element.scss?inline";
@customElement("sts-text-field")
export class TextFieldElement extends FormFieldBaseElement {
static override styles = unsafeCSS(styles);
static override shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
}
@property() value = "";
@on("keydown")
@on("keyup")
blockKeypressBubbling(event: KeyboardEvent): void {
if (event.key !== "Alt" && event.key !== "Control")
event.stopPropagation();
}
#onInput(event: InputEvent): void {
const updated = (event.target as HTMLInputElement).value;
console.log("updated:", updated);
this.dispatchEvent(new CustomEvent("value-change", { detail: updated }));
}
protected override render = () => html`
<input
class="input"
type="text"
.value=${this.value}
@input=${this.#onInput}
/>
`;
}
+6
View File
@@ -28,6 +28,9 @@ import fasCheck from "@fortawesome/fontawesome-pro/svgs/solid/check.svg?raw";
import fasXmark from "@fortawesome/fontawesome-pro/svgs/solid/xmark.svg?raw";
import fasCircleCheck from "@fortawesome/fontawesome-pro/svgs/solid/circle-check.svg?raw";
import fasCircleXmark from "@fortawesome/fontawesome-pro/svgs/solid/circle-xmark.svg?raw";
import fasMinus from "@fortawesome/fontawesome-pro/svgs/solid/minus.svg?raw";
import fasListTree from "@fortawesome/fontawesome-pro/svgs/solid/list-tree.svg?raw";
import fasBars from "@fortawesome/fontawesome-pro/svgs/solid/bars.svg?raw";
import styles from "./icon.element.scss?inline";
@@ -54,6 +57,7 @@ function renderSvg(iconId: string) {
return RENDER_RESULTS.get(iconId)!;
const svgSrc = match (iconId, {
"bars": () => fasBars,
"bone": () => farBone,
"bwd-fast": () => fasBwdFast,
"bwd-step": () => fasBwdStep,
@@ -69,6 +73,8 @@ function renderSvg(iconId: string) {
"fwd-step": () => fasFwdStep,
"key": () => fasKey,
"light": () => fasBrightness,
"list-tree": () => fasListTree,
"minus": () => fasMinus,
"globe": () => farGlobe,
"play": () => fasPlay,
"question": () => fasQuestion,
+37 -13
View File
@@ -1,8 +1,6 @@
import { on } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import { type EntitySelectEvent } from "@storyteller/studio";
import { LitElement, html, nothing, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { type DynamicProvider, inject, UniqueToken } from "@storyteller/framework";
import { LitElement, type PropertyValues, html, nothing, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -10,6 +8,12 @@ import "./icon.element";
import styles from "./tree.element.scss?inline";
export const TREE_SELECTION_PROVIDER = UniqueToken.create<TreeSelectionProvider>();
export interface TreeSelectionProvider extends DynamicProvider<TreeSelectionProvider> {
readonly selectedId: string | null;
}
export interface Tree {
id?: string;
name: string;
@@ -26,20 +30,40 @@ export class TreeElement extends LitElement {
@property({ attribute: false }) treeChildren?: Tree[];
@property({ type: Boolean }) expanded = true;
@state() selected = false;
get selected() {
return !!this.id && this._selectionProvider?.selectedId === this.id
}
@inject(TREE_SELECTION_PROVIDER)
_selectionProvider?: TreeSelectionProvider;
override connectedCallback(): void {
this._selectionProvider?.addEventListener("property-changes", this.onProviderChanges);
super.connectedCallback();
}
override disconnectedCallback(): void {
this._selectionProvider?.removeEventListener("property-changes", this.onProviderChanges);
super.disconnectedCallback();
}
onProviderChanges = ({ detail: changes }: CustomEvent<PropertyValues<TreeSelectionProvider>>) => {
if (changes.has("selectedId"))
this.requestUpdate();
}
toggleExpanded(): void {
this.expanded = !this.expanded;
}
@on("window:entity-select")
onEntitySelect({ detail: entity }: EntitySelectEvent): void {
this.selected = this.id === entity;
}
selectEntity(): void {
if (this.id)
studio.select(this.id);
if (this.id) {
this.dispatchEvent(new CustomEvent("tree-select", {
detail: this.id,
bubbles: true,
composed: true,
}));
}
}
// TODO: A11y
+3
View File
@@ -1,5 +1,8 @@
export * from "./lib/canvas-provider.element";
export * from "./lib/inspector/entity-inspector.element";
export * from "./lib/inspector/inspector.types";
export * from "./lib/loading.element";
export * from "./lib/scene-data-provider.element";
export * from "./lib/scene-hierarchy.element";
export * from "./lib/studio.element";
export * from "./lib/transform-toolbar.element";
@@ -1,4 +1,4 @@
import { Token, UniqueToken, on, provide } from "@storyteller/framework";
import { UniqueToken, on, provide } from "@storyteller/framework";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
@@ -35,7 +35,7 @@ export interface CanvasProvider {
returnCanvas(canvas: HTMLCanvasElement): void;
}
export const CANVAS_PROVIDER: Token<CanvasProvider> = new UniqueToken("CanvasProvider");
export const CANVAS_PROVIDER = UniqueToken.create<CanvasProvider>();
@customElement("sts-canvas-provider")
@provide(CANVAS_PROVIDER)
@@ -0,0 +1,33 @@
:host {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
overflow: auto;
color: #FFFC;
&::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
&-thumb {
border-radius: 2px;
background: #FFF2;
&:hover {
background: #FFF3;
}
}
&-corner {
background: transparent;
}
}
}
*, *::before, *::after {
box-sizing: border-box;
}
@@ -0,0 +1,75 @@
import { inject } from "@storyteller/framework";
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "../scene-data-provider.element";
import { type InspectorComponent } from "./inspector.types";
import "../scene-data-provider.element";
import "./name-inspector.element";
import "./transform-inspector.element";
import styles from "./entity-inspector.element.scss?inline";
@customElement("sts-entity-inspector")
export class EntityInspectorElement extends LitElement {
static override styles = unsafeCSS(styles);
@inject(SCENE_DATA_PROVIDER)
_sceneData?: SceneDataProvider;
override connectedCallback(): void {
this._sceneData?.addEventListener("property-changes", this.onProviderChanges);
super.connectedCallback();
}
override disconnectedCallback(): void {
this._sceneData?.removeEventListener("property-changes", this.onProviderChanges);
super.disconnectedCallback();
}
onProviderChanges = ({ detail: changes }: CustomEvent<PropertyValues<SceneDataProvider>>) => {
if (changes.has("selectionComponents"))
this.requestUpdate();
}
protected override render = () => html`
${this._sceneData
?.selectionComponents
.slice()
.sort((a, b) => {
const ordA = COMPONENT_SORT_ORDER.indexOf(a.name);
const ordB = COMPONENT_SORT_ORDER.indexOf(b.name);
return ordA - ordB;
})
.map(({ name, value }) => {
switch (name) {
case "bevy_core::name::Name":
return html`
<sts-name-inspector
.value=${value.name}
></sts-name-inspector>
`;
case "bevy_transform::components::transform::Transform":
return html`
<sts-transform-inspector
.translation=${value.translation}
.rotation=${value.rotation}
.scale=${value.scale}
></sts-transform-inspector>
`;
default:
return html`
<h3>Unknown Component: ${name}</h3>
<pre>${JSON.stringify({ name, value }, null, " ")}</pre>
`;
}
})
}
`;
}
const COMPONENT_SORT_ORDER: Array<InspectorComponent["name"]> = [
"bevy_core::name::Name",
"bevy_transform::components::transform::Transform",
];
@@ -0,0 +1,33 @@
export type InspectorComponent
= BevyReflect<"bevy_transform::components::transform::Transform", BevyTransform>
| BevyReflect<"bevy_core::name::Name", BevyName>
;
export interface BevyReflect<K extends string, T> {
name: K;
value: T;
}
export interface BevyTransform {
rotation: Quat;
scale: Vec3;
translation: Vec3;
}
export interface BevyName {
hash: number;
name: string;
}
export interface Quat {
w: number;
x: number;
y: number;
z: number;
}
export interface Vec3 {
x: number;
y: number;
z: number;
}
@@ -0,0 +1,3 @@
:host {
color-scheme: dark;
}
@@ -0,0 +1,25 @@
import { bind } from "@storyteller/framework";
import { LitElement, PropertyValues, type TemplateResult, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import "@storyteller/studio-ui/forms/text-field";
import styles from "./name-inspector.element.scss?inline";
@customElement("sts-name-inspector")
export class NameInspectorElement extends LitElement {
static override styles = unsafeCSS(styles);
@property() value = "";
protected override updated(changes: PropertyValues<this>): void {
if (changes.has("value"))
console.log("New value:", this.value);
super.updated(changes);
}
protected override render = (): TemplateResult => html`
<sts-text-field .value=${bind(this, "value")}></sts-text-field>
`;
}
@@ -0,0 +1,88 @@
@use "sass:math";
:host {
display: flex;
flex-direction: column;
gap: 0.75rem;
color-scheme: dark;
font-family: "Fira Sans", sans-serif;
}
*, *::before, *::after {
box-sizing: border-box;
}
h4 {
font-weight: 500;
margin: 0 0 0.25rem;
}
fieldset {
display: flex;
width: 100%;
padding: 0;
margin: 0;
gap: 0.25rem;
border: none;
legend {
color: #FFF9;
font-size: math.div(14, 16) * 1rem;
margin-block-end: 0.333rem;
}
input {
appearance: textfield;
width: math.div(1, 3) * 100%;
padding: 0.5rem;
border-radius: 0.25rem;
background: #0004;
border: 1px solid #FFF2 {
left: 4px solid #FFF2;
}
color: #FFFC;
transition: {
property: background-color, border-color, color;
duration: 33.333ms;
timing-function: linear;
}
font: {
family: "Fira Sans", sans-serif;
size: math.div(15, 16) * 1rem;
}
&:focus {
outline: none;
border-color: #FFF4;
background: #0006;
color: #FFFF;
}
&:nth-of-type(1) {
border-left-color: rgb(220, 53, 69);
&:focus {
border-left-color: rgb(255, 48, 68);
}
}
&:nth-of-type(2) {
border-left-color: rgb(25, 135, 84);
&:focus {
border-left-color: rgb(0, 187, 100);
}
}
&:nth-of-type(3) {
border-left-color: rgb(13, 110, 253);
&:focus {
border-left-color: rgb(47, 131, 255);
}
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
}
}
@@ -0,0 +1,43 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { type Quat, type Vec3 } from "./inspector.types";
import styles from "./transform-inspector.element.scss?inline";
@customElement("sts-transform-inspector")
export class TransformInspectorElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ attribute: false })
translation: Vec3 = { x: 0, y: 0, z: 0 };
@property({ attribute: false })
rotation: Quat = { w: 1, x: 0, y: 0, z: 0 };
@property({ attribute: false })
scale: Vec3 = { x: 1, y: 1, z: 1 };
protected override render = () => html`
<!-- TODO -->
<h4>Transform</h4>
<fieldset>
<legend>Translation</legend>
<input type="number" value=${this.translation.x.toFixed(3)} />
<input type="number" value=${this.translation.y.toFixed(3)} />
<input type="number" value=${this.translation.z.toFixed(3)} />
</fieldset>
<fieldset>
<legend>Rotation</legend>
<input type="number" value=${this.rotation.x.toFixed(3)} />
<input type="number" value=${this.rotation.y.toFixed(3)} />
<input type="number" value=${this.rotation.z.toFixed(3)} />
<!-- <input type="number" value=${this.rotation.w.toFixed(3)} /> -->
</fieldset>
<fieldset>
<legend>Scale</legend>
<input type="number" value=${this.scale.x.toFixed(3)} />
<input type="number" value=${this.scale.y.toFixed(3)} />
<input type="number" value=${this.scale.z.toFixed(3)} />
</fieldset>
`;
}
@@ -0,0 +1,156 @@
import { DynamicProvider, UniqueToken, on, provide } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import {
SceneElement,
type EntityDespawnEvent,
type EntityMultiSpawnEvent,
type EntitySelectEvent,
type EntitySpawnEvent,
type InspectorChanged,
} from "@storyteller/studio";
import { TREE_SELECTION_PROVIDER, TreeSelectionProvider } from "@storyteller/studio-ui/tree";
import { match } from "@storyteller/utility";
import { LitElement, type PropertyValues, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { InspectorComponent } from "./inspector/inspector.types";
export interface SceneDataProvider extends DynamicProvider<SceneDataProvider> {
readonly tree: readonly SceneObject[];
readonly entityMap: ReadonlyMap<string, SceneObject>;
readonly selectedEntity: string | null;
readonly selectedObject: SceneObject | null;
readonly selectionComponents: readonly InspectorComponent[];
}
export const SCENE_DATA_PROVIDER = UniqueToken.create<SceneDataProvider>();
export interface SceneObject extends Omit<studio.SceneObject, "children"> {
icon?: string;
children: SceneObject[];
}
@customElement("sts-scene-data-provider")
@provide(SCENE_DATA_PROVIDER)
@provide(TREE_SELECTION_PROVIDER)
export class SceneDataProviderElement
extends LitElement
implements SceneDataProvider, TreeSelectionProvider
{
get tree() { return this._tree; }
get entityMap() { return this.#entityMap; }
get selectedEntity() { return this._selection; }
get selectedId() { return this._selection; }
get selectedObject() {
if (!this._selection) return null;
return this.#entityMap.get(this._selection) ?? null;
}
get selectionComponents() { return this._components; }
@state() _tree: SceneObject[] = [];
#sceneObjectsFlat: studio.SceneObject[] = [];
#entityMap = new Map<string, SceneObject>();
@state() _selection: string | null = null;
@state() _components: InspectorComponent[] = [];
// TODO: This workflow allows dependents of this provider to listen for the
// "property-changes" event and manually trigger updates when relevant
// properties change, but it's pretty fiddly to set up. Probably not
// worth fixing before we migrate over to Bevy-native UI.
protected override update(changes: PropertyValues<this>): void {
const downstreamChanges: PropertyValues<SceneDataProvider & TreeSelectionProvider>
= new Map();
if (changes.has("_tree")) {
downstreamChanges.set("tree", changes.get("_tree"));
downstreamChanges.set("entityMap", new Map());
}
if (changes.has("_selection")) {
const prevSelection = changes.get("_selection");
downstreamChanges.set("selectedEntity", prevSelection);
downstreamChanges.set("selectedId", prevSelection);
downstreamChanges.set(
"selectedObject",
prevSelection
? this.#entityMap.get(prevSelection) ?? null
: null
);
}
if (changes.has("_components"))
downstreamChanges.set("selectionComponents", changes.get("_components"));
this.dispatchEvent(new CustomEvent("property-changes", { detail: downstreamChanges }));
super.update(changes);
}
@on("window:entity-spawn")
@on("window:entity-multi-spawn")
_onEntitiesSpawned({ detail: entities }: EntitySpawnEvent | EntityMultiSpawnEvent): void {
this.#sceneObjectsFlat = this.#sceneObjectsFlat.concat(entities);
this.#rebuildTree();
}
@on("window:entity-despawn")
_onEntityDespawn({ detail: entity }: EntityDespawnEvent): void {
this.#sceneObjectsFlat = this.#sceneObjectsFlat.filter(({ id }) => id !== entity);
this.#rebuildTree();
}
@on("window:entity-select")
onEntitySelect({ detail: entity }: EntitySelectEvent): void {
this._selection = entity;
}
@on("window:inspector-changed")
onInspectorChanged({ detail: json }: InspectorChanged): void {
this._components = json ? JSON.parse(json) : [];
}
#rebuildTree(): void {
this.#entityMap.clear();
for (let obj of this.#sceneObjectsFlat) {
this.#entityMap.set(obj.id, {
...obj,
icon: match (obj.type, {
[SceneElement.Generic]: () => "cube",
[SceneElement.Mesh]: () => "cube", // TODO ?
[SceneElement.Skeleton]: () => "skeleton",
[SceneElement.Bone]: () => "bone",
[SceneElement.Light]: () => "light",
[SceneElement.Camera]: () => "video",
}),
children: []
});
}
for (let obj of this.#entityMap.values()) {
if (obj.parent != null) {
const parent = this.#entityMap.get(obj.parent);
if (!parent)
throw new Error(`Missing parent: ${obj.parent}`);
parent.children.push(obj);
}
}
for (let obj of this.#entityMap.values())
obj.children.sort((a, b) => a.name.localeCompare(b.name));
this._tree = Array
.from(this.#entityMap.values())
.filter(obj => obj.parent == null)
.sort((a, b) => a.name.localeCompare(b.name))
}
protected override render = () => html`
<slot></slot>
`;
}
@@ -3,14 +3,9 @@
padding: 0.5rem 1rem;
overflow: auto;
background: #0006;
backdrop-filter: blur(16px);
border: 8px solid transparent;
border-radius: 0.5rem;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 0.5rem;
height: 0.5rem;
&-thumb {
border-radius: 2px;
+18 -66
View File
@@ -1,92 +1,44 @@
import { on } from "@storyteller/framework";
import { inject } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import {
SceneElement,
type EntityDespawnEvent,
type EntityMultiSpawnEvent,
type EntitySpawnEvent,
} from "@storyteller/studio";
import { match } from "@storyteller/utility";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import { LitElement, type PropertyValues, html, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import styles from "./scene-hierarchy.element.scss?inline";
import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "./scene-data-provider.element";
import "@storyteller/studio-ui/tree";
export interface SceneObject extends Omit<studio.SceneObject, "children"> {
icon?: string;
children: SceneObject[];
}
import styles from "./scene-hierarchy.element.scss?inline";
@customElement("sts-scene-hierarchy")
export class SceneHierarchyElement extends LitElement {
static override styles = unsafeCSS(styles);
get tree() { return this._tree as readonly SceneObject[]; }
get entityMap() { return this.#entityMap as ReadonlyMap<string, SceneObject>; }
@inject(SCENE_DATA_PROVIDER)
_sceneData?: SceneDataProvider;
@state() private _tree: SceneObject[] = [];
#sceneObjectsFlat: studio.SceneObject[] = [];
#entityMap = new Map<string, SceneObject>();
@on("window:entity-spawn")
@on("window:entity-multi-spawn")
_onEntitiesSpawned({ detail: entities }: EntitySpawnEvent | EntityMultiSpawnEvent): void {
this.#sceneObjectsFlat = this.#sceneObjectsFlat.concat(entities);
this.#rebuildTree();
override connectedCallback(): void {
this._sceneData?.addEventListener("property-changes", this.onProviderChanges);
super.connectedCallback();
}
@on("window:entity-despawn")
_onEntityDespawn({ detail: entity }: EntityDespawnEvent): void {
this.#sceneObjectsFlat = this.#sceneObjectsFlat.filter(({ id }) => id !== entity);
this.#rebuildTree();
override disconnectedCallback(): void {
this._sceneData?.removeEventListener("property-changes", this.onProviderChanges);
super.disconnectedCallback();
}
#rebuildTree(): void {
this.#entityMap.clear();
for (let obj of this.#sceneObjectsFlat) {
this.#entityMap.set(obj.id, {
...obj,
icon: match (obj.type, {
[SceneElement.Generic]: () => "cube",
[SceneElement.Mesh]: () => "cube", // TODO ?
[SceneElement.Skeleton]: () => "skeleton",
[SceneElement.Bone]: () => "bone",
[SceneElement.Light]: () => "light",
[SceneElement.Camera]: () => "video",
}),
children: []
});
}
for (let obj of this.#entityMap.values()) {
if (obj.parent != null) {
const parent = this.#entityMap.get(obj.parent);
if (!parent)
throw new Error(`Missing parent: ${obj.parent}`);
parent.children.push(obj);
}
}
for (let obj of this.#entityMap.values())
obj.children.sort((a, b) => a.name.localeCompare(b.name));
this._tree = Array
.from(this.#entityMap.values())
.filter(obj => obj.parent == null)
.sort((a, b) => a.name.localeCompare(b.name))
onProviderChanges = ({ detail: changes }: CustomEvent<PropertyValues<SceneDataProvider>>) => {
if (changes.has("tree"))
this.requestUpdate();
}
protected override render = () => html`
${this.tree.map(obj => html`
${this._sceneData?.tree.map(obj => html`
<sts-tree
id=${obj.id}
.name=${obj.name}
.icon=${obj.icon}
.treeChildren=${obj.children}
@tree-select=${(event: CustomEvent<string>) => studio.select(event.detail)}
></sts-tree>
`)}
`
+3
View File
@@ -23,9 +23,12 @@
"@storyteller/studio-ui/progress": ["studio-ui/src/lib/progress.element.ts"],
"@storyteller/studio-ui/toolbar": ["studio-ui/src/lib/toolbar.element.ts"],
"@storyteller/studio-ui/tree": ["studio-ui/src/lib/tree.element.ts"],
"@storyteller/studio-ui/forms/text-field": ["studio-ui/src/lib/forms/text-field.element.ts"],
"@storyteller/studio-web": ["studio-web/src/index.ts"],
"@storyteller/studio-web/canvas-provider": ["studio-web/src/lib/canvas-provider.element.ts"],
"@storyteller/studio-web/entity-inspector": ["studio-web/src/lib/inspector/entity-inspector.element.ts"],
"@storyteller/studio-web/loading": ["studio-web/src/lib/loading.element.ts"],
"@storyteller/studio-web/scene-data-provider": ["studio-web/src/lib/scene-data-provider.element.ts"],
"@storyteller/studio-web/scene-hierarchy": ["studio-web/src/lib/scene-hierarchy.element.ts"],
"@storyteller/studio-web/studio": ["studio-web/src/lib/studio.element.ts"],
"@storyteller/studio-web/transform-toolbar": ["studio-web/src/lib/transform-toolbar.element.ts"],