From dc6ab893a6b75f8d5e2abf2079e583d3be89a230 Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Fri, 22 Mar 2024 16:19:24 -0400 Subject: [PATCH] Add Scalar input field --- studio-ui/src/lib/forms/index.ts | 1 + .../src/lib/forms/scalar-field.element.scss | 48 ++++ .../src/lib/forms/scalar-field.element.ts | 209 ++++++++++++++++++ .../inspector/material-inspector.element.ts | 41 ++-- .../transform-inspector.element.scss | 64 +----- .../inspector/transform-inspector.element.ts | 119 ++++++---- tsconfig.base.json | 1 + 7 files changed, 364 insertions(+), 119 deletions(-) create mode 100644 studio-ui/src/lib/forms/scalar-field.element.scss create mode 100644 studio-ui/src/lib/forms/scalar-field.element.ts diff --git a/studio-ui/src/lib/forms/index.ts b/studio-ui/src/lib/forms/index.ts index 852be6d..ae82dc4 100644 --- a/studio-ui/src/lib/forms/index.ts +++ b/studio-ui/src/lib/forms/index.ts @@ -1 +1,2 @@ export * from "./text-field.element"; +export * from "./scalar-field.element"; diff --git a/studio-ui/src/lib/forms/scalar-field.element.scss b/studio-ui/src/lib/forms/scalar-field.element.scss new file mode 100644 index 0000000..6d20b1e --- /dev/null +++ b/studio-ui/src/lib/forms/scalar-field.element.scss @@ -0,0 +1,48 @@ +@use "sass:math"; + +:host { + box-sizing: border-box; + display: block; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + + background: #0004; + border: 1px solid #FFF2; + color: #FFFC; + outline: none !important; + 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-within) { + border-color: #FFF4; + background: #0006; + color: #FFFF; +} + +:host(:not(.editing)) { + cursor: ew-resize; +} + +:host(:disabled) { + pointer-events: none !important; +} + +.input { + width: 100%; + height: 100%; + border: none; + outline: none !important; + + &:first-line { + display: inline-block; + } +} diff --git a/studio-ui/src/lib/forms/scalar-field.element.ts b/studio-ui/src/lib/forms/scalar-field.element.ts new file mode 100644 index 0000000..06a592d --- /dev/null +++ b/studio-ui/src/lib/forms/scalar-field.element.ts @@ -0,0 +1,209 @@ +import { on } from "@storyteller/framework"; +import { PropertyValues, html, unsafeCSS } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; + +import { FormFieldBaseElement } from "./form-field-base.element"; + +import styles from "./scalar-field.element.scss?inline"; + +const VALID_INPUT_PATTERN = /[-+*\/.0-9 ]/; + +// NOTE: This code was adapted from here: +// https://stackblitz.com/edit/angular-wfikuy?file=src%2Ffloat-input%2Ffloat-input.component.ts +@customElement("sts-scalar-field") +export class ScalarFieldElement extends FormFieldBaseElement { + static override styles = unsafeCSS(styles); + + @property({ reflect: true }) + override role = "slider"; + + @property({ + type: Number, + attribute: "aria-valuenow", + reflect: true, + }) + value = 0; + + @property({ + type: Number, + attribute: "aria-valuemax", + reflect: true, + }) + max = Infinity; + + @property({ + type: Number, + attribute: "aria-valuemin", + reflect: true, + }) + min = -Infinity; + + @property({ type: Number }) + sensitivity = 0.01; + + @property({ + type: Number, + attribute: "tabindex", + reflect: true, + }) + override tabIndex = 0; + + @state() _editing = false; + + #inputRef = createRef(); + #abortControllers = { + "pointermove": null as AbortController | null, + "pointerup": null as AbortController | null, + }; + + #fmtDecimal = new Intl.NumberFormat("en-US", { + style: "decimal", + minimumIntegerDigits: 1, + minimumFractionDigits: 1, + maximumFractionDigits: 3, + }); + + protected override update(changes: PropertyValues): void { + if (changes.has("_editing")) + this.classList.toggle("editing", this._editing); + + super.update(changes); + } + + override disconnectedCallback(): void { + this.#abortControllers.pointermove?.abort(); + this.#abortControllers.pointerup?.abort(); + super.disconnectedCallback(); + } + + @on("pointerdown") + onPointerDown(event: PointerEvent): void { + if (this._editing || event.button !== 0) + return; + + event.preventDefault(); + + this.requestPointerLock(); + + this.#abortControllers.pointermove?.abort(); + this.#abortControllers.pointermove = new AbortController(); + + this.#abortControllers.pointerup?.abort(); + this.#abortControllers.pointerup = new AbortController(); + + const pointerMoveAbortSignal = this.#abortControllers.pointermove.signal; + + let totalMovement = 0; + + this.addEventListener("pointermove", event => { + totalMovement += event.movementX; + this.#emitValue(this.value + event.movementX * this.sensitivity); + }, { + signal: pointerMoveAbortSignal, + }); + + pointerMoveAbortSignal.addEventListener("abort", () => { + document.exitPointerLock(); + // If no actual dragging occurred between pointerdown and pointerup, + // treat this like a click + if (totalMovement === 0) + this.#activateDirectInput(); + }); + + this.addEventListener("pointerup", () => { + this.#abortControllers.pointermove?.abort(); + }, { + once: true, + signal: this.#abortControllers.pointerup.signal, + }); + } + + @on("focus") + onFocus(): void { + this.#activateDirectInput(); + } + + onInputEnter(event: KeyboardEvent): void { + event.preventDefault(); + event.stopPropagation(); + + const input = this.#inputRef.value; + if (!input) return; + + input.textContent = this.#fmtDecimal.format(this.value); + } + + onInputChange(event: InputEvent): void { + try { + const input = this.#inputRef.value!; + const textContent = input.textContent?.trim() ?? ""; + + if (event.data && !VALID_INPUT_PATTERN.test(event.data)) { + input.textContent = this.value.toString(10); + } else { + const value = parseFloat(textContent); + this.#emitValue(value); + } + } catch {} + } + + async #activateDirectInput(): Promise { + this._editing = true; + this.tabIndex = -1; + + this.requestUpdate(); + await this.updateComplete; + + const input = this.#inputRef.value; + if (!input) + throw new Error("Expected input to be available in editing mode"); + + input.addEventListener("blur", () => { + this._editing = false; + this.tabIndex = 0; + }, { + once: true, + }); + + input.focus(); + input.textContent = this.#fmtDecimal.format(this.value); + + const range = document.createRange(); + range.selectNodeContents(input); + + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + } + + #emitValue(value: number): void { + if (this.max != null) + value = Math.min(value, this.max); + + if (this.min != null) + value = Math.max(value, this.min); + + this.dispatchEvent(new CustomEvent("value-change", { + detail: value, + })); + } + + protected override render = () => html` + ${!this._editing ? html` + ${this.#fmtDecimal.format(this.value)} + ` : html` + { + if (event.key === "Enter") + this.onInputEnter(event); + }} + > + `} + `; +} diff --git a/studio-web/src/lib/inspector/material-inspector.element.ts b/studio-web/src/lib/inspector/material-inspector.element.ts index bbef849..77aa6f8 100644 --- a/studio-web/src/lib/inspector/material-inspector.element.ts +++ b/studio-web/src/lib/inspector/material-inspector.element.ts @@ -6,6 +6,8 @@ import { v4 as uuid } from "uuid"; import { StandardMaterial, type Rgba } from "./inspector.types"; +import "@storyteller/studio-ui/forms/scalar-field"; + import styles from "./material-inspector.element.scss?inline"; @customElement("sts-material-inspector") @@ -47,15 +49,12 @@ export class MaterialInspectorElement extends LitElement { } updateField(reflectPath: Exclude) { - return ({ detail }: CustomEvent) => { - const value = parseFloat(detail); - if (Number.isFinite(value) && !Number.isNaN(value)) { - studio.updateComponentField({ - component: "bevy_pbr::pbr_material::StandardMaterial", - reflectPath, - value, - }); - } + return ({ detail: value }: CustomEvent) => { + studio.updateComponentField({ + component: "bevy_pbr::pbr_material::StandardMaterial", + reflectPath, + value, + }); } } @@ -82,32 +81,32 @@ export class MaterialInspectorElement extends LitElement { - + > - + > - + > `; } diff --git a/studio-web/src/lib/inspector/transform-inspector.element.scss b/studio-web/src/lib/inspector/transform-inspector.element.scss index 99a3a01..2ccd8a2 100644 --- a/studio-web/src/lib/inspector/transform-inspector.element.scss +++ b/studio-web/src/lib/inspector/transform-inspector.element.scss @@ -18,73 +18,25 @@ h4 { } .fieldset { - position: relative; - padding-block-start: math.div(14, 16) + 0.333rem !important; - - legend { - position: absolute; - inset-block-start: 0; - } -} - -fieldset, .fieldset { display: flex; + position: relative; width: 100%; - padding: 0; + padding: 0 { + block-start: math.div(14, 16) + 0.333rem !important; + } margin: 0; gap: 0.25rem; border: none; legend { + position: absolute; + inset-block-start: 0; + margin-block-end: 0.333rem; color: #FFF9; font-size: math.div(14, 16) * 1rem; - margin-block-end: 0.333rem; } - input { - appearance: textfield; - 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; - } - - &:focus { - outline: none; - border-color: #FFF4; - background: #0006; - color: #FFFF; - } - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - margin: 0; - } - } - - sts-text-field::part(input) { - flex: unset; - width: 100%; - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - appearance: none; - margin: 0; - } - } - - input, sts-text-field { + .input { padding: 0.5rem; width: math.div(1, 3) * 100%; border-left-width: 4px; diff --git a/studio-web/src/lib/inspector/transform-inspector.element.ts b/studio-web/src/lib/inspector/transform-inspector.element.ts index bb729ce..bb1e204 100644 --- a/studio-web/src/lib/inspector/transform-inspector.element.ts +++ b/studio-web/src/lib/inspector/transform-inspector.element.ts @@ -1,8 +1,12 @@ import * as studio from "@storyteller/studio" import { LitElement, html, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { v4 as uuid } from "uuid"; + import { type Quat, type Vec3 } from "./inspector.types"; +import "@storyteller/studio-ui/forms/scalar-field"; + import styles from "./transform-inspector.element.scss?inline"; @customElement("sts-transform-inspector") @@ -18,6 +22,10 @@ export class TransformInspectorElement extends LitElement { @property({ attribute: false }) scale: Vec3 = { x: 1, y: 1, z: 1 }; + #positionLabelId = uuid(); + #rotationLabelId = uuid(); + #scaleLabelId = uuid(); + updateField(reflectPath: `${"translation"|"rotation"|"scale"}.${"x"|"y"|"z"}`) { return ({ detail }: CustomEvent) => { const value = parseFloat(detail); @@ -33,61 +41,88 @@ export class TransformInspectorElement extends LitElement { protected override render = () => html`

Transform

-
- Position - + + Position + + - + - + + >
-
- Rotation +
+ + Rotation + - - + - + + >
-
- Scale - + + Scale + + - + - + + >
`; } diff --git a/tsconfig.base.json b/tsconfig.base.json index abe4e99..7b67ed2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -27,6 +27,7 @@ "@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-ui/forms/scalar-field": ["studio-ui/src/lib/forms/scalar-field.element.ts"], "@storyteller/studio-web": ["studio-web/src/index.ts"], "@storyteller/studio-web/add-to-scene-menu": ["studio-web/src/lib/add-to-scene-menu.element.ts"], "@storyteller/studio-web/canvas-provider": ["studio-web/src/lib/canvas-provider.element.ts"],