mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-27 08:48:14 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user