From c9904a5293aba8251626aa46842d918563102ec1 Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Mon, 4 Mar 2024 15:13:03 -0500 Subject: [PATCH] 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 --- framework/src/index.ts | 3 +- framework/src/lib/di/types.ts | 21 ++- framework/src/lib/directives/bind.ts | 146 +++++++++++++++ .../src/app/anim-editor.element.ts | 37 +--- .../src/app/anim-timeline.element.ts | 14 +- studio-frontend/src/app/app.element.scss | 142 ++++++++++++++- studio-frontend/src/app/app.element.ts | 170 ++++++++++++++---- .../src/lib/forms/form-field-base.element.ts | 37 ++++ studio-ui/src/lib/forms/index.ts | 1 + .../src/lib/forms/text-field.element.scss | 40 +++++ studio-ui/src/lib/forms/text-field.element.ts | 41 +++++ studio-ui/src/lib/icon.element.ts | 6 + studio-ui/src/lib/tree.element.ts | 50 ++++-- studio-web/src/index.ts | 3 + studio-web/src/lib/canvas-provider.element.ts | 4 +- .../inspector/entity-inspector.element.scss | 33 ++++ .../lib/inspector/entity-inspector.element.ts | 75 ++++++++ .../src/lib/inspector/inspector.types.ts | 33 ++++ .../lib/inspector/name-inspector.element.scss | 3 + .../lib/inspector/name-inspector.element.ts | 25 +++ .../transform-inspector.element.scss | 88 +++++++++ .../inspector/transform-inspector.element.ts | 43 +++++ .../src/lib/scene-data-provider.element.ts | 156 ++++++++++++++++ .../src/lib/scene-hierarchy.element.scss | 9 +- studio-web/src/lib/scene-hierarchy.element.ts | 84 ++------- tsconfig.base.json | 3 + 26 files changed, 1093 insertions(+), 174 deletions(-) create mode 100644 framework/src/lib/directives/bind.ts create mode 100644 studio-ui/src/lib/forms/form-field-base.element.ts create mode 100644 studio-ui/src/lib/forms/index.ts create mode 100644 studio-ui/src/lib/forms/text-field.element.scss create mode 100644 studio-ui/src/lib/forms/text-field.element.ts create mode 100644 studio-web/src/lib/inspector/entity-inspector.element.scss create mode 100644 studio-web/src/lib/inspector/entity-inspector.element.ts create mode 100644 studio-web/src/lib/inspector/inspector.types.ts create mode 100644 studio-web/src/lib/inspector/name-inspector.element.scss create mode 100644 studio-web/src/lib/inspector/name-inspector.element.ts create mode 100644 studio-web/src/lib/inspector/transform-inspector.element.scss create mode 100644 studio-web/src/lib/inspector/transform-inspector.element.ts create mode 100644 studio-web/src/lib/scene-data-provider.element.ts diff --git a/framework/src/index.ts b/framework/src/index.ts index 7c8a3ee..3842106 100644 --- a/framework/src/index.ts +++ b/framework/src/index.ts @@ -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"; diff --git a/framework/src/lib/di/types.ts b/framework/src/lib/di/types.ts index 1e9c965..112076f 100644 --- a/framework/src/lib/di/types.ts +++ b/framework/src/lib/di/types.ts @@ -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(name?: string): Token { + return new UniqueToken(name ?? crypto.randomUUID()); + } + toString(): string { return `UniqueToken(${String(this.#id)})`; } @@ -23,3 +28,15 @@ export interface Provider { token: Token; value: T; } + +export interface DynamicProvider { + addEventListener( + type: "property-changes", + listener: Fn<[CustomEvent>], any>, + ): void; + + removeEventListener( + type: "property-changes", + listener: Fn<[CustomEvent>], any>, + ): void; +} diff --git a/framework/src/lib/directives/bind.ts b/framework/src/lib/directives/bind.ts new file mode 100644 index 0000000..9f9b605 --- /dev/null +++ b/framework/src/lib/directives/bind.ts @@ -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, K]; + +class BindDirective extends Directive { + #host?: HTMLElement; + #hostKey: string; + #listener?: (this: T, event: CustomEvent) => 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) { + return receiver[key]; + } + + override update(part: AttributePart, [[receiver, key]]: DirectiveParameters) { + 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) { + 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` + * + * `; + * } + * + * \@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` + * + * `; + * } + * ``` + * + * @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 "<property>-change" whenever the property value + * should change. (The new value should be emitted as the event `detail`.) + * + * - "<property>-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) { + * this.receiverValue = event.detail; + * } + * + * render = () => html` + * + * `; + * } + * ``` + */ +export default function (receiver: T, key: K) { + return directive(BindDirective)([receiver, key]); +} diff --git a/studio-frontend/src/app/anim-editor.element.ts b/studio-frontend/src/app/anim-editor.element.ts index 47500ed..4e3cbbd 100644 --- a/studio-frontend/src/app/anim-editor.element.ts +++ b/studio-frontend/src/app/anim-editor.element.ts @@ -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): void { - this.timelineStart = event.detail; - } - - #onTimelineEndChange(event: CustomEvent): void { - this.timelineEnd = event.detail; - } - - #onPlayHeadChange(event: CustomEvent): void { - this.playHead = event.detail; - } - - #onKeyframesChange(event: CustomEvent>): void { - this.keyframes = event.detail; - } - - protected override render = () => html` + protected override render = (): TemplateResult => html` `; } diff --git a/studio-frontend/src/app/anim-timeline.element.ts b/studio-frontend/src/app/anim-timeline.element.ts index 3d95fdd..537c56d 100644 --- a/studio-frontend/src/app/anim-timeline.element.ts +++ b/studio-frontend/src/app/anim-timeline.element.ts @@ -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 { diff --git a/studio-frontend/src/app/app.element.scss b/studio-frontend/src/app/app.element.scss index c292849..5d05551 100644 --- a/studio-frontend/src/app/app.element.scss +++ b/studio-frontend/src/app/app.element.scss @@ -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; + } + } } diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index d50e912..b55927b 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -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(); + #hierarchyRef = createRef(); - #hierarchy = createRef(); #params = new StudioURLParams(); - #canvasProvider: Ref = 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 { 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` - + + ${this.#params.mode === StudioMode.Editor ? html` - - - - + + + + ${this.showInspector ? html` +
+
+

+ Inspector +

+ +
+ + + +
+ + + +
+
+ ` : html` + + `} + + ${this.#enableAnimTimeline ? html` + + ` : nothing} ` : nothing} @@ -164,6 +257,7 @@ export class AppElement extends LitElement { ${this.loading ? html` ` : nothing} +
`; } diff --git a/studio-ui/src/lib/forms/form-field-base.element.ts b/studio-ui/src/lib/forms/form-field-base.element.ts new file mode 100644 index 0000000..99f81b2 --- /dev/null +++ b/studio-ui/src/lib/forms/form-field-base.element.ts @@ -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; + } +} diff --git a/studio-ui/src/lib/forms/index.ts b/studio-ui/src/lib/forms/index.ts new file mode 100644 index 0000000..852be6d --- /dev/null +++ b/studio-ui/src/lib/forms/index.ts @@ -0,0 +1 @@ +export * from "./text-field.element"; diff --git a/studio-ui/src/lib/forms/text-field.element.scss b/studio-ui/src/lib/forms/text-field.element.scss new file mode 100644 index 0000000..27f954b --- /dev/null +++ b/studio-ui/src/lib/forms/text-field.element.scss @@ -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; +} diff --git a/studio-ui/src/lib/forms/text-field.element.ts b/studio-ui/src/lib/forms/text-field.element.ts new file mode 100644 index 0000000..95b4fe9 --- /dev/null +++ b/studio-ui/src/lib/forms/text-field.element.ts @@ -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` + + `; +} diff --git a/studio-ui/src/lib/icon.element.ts b/studio-ui/src/lib/icon.element.ts index 0f10b2f..7e6b556 100644 --- a/studio-ui/src/lib/icon.element.ts +++ b/studio-ui/src/lib/icon.element.ts @@ -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, diff --git a/studio-ui/src/lib/tree.element.ts b/studio-ui/src/lib/tree.element.ts index f8621ab..f8026e3 100644 --- a/studio-ui/src/lib/tree.element.ts +++ b/studio-ui/src/lib/tree.element.ts @@ -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(); + +export interface TreeSelectionProvider extends DynamicProvider { + 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>) => { + 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 diff --git a/studio-web/src/index.ts b/studio-web/src/index.ts index 0420917..d65c93a 100644 --- a/studio-web/src/index.ts +++ b/studio-web/src/index.ts @@ -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"; diff --git a/studio-web/src/lib/canvas-provider.element.ts b/studio-web/src/lib/canvas-provider.element.ts index 31f1889..b584195 100644 --- a/studio-web/src/lib/canvas-provider.element.ts +++ b/studio-web/src/lib/canvas-provider.element.ts @@ -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 = new UniqueToken("CanvasProvider"); +export const CANVAS_PROVIDER = UniqueToken.create(); @customElement("sts-canvas-provider") @provide(CANVAS_PROVIDER) diff --git a/studio-web/src/lib/inspector/entity-inspector.element.scss b/studio-web/src/lib/inspector/entity-inspector.element.scss new file mode 100644 index 0000000..8dc794d --- /dev/null +++ b/studio-web/src/lib/inspector/entity-inspector.element.scss @@ -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; +} diff --git a/studio-web/src/lib/inspector/entity-inspector.element.ts b/studio-web/src/lib/inspector/entity-inspector.element.ts new file mode 100644 index 0000000..7ab2c04 --- /dev/null +++ b/studio-web/src/lib/inspector/entity-inspector.element.ts @@ -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>) => { + 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` + + `; + case "bevy_transform::components::transform::Transform": + return html` + + `; + default: + return html` +

Unknown Component: ${name}

+
${JSON.stringify({ name, value }, null, "   ")}
+ `; + } + }) + } + `; +} + +const COMPONENT_SORT_ORDER: Array = [ + "bevy_core::name::Name", + "bevy_transform::components::transform::Transform", +]; diff --git a/studio-web/src/lib/inspector/inspector.types.ts b/studio-web/src/lib/inspector/inspector.types.ts new file mode 100644 index 0000000..06e585c --- /dev/null +++ b/studio-web/src/lib/inspector/inspector.types.ts @@ -0,0 +1,33 @@ +export type InspectorComponent + = BevyReflect<"bevy_transform::components::transform::Transform", BevyTransform> + | BevyReflect<"bevy_core::name::Name", BevyName> + ; + +export interface BevyReflect { + 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; +} diff --git a/studio-web/src/lib/inspector/name-inspector.element.scss b/studio-web/src/lib/inspector/name-inspector.element.scss new file mode 100644 index 0000000..5862e71 --- /dev/null +++ b/studio-web/src/lib/inspector/name-inspector.element.scss @@ -0,0 +1,3 @@ +:host { + color-scheme: dark; +} diff --git a/studio-web/src/lib/inspector/name-inspector.element.ts b/studio-web/src/lib/inspector/name-inspector.element.ts new file mode 100644 index 0000000..aff85f5 --- /dev/null +++ b/studio-web/src/lib/inspector/name-inspector.element.ts @@ -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): void { + if (changes.has("value")) + console.log("New value:", this.value); + + super.updated(changes); + } + + protected override render = (): TemplateResult => html` + + `; +} diff --git a/studio-web/src/lib/inspector/transform-inspector.element.scss b/studio-web/src/lib/inspector/transform-inspector.element.scss new file mode 100644 index 0000000..c9afa4b --- /dev/null +++ b/studio-web/src/lib/inspector/transform-inspector.element.scss @@ -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; + } + } +} diff --git a/studio-web/src/lib/inspector/transform-inspector.element.ts b/studio-web/src/lib/inspector/transform-inspector.element.ts new file mode 100644 index 0000000..006c441 --- /dev/null +++ b/studio-web/src/lib/inspector/transform-inspector.element.ts @@ -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` + +

Transform

+
+ Translation + + + +
+
+ Rotation + + + + +
+
+ Scale + + + +
+ `; +} diff --git a/studio-web/src/lib/scene-data-provider.element.ts b/studio-web/src/lib/scene-data-provider.element.ts new file mode 100644 index 0000000..ac1e1d8 --- /dev/null +++ b/studio-web/src/lib/scene-data-provider.element.ts @@ -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 { + readonly tree: readonly SceneObject[]; + readonly entityMap: ReadonlyMap; + readonly selectedEntity: string | null; + readonly selectedObject: SceneObject | null; + readonly selectionComponents: readonly InspectorComponent[]; +} + +export const SCENE_DATA_PROVIDER = UniqueToken.create(); + +export interface SceneObject extends Omit { + 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(); + + @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): void { + const downstreamChanges: PropertyValues + = 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` + + `; +} diff --git a/studio-web/src/lib/scene-hierarchy.element.scss b/studio-web/src/lib/scene-hierarchy.element.scss index 7d504c9..2c74676 100644 --- a/studio-web/src/lib/scene-hierarchy.element.scss +++ b/studio-web/src/lib/scene-hierarchy.element.scss @@ -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; diff --git a/studio-web/src/lib/scene-hierarchy.element.ts b/studio-web/src/lib/scene-hierarchy.element.ts index 5d06e05..2254b2a 100644 --- a/studio-web/src/lib/scene-hierarchy.element.ts +++ b/studio-web/src/lib/scene-hierarchy.element.ts @@ -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 { - 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; } + @inject(SCENE_DATA_PROVIDER) + _sceneData?: SceneDataProvider; - @state() private _tree: SceneObject[] = []; - #sceneObjectsFlat: studio.SceneObject[] = []; - #entityMap = new Map(); - - @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>) => { + if (changes.has("tree")) + this.requestUpdate(); } protected override render = () => html` - ${this.tree.map(obj => html` + ${this._sceneData?.tree.map(obj => html` ) => studio.select(event.detail)} > `)} ` diff --git a/tsconfig.base.json b/tsconfig.base.json index 707a282..9b8ac87 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],