From bfc638b6440736e84e5db39c0d0d160b96202d4f Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Fri, 5 Apr 2024 20:15:09 -0400 Subject: [PATCH] UI styling, main workspace layout (#119) --- framework/src/lib/di/index.ts | 1 + framework/src/lib/di/observe.spec.ts | 79 +++++ framework/src/lib/di/observe.ts | 80 +++++ studio-frontend/index.html | 2 +- .../src/app/anim-editor.element.scss | 5 +- .../src/app/anim-editor.element.ts | 104 ++++--- .../app/anim-timeline-controller.element.scss | 3 + .../app/anim-timeline-controller.element.ts | 228 ++++++++++++++ .../app/anim-timeline-controls.element.scss | 28 ++ .../src/app/anim-timeline-controls.element.ts | 63 ++++ .../src/app/anim-timeline.element.scss | 1 - studio-frontend/src/app/app.element.scss | 142 ++------- studio-frontend/src/app/app.element.ts | 205 +++++-------- .../src/app/studio-viewport.element.scss | 152 +++++++++ .../src/app/studio-viewport.element.ts | 288 ++++++++++++++++++ studio-frontend/src/styles.scss | 21 +- studio-ui/src/assets/icons/cube-solid.svg | 3 + studio-ui/src/assets/icons/cylinder-solid.svg | 3 + studio-ui/src/assets/icons/sphere-solid.svg | 3 + studio-ui/src/assets/icons/torus-solid.svg | 4 + studio-ui/src/assets/icons/xform-local.svg | 3 + studio-ui/src/lib/button.element.scss | 45 ++- studio-ui/src/lib/button.element.ts | 62 +++- studio-ui/src/lib/dialog/dialog.element.ts | 8 +- studio-ui/src/lib/forms/index.ts | 3 +- studio-ui/src/lib/forms/radio/index.ts | 5 + .../lib/forms/radio/radio-group.element.scss | 4 + .../lib/forms/radio/radio-group.element.ts | 61 ++++ .../src/lib/forms/radio/radio.element.scss | 30 ++ .../src/lib/forms/radio/radio.element.ts | 29 ++ .../src/lib/forms/scalar-field.element.scss | 31 +- studio-ui/src/lib/forms/select/index.ts | 5 + .../src/lib/forms/select/listbox.element.scss | 7 + .../src/lib/forms/select/listbox.element.ts | 16 + .../src/lib/forms/select/option.element.scss | 28 ++ .../src/lib/forms/select/option.element.ts | 35 +++ .../src/lib/forms/select/select.element.scss | 40 +++ .../src/lib/forms/select/select.element.ts | 148 +++++++++ studio-ui/src/lib/icon.element.ts | 80 ++--- studio-ui/src/lib/menu.element.scss | 13 +- studio-ui/src/lib/menu.element.ts | 2 +- studio-ui/src/lib/toolbar-group.element.scss | 18 +- studio-ui/src/lib/toolbar.element.scss | 14 +- studio-ui/src/lib/tree.element.ts | 22 +- .../inspector/entity-inspector.element.scss | 17 +- .../lib/inspector/entity-inspector.element.ts | 134 ++++---- .../inspector/material-inspector.element.scss | 2 +- .../lib/inspector/quat-inspector.element.scss | 57 ++-- .../lib/inspector/quat-inspector.element.ts | 68 +++-- .../transform-inspector.element.scss | 68 +++-- .../inspector/transform-inspector.element.ts | 123 +++++--- .../src/lib/media-library-dialog.element.ts | 19 +- studio-web/src/lib/scene-hierarchy.element.ts | 22 +- studio-web/src/lib/url-params.ts | 43 ++- studio/src/anim/timeline_animation.rs | 15 +- studio/src/scene/mod.rs | 2 + tsconfig.base.json | 4 +- 57 files changed, 2004 insertions(+), 694 deletions(-) create mode 100644 framework/src/lib/di/observe.spec.ts create mode 100644 framework/src/lib/di/observe.ts create mode 100644 studio-frontend/src/app/anim-timeline-controller.element.scss create mode 100644 studio-frontend/src/app/anim-timeline-controller.element.ts create mode 100644 studio-frontend/src/app/anim-timeline-controls.element.scss create mode 100644 studio-frontend/src/app/anim-timeline-controls.element.ts create mode 100644 studio-frontend/src/app/studio-viewport.element.scss create mode 100644 studio-frontend/src/app/studio-viewport.element.ts create mode 100644 studio-ui/src/assets/icons/cube-solid.svg create mode 100644 studio-ui/src/assets/icons/cylinder-solid.svg create mode 100644 studio-ui/src/assets/icons/sphere-solid.svg create mode 100644 studio-ui/src/assets/icons/torus-solid.svg create mode 100644 studio-ui/src/assets/icons/xform-local.svg create mode 100644 studio-ui/src/lib/forms/radio/index.ts create mode 100644 studio-ui/src/lib/forms/radio/radio-group.element.scss create mode 100644 studio-ui/src/lib/forms/radio/radio-group.element.ts create mode 100644 studio-ui/src/lib/forms/radio/radio.element.scss create mode 100644 studio-ui/src/lib/forms/radio/radio.element.ts create mode 100644 studio-ui/src/lib/forms/select/index.ts create mode 100644 studio-ui/src/lib/forms/select/listbox.element.scss create mode 100644 studio-ui/src/lib/forms/select/listbox.element.ts create mode 100644 studio-ui/src/lib/forms/select/option.element.scss create mode 100644 studio-ui/src/lib/forms/select/option.element.ts create mode 100644 studio-ui/src/lib/forms/select/select.element.scss create mode 100644 studio-ui/src/lib/forms/select/select.element.ts diff --git a/framework/src/lib/di/index.ts b/framework/src/lib/di/index.ts index ce72d0c..0127c14 100644 --- a/framework/src/lib/di/index.ts +++ b/framework/src/lib/di/index.ts @@ -1,3 +1,4 @@ export * from "./decorators"; export * from "./events"; +export * from "./observe"; export * from "./types"; diff --git a/framework/src/lib/di/observe.spec.ts b/framework/src/lib/di/observe.spec.ts new file mode 100644 index 0000000..a3c73c8 --- /dev/null +++ b/framework/src/lib/di/observe.spec.ts @@ -0,0 +1,79 @@ +import { LitElement, PropertyDeclaration, PropertyValues, html, render } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; + +import { inject, provide } from "./decorators"; +import { observe } from "./observe"; +import { DynamicProvider, UniqueToken } from "./types"; + +interface CountProvider extends DynamicProvider { + readonly count: number; +} + +const COUNT_PROVIDER = UniqueToken.create(); + +@customElement("test-count-provider") +@provide(COUNT_PROVIDER) +class TestCountProvider extends LitElement implements CountProvider { + @state() count = 0; + + protected override update(changes: PropertyValues): void { + if (changes.has("count")) { + const downstreamChanges: PropertyValues = new Map(); + downstreamChanges.set("count", changes.get("count")); + + this.dispatchEvent(new CustomEvent("property-changes", { + detail: downstreamChanges, + })); + } + super.update(changes); + } + + protected override render = () => html` + + `; +} + +@customElement("test-count-dependent") +class TestCountDependent extends LitElement { + @observe(["count"]) + @inject(COUNT_PROVIDER) + countProvider!: CountProvider; + + override requestUpdate( + name?: PropertyKey | undefined, + oldValue?: unknown, + options?: PropertyDeclaration | undefined + ): void { + super.requestUpdate(name, oldValue, options); + } + + protected override update(changes: PropertyValues): void { + this.setAttribute("count", this.countProvider?.count?.toString() ?? null); + super.update(changes); + } +} + +describe("@observe", () => { + it("should work", async () => { + const providerRef = createRef(); + const dependentRef = createRef(); + + render(html` + + + + `, document.body); + + expect(providerRef.value).toBeDefined(); + expect(dependentRef.value).toBeDefined(); + + while (!(await dependentRef.value!.updateComplete)); + expect(dependentRef.value!.getAttribute("count")).toEqual("0"); + + providerRef.value!.count = 42; + while (!(await providerRef.value!.updateComplete)); + while (!(await dependentRef.value!.updateComplete)); + expect(dependentRef.value!.getAttribute("count")).toEqual("42"); + }); +}); diff --git a/framework/src/lib/di/observe.ts b/framework/src/lib/di/observe.ts new file mode 100644 index 0000000..02c3acc --- /dev/null +++ b/framework/src/lib/di/observe.ts @@ -0,0 +1,80 @@ +import { LitElement, PropertyValues } from "lit"; +import { appendRoutine, prependRoutine } from "../decorators/internal"; +import { DynamicProvider } from "./types"; + +/** + * Observe the given property names of the decorated {@linkcode DynamicProvider}, + * calling `requestUpdate` to re-render the component when one of those + * properties is updated in the provider. + * + * @example + * ```ts + * const COUNT_PROVIDER = UniqueToken.create(); + * + * interface CountProvider extends DynamicProvider { + * readonly count: number; + * } + * + * \@customElement("sts-count-observer") + * export class CountObserverElement extends LitElement { + * \@observe(["count"]) + * \@inject(COUNT_PROVIDER) + * _countProvider!: CountProvider; + * + * protected override render = () => html` + * + * The count is: ${this._countProvider.count} + * `; + * } + * ``` + */ +export function observe< + T extends LitElement, + K extends keyof T, + P extends (T[K] & object), +>(observedProps: (keyof P)[]) + : T[K] extends DynamicProvider + ? ((proto: T, propName: K) => void) + : never +{ + return ((proto: T, propName: K) => { + function onProviderChanges( + this: T, + { detail: changes }: CustomEvent>, + ): void { + // FIXME: Holy shit TypeScript, gg + if (observedProps.some(prop => changes.has(prop as never))) + this.requestUpdate(); + } + + const $onProviderChanges = Symbol("onProviderChanges"); + + type Decorated = T & { + [$onProviderChanges]: typeof onProviderChanges; + } + + appendRoutine(proto, "connectedCallback", function (this: T) { + assertType(this); + this[$onProviderChanges] ??= onProviderChanges.bind(this); + + const provider = this[propName]; + assertType>(provider); + + provider?.addEventListener?.("property-changes", this[$onProviderChanges]); + }); + + prependRoutine(proto, "disconnectedCallback", function (this: T) { + assertType(this); + + const provider = this[propName]; + assertType>(provider); + + provider?.removeEventListener?.("property-changes", this[$onProviderChanges]); + }); + }) as any +} + +function assertType(value: unknown): asserts value is T {} diff --git a/studio-frontend/index.html b/studio-frontend/index.html index 2426834..f97b9b1 100644 --- a/studio-frontend/index.html +++ b/studio-frontend/index.html @@ -10,7 +10,7 @@ - + diff --git a/studio-frontend/src/app/anim-editor.element.scss b/studio-frontend/src/app/anim-editor.element.scss index 000266b..db73fd7 100644 --- a/studio-frontend/src/app/anim-editor.element.scss +++ b/studio-frontend/src/app/anim-editor.element.scss @@ -23,7 +23,6 @@ .timeline-wrapper { grid-area: timeline; display: grid; - border-radius: 8px; background: var(--color-panel-background); grid-template-columns: [sidebar-start] min-content [sidebar-end @@ -146,7 +145,7 @@ grid-template-columns: subgrid; grid-row: 2 / span var(--track-count); - + .track { &:hover { background: #0000001F; @@ -162,4 +161,4 @@ } } } -} \ No newline at end of file +} diff --git a/studio-frontend/src/app/anim-editor.element.ts b/studio-frontend/src/app/anim-editor.element.ts index f0729cf..8a80fc8 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 { EntitySelectEvent, SeekTimeChanged, UpdateAnimationTargets, Keyframe } from "@storyteller/studio"; +import type { EntitySelectEvent, SeekTimeChanged, UpdateAnimationTargets, Keyframe, AnimationTrack } from "@storyteller/studio"; import { bind, drag, inject, on, provide } from "@storyteller/framework"; -import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult, PropertyValueMap } from "lit"; +import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { styleMap } from "lit/directives/style-map.js"; @@ -27,6 +27,8 @@ function isOutsideEntity( return "outsideEntity" in value; } +// TODO: Consider refactoring to get/set most of the `@state` here through +// `AnimTimelineController` @customElement("sts-anim-editor") @provide(TREE_SELECTION_PROVIDER) export class AnimEditorElement extends LitElement @@ -47,7 +49,7 @@ export class AnimEditorElement extends LitElement @state() playHead = 0; @state() playing = false; - @state() tracks: Record = {}; + @state() tracks: Record = {}; @state() track_ordered: [string, studio.AnimationTarget][] = []; @@ -115,7 +117,7 @@ export class AnimEditorElement extends LitElement delete tracks[track_entity]; } - this.track_ordered = Object.keys(tracks).map(entity => [entity, tracks[entity].animation_target]); + this.track_ordered = Object.keys(tracks).map(entity => [entity, tracks[entity].animationTarget]); if (typeof this.selectedTrack === "object") { if (!isOutsideEntity(this.selectedTrack) && !(this.selectedTrack.track in tracks)) { @@ -309,44 +311,45 @@ export class AnimEditorElement extends LitElement let timeline_css_variables = { '--track-count': `${this.trackCount()}`, - '--row-count': `calc(var(--track-count) + ${canAddSelected ? 3 : 2})`, + '--row-count': `calc(var(--track-count) + ${/*canAddSelected ? 3 :*/ 2})`, '--clip-end': `${clip_end * 100}%`, '--clip-start': `${clip_start * 100}%` }; const tracklist = this.track_ordered.map(([entity,]) => { let track = this.tracks[entity]; - let name = track.name ?? track.target_type; + let name = track.name ?? track.targetType; let icon = this.sceneData?.entityMap.get(entity)?.icon ?? "minus"; return html` - { - studio.select(entity); - }} - .id=${entity} - > + { + studio.select(entity); + }} + .id=${entity} + > `; }); - if (canAddSelected) { - tracklist.push(html` - - `); - } + // if (canAddSelected) { + // tracklist.push(html` + // + // `); + // } const none_selected = !selected; return html` +
- ` }; diff --git a/studio-frontend/src/app/anim-timeline-controller.element.scss b/studio-frontend/src/app/anim-timeline-controller.element.scss new file mode 100644 index 0000000..d201e93 --- /dev/null +++ b/studio-frontend/src/app/anim-timeline-controller.element.scss @@ -0,0 +1,3 @@ +:host { + display: contents; +} diff --git a/studio-frontend/src/app/anim-timeline-controller.element.ts b/studio-frontend/src/app/anim-timeline-controller.element.ts new file mode 100644 index 0000000..b30e823 --- /dev/null +++ b/studio-frontend/src/app/anim-timeline-controller.element.ts @@ -0,0 +1,228 @@ +import { DynamicProvider, UniqueToken, inject, on, provide } from "@storyteller/framework"; +import * as studio from "@storyteller/studio"; +import { + type AnimationTarget, + type AnimationTrack, + type Keyframe, + type SeekTimeChanged, + type UpdateAnimationTargets, + type UpdateKeyframes, +} from "@storyteller/studio"; +import { + SCENE_DATA_PROVIDER, + type SceneDataProvider, +} from "@storyteller/studio-web/scene-data-provider"; +import { LitElement, PropertyValues, html, unsafeCSS } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import styles from "./anim-timeline-controller.element.scss?inline"; + +export const ANIM_TIMELINE_CONTROLLER = UniqueToken.create(); + +export interface AnimTimelineController extends DynamicProvider { + readonly seekTime: number; + readonly playing: boolean; + readonly tracks: Record; + readonly tracksOrdered: [Entity, AnimationTarget][]; + readonly keyframes: Record; + readonly clipStart: number; + readonly clipEnd: number; + + setSeekTime(time: number): void; + addTrack(entity: Entity): void; + removeTrack(entity: Entity): void; + addKeyframe(): void; + togglePlaying(): void; + play(): void; + pause(): void; + jumpToStart(): void; + jumpToEnd(): void; +} + +export type Entity = string; + +@customElement("sts-anim-timeline-controller") +@provide(ANIM_TIMELINE_CONTROLLER) +export class AnimTimelineControllerElement + extends LitElement + implements AnimTimelineController +{ + static override styles = unsafeCSS(styles); + + @state() seekTime = 0; + @state() playing = false; + @state() tracks: Record = {}; + @state() tracksOrdered: [Entity, AnimationTarget][] = []; + @state() selectedTrack: AnimationTrack | null = null; + @state() keyframes: Record = {}; + @state() clipStart = 0; + @state() clipEnd = 0; + + @inject(SCENE_DATA_PROVIDER) + _sceneData!: SceneDataProvider; + + setSeekTime(time: number): void { + studio.seekTime(time); + } + + addTrack(entity: Entity): void { + if (!(entity in this.tracks)) + studio.addEntityTrack(entity); + } + + removeTrack(entity: Entity): void { + this.tracks = Object.fromEntries( + Object.entries(this.tracks).filter(([key]) => key !== entity) + ) + } + + addKeyframe(): void { + const selected = this._sceneData.selectedEntity; + if (selected != null && selected in this.tracks) { + studio.recordKeyframe([selected]); + } + } + + togglePlaying(): void { + if (this.playing) studio.pause(); + else studio.play(); + } + + play(): void { + studio.play(); + } + + pause(): void { + studio.pause(); + } + + jumpToStart(): void { + if (Math.abs(this.seekTime - this.clipStart) < 0.0001) { + studio.seekTime(0); + } else { + studio.seekTime(this.clipStart); + } + } + + jumpToEnd(): void { + studio.seekTime(this.clipEnd); + } + + override connectedCallback(): void { + this._sceneData?.addEventListener("property-changes", this._onSceneDataUpdated); + super.connectedCallback(); + } + + override disconnectedCallback(): void { + this._sceneData?.removeEventListener("property-changes", this._onSceneDataUpdated); + super.disconnectedCallback() + } + + protected override willUpdate(changes: PropertyValues): void { + if (changes.has("tracks")) { + this.tracksOrdered = Object + .entries(this.tracks) + .map(([entity, track]) => [entity, track.animationTarget]); + } + + if (changes.has("keyframes")) { + let min = Infinity + let max = -Infinity; + + const keyframeKeys = Object.keys(this.keyframes); + if (!keyframeKeys.length) { + this.clipStart = 0; + this.clipEnd = 0; + + return; + } + + for (const keyframe of keyframeKeys) { + const current = this.keyframes[keyframe]; + let time = current.time; + if (typeof time != "number") { + continue; + } + if (time > max) { + max = time; + } + if (time < min) { + min = time; + } + if ("duration" in current) { + const endTime = time + current.duration; + if (endTime > max) { + max = endTime + } + } + } + + this.clipStart = Math.max(min, 0.); + this.clipEnd = Math.max(max, min); + } + + super.willUpdate(changes); + } + + protected override update(changes: PropertyValues): void { + this.dispatchEvent(new CustomEvent("property-changes", { + detail: new Map(changes.entries()) + })); + + super.update(changes); + } + + _onSceneDataUpdated = ({ detail: changes }: CustomEvent>) => { + if (changes.has("selectedEntity")) { + const selected = this._sceneData.selectedEntity; + if (selected) { + this.selectedTrack = this.tracks[selected] ?? null; + } else { + this.selectedTrack = null; + } + } + } + + @on("window:seek-time-changed") + _onSeekTimeChanged({ detail: { time, playing }}: SeekTimeChanged): void { + this.seekTime = time; + this.playing = playing; + } + + @on("window:update-animation-targets") + _onUpdateAnimationTargets({ detail: { added, removed }}: UpdateAnimationTargets): void { + if (Object.keys(added).length === 0 && removed.length === 0) + return; + + const updated = { + ...this.tracks, + ...added, + }; + + for (let entity of removed) + delete updated[entity]; + + this.tracks = updated; + console.log("tracks:", this.tracks); + } + + @on("window:update-keyframes") + _onUpdateKeyframes({ detail: { added, removed }}: UpdateKeyframes): void { + if (Object.keys(added).length === 0 && removed.length === 0) + return; + + const updated = { + ...this.keyframes, + ...added, + }; + + for (let key of removed) + delete updated[key]; + + this.keyframes = updated; + } + + protected override render = () => html` + + `; +} diff --git a/studio-frontend/src/app/anim-timeline-controls.element.scss b/studio-frontend/src/app/anim-timeline-controls.element.scss new file mode 100644 index 0000000..78981b0 --- /dev/null +++ b/studio-frontend/src/app/anim-timeline-controls.element.scss @@ -0,0 +1,28 @@ +.play-pause { + align-self: center; + padding: 0.4rem; + border-radius: 50%; + background: #FFF; + color: var(--neutral-100); + + &::part(icon) { + width: 1em; + font-size: 0.75rem; + } +} + +.timecode { + display: block; + align-self: center; + margin-inline: 0.5rem; + color: rgba(#FFF, 0.5); + font: { + size: 0.875rem; + weight: 500; + variant-numeric: tabular-nums; + } + + strong { + color: #FFF; + } +} diff --git a/studio-frontend/src/app/anim-timeline-controls.element.ts b/studio-frontend/src/app/anim-timeline-controls.element.ts new file mode 100644 index 0000000..f7987de --- /dev/null +++ b/studio-frontend/src/app/anim-timeline-controls.element.ts @@ -0,0 +1,63 @@ +import { inject, observe } from "@storyteller/framework"; +import { LitElement, html, unsafeCSS } from "lit"; +import { customElement } from "lit/decorators.js"; +import { + ANIM_TIMELINE_CONTROLLER, + type AnimTimelineController, +} from "./anim-timeline-controller.element"; + +import styles from "./anim-timeline-controls.element.scss?inline"; + +@customElement("sts-anim-timeline-controls") +export class AnimTimelineControlsElement extends LitElement { + static override styles = unsafeCSS(styles); + + @observe(["seekTime", "clipEnd", "playing"]) + @inject(ANIM_TIMELINE_CONTROLLER) + _timeline!: AnimTimelineController; + + timestamp(fractionalSeconds: number): string { + function modf(num: number, divisor: number): [number, number] { + return [Math.floor(num / divisor), Math.floor(num % divisor)]; + } + + const totalMs = Math.round(fractionalSeconds * 1000); + const [totalSeconds, ms] = modf(totalMs, 1000); + const [minutes, seconds] = modf(totalSeconds, 60); + + return `${ + minutes.toString().padStart(2, "0") + }:${ + seconds.toString().padStart(2, "0") + }.${ + ms.toString().padStart(3, "0") + }` + } + + protected override render = () => html` + + + this._timeline.jumpToStart()} + > + + this._timeline.togglePlaying()} + > + + this._timeline.jumpToEnd()} + > + + + ${this.timestamp(this._timeline.seekTime)} + / ${this.timestamp(this._timeline.clipEnd)} + + + + `; +} diff --git a/studio-frontend/src/app/anim-timeline.element.scss b/studio-frontend/src/app/anim-timeline.element.scss index 77f84e8..dc998f2 100644 --- a/studio-frontend/src/app/anim-timeline.element.scss +++ b/studio-frontend/src/app/anim-timeline.element.scss @@ -64,7 +64,6 @@ } user-select: none; pointer-events: none; - z-index: 1; .frac { color: #FFF3; diff --git a/studio-frontend/src/app/app.element.scss b/studio-frontend/src/app/app.element.scss index 3c6a0d0..f018db4 100644 --- a/studio-frontend/src/app/app.element.scss +++ b/studio-frontend/src/app/app.element.scss @@ -3,94 +3,32 @@ $hierarchy-width: 290px; .app-root { - position: fixed; - inset: 0; --sidebar-width: 300px; - --timeline-height: max-content; -} + --timeline-height: min-content; -.debug-mode { - .studio-scene { - z-index: 100; - } -} - -.wrapper { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; display: grid; - pointer-events: none; - - grid-template-columns: [timeline-start screen-start] 2rem [left-toolbar-start help-start] max-content [left-toolbar-end] auto [center-toolbar-start] max-content [center-toolbar-end] auto [right-toolbar-start] max-content [right-toolbar-end] 2rem [screen-end help-end sidebar-start] var(--sidebar-width) [sidebar-end timeline-end]; - grid-template-rows: [top-toolbar-start sidebar-start screen-start] min-content [top-toolbar-end help-start sidebar-view-start] max-content [help-end] auto [bottom-toolbar-start] max-content [bottom-toolbar-end sidebar-end sidebar-view-end screen-end timeline-start] var(--timeline-height) [timeline-end]; - - .studio-scene { - position: fixed; - bottom: 0; - left: 0; - right: 0; - pointer-events: all; + width: 100vw; + height: 100vh; + grid-template: { + areas: + "viewport sidebar" + "timeline timeline"; + rows: minmax(0, 1fr) var(--timeline-height); + columns: minmax(0, 1fr) var(--sidebar-width); } - .action-toolbar { - grid-column: left-toolbar; - grid-row: top-toolbar; - padding-top: var(--padding); - - sts-toolbar-group { - gap: var(--padding); - } - - sts-button { - background-color: var(--color-toolbar-background); - } - - z-index: 1; - } - - .timeline { - grid-row-start: bottom-toolbar-start; - grid-row-end: timeline-end; - grid-column: timeline; - z-index: 1; - border-radius: 0; - } + .viewport { grid-area: viewport; } + .sidebar { grid-area: sidebar; } + .timeline { grid-area: timeline; } .sidebar { - display: grid; - grid-area: sidebar; - grid-template-rows: subgrid; - z-index: 1; + display: flex; + flex-direction: column; background-color: var(--color-panel-background); - border-radius: 0; - - - position: relative; - padding: var(--padding); - padding-left: 0.5rem; - - .resize-handle { - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 0.5rem; - background: #0004; - transition: 66.666ms background linear; - - &:hover { - background: #FFF1; - } - } .sidebar-header { - grid-row: top-toolbar; display: flex; flex-direction: row; - justify-content: stretch; gap: 0; .sidebar-tab { @@ -108,51 +46,19 @@ $hierarchy-width: 290px; } .sidebar-view { - grid-row: sidebar-view; - } - } - - .help { - grid-area: help; - z-index: 1; - display: flex; - flex-direction: row; - justify-content: start; - } - - .entity-inspector { - grid-column: left-toolbar; - grid-row: bottom-toolbar; - z-index: 1; - padding-bottom: var(--padding); - & > * { - pointer-events: all; - } - } - - .transform-toolbar { - grid-column: center-toolbar; - grid-row: top-toolbar; - background-color: var(--color-toolbar-background); - z-index: 1; - border-top-right-radius: 0; - border-top-left-radius: 0; - border-bottom-right-radius: var(--corner-radius); - border-bottom-left-radius: var(--corner-radius); - padding: var(--padding); - display: flex; - flex-direction: row; - gap: 1rem; - - .add-to-scene-dropdown { - background-color: var(--color-toolbar-background-light); - font-size: 16px; - padding: 10px 12px; + padding: 1rem; } } } -.studio-scene.block-pointer-events::part(canvas) { +.help { + grid-area: help; + display: flex; + flex-direction: row; + justify-content: start; +} + +.studio.block-pointer-events::part(canvas) { pointer-events: none; } @@ -215,4 +121,4 @@ $hierarchy-width: 290px; .menu-button-icon { font-size: 12px; opacity: 0.75; -} \ No newline at end of file +} diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 45b04fa..37cf522 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -1,14 +1,14 @@ import { drag, on, provide } from "@storyteller/framework"; -import * as studio from "@storyteller/studio"; import { type SceneSavedEvent, type SceneStateEvent, SceneState, StudioMode, + type DebugModeToggled, } from "@storyteller/studio"; import { ButtonElement } from "@storyteller/studio-ui/button"; -import { DialogElement } from "@storyteller/studio-ui/dialog"; import { + STUDIO_PARAMS, SceneHierarchyElement, StorytellerApi, StorytellerLocalApi, @@ -18,10 +18,9 @@ import { } from "@storyteller/studio-web"; import { match } from "@storyteller/utility"; import { LitElement, TemplateResult, html, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { customElement, state } from "lit/decorators.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 "@storyteller/studio-ui/button"; import "@storyteller/studio-ui/dialog"; @@ -39,7 +38,9 @@ import "@storyteller/studio-web/scene-hierarchy"; import "@storyteller/studio-web/studio"; import "@storyteller/studio-web/transform-toolbar"; import "./anim-editor.element"; +import "./anim-timeline-controller.element"; import "./help.element"; +import "./studio-viewport.element"; import "./temp-video-preview.element"; import "./app.element.scss"; @@ -49,12 +50,16 @@ import "./app.element.scss"; token: StorytellerApi, provide() { return this.api; } }) +@provide({ + token: STUDIO_PARAMS, + provide() { return this.params; } +}) export class AppElement extends LitElement { @state() showHelp = false; @state() loading = true; - @state() resizingPanel = false; - @state() inspectorWidth = 320; + // @state() resizingPanel = false; + // @state() inspectorWidth = 320; @state() debugMode = false; @@ -62,16 +67,13 @@ export class AppElement extends LitElement { @state() currentSidebar : string = 'hierarchy'; - @property({ attribute: false }) api: StorytellerApi = new StorytellerApi(mockApi ? new StorytellerLocalApi() : new StorytellerRemoteApi()); + api = new StorytellerApi(mockApi ? new StorytellerLocalApi() : new StorytellerRemoteApi()); + params = new StudioURLParams(); - #addToSceneButtonRef = createRef(); #videoStylePreviewBtnRef = createRef(); - #mediaLibraryRef = createRef(); #inspectorRef = createRef(); #hierarchyRef = createRef(); - #params = new StudioURLParams(); - protected override createRenderRoot(): Element | ShadowRoot { return this; } @@ -80,7 +82,7 @@ export class AppElement extends LitElement { async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise { try { const file = new File([scene], "scene.scn.ron", { type: "text/ron" }); - + const token = await this.api.createScene(file); this.dispatchEvent(new CustomEvent("scene-uploaded", { @@ -88,7 +90,7 @@ export class AppElement extends LitElement { bubbles: true, composed: true, })); - + } catch (err) { console.error(err); @@ -106,22 +108,18 @@ export class AppElement extends LitElement { } @on("window:debug-mode-toggled") - onDebugModeToggled({detail: { debugModeActive }}: studio.DebugModeToggled ): void { + onDebugModeToggled({detail: { debugModeActive }}: DebugModeToggled ): void { this.debugMode = debugModeActive; } - onPanelResizeStart(): void { - this.resizingPanel = true; - } + // onPanelResizeEnd(): void { + // this.resizingPanel = false; + // } - onPanelResizeEnd(): void { - this.resizingPanel = false; - } - - resizeInspectorWidth(event: PointerEvent): void { - const updated = this.inspectorWidth - event.movementX; - this.inspectorWidth = Math.max(Math.min(updated, 1024), 128); - } + // resizeInspectorWidth(event: PointerEvent): void { + // const updated = this.inspectorWidth - event.movementX; + // this.inspectorWidth = Math.max(Math.min(updated, 1024), 128); + // } toggleHelp(): void { this.showHelp = !this.showHelp; @@ -140,134 +138,77 @@ export class AppElement extends LitElement { protected override render() { return html` - + - + + - ${this.#params.mode === StudioMode.Editor ? html` - { - this.#mediaLibraryRef.value?.open(); - }} - > - - ` : html`<>`} - -
- - - - - - - ${this.#params.mode === StudioMode.Editor ? html` - studio.saveScene()} - > - Save Scene - - ` : nothing} - - Help - - - + - ${this.#params.mode === StudioMode.Editor ? html` -
- - - - - -
- - - -
- + ${this.params.mode === StudioMode.Editor ? html` + + + + + ` : nothing} - + - ${this.showHelp ? html` -
- +
+
` : nothing} -
- ${this.loading ? html` ` : nothing} + + `; diff --git a/studio-frontend/src/app/studio-viewport.element.scss b/studio-frontend/src/app/studio-viewport.element.scss new file mode 100644 index 0000000..cd10bc2 --- /dev/null +++ b/studio-frontend/src/app/studio-viewport.element.scss @@ -0,0 +1,152 @@ +@use "sass:math"; + +sts-studio-viewport { + background: var(--neutral-100); + + .studio { + position: relative; + + .debug-mode & :not([slot="canvas"]) { + display: none; + } + } + + .stylize { + gap: 0.5rem; + padding: 0.5rem 0.75rem; + line-height: 1; + + sts-icon { + align-self: center; + } + } + + .timeline-controls { + background: var(--neutral-200); + border-radius: 0.5rem; + } + + sts-toolbar, + .timeline-controls { + &.v-start { + border: { + top-left-radius: 0; + top-right-radius: 0; + } + } + + &.v-end { + border: { + bottom-left-radius: 0; + bottom-right-radius: 0; + } + } + } + + .abs { + position: absolute; + } + + .h-start { + left: 0; + &.offset { + left: 1rem; + } + } + + .h-end { + right: 0; + &.offset { + right: 1rem; + } + } + + .v-start { + top: 0; + &.offset { + top: 1rem; + } + } + + .v-end { + bottom: 0; + &.offset { + bottom: 1rem; + } + } + + .h-center { + left: 50%; + transform: translateX(-50%); + } + + .primitive::part(icon) { + font-size: 1.125rem; + } + + .transform-space { + margin-inline-end: 0.75rem; + } + + .inspector { + min-width: math.div(330, 16) * 1rem; + padding: 1rem; + border: 1px solid var(--neutral-300); + border-radius: 0.5rem; + background: var(--neutral-100); + + &__header { + display: flex; + align-items: center; + gap: 1.5rem; + margin: 0 0 1.25rem; + color: #FFF; + + .object-name { + display: flex; + align-items: center; + gap: 0.5rem; + + h3 { + margin: 0; + font: { + size: 1rem; + weight: 700; + } + } + } + + .swap-object { + padding: 0; + gap: 0.375rem; + margin-inline-start: auto; + background: transparent; + color: rgba(#FFF, 0.6); + font-size: 0.75rem; + + &:hover { + color: #FFF; + } + } + } + + &__footer { + display: flex; + gap: 0.5rem; + margin: 1.75rem 0 0; + + .add-keyframe, + .add-to-timeline { + flex: 1 0 0; + } + + .add-to-timeline { + justify-content: center; + } + + .delete { + padding-inline: 0.8rem; + } + } + } +} diff --git a/studio-frontend/src/app/studio-viewport.element.ts b/studio-frontend/src/app/studio-viewport.element.ts new file mode 100644 index 0000000..fbebcdc --- /dev/null +++ b/studio-frontend/src/app/studio-viewport.element.ts @@ -0,0 +1,288 @@ +import { bind, inject, observe } from "@storyteller/framework"; +import * as studio from "@storyteller/studio"; +import { BlockingCommand, TransformSpace, TransformType } from "@storyteller/studio"; +import { + SCENE_DATA_PROVIDER, + STUDIO_PARAMS, + type SceneDataProvider, + type StudioParams, +} from "@storyteller/studio-web"; +import { ButtonElement } from "@storyteller/studio-ui/button"; +import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import { + ANIM_TIMELINE_CONTROLLER, + type AnimTimelineController, +} from "./anim-timeline-controller.element"; + +import "@storyteller/studio-web/entity-inspector"; +import "@storyteller/studio-web/studio"; +import "@storyteller/studio-ui/button"; +import "@storyteller/studio-ui/forms/radio"; +import "@storyteller/studio-ui/forms/select"; +import "@storyteller/studio-ui/toolbar"; +import "./anim-timeline-controls.element"; + +import "./studio-viewport.element.scss"; + +@customElement("sts-studio-viewport") +export class StudioViewportElement extends LitElement { + @inject(STUDIO_PARAMS) + _params!: StudioParams; + + @observe(["selectedEntity", "selectedObject"]) + @inject(SCENE_DATA_PROVIDER) + _sceneData!: SceneDataProvider; + + @observe(["tracks"]) + @inject(ANIM_TIMELINE_CONTROLLER) + _timeline!: AnimTimelineController; + + @state() _transformSpace = TransformSpace.Local; + @state() _transformType = TransformType.Translate; + + get #selectedEntityInTracklist() { + return ( + this._sceneData.selectedEntity != null + && this._sceneData.selectedEntity in this._timeline.tracks + ); + } + + #fileTriggerRef = createRef(); + #mediaLibraryTriggerRef = createRef(); + + get #selectedObject() { + return this._sceneData.selectedObject; + } + + protected override createRenderRoot = () => this; + + protected override update(changes: PropertyValues): void { + if (changes.has("_transformSpace")) + studio.setTransformSpace(this._transformSpace); + + if (changes.has("_transformType")) + studio.setTransformType(this._transformType); + + super.update(changes); + } + + protected override render(): TemplateResult { + return html` + + + + + + + + +
+ + studio.dispatchBlockingCommand(BlockingCommand.Cube)} + > + studio.dispatchBlockingCommand(BlockingCommand.Cylinder)} + > + studio.dispatchBlockingCommand(BlockingCommand.Sphere)} + > + studio.dispatchBlockingCommand(BlockingCommand.Torus)} + > + + +
+ + + + + + Local + + + + Global + + + + + + Translate + + + Rotate + + + Scale + + + + +
+ + + + Stylize + + +
+ +
+ Switch to Camera View +
+ + + + ${this.#selectedObject != null ? html` +
+
+
+ ${this.#selectedObject.icon != null ? html` + + ` : nothing} +

${this.#selectedObject.name}

+
+ + Swap object + +
+ +
+ ${this.#selectedEntityInTracklist ? html` + this._timeline.addKeyframe()} + > + Add Keyframe + + ` : this._sceneData.selectedEntity != null ? html` + this._timeline.addTrack(this._sceneData.selectedEntity!)} + > + Add to Timeline + + ` : nothing} + + +
+
+ ` : nothing} + +
+ + + { + // TODO + }} + > + New scene + + { + // TODO + }} + > + New scene from template... + +
+ { + // TODO: Should we show a spinner here while we're waiting? + // Toast message for success/failure? + // TODO: Need to disable this when the scene is not ours + studio.saveScene(); + }} + > + Save scene + + { + // TODO + }} + > + Save scene as copy... + +
+ + + `; + } +} diff --git a/studio-frontend/src/styles.scss b/studio-frontend/src/styles.scss index ac635b1..4b9164e 100644 --- a/studio-frontend/src/styles.scss +++ b/studio-frontend/src/styles.scss @@ -10,14 +10,33 @@ html, body { --color-panel-background: #1e1e2c; --color-toolbar-background: #2c2c3b; - --color-toolbar-background-light: #595970; + --color-toolbar-background-light: #595970; --color-panel-item-name-background: #39394d; --color-panel-item-background: #55556b; --color-accent: #fc6b68; + --color-accent-hover: #FF8D8B; + --color-accent-fade: #fc6b6840; --color-text: #dbdbdf; --color-text-bright: #ffffff; --color-text-disabled: #7c7c85; + --axis-red: rgb(220, 53, 69); + --axis-green: rgb(25, 135, 84); + --axis-blue: rgb(13, 110, 253); + --corner-radius: 0.75rem; --padding: 0.4rem; + + --neutral-100: #242433; + --neutral-200: #39394D; + --neutral-250: #44445C; + --neutral-300: #515168; + --neutral-350: #505068; + --neutral-400: #676781; + --neutral-450: #6D6D80; +} + +body { + color-scheme: dark; + font-family: "Fira Sans", sans-serif; } diff --git a/studio-ui/src/assets/icons/cube-solid.svg b/studio-ui/src/assets/icons/cube-solid.svg new file mode 100644 index 0000000..d30cee2 --- /dev/null +++ b/studio-ui/src/assets/icons/cube-solid.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio-ui/src/assets/icons/cylinder-solid.svg b/studio-ui/src/assets/icons/cylinder-solid.svg new file mode 100644 index 0000000..cddb51b --- /dev/null +++ b/studio-ui/src/assets/icons/cylinder-solid.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio-ui/src/assets/icons/sphere-solid.svg b/studio-ui/src/assets/icons/sphere-solid.svg new file mode 100644 index 0000000..78c0320 --- /dev/null +++ b/studio-ui/src/assets/icons/sphere-solid.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio-ui/src/assets/icons/torus-solid.svg b/studio-ui/src/assets/icons/torus-solid.svg new file mode 100644 index 0000000..1b800bf --- /dev/null +++ b/studio-ui/src/assets/icons/torus-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio-ui/src/assets/icons/xform-local.svg b/studio-ui/src/assets/icons/xform-local.svg new file mode 100644 index 0000000..5ed5485 --- /dev/null +++ b/studio-ui/src/assets/icons/xform-local.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio-ui/src/lib/button.element.scss b/studio-ui/src/lib/button.element.scss index 8ec7210..f4993c7 100644 --- a/studio-ui/src/lib/button.element.scss +++ b/studio-ui/src/lib/button.element.scss @@ -1,13 +1,15 @@ +@use "sass:math"; + :host { display: inline-flex; - align-items: center; + align-items: baseline; gap: 0.75rem; padding: 0.5rem 1rem; border-radius: 0.5rem; font: { family: "Fira Sans", sans-serif; - size: 15px; + size: math.div(15, 16) * 1rem; weight: 500; } line-height: 1.5; @@ -16,9 +18,8 @@ cursor: pointer; user-select: none; - background: var(--background-color, #0006); - color: #FFFC; - backdrop-filter: blur(16px); + background: var(--neutral-200); + color: #FFF; transition: { property: background, color; duration: 0.1s; @@ -26,11 +27,20 @@ } } -:host(:hover) { - background: #0008; - color: #FFFF; +:host(.icon-only) { + padding-inline: 0.5rem; } +:host(:hover) { + background: var(--neutral-250); +} + +:host(.primary) { background: var(--color-accent) } +:host(.primary:hover) { background: var(--color-accent-hover) } + +:host(.secondary) { background: var(--neutral-400) } +:host(.secondary:hover) { background: var(--neutral-450) } + :host(.disabled), :host(.disabled:hover) { background: #0003; @@ -38,6 +48,23 @@ cursor: default; } -.icon { +.icon, .dropdown-icon { + align-self: center; fill: currentColor; } + +.dropdown-icon { + font-size: 0.75rem; +} + +.keybind { + // margin-inline-start: auto; + flex: 1 0 0; + margin-inline-start: 2.25rem; + color: rgba(#FFF, 0.75); + font: { + size: 0.875rem; + weight: 400; + } + text-align: end; +} diff --git a/studio-ui/src/lib/button.element.ts b/studio-ui/src/lib/button.element.ts index 06d25bc..1eb7f5a 100644 --- a/studio-ui/src/lib/button.element.ts +++ b/studio-ui/src/lib/button.element.ts @@ -1,6 +1,7 @@ import { on } from "@storyteller/framework"; import { LitElement, type PropertyValues, html, nothing, unsafeCSS } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; import styles from "./button.element.scss?inline"; @@ -16,11 +17,33 @@ export class ButtonElement extends LitElement { @property() icon?: string; @property({ type: Boolean }) disabled = false; - @property({ type: Boolean }) - primary?: boolean; + @property({ type: Boolean }) primary?: boolean; + @property({ type: Boolean }) secondary?: boolean; + @property({ type: Boolean }) dropdown?: boolean; + @property() keybind?: string; + + @property({ reflect: true, type: Number }) override tabIndex = 0; + @state() _hasSlottedContent = false; + + #defaultSlotRef = createRef(); + + _checkForSlotContent(): void { + const nodes = this.#defaultSlotRef.value?.assignedNodes() ?? []; + + this._hasSlottedContent = ( + nodes.length > 0 + && nodes.some(node => Boolean(node.textContent?.trim())) + ); + } + + override connectedCallback(): void { + super.connectedCallback(); + this._checkForSlotContent(); + } + protected override update(changes: PropertyValues): void { if (changes.has("disabled")) { this.ariaDisabled = this.disabled ? "true" : null; @@ -28,13 +51,14 @@ export class ButtonElement extends LitElement { this.classList.toggle("disabled", this.disabled); } - if (changes.has("primary")) { - if (this.primary) { - this.style.setProperty("--background-color", "var(--color-accent)"); - } else { - this.style.removeProperty("--background-color"); - } - } + if (changes.has("primary")) + this.classList.toggle("primary", Boolean(this.primary)); + + if (changes.has("secondary")) + this.classList.toggle("secondary", Boolean(this.secondary)) + + if (changes.has("_hasSlottedContent")) + this.classList.toggle("icon-only", !this._hasSlottedContent); super.update(changes); } @@ -50,11 +74,27 @@ export class ButtonElement extends LitElement { protected override render = () => html` ${this.icon ? html` ` : nothing} - + + + ${this.keybind ? html` + ${this.keybind} + ` : nothing} + + ${this.dropdown ? html` + + ` : nothing} ` } diff --git a/studio-ui/src/lib/dialog/dialog.element.ts b/studio-ui/src/lib/dialog/dialog.element.ts index c5c1e72..76fa2ff 100644 --- a/studio-ui/src/lib/dialog/dialog.element.ts +++ b/studio-ui/src/lib/dialog/dialog.element.ts @@ -1,6 +1,6 @@ import { deref, inject } from "@storyteller/framework"; import { type ArrayOrSingle, asArray, exists } from "@storyteller/utility"; -import { LitElement, TemplateResult, type PropertyValues } from "lit"; +import { LitElement, TemplateResult, type PropertyValues, css } from "lit"; import { html, unsafeStatic } from "lit/static-html.js"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -86,6 +86,12 @@ import { type DialogBaseElement } from "./dialog-base.element"; */ @customElement("sts-dialog") export class DialogElement extends LitElement { + static override styles = css` + :host { + display: none; + } + `; + /** * Tag-name of a custom element inheriting from {@linkcode DialogBaseElement}` */ diff --git a/studio-ui/src/lib/forms/index.ts b/studio-ui/src/lib/forms/index.ts index 9f1ad51..b9dab31 100644 --- a/studio-ui/src/lib/forms/index.ts +++ b/studio-ui/src/lib/forms/index.ts @@ -1,3 +1,4 @@ export * from "./form-field-base.element"; -export * from "./text-field.element"; export * from "./scalar-field.element"; +export * from "./select"; +export * from "./text-field.element"; diff --git a/studio-ui/src/lib/forms/radio/index.ts b/studio-ui/src/lib/forms/radio/index.ts new file mode 100644 index 0000000..913e046 --- /dev/null +++ b/studio-ui/src/lib/forms/radio/index.ts @@ -0,0 +1,5 @@ +import "./radio-group.element"; +import "./radio.element"; + +export * from "./radio-group.element"; +export * from "./radio.element"; diff --git a/studio-ui/src/lib/forms/radio/radio-group.element.scss b/studio-ui/src/lib/forms/radio/radio-group.element.scss new file mode 100644 index 0000000..30b9635 --- /dev/null +++ b/studio-ui/src/lib/forms/radio/radio-group.element.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + gap: 0.25rem; +} diff --git a/studio-ui/src/lib/forms/radio/radio-group.element.ts b/studio-ui/src/lib/forms/radio/radio-group.element.ts new file mode 100644 index 0000000..baf44c8 --- /dev/null +++ b/studio-ui/src/lib/forms/radio/radio-group.element.ts @@ -0,0 +1,61 @@ +import { on } from "@storyteller/framework"; +import { PropertyValues, html, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; + +import { FormFieldBaseElement } from "../form-field-base.element"; +import { RadioElement } from "./radio.element"; + +import styles from "./radio-group.element.scss?inline"; + +@customElement("sts-radio-group") +export class RadioGroupElement extends FormFieldBaseElement { + static override styles = unsafeCSS(styles); + + @property({ reflect: true }) + override role = "radiogroup"; + + @property({ attribute: false }) + value?: any; + + #slotRef = createRef(); + + override connectedCallback(): void { + super.connectedCallback(); + this.#checkValue(); + } + + protected override update(changes: PropertyValues): void { + if (changes.has("value")) + this.#checkValue(); + + super.update(changes); + } + + @on("click", { capture: true }) + onClick(event: PointerEvent): void { + if (event.target instanceof RadioElement) { + this.dispatchEvent(new CustomEvent("value-change", { + detail: event.target.value, + })); + } + } + + #checkValue(): void { + for (let node of this.#slotRef.value?.assignedElements() ?? []) { + if (node instanceof RadioElement) { + node.checked = node.value != null && node.value === this.value; + + if (node.checked) + this.internals.ariaValueText = node.textContent; + } + } + } + + protected override render = () => html` + + `; +} diff --git a/studio-ui/src/lib/forms/radio/radio.element.scss b/studio-ui/src/lib/forms/radio/radio.element.scss new file mode 100644 index 0000000..54a113b --- /dev/null +++ b/studio-ui/src/lib/forms/radio/radio.element.scss @@ -0,0 +1,30 @@ +:host { + position: relative; + padding: 0.5rem; + border-radius: 0.5rem; + color: #FFF; + outline: transparent 2px solid; + + transition: { + property: background, outline-color; + duration: 100ms; + timing-function: linear; + } + cursor: pointer; +} + +::slotted(*) { + pointer-events: none; +} + +:host(:hover) { + background: var(--neutral-250); +} + +:host(.checked) { + outline-color: var(--color-accent); +} + +:host(.checked:hover) { + background: var(--color-accent-fade); +} diff --git a/studio-ui/src/lib/forms/radio/radio.element.ts b/studio-ui/src/lib/forms/radio/radio.element.ts new file mode 100644 index 0000000..5d707ab --- /dev/null +++ b/studio-ui/src/lib/forms/radio/radio.element.ts @@ -0,0 +1,29 @@ +import { LitElement, PropertyValues, html, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import styles from "./radio.element.scss?inline"; + +@customElement("sts-radio") +export class RadioElement extends LitElement { + static override styles = unsafeCSS(styles); + + @property({ reflect: true }) + override role = "radio"; + + @property({ attribute: false }) + value?: any; + + @property({ type: Boolean }) + checked = false; + + protected override update(changes: PropertyValues): void { + if (changes.has("checked")) + this.classList.toggle("checked", this.checked); + + super.update(changes); + } + + protected override render = () => html` + + `; +} diff --git a/studio-ui/src/lib/forms/scalar-field.element.scss b/studio-ui/src/lib/forms/scalar-field.element.scss index 6d20b1e..fc1bdfe 100644 --- a/studio-ui/src/lib/forms/scalar-field.element.scss +++ b/studio-ui/src/lib/forms/scalar-field.element.scss @@ -4,28 +4,33 @@ box-sizing: border-box; display: block; padding: 0.5rem 0.75rem; - border-radius: 0.25rem; + border-radius: 0.5rem; + + background: var(--neutral-200); + border: 1px solid transparent; + color: #FFF; + outline: transparent 2px solid { + offset: -2px; + } - background: #0004; - border: 1px solid #FFF2; - color: #FFFC; - outline: none !important; transition: { - property: background-color, border-color, color; - duration: 33.333ms; + property: background-color, outline-color; + duration: 100ms; timing-function: linear; } font: { family: "Fira Sans", sans-serif; - size: math.div(15, 16) * 1rem; + size: math.div(14, 16) * 1rem; + weight: 500; } + line-height: 1; + white-space: nowrap; } :host(:focus-within) { - border-color: #FFF4; - background: #0006; - color: #FFFF; + outline-color: var(--color-accent); + background: var(--neutral-100) } :host(:not(.editing)) { @@ -45,4 +50,8 @@ &:first-line { display: inline-block; } + + &::selection { + background: var(--neutral-300); + } } diff --git a/studio-ui/src/lib/forms/select/index.ts b/studio-ui/src/lib/forms/select/index.ts new file mode 100644 index 0000000..8926a99 --- /dev/null +++ b/studio-ui/src/lib/forms/select/index.ts @@ -0,0 +1,5 @@ +import "./select.element"; +import "./option.element"; + +export * from "./select.element"; +export * from "./option.element"; diff --git a/studio-ui/src/lib/forms/select/listbox.element.scss b/studio-ui/src/lib/forms/select/listbox.element.scss new file mode 100644 index 0000000..2f58e25 --- /dev/null +++ b/studio-ui/src/lib/forms/select/listbox.element.scss @@ -0,0 +1,7 @@ +:host { + padding: 0.375rem 0; + border-radius: 0.5rem; + background: var(--neutral-350); + transition: background 100ms linear; + overflow: hidden; +} diff --git a/studio-ui/src/lib/forms/select/listbox.element.ts b/studio-ui/src/lib/forms/select/listbox.element.ts new file mode 100644 index 0000000..c2671ed --- /dev/null +++ b/studio-ui/src/lib/forms/select/listbox.element.ts @@ -0,0 +1,16 @@ +import { LitElement, html, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import styles from "./listbox.element.scss?inline"; + +@customElement("sts-listbox") +export class ListboxElement extends LitElement { + static override styles = unsafeCSS(styles); + + @property({ reflect: true }) + override role = "listbox"; + + protected override render = () => html` + + `; +} diff --git a/studio-ui/src/lib/forms/select/option.element.scss b/studio-ui/src/lib/forms/select/option.element.scss new file mode 100644 index 0000000..a47e592 --- /dev/null +++ b/studio-ui/src/lib/forms/select/option.element.scss @@ -0,0 +1,28 @@ +@use "sass:math"; + +:host { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + + color: #FFF; + transition: background 100ms linear; + cursor: pointer; + user-select: none; + + font-size: math.div(15, 16) * 1rem; + line-height: 1; +} + +:host(:hover) { + background: rgba(#FFF, 0.1); +} + +:host(.selected) { + background: var(--neutral-200); +} + +::slotted(*) { + pointer-events: none; +} diff --git a/studio-ui/src/lib/forms/select/option.element.ts b/studio-ui/src/lib/forms/select/option.element.ts new file mode 100644 index 0000000..26688c6 --- /dev/null +++ b/studio-ui/src/lib/forms/select/option.element.ts @@ -0,0 +1,35 @@ +import { LitElement, PropertyValues, html, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import styles from "./option.element.scss?inline"; + +@customElement("sts-option") +export class OptionElement extends LitElement { + static override styles = unsafeCSS(styles); + + @property({ reflect: true }) + override role = "option"; + + @property({ attribute: false }) + value?: any; + + @property({ type: Boolean }) + selected?: boolean; + + protected override update(changes: PropertyValues): void { + if (changes.has("selected")) { + this.classList.toggle("selected", this.selected); + + if (this.selected) + this.setAttribute("aria-selected", "true") + else + this.removeAttribute("aria-selected"); + } + + super.update(changes); + } + + protected override render = () => html` + + `; +} diff --git a/studio-ui/src/lib/forms/select/select.element.scss b/studio-ui/src/lib/forms/select/select.element.scss new file mode 100644 index 0000000..cf60ef3 --- /dev/null +++ b/studio-ui/src/lib/forms/select/select.element.scss @@ -0,0 +1,40 @@ +@use "sass:math"; + +:host { + padding: 0.5rem; + background: var(--neutral-350); + color: #FFF; + + transition: { + property: background; + duration: 100ms; + timing-function: linear; + } + user-select: none; + cursor: pointer; + + font-size: math.div(15, 16) * 1rem; + line-height: 1; +} + +:host(:hover) { + background: var(--neutral-400); +} + +.selected { + display: flex; + align-items: center; + gap: 0.75rem; + + sts-option { + padding: 0; + } +} + +.dropdown-icon { + font-size: 0.75rem; +} + +.hidden { + display: none; +} diff --git a/studio-ui/src/lib/forms/select/select.element.ts b/studio-ui/src/lib/forms/select/select.element.ts new file mode 100644 index 0000000..81db137 --- /dev/null +++ b/studio-ui/src/lib/forms/select/select.element.ts @@ -0,0 +1,148 @@ +import { inject, on } from "@storyteller/framework"; +import { PropertyValues, html, nothing, unsafeCSS } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; +import { styleMap } from "lit/directives/style-map.js"; + +import { + OVERLAY_PROVIDER, + OverlayHandle, + type OverlayProvider, +} from "../../overlay-provider.element"; +import { FormFieldBaseElement } from "../form-field-base.element"; +import { OptionElement } from "./option.element"; + +import "./listbox.element"; + +import styles from "./select.element.scss?inline"; + +@customElement("sts-select") +export class SelectElement extends FormFieldBaseElement { + static override styles = unsafeCSS(styles); + + @property({ reflect: true }) + override role = "combobox"; + + @property({ attribute: false }) + value?: any; + + @property() + placeholder?: string; + + @property({ type: Number, reflect: true }) + override tabIndex = 0; + + @inject(OVERLAY_PROVIDER) + _overlay!: OverlayProvider + + @state() _selected?: OptionElement; + + #slotRef = createRef(); + #overlayHandle?: WeakRef; + + get #selectedOption() { + return this.#slotRef.value + ?.assignedNodes() + .find((node: Node): node is OptionElement => ( + node.nodeType === Node.ELEMENT_NODE + && node instanceof OptionElement + && node.value === this.value + )); + } + + override connectedCallback(): void { + super.connectedCallback(); + this.#checkValue(); + } + + protected override update(changes: PropertyValues): void { + if (changes.has("value")) + this.#checkValue(); + + super.update(changes); + } + + @on("click") + onClick(): void { + const nodes = this.#slotRef.value + ?.assignedNodes() + .map(node => { + const cloned = node.cloneNode(true); + if ( + cloned.nodeType === Node.ELEMENT_NODE + && cloned instanceof OptionElement + ) { + cloned.value = (node as OptionElement).value; + cloned.selected = cloned.value === this.value; + } + + return cloned; + }); + + const rect = this.getBoundingClientRect(); + + this.#overlayHandle = this._overlay.attach(this, html` + + ${nodes ?? nothing} + + `, { + onBackdropClick: () => this.#close(), + }); + } + + onListboxClick(event: PointerEvent): void { + if (event.target instanceof OptionElement) { + this.dispatchEvent(new CustomEvent("value-change", { + detail: event.target.value, + })); + + this.#close(); + } + } + + #checkValue(): void { + const cloned = this.#selectedOption?.cloneNode(true) as OptionElement | undefined; + + if (cloned) { + this.internals.ariaValueText = cloned.textContent; + + cloned.removeAttribute("role"); + cloned.style.setProperty("pointer-events", "none"); + this._selected = cloned; + } + } + + #close(): void { + if (!this.#overlayHandle) return; + this._overlay.detach(this.#overlayHandle); + this.#overlayHandle = undefined; + } + + protected override render = () => html` +
+ ${this._selected ?? html` +
+ ${this.placeholder ?? nothing} +
+ `} + +
+ + `; +} diff --git a/studio-ui/src/lib/icon.element.ts b/studio-ui/src/lib/icon.element.ts index 952733d..328bc1a 100644 --- a/studio-ui/src/lib/icon.element.ts +++ b/studio-ui/src/lib/icon.element.ts @@ -4,13 +4,12 @@ import { LitElement, TemplateResult, html, nothing, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; import { unsafeSVG } from "lit/directives/unsafe-svg.js"; -import farCrosshairs from "@fortawesome/fontawesome-pro/svgs/regular/crosshairs.svg?raw"; -import farVideo from "@fortawesome/fontawesome-pro/svgs/regular/video.svg?raw"; +import fasCrosshairs from "@fortawesome/fontawesome-pro/svgs/solid/crosshairs.svg?raw"; +import fasVideo from "@fortawesome/fontawesome-pro/svgs/solid/video.svg?raw"; import fasCircleQuestion from "@fortawesome/fontawesome-pro/svgs/solid/circle-question.svg?raw"; import fasKey from "@fortawesome/fontawesome-pro/svgs/solid/key.svg?raw"; -import farCube from "@fortawesome/fontawesome-pro/svgs/regular/cube.svg?raw"; -import farGlobe from "@fortawesome/fontawesome-pro/svgs/regular/globe.svg?raw"; -import farUpDownLeftRight from "@fortawesome/fontawesome-pro/svgs/regular/arrows-up-down-left-right.svg?raw"; +import fasGlobe from "@fortawesome/fontawesome-pro/svgs/solid/globe.svg?raw"; +import fasUpDownLeftRight from "@fortawesome/fontawesome-pro/svgs/solid/arrows-up-down-left-right.svg?raw"; import fasPlay from "@fortawesome/fontawesome-pro/svgs/solid/play.svg?raw"; import fasPause from "@fortawesome/fontawesome-pro/svgs/solid/pause.svg?raw"; import fasFwdStep from "@fortawesome/fontawesome-pro/svgs/solid/forward-step.svg?raw"; @@ -18,12 +17,12 @@ import fasFwdFast from "@fortawesome/fontawesome-pro/svgs/solid/forward-fast.svg import fasBwdStep from "@fortawesome/fontawesome-pro/svgs/solid/backward-step.svg?raw"; import fasBwdFast from "@fortawesome/fontawesome-pro/svgs/solid/backward-fast.svg?raw"; import fasQuestion from "@fortawesome/fontawesome-pro/svgs/solid/question.svg?raw"; -import farRotate from "@fortawesome/fontawesome-pro/svgs/regular/arrows-rotate.svg?raw"; -import farScale from "@fortawesome/fontawesome-pro/svgs/regular/arrow-up-right-and-arrow-down-left-from-center.svg?raw"; +import fasRotate from "@fortawesome/fontawesome-pro/svgs/solid/arrows-rotate.svg?raw"; +import fasScale from "@fortawesome/fontawesome-pro/svgs/solid/arrow-up-right-and-arrow-down-left-from-center.svg?raw"; import fasCaretRight from "@fortawesome/fontawesome-pro/svgs/solid/caret-right.svg?raw"; import fasFloppyDisk from "@fortawesome/fontawesome-pro/svgs/solid/floppy-disk.svg?raw"; -import farSkeleton from "@fortawesome/fontawesome-pro/svgs/regular/skeleton.svg?raw"; -import farBone from "@fortawesome/fontawesome-pro/svgs/regular/bone.svg?raw"; +import fasSkeleton from "@fortawesome/fontawesome-pro/svgs/regular/skeleton.svg?raw"; +import fasBone from "@fortawesome/fontawesome-pro/svgs/regular/bone.svg?raw"; import fasBrightness from "@fortawesome/fontawesome-pro/svgs/solid/brightness.svg?raw"; import fasCheck from "@fortawesome/fontawesome-pro/svgs/solid/check.svg?raw"; import fasXmark from "@fortawesome/fontawesome-pro/svgs/solid/xmark.svg?raw"; @@ -33,23 +32,29 @@ 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 fasPlusLarge from "@fortawesome/fontawesome-pro/svgs/solid/plus-large.svg?raw"; -import farFileImport from "@fortawesome/fontawesome-pro/svgs/regular/file-import.svg?raw"; -import farPhotoFilmMusic from "@fortawesome/fontawesome-pro/svgs/regular/photo-film-music.svg?raw"; +import fasFileImport from "@fortawesome/fontawesome-pro/svgs/regular/file-import.svg?raw"; +import fasPhotoFilmMusic from "@fortawesome/fontawesome-pro/svgs/regular/photo-film-music.svg?raw"; import fasChevronDown from "@fortawesome/fontawesome-pro/svgs/solid/chevron-down.svg?raw"; -import farVolume from "@fortawesome/fontawesome-pro/svgs/regular/volume.svg?raw"; -import farFilm from "@fortawesome/fontawesome-pro/svgs/regular/film.svg?raw"; +import fasVolume from "@fortawesome/fontawesome-pro/svgs/regular/volume.svg?raw"; +import fasFilm from "@fortawesome/fontawesome-pro/svgs/regular/film.svg?raw"; import fasPersonRunning from "@fortawesome/fontawesome-pro/svgs/solid/person-running.svg?raw"; -import farMountainSun from "@fortawesome/fontawesome-pro/svgs/regular/mountain-sun.svg?raw"; -import farArrowRightToBracket from "@fortawesome/fontawesome-pro/svgs/regular/arrow-right-to-bracket.svg?raw"; -import farMagnifyingGlass from "@fortawesome/fontawesome-pro/svgs/regular/magnifying-glass.svg?raw"; +import fasMountainSun from "@fortawesome/fontawesome-pro/svgs/regular/mountain-sun.svg?raw"; +import fasArrowRightToBracket from "@fortawesome/fontawesome-pro/svgs/regular/arrow-right-to-bracket.svg?raw"; +import fasMagnifyingGlass from "@fortawesome/fontawesome-pro/svgs/regular/magnifying-glass.svg?raw"; import fasHouse from "@fortawesome/fontawesome-pro/svgs/solid/house.svg?raw"; import fasRecord from "@fortawesome/fontawesome-pro/svgs/solid/circle.svg?raw"; import fasUpload from "@fortawesome/fontawesome-pro/svgs/solid/upload.svg?raw"; import fasFilms from "@fortawesome/fontawesome-pro/svgs/solid/films.svg?raw"; +import fasFile from "@fortawesome/fontawesome-pro/svgs/solid/file.svg?raw"; +import fasTrash from "@fortawesome/fontawesome-pro/svgs/solid/trash.svg?raw"; +import fasRightLeft from "@fortawesome/fontawesome-pro/svgs/solid/right-left.svg?raw"; +import fasChevronRight from "@fortawesome/fontawesome-pro/svgs/solid/chevron-right.svg?raw"; -import customCylinder from "../assets/icons/cylinder.svg?raw"; -import customSphere from "../assets/icons/sphere.svg?raw"; -import customTorus from "../assets/icons/torus.svg?raw"; +import customCube from "../assets/icons/cube-solid.svg?raw"; +import customCylinder from "../assets/icons/cylinder-solid.svg?raw"; +import customSphere from "../assets/icons/sphere-solid.svg?raw"; +import customTorus from "../assets/icons/torus-solid.svg?raw"; +import customXformLocal from "../assets/icons/xform-local.svg?raw"; import styles from "./icon.element.scss?inline"; @@ -77,49 +82,54 @@ function renderSvg(iconId: string) { const svgSrc = match (iconId, { "bars": () => fasBars, - "bone": () => farBone, + "bone": () => fasBone, "bwd-fast": () => fasBwdFast, "bwd-step": () => fasBwdStep, "caret-right": () => fasCaretRight, "check": () => fasCheck, "chevron-down": () => fasChevronDown, + "chevron-right": () => fasChevronRight, "circle-check": () => fasCircleCheck, "circle-question": () => fasCircleQuestion, "circle-xmark": () => fasCircleXmark, - "crosshairs": () => farCrosshairs, - "cube": () => farCube, + "crosshairs": () => fasCrosshairs, + "cube": () => customCube, "cylinder": () => customCylinder, "figure": () => fasPersonRunning, - "file-import": () => farFileImport, - "film": () => farFilm, + "file": () => fasFile, + "file-import": () => fasFileImport, + "film": () => fasFilm, "films": () => fasFilms, "floppy-disk": () => fasFloppyDisk, "fwd-fast": () => fasFwdFast, "fwd-step": () => fasFwdStep, "home": () => fasHouse, - "import": () => farArrowRightToBracket, + "import": () => fasArrowRightToBracket, "key": () => fasKey, "light": () => fasBrightness, "list-tree": () => fasListTree, "minus": () => fasMinus, - "globe": () => farGlobe, - "photo-film-music": () => farPhotoFilmMusic, + "globe": () => fasGlobe, + "photo-film-music": () => fasPhotoFilmMusic, "play": () => fasPlay, "pause": () => fasPause, "plus-large": () => fasPlusLarge, "question": () => fasQuestion, "record": () => fasRecord, - "rotate": () => farRotate, - "scale": () => farScale, - "scene": () => farMountainSun, - "search": () => farMagnifyingGlass, - "skeleton": () => farSkeleton, + "rotate": () => fasRotate, + "scale": () => fasScale, + "scene": () => fasMountainSun, + "search": () => fasMagnifyingGlass, + "skeleton": () => fasSkeleton, "sphere": () => customSphere, + "swap": () => fasRightLeft, + "trash": () => fasTrash, "torus": () => customTorus, - "up-down-left-right": () => farUpDownLeftRight, + "up-down-left-right": () => fasUpDownLeftRight, "upload": () => fasUpload, - "video": () => farVideo, - "volume": () => farVolume, + "video": () => fasVideo, + "volume": () => fasVolume, + "xform-local": () => customXformLocal, "xmark": () => fasXmark, _: () => null, }); diff --git a/studio-ui/src/lib/menu.element.scss b/studio-ui/src/lib/menu.element.scss index f1cb459..2a882df 100644 --- a/studio-ui/src/lib/menu.element.scss +++ b/studio-ui/src/lib/menu.element.scss @@ -5,24 +5,17 @@ padding: 0.5rem 0; min-width: 220px; - background: #0006; - backdrop-filter: blur(16px); + background: var(--neutral-200); overflow: hidden; } ::slotted(sts-button) { background: unset; - backdrop-filter: unset; border-radius: unset; } ::slotted(sts-button:hover) { - background: #0004; - - // FIXME: https://github.com/w3c/csswg-drafts/issues/3896 - &::part(icon) { - opacity: 0.75; - } + background: var(--neutral-300); } ::slotted(hr) { @@ -31,5 +24,5 @@ border: none; height: 1px; margin: 0.5rem 0; - background: #FFF1; + background: rgba(#FFF, 0.1); } diff --git a/studio-ui/src/lib/menu.element.ts b/studio-ui/src/lib/menu.element.ts index f1d9a99..6a7fea4 100644 --- a/studio-ui/src/lib/menu.element.ts +++ b/studio-ui/src/lib/menu.element.ts @@ -60,7 +60,7 @@ import panelStyles from "./menu.element.scss?inline"; export class MenuElement extends LitElement { static override styles = css` :host { - visibility: hidden; + display: none; } `; diff --git a/studio-ui/src/lib/toolbar-group.element.scss b/studio-ui/src/lib/toolbar-group.element.scss index 5e2eaf0..a5b232a 100644 --- a/studio-ui/src/lib/toolbar-group.element.scss +++ b/studio-ui/src/lib/toolbar-group.element.scss @@ -1,22 +1,8 @@ :host { display: flex; - gap: 2px; + gap: 0.25rem; } ::slotted(*) { - border-radius: var(--corner-radius); -} - -::slotted(:first-child) { - border: { - top-left-radius: 8px; - bottom-left-radius: 8px; - } -} - -::slotted(:last-child) { - border: { - top-right-radius: 8px; - bottom-right-radius: 8px; - } + border-radius: 0.5rem; } diff --git a/studio-ui/src/lib/toolbar.element.scss b/studio-ui/src/lib/toolbar.element.scss index 7d5aa1f..2449c4a 100644 --- a/studio-ui/src/lib/toolbar.element.scss +++ b/studio-ui/src/lib/toolbar.element.scss @@ -1,4 +1,16 @@ :host { display: flex; - gap: 16px; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 0.5rem; + background: var(--neutral-200); +} + +::slotted(hr) { + display: block; + width: 2px; + height: 1.125rem; + border: none; + background: rgba(#FFF, 0.1); } diff --git a/studio-ui/src/lib/tree.element.ts b/studio-ui/src/lib/tree.element.ts index f8026e3..46bf719 100644 --- a/studio-ui/src/lib/tree.element.ts +++ b/studio-ui/src/lib/tree.element.ts @@ -1,5 +1,5 @@ -import { type DynamicProvider, inject, UniqueToken } from "@storyteller/framework"; -import { LitElement, type PropertyValues, html, nothing, unsafeCSS } from "lit"; +import { type DynamicProvider, inject, UniqueToken, observe } from "@storyteller/framework"; +import { LitElement, 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"; @@ -34,23 +34,9 @@ export class TreeElement extends LitElement { return !!this.id && this._selectionProvider?.selectedId === this.id } + @observe(["selectedId"]) @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(); - } + _selectionProvider!: TreeSelectionProvider; toggleExpanded(): void { this.expanded = !this.expanded; diff --git a/studio-web/src/lib/inspector/entity-inspector.element.scss b/studio-web/src/lib/inspector/entity-inspector.element.scss index ddc3023..976b7d6 100644 --- a/studio-web/src/lib/inspector/entity-inspector.element.scss +++ b/studio-web/src/lib/inspector/entity-inspector.element.scss @@ -3,12 +3,9 @@ display: flex; flex-direction: column; - gap: var(--padding); + gap: 1rem; overflow: auto; - - --background-color: var(--color-panel-background); - - color: #FFFC; + color: #FFF; &::-webkit-scrollbar { width: 0.5rem; @@ -32,13 +29,3 @@ *, *::before, *::after { box-sizing: border-box; } - -.container { - background-color: var(--background-color); - border-radius: var(--corner-radius); - display: flex; - flex-direction: column; - justify-content: flex-end; - padding: var(--padding); - gap: var(--padding); -} \ No newline at end of file diff --git a/studio-web/src/lib/inspector/entity-inspector.element.ts b/studio-web/src/lib/inspector/entity-inspector.element.ts index b7865a9..2ee6071 100644 --- a/studio-web/src/lib/inspector/entity-inspector.element.ts +++ b/studio-web/src/lib/inspector/entity-inspector.element.ts @@ -1,5 +1,5 @@ -import { inject } from "@storyteller/framework"; -import { LitElement, type PropertyValues, html, unsafeCSS, nothing } from "lit"; +import { inject, observe } from "@storyteller/framework"; +import { LitElement, html, unsafeCSS, nothing } from "lit"; import { customElement } from "lit/decorators.js"; import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "../scene-data-provider.element"; @@ -16,89 +16,69 @@ import styles from "./entity-inspector.element.scss?inline"; export class EntityInspectorElement extends LitElement { static override styles = unsafeCSS(styles); + @observe(["selectionComponents"]) @inject(SCENE_DATA_PROVIDER) - _sceneData?: SceneDataProvider; + _sceneData!: SceneDataProvider; - override connectedCallback(): void { - this._sceneData?.addEventListener("property-changes", this.onProviderChanges); - super.connectedCallback(); - } + 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); - override disconnectedCallback(): void { - this._sceneData?.removeEventListener("property-changes", this.onProviderChanges); - super.disconnectedCallback(); - } + if (ordA === -1) return Infinity; + if (ordB === -1) return -Infinity; - onProviderChanges = ({ detail: changes }: CustomEvent>) => { - if (changes.has("selectionComponents")) - this.requestUpdate(); - } + return ordA - ordB; + }) + .map(({ name, value }) => { + switch (name) { + // TODO? + // case "bevy_core::name::Name": { + // return html` + // + // `; + // } + case "bevy_transform::components::transform::Transform": { + return html` + + `; + } + case "bevy_pbr::pbr_material::StandardMaterial": { + const { colorSpace, baseColor } = isLinear(value.base_color) + ? { colorSpace: "linear", baseColor: value.base_color.RgbaLinear } + : { colorSpace: "srgb", baseColor: value.base_color.Rgba }; - protected override render () { - let components = this._sceneData - ?.selectionComponents - .slice() - .sort((a, b) => { - const ordA = COMPONENT_SORT_ORDER.indexOf(a.name); - const ordB = COMPONENT_SORT_ORDER.indexOf(b.name); - - if (ordA === -1) return Infinity; - if (ordB === -1) return -Infinity; - - return ordA - ordB; - }) - .map(({ name, value }) => { - switch (name) { - case "bevy_core::name::Name": { - return html` - - `; + return html` + + `; + } + default: { + return nothing; + // return html` + //

Unknown Component: ${name}

+ //
${JSON.stringify({ name, value }, null, "   ")}
+ // `; + } } - case "bevy_transform::components::transform::Transform": { - return html` - - `; - } - case "bevy_pbr::pbr_material::StandardMaterial": { - const { colorSpace, baseColor } = isLinear(value.base_color) - ? { colorSpace: "linear", baseColor: value.base_color.RgbaLinear } - : { colorSpace: "srgb", baseColor: value.base_color.Rgba }; - - return html` - - `; - } - default: { - return nothing; - // return html` - //

Unknown Component: ${name}

- //
${JSON.stringify({ name, value }, null, "   ")}
- // `; - } - } - }).filter((value) => value !== nothing) || []; - if (components?.length === 0) { - return nothing; + }) + ?? nothing } - return html` -
- ${components - } -
`; - } } const COMPONENT_SORT_ORDER: Array = [ diff --git a/studio-web/src/lib/inspector/material-inspector.element.scss b/studio-web/src/lib/inspector/material-inspector.element.scss index 5e893b0..4a15ae7 100644 --- a/studio-web/src/lib/inspector/material-inspector.element.scss +++ b/studio-web/src/lib/inspector/material-inspector.element.scss @@ -11,7 +11,7 @@ h4 { font-weight: 500; - margin: 0 0 1rem; + margin: 0 0 0.5rem; } .fields { diff --git a/studio-web/src/lib/inspector/quat-inspector.element.scss b/studio-web/src/lib/inspector/quat-inspector.element.scss index 0d440a2..801fd3b 100644 --- a/studio-web/src/lib/inspector/quat-inspector.element.scss +++ b/studio-web/src/lib/inspector/quat-inspector.element.scss @@ -2,30 +2,41 @@ :host { display: flex; - gap: 0.25rem; + gap: 0.5rem; +} + +.field { + display: flex; + width: math.div(1, 3) * 100%; + border-radius: 0.5rem; + overflow: hidden; + outline: transparent 2px solid { + offset: -2px; + } + transition: outline-color 100ms linear; + + &--x:focus-within { outline-color: var(--axis-red) } + &--y:focus-within { outline-color: var(--axis-green) } + &--z:focus-within { outline-color: var(--axis-blue) } +} + +.label { + display: flex; + align-items: center; + padding: 0 0.375rem; + font: { + size: math.div(14, 16) * 1rem; + weight: 500; + } + + &--x { background: var(--axis-red) } + &--y { background: var(--axis-green) } + &--z { background: var(--axis-blue) } } .input { - padding: 0.5rem; - width: math.div(1, 3) * 100%; - border-left-width: 4px; - - &: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); - } - } + flex: 1 0 0; + border-radius: 0; + border: none; + outline: none !important; } diff --git a/studio-web/src/lib/inspector/quat-inspector.element.ts b/studio-web/src/lib/inspector/quat-inspector.element.ts index 9d52614..b768475 100644 --- a/studio-web/src/lib/inspector/quat-inspector.element.ts +++ b/studio-web/src/lib/inspector/quat-inspector.element.ts @@ -46,36 +46,44 @@ export class QuatInspectorElement extends LitElement { } 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 index 451e37c..93e178a 100644 --- a/studio-web/src/lib/inspector/transform-inspector.element.scss +++ b/studio-web/src/lib/inspector/transform-inspector.element.scss @@ -22,43 +22,57 @@ h4 { position: relative; width: 100%; padding: 0 { - block-start: math.div(14, 16) + 0.333rem !important; + block-start: math.div(17, 16) + 0.25rem !important; } margin: 0; - gap: 0.25rem; + gap: 0.5rem; border: none; legend { position: absolute; inset-block-start: 0; - margin-block-end: 0.333rem; - color: #FFF9; - font-size: math.div(14, 16) * 1rem; + padding: 0; + color: #FFF; + font: { + size: math.div(14, 16) * 1rem; + weight: 600; + } + } + + .field { + display: flex; + width: math.div(1, 3) * 100%; + border-radius: 0.5rem; + overflow: hidden; + outline: transparent 2px solid { + offset: -2px; + } + transition: outline-color 100ms linear; + + &--x:focus-within { outline-color: var(--axis-red) } + &--y:focus-within { outline-color: var(--axis-green) } + &--z:focus-within { outline-color: var(--axis-blue) } + } + + .label { + display: flex; + align-items: center; + padding: 0 0.375rem; + font: { + size: math.div(14, 16) * 1rem; + weight: 500; + } + + &--x { background: var(--axis-red) } + &--y { background: var(--axis-green) } + &--z { background: var(--axis-blue) } } .input { - padding: 0.5rem; - width: math.div(1, 3) * 100%; - border-left-width: 4px; - - &: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); - } - } + flex: 1 0 0; + border-radius: 0; + border: none; + outline: none !important; } } diff --git a/studio-web/src/lib/inspector/transform-inspector.element.ts b/studio-web/src/lib/inspector/transform-inspector.element.ts index aaecb83..36b8a76 100644 --- a/studio-web/src/lib/inspector/transform-inspector.element.ts +++ b/studio-web/src/lib/inspector/transform-inspector.element.ts @@ -39,7 +39,6 @@ export class TransformInspectorElement extends LitElement { } protected override render = () => html` -

Transform

Position - - - + + + + + +
+
+
Scale - - - + + + + + +
`; } diff --git a/studio-web/src/lib/media-library-dialog.element.ts b/studio-web/src/lib/media-library-dialog.element.ts index 1f741e1..5bfb7e2 100644 --- a/studio-web/src/lib/media-library-dialog.element.ts +++ b/studio-web/src/lib/media-library-dialog.element.ts @@ -1,6 +1,6 @@ import * as studio from "@storyteller/studio"; import { SceneElement } from "@storyteller/studio"; -import { bind, inject } from "@storyteller/framework"; +import { bind, inject, observe } from "@storyteller/framework"; import { DialogBaseElement, DialogElement } from "@storyteller/studio-ui/dialog"; import { type MediaFile as IMediaFile, @@ -56,6 +56,7 @@ export class MediaLibraryDialogElement extends DialogBaseElement { @inject(StorytellerApi) _api!: StorytellerApi; + @observe(["selectedObject"]) @inject(SCENE_DATA_PROVIDER) _sceneData!: SceneDataProvider; @@ -104,22 +105,6 @@ export class MediaLibraryDialogElement extends DialogBaseElement { return oneAnimationSelected && skeletonSelected; } - override connectedCallback(): void { - this._sceneData?.addEventListener("property-changes", this.onProviderChanges); - this.loadResults(); - super.connectedCallback(); - } - - override disconnectedCallback(): void { - this._sceneData?.removeEventListener("property-changes", this.onProviderChanges); - super.disconnectedCallback(); - } - - onProviderChanges = ({ detail: changes }: CustomEvent>) => { - if (changes.has("selectedObject")) - this.requestUpdate(); - } - protected override willUpdate(changes: PropertyValues): void { if (changes.has("_activeCategory") || changes.has("_searchTerm")) { this._filters = [ diff --git a/studio-web/src/lib/scene-hierarchy.element.ts b/studio-web/src/lib/scene-hierarchy.element.ts index 2254b2a..5090c30 100644 --- a/studio-web/src/lib/scene-hierarchy.element.ts +++ b/studio-web/src/lib/scene-hierarchy.element.ts @@ -1,6 +1,6 @@ -import { inject } from "@storyteller/framework"; +import { inject, observe } from "@storyteller/framework"; import * as studio from "@storyteller/studio"; -import { LitElement, type PropertyValues, html, unsafeCSS } from "lit"; +import { LitElement, html, unsafeCSS } from "lit"; import { customElement } from "lit/decorators.js"; import { SCENE_DATA_PROVIDER, type SceneDataProvider } from "./scene-data-provider.element"; @@ -13,23 +13,9 @@ import styles from "./scene-hierarchy.element.scss?inline"; export class SceneHierarchyElement extends LitElement { static override styles = unsafeCSS(styles); + @observe(["tree"]) @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("tree")) - this.requestUpdate(); - } + _sceneData!: SceneDataProvider; protected override render = () => html` ${this._sceneData?.tree.map(obj => html` diff --git a/studio-web/src/lib/url-params.ts b/studio-web/src/lib/url-params.ts index 7afe080..81fceb3 100644 --- a/studio-web/src/lib/url-params.ts +++ b/studio-web/src/lib/url-params.ts @@ -1,6 +1,9 @@ +import { UniqueToken } from "@storyteller/framework"; import { StudioMode } from "@storyteller/studio"; import { match } from "@storyteller/utility"; +export const STUDIO_PARAMS = UniqueToken.create(); + /** * A thin wrapper around * {@linkcode https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams URLSearchParams} @@ -26,42 +29,37 @@ import { match } from "@storyteller/utility"; * - **remote://<media-token>.<file-extension>** - A token for a * media file that can be fetched from the Storyteller.ai API */ -export class StudioURLParams { - #_nativeParams?: URLSearchParams; - get #nativeParams() { - return this.#_nativeParams ??= new URLSearchParams(document.location.search); - } - +export interface StudioParams { /** * Initialize the application from an asset in the "bundled" asset library. * Takes a filename, including the extension, that can be found under * `studio/assets/gltf` (e.g., "base-human-female.gltf"). */ - get objectId() { return this.#nativeParams.get("objectId") ?? undefined; } + readonly objectId?: string; /** * Initialize the application by retargeting a BVH animation (currently only * supports the MocapNET skeleton) onto the bundled `Mannequin.gltf` model. */ - get bvh() { return this.#nativeParams.get("bvh") ?? undefined; } + readonly bvh?: string; /** * Initialize the application by retargeting a Mixamo animation onto the * bundled `Mannequin.gltf` model. */ - get mixamo() { return this.#nativeParams.get("mixamo") ?? undefined; } + readonly mixamo?: string; /** * Initialize the application by loading an arbitrary glTF scene from any * source. */ - get sceneImport() { return this.#nativeParams.get("sceneImport") ?? undefined; } + readonly sceneImport?: string; /** * Initialize the application by loading a previously saved Storyteller * Studio scene in `*.scn.ron` format. */ - get scene() { return this.#nativeParams.get("scene") ?? undefined; } + readonly scene?: string; /** * One of: @@ -70,7 +68,7 @@ export class StudioURLParams { * "specular" qualifiers (e.g., "gum_trees_4k"). * - A hex-formatted color, without the leading `#` (e.g., "1A1A27") */ - get skybox() { return this.#nativeParams.get("skybox") ?? undefined; } + readonly skybox?: string; /** * Determines whether the app initializes in a minimal, read-only "viewer" @@ -82,6 +80,27 @@ export class StudioURLParams { * * @default StudioMode.Viewer */ + readonly mode: StudioMode; +} + +export class StudioURLParams implements StudioParams { + #_nativeParams?: URLSearchParams; + get #nativeParams() { + return this.#_nativeParams ??= new URLSearchParams(document.location.search); + } + + get objectId() { return this.#nativeParams.get("objectId") ?? undefined; } + + get bvh() { return this.#nativeParams.get("bvh") ?? undefined; } + + get mixamo() { return this.#nativeParams.get("mixamo") ?? undefined; } + + get sceneImport() { return this.#nativeParams.get("sceneImport") ?? undefined; } + + get scene() { return this.#nativeParams.get("scene") ?? undefined; } + + get skybox() { return this.#nativeParams.get("skybox") ?? undefined; } + get mode(): StudioMode { return match(this.#nativeParams.get("mode"), { "studio": () => StudioMode.Editor, diff --git a/studio/src/anim/timeline_animation.rs b/studio/src/anim/timeline_animation.rs index e4b8128..a7ee7b4 100644 --- a/studio/src/anim/timeline_animation.rs +++ b/studio/src/anim/timeline_animation.rs @@ -1164,10 +1164,16 @@ mod wasm { }; } + export interface AnimationTrack { + name?: string; + targetType: SceneElement; + animationTarget: AnimationTarget; + } + export interface UpdateAnimationTargets extends CustomEvent { type: "update-animation-targets", detail: { - added: Record, + added: Record, removed: string[] } } @@ -1177,9 +1183,9 @@ mod wasm { time: number, entity: string, incoming_easing: "linear" | "cubic-hermite", - affect_translation: bool, - affect_rotation: bool, - affect_scale: bool + affect_translation: boolean, + affect_rotation: boolean, + affect_scale: boolean } export interface TimedAnimationClipInfo { @@ -1221,6 +1227,7 @@ mod wasm { "#; #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] struct AddedAnimationTargetRecordItem { name: Option, target_type: SceneElement, diff --git a/studio/src/scene/mod.rs b/studio/src/scene/mod.rs index fc372dc..b49f6f7 100644 --- a/studio/src/scene/mod.rs +++ b/studio/src/scene/mod.rs @@ -316,6 +316,8 @@ fn initialize(mut cmd: Commands) { ..default() }, Fxaa::default(), + // FIXME: This should not be the user-visible/keyframable camera + SceneElement::Camera, )); } diff --git a/tsconfig.base.json b/tsconfig.base.json index df667bb..26a5db0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -27,8 +27,10 @@ "@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/form-field-base": ["studio-ui/src/lib/forms/form-field-base.element.ts"], - "@storyteller/studio-ui/forms/text-field": ["studio-ui/src/lib/forms/text-field.element.ts"], + "@storyteller/studio-ui/forms/radio": ["studio-ui/src/lib/forms/radio/index.ts"], "@storyteller/studio-ui/forms/scalar-field": ["studio-ui/src/lib/forms/scalar-field.element.ts"], + "@storyteller/studio-ui/forms/select": ["studio-ui/src/lib/forms/select/index.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/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"],