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
+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>
`;
}