Refactor studio-frontend (#19)

This PR does some rudimentary cleanup / reorganizing to the
`studio-frontend` project.

### Changes
* Refactored `studio-frontend` to use the `<sts-canvas-provider/>` and
  `<sts-viewer/>` components from `studio-web`
* Added some CSS layout utilities for positioning overlayed UI elements
* Extracted `<sts-toolbar/>` and `<sts-toolbar-group/>` to their own
  components
* Extracted `<sts-transform-toolbar/>` to its own component
This commit is contained in:
Danny McGee
2024-01-12 16:35:32 -05:00
committed by GitHub
parent 16fdd008a4
commit afe6139f75
10 changed files with 296 additions and 233 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"recommendations": [
"runem.lit-plugin"
]
}
-1
View File
@@ -14,7 +14,6 @@
</head>
<body>
<app-root></app-root>
<canvas id="studio_canvas"></canvas>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+13 -94
View File
@@ -1,112 +1,31 @@
:host {
.app-root {
position: fixed;
inset: 0;
pointer-events: none;
}
.btn-help {
// Layout utilities
.v-start {
position: absolute;
top: 16px;
right: 16px;
}
.toolbar {
display: flex;
gap: 16px;
.v-after-toolbar {
position: absolute;
top: 16px;
top: 60px;
}
.h-start { left: 16px }
.h-end { right: 16px }
.h-center {
left: 50%;
transform: translateX(-50%);
}
.toolbar-group {
display: flex;
gap: 2px;
}
.btn-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
font-size: 16px;
padding: 10px 12px;
pointer-events: auto;
outline: 0 solid transparent;
// TODO: Copypasta
background: #0006;
color: #FFFC;
backdrop-filter: blur(16px);
transition: {
property: background, color, outline-width, outline-color;
duration: 0.1s;
timing-function: linear;
}
&:first-child {
border: {
top-left-radius: 8px;
bottom-left-radius: 8px;
}
}
&:last-child {
border: {
top-right-radius: 8px;
bottom-right-radius: 8px;
}
}
&:hover {
background: #0008;
color: #FFF;
}
&:has(:focus-visible) {
z-index: 1;
outline: 4px solid #2F83FFBF;
}
&:has(:checked) {
background: #7A2C34E3;
color: #FFF;
&:hover {
background: #90363F;
}
}
&:has(:disabled) {
background: #0003 !important;
color: #FFF6 !important;
}
input {
display: block;
appearance: none;
border: 0;
margin: 0;
padding: 0;
opacity: 0;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
}
}
.help {
position: absolute;
top: 60px;
right: 16px;
sts-button.active {
background: #7A2C34E3;
color: #FFF;
}
// Hierarchy (TODO: Probably move this)
.hierarchy {
position: absolute;
top: 16px;
left: 16px;
overflow: auto;
box-sizing: border-box;
+68 -137
View File
@@ -8,19 +8,25 @@ import {
type SceneStateEvent,
TransformSpace,
TransformType,
ViewportSize,
SceneState,
} from "@storyteller/studio";
import { CanvasProvider } from "@storyteller/studio-web/canvas-provider";
import { match } from "@storyteller/utility";
import { LitElement, html, nothing, unsafeCSS } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit-html/directives/class-map.js";
import { Ref, createRef, ref } from "lit-html/directives/ref.js";
import "@storyteller/studio-web/canvas-provider";
import "@storyteller/studio-web/viewer";
import "./button.element";
import "./help.element";
import "./icon.element";
import "./toolbar.element";
import "./transform-toolbar.element";
import "./tree.element";
import styles from "./app.element.scss?inline";
import "./app.element.scss";
interface SceneObject extends Omit<studio.SceneObject, "children"> {
children: SceneObject[];
@@ -28,8 +34,6 @@ interface SceneObject extends Omit<studio.SceneObject, "children"> {
@customElement("app-root")
export class AppElement extends LitElement {
static override styles = unsafeCSS(styles);
@state() showHelp = false;
@state() loading = true;
@@ -41,18 +45,17 @@ export class AppElement extends LitElement {
#sceneObjects: studio.SceneObject[] = [];
#entityMap = new Map<string, SceneObject>();
#studioCanvas?: HTMLCanvasElement | null;
#canvasProvider: Ref<CanvasProvider> = createRef();
override connectedCallback(): void {
this.#studioCanvas = this.#initStudio();
super.connectedCallback();
get #studioCanvas(): HTMLCanvasElement | null {
const provider = this.#canvasProvider.value;
if (!provider) return null;
return document.getElementById(provider.canvasId) as HTMLCanvasElement | null;
}
@on("window:resize")
onWindowResize(): void {
const width = window.innerWidth;
const height = window.innerHeight;
studio.resize(new ViewportSize(width, height));
protected override createRenderRoot(): Element | ShadowRoot {
return this;
}
@on("keydown")
@@ -82,7 +85,6 @@ export class AppElement extends LitElement {
}));
}
@on("window:scene-state")
onSceneStateChange({ detail: state }: SceneStateEvent): void {
console.log("scene state change:", SceneState[state]);
@@ -124,38 +126,14 @@ export class AppElement extends LitElement {
this.showHelp = !this.showHelp;
}
onTransformSpaceChange(event: InputEvent): void {
const { value } = event.target as HTMLInputElement;
const space = match (value, {
"local": () => TransformSpace.Local,
"world": () => TransformSpace.World,
_: () => {
throw new Error(`Unknown transform space: "${value}"`);
}
});
onTransformSpaceChange({ detail: space }: CustomEvent<TransformSpace>): void {
this.transformSpace = space;
studio.setTransformSpace(space);
}
onTransformTypeChange(event: InputEvent): void {
const { name, checked } = event.target as HTMLInputElement;
const flag = match (name, {
"translate": () => TransformType.Translate,
"rotate": () => TransformType.Rotate,
"scale": () => TransformType.Scale,
_: () => {
throw new Error(`Unknown transform type: "${name}"`);
}
});
if (checked) {
this.transformType |= flag;
} else {
this.transformType &= ~flag;
}
studio.setTransformType(this.transformType);
onTransformTypeChange({ detail: type }: CustomEvent<TransformType>): void {
this.transformType = type;
studio.setTransformType(type);
}
// TODO: Get some help from someone who knows what an algorithm is
@@ -186,105 +164,58 @@ export class AppElement extends LitElement {
console.log("Hierarchy:", this.sceneObjects);
}
#initStudio(): HTMLCanvasElement | null {
const width = window.innerWidth;
const height = window.innerHeight;
try {
studio.startViewer(
"couch",
"test_scene",
"#studio_canvas",
"Storyteller Studio",
new ViewportSize(width, height),
);
} catch (err) {
if (!(err as Error).message?.startsWith("Using exceptions for control flow"))
throw err;
}
return document.querySelector<HTMLCanvasElement>(`canvas[alt="Storyteller Studio"]`);
}
protected override render = () => html`
<!-- TODO: What's a "component"? -->
<div class="toolbar" role="toolbar">
<div class="toolbar-group" role="group">
<label class="btn-toggle">
<sts-icon icon="cube">Local</sts-icon>
<input type="radio"
name="transform-space"
value="local"
?checked=${this.transformSpace === TransformSpace.Local}
@input=${this.onTransformSpaceChange}
/>
</label>
<label class="btn-toggle">
<sts-icon icon="globe">Global</sts-icon>
<input type="radio"
name="transform-space"
value="world"
?checked=${this.transformSpace === TransformSpace.World}
@input=${this.onTransformSpaceChange}
/>
</label>
</div>
<div class="toolbar-group" role="group">
<label class="btn-toggle">
<sts-icon icon="up-down-left-right">Translate</sts-icon>
<input type="checkbox"
name="translate"
?checked=${this.transformType & TransformType.Translate}
@input=${this.onTransformTypeChange}
/>
</label>
<!-- disabled -->
<label class="btn-toggle">
<sts-icon icon="rotate">Rotate</sts-icon>
<input type="checkbox"
name="rotate"
?checked=${this.transformType & TransformType.Rotate}
@input=${this.onTransformTypeChange}
/>
</label>
<label class="btn-toggle">
<sts-icon icon="scale">Scale</sts-icon>
<input type="checkbox"
name="scale"
disabled
?checked=${this.transformType & TransformType.Scale}
@input=${this.onTransformTypeChange}
/>
</label>
</div>
</div>
<div class="hierarchy">
${this.sceneObjects.map(obj => html`
<sts-tree class="scene-object"
id=${obj.id}
.name=${obj.name}
.treeChildren=${obj.children}
></sts-tree>
`)}
</div>
<sts-button class="btn-help"
icon="circle-info"
@click=${this.toggleHelp}
<sts-canvas-provider
class="app-root"
${ref(this.#canvasProvider as any)}
>
Help
</sts-button>
<sts-viewer
objectId="sample-room"
skyboxId="test_scene"
@scene-state=${this.onSceneStateChange}
></sts-viewer>
${this.showHelp ? html`
<sts-help class="help"></sts-help>
` : nothing}
<sts-toolbar class="v-start h-start">
<sts-toolbar-group>
<sts-button
class=${classMap({ active: this.showHelp })}
icon="circle-info"
@click=${this.toggleHelp}
>
Help
</sts-button>
</sts-toolbar-group>
</sts-toolbar>
${this.loading ? html`
<!-- TODO: Animated spinner or something -- talk to our designers -->
<div class="loading">
<h1>Loading</h1>
<sts-transform-toolbar
class="v-start h-center"
.type=${this.transformType}
@type-change=${this.onTransformTypeChange}
.space=${this.transformSpace}
@space-change=${this.onTransformSpaceChange}
></sts-transform-toolbar>
<div class="hierarchy v-start h-end">
${this.sceneObjects.map(obj => html`
<sts-tree class="scene-object"
id=${obj.id}
.name=${obj.name}
.treeChildren=${obj.children}
></sts-tree>
`)}
</div>
` : nothing}
${this.showHelp ? html`
<sts-help class="help v-after-toolbar h-start"></sts-help>
` : nothing}
${this.loading ? html`
<!-- TODO: Animated spinner or something -- talk to our designers -->
<div class="loading">
<h1>Loading</h1>
</div>
` : nothing}
</sts-canvas-provider>
`;
}
@@ -0,0 +1,22 @@
:host {
display: flex;
gap: 2px;
}
::slotted(*) {
border-radius: 0;
}
::slotted(:first-child) {
border: {
top-left-radius: 8px;
bottom-left-radius: 8px;
}
}
::slotted(:last-child) {
border: {
top-right-radius: 8px;
bottom-right-radius: 8px;
}
}
@@ -0,0 +1,4 @@
:host {
display: flex;
gap: 16px;
}
@@ -0,0 +1,21 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import toolbarStyles from "./toolbar.element.scss?inline";
import toolbarGroupStyles from "./toolbar-group.element.scss?inline";
// TODO: A11y - https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/
@customElement("sts-toolbar")
export class ToolbarElement extends LitElement {
static override styles = unsafeCSS(toolbarStyles);
override role = "toolbar";
protected override render = () => html`<slot></slot>`;
}
@customElement("sts-toolbar-group")
export class ToolbarGroupElement extends LitElement {
static override styles = unsafeCSS(toolbarGroupStyles);
override role = "group";
protected override render = () => html`<slot></slot>`;
}
@@ -0,0 +1,57 @@
.btn-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
font-size: 16px;
padding: 10px 12px;
pointer-events: auto;
// TODO: Copypasta
background: #0006;
color: #FFFC;
backdrop-filter: blur(16px);
transition: {
property: background, color;
duration: 0.1s;
timing-function: linear;
}
&:hover {
background: #0008;
color: #FFF;
}
&:has(:focus-visible) {
z-index: 1;
outline: 4px solid #2F83FFBF;
}
&:has(:checked) {
background: #7A2C34E3;
color: #FFF;
&:hover {
background: #90363F;
}
}
&:has(:disabled) {
background: #0003 !important;
color: #FFF6 !important;
}
input {
display: block;
appearance: none;
border: 0;
margin: 0;
padding: 0;
opacity: 0;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
}
}
@@ -0,0 +1,105 @@
import { TransformSpace, TransformType } from "@storyteller/studio";
import { match } from "@storyteller/utility";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import styles from "./transform-toolbar.element.scss?inline";
@customElement("sts-transform-toolbar")
export class TransformToolbarElement extends LitElement {
static override styles = unsafeCSS(styles);
@property({ type: Number }) space = TransformSpace.Local;
@property({ type: Number }) type = TransformType.Translate;
#onSpaceChange(event: InputEvent): void {
const { value } = event.target as HTMLInputElement;
const space = match (value, {
"local": () => TransformSpace.Local,
"world": () => TransformSpace.World,
_: () => {
throw new Error(`Unknown transform space: "${value}"`);
}
});
this.dispatchEvent(new CustomEvent<TransformSpace>("space-change", {
detail: space,
bubbles: true,
composed: true,
}));
}
#onTypeChange(event: InputEvent): void {
const { name, checked } = event.target as HTMLInputElement;
const flag = match (name, {
"translate": () => TransformType.Translate,
"rotate": () => TransformType.Rotate,
"scale": () => TransformType.Scale,
_: () => {
throw new Error(`Unknown transform type: "${name}"`);
}
});
const type = checked
? this.type | flag
: this.type & ~flag;
this.dispatchEvent(new CustomEvent<TransformType>("type-change", {
detail: type,
bubbles: true,
composed: true,
}));
}
protected override render = () => html`
<sts-toolbar>
<sts-toolbar-group>
<label class="btn-toggle">
<sts-icon icon="cube">Local</sts-icon>
<input type="radio"
name="transform-space"
value="local"
?checked=${this.space === TransformSpace.Local}
@input=${this.#onSpaceChange}
/>
</label>
<label class="btn-toggle">
<sts-icon icon="globe">Global</sts-icon>
<input type="radio"
name="transform-space"
value="world"
?checked=${this.space === TransformSpace.World}
@input=${this.#onSpaceChange}
/>
</label>
</sts-toolbar-group>
<sts-toolbar-group>
<label class="btn-toggle">
<sts-icon icon="up-down-left-right">Translate</sts-icon>
<input type="checkbox"
name="translate"
?checked=${Boolean(this.type & TransformType.Translate)}
@input=${this.#onTypeChange}
/>
</label>
<label class="btn-toggle">
<sts-icon icon="rotate">Rotate</sts-icon>
<input type="checkbox"
name="rotate"
?checked=${Boolean(this.type & TransformType.Rotate)}
@input=${this.#onTypeChange}
/>
</label>
<label class="btn-toggle">
<sts-icon icon="scale">Scale</sts-icon>
<input type="checkbox"
name="scale"
disabled
?checked=${Boolean(this.type & TransformType.Scale)}
@input=${this.#onTypeChange}
/>
</label>
</sts-toolbar-group>
</sts-toolbar>
`;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { inject } from "@storyteller/framework";
import { LitElement, PropertyValues, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import { CANVAS_PROVIDER, CanvasProvider } from "./canvas-provider.element";
import { CANVAS_PROVIDER, type CanvasProvider } from "./canvas-provider.element";
import styles from "./viewer.element.scss?inline";