From 4a47b58f6e841c81ee61f88fa56f9ff51d857b6d Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Wed, 6 Mar 2024 21:19:28 -0500 Subject: [PATCH] Create "Add to Scene" menu --- studio-frontend/src/app/app.element.scss | 6 + studio-frontend/src/app/app.element.ts | 70 +++++++- studio-ui/src/assets/icons/cylinder.svg | 3 + studio-ui/src/assets/icons/sphere.svg | 9 + studio-ui/src/assets/icons/torus.svg | 4 + studio-ui/src/index.ts | 2 + studio-ui/src/lib/button.element.ts | 11 +- studio-ui/src/lib/icon.element.ts | 19 +- studio-ui/src/lib/menu.element.scss | 35 ++++ studio-ui/src/lib/menu.element.ts | 166 ++++++++++++++++++ studio-ui/src/lib/overlay-provider.element.ts | 110 ++++++++++++ tsconfig.base.json | 4 +- 12 files changed, 433 insertions(+), 6 deletions(-) create mode 100644 studio-ui/src/assets/icons/cylinder.svg create mode 100644 studio-ui/src/assets/icons/sphere.svg create mode 100644 studio-ui/src/assets/icons/torus.svg create mode 100644 studio-ui/src/lib/menu.element.scss create mode 100644 studio-ui/src/lib/menu.element.ts create mode 100644 studio-ui/src/lib/overlay-provider.element.ts diff --git a/studio-frontend/src/app/app.element.scss b/studio-frontend/src/app/app.element.scss index 5d05551..5ad99a2 100644 --- a/studio-frontend/src/app/app.element.scss +++ b/studio-frontend/src/app/app.element.scss @@ -165,3 +165,9 @@ sts-button.active { } } } + +// TODO: Move this +.menu-button-icon { + font-size: 12px; + opacity: 0.75; +} diff --git a/studio-frontend/src/app/app.element.ts b/studio-frontend/src/app/app.element.ts index 9212f25..bb2059b 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -5,7 +5,9 @@ import { type SceneStateEvent, SceneState, StudioMode, + BlockingCommand, } from "@storyteller/studio"; +import { ButtonElement } from "@storyteller/studio-ui/button"; import { SceneHierarchyElement, StudioURLParams, @@ -20,6 +22,8 @@ import { v4 as uuid } from "uuid"; import "@storyteller/studio-ui/button"; import "@storyteller/studio-ui/icon"; +import "@storyteller/studio-ui/menu"; +import "@storyteller/studio-ui/overlay-provider"; import "@storyteller/studio-ui/toolbar"; import "@storyteller/studio-ui/tree"; import "@storyteller/studio-web/canvas-provider"; @@ -48,6 +52,7 @@ export class AppElement extends LitElement { #enableAnimTimeline = false; // TODO + #addToSceneButtonRef = createRef(); #inspectorRef = createRef(); #hierarchyRef = createRef(); @@ -145,6 +150,7 @@ export class AppElement extends LitElement { } protected override render = () => html` + @@ -174,6 +180,16 @@ export class AppElement extends LitElement { > Save + + Add to Scene + + ` : nothing} ${this.#params.mode === StudioMode.Editor ? html` - + + + { + studio.dispatchBlockingCommand(BlockingCommand.Cube); + }} + > + Cube + + { + studio.dispatchBlockingCommand(BlockingCommand.Sphere); + }} + > + Sphere + + { + studio.dispatchBlockingCommand(BlockingCommand.Torus); + }} + > + Torus + + { + studio.dispatchBlockingCommand(BlockingCommand.Cylinder); + }} + > + Cylinder + +
+ + Import asset... + + + Media library... + +
@@ -276,5 +343,6 @@ export class AppElement extends LitElement { ` : nothing}
+
`; } diff --git a/studio-ui/src/assets/icons/cylinder.svg b/studio-ui/src/assets/icons/cylinder.svg new file mode 100644 index 0000000..c411bd6 --- /dev/null +++ b/studio-ui/src/assets/icons/cylinder.svg @@ -0,0 +1,3 @@ + + + diff --git a/studio-ui/src/assets/icons/sphere.svg b/studio-ui/src/assets/icons/sphere.svg new file mode 100644 index 0000000..7c948a1 --- /dev/null +++ b/studio-ui/src/assets/icons/sphere.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/studio-ui/src/assets/icons/torus.svg b/studio-ui/src/assets/icons/torus.svg new file mode 100644 index 0000000..7cd4b8b --- /dev/null +++ b/studio-ui/src/assets/icons/torus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/studio-ui/src/index.ts b/studio-ui/src/index.ts index fe3b7a5..3a2c7e2 100644 --- a/studio-ui/src/index.ts +++ b/studio-ui/src/index.ts @@ -1,5 +1,7 @@ export * from "./lib/button.element"; export * from "./lib/icon.element"; +export * from "./lib/menu.element"; +export * from "./lib/overlay-provider.element"; export * from "./lib/progress.element"; export * from "./lib/toolbar.element"; export * from "./lib/tree.element"; diff --git a/studio-ui/src/lib/button.element.ts b/studio-ui/src/lib/button.element.ts index 73d6f5b..81d47be 100644 --- a/studio-ui/src/lib/button.element.ts +++ b/studio-ui/src/lib/button.element.ts @@ -10,10 +10,12 @@ import "./icon.element"; export class ButtonElement extends LitElement { static override styles = unsafeCSS(styles); + @property({ reflect: true }) + override role = "button"; + @property() icon?: string; @property({ type: Boolean }) disabled = false; - override role = "button"; override tabIndex = 0; protected override willUpdate(changes: PropertyValues): void { @@ -36,7 +38,12 @@ export class ButtonElement extends LitElement { protected override render = () => html` ${this.icon - ? html`` + ? html` + ` : nothing} ` diff --git a/studio-ui/src/lib/icon.element.ts b/studio-ui/src/lib/icon.element.ts index 7e6b556..1f4f345 100644 --- a/studio-ui/src/lib/icon.element.ts +++ b/studio-ui/src/lib/icon.element.ts @@ -31,6 +31,14 @@ import fasCircleXmark from "@fortawesome/fontawesome-pro/svgs/solid/circle-xmark 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 fasChevronDown from "@fortawesome/fontawesome-pro/svgs/solid/chevron-down.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 styles from "./icon.element.scss?inline"; @@ -61,13 +69,16 @@ function renderSvg(iconId: string) { "bone": () => farBone, "bwd-fast": () => fasBwdFast, "bwd-step": () => fasBwdStep, + "caret-right": () => fasCaretRight, "check": () => fasCheck, + "chevron-down": () => fasChevronDown, "circle-check": () => fasCircleCheck, "circle-question": () => fasCircleQuestion, "circle-xmark": () => fasCircleXmark, - "caret-right": () => fasCaretRight, "crosshairs": () => farCrosshairs, "cube": () => farCube, + "cylinder": () => customCylinder, + "file-import": () => farFileImport, "floppy-disk": () => fasFloppyDisk, "fwd-fast": () => fasFwdFast, "fwd-step": () => fasFwdStep, @@ -76,11 +87,15 @@ function renderSvg(iconId: string) { "list-tree": () => fasListTree, "minus": () => fasMinus, "globe": () => farGlobe, + "photo-film-music": () => farPhotoFilmMusic, "play": () => fasPlay, + "plus-large": () => fasPlusLarge, "question": () => fasQuestion, "rotate": () => farRotate, "scale": () => farScale, "skeleton": () => farSkeleton, + "sphere": () => customSphere, + "torus": () => customTorus, "up-down-left-right": () => farUpDownLeftRight, "video": () => farVideo, "xmark": () => fasXmark, @@ -89,7 +104,7 @@ function renderSvg(iconId: string) { if (!svgSrc) return nothing; - const [, attrsString, body] = svgSrc.match(/(.+?)<\/svg>/)!; + const [, attrsString, body] = svgSrc.match(/(.+?)<\/svg>/s)!; const attrs = Object.fromEntries( Array.from(attrsString.matchAll(/([-:_a-zA-Z0-9]+)(?:=['"]?([^'"]*)['"]?)/g)) .map(([, attr, value]) => [attr, value]) diff --git a/studio-ui/src/lib/menu.element.scss b/studio-ui/src/lib/menu.element.scss new file mode 100644 index 0000000..f1cb459 --- /dev/null +++ b/studio-ui/src/lib/menu.element.scss @@ -0,0 +1,35 @@ +:host { + display: flex; + flex-direction: column; + border-radius: 0.5rem; + padding: 0.5rem 0; + min-width: 220px; + + background: #0006; + backdrop-filter: blur(16px); + 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; + } +} + +::slotted(hr) { + display: block; + width: 100%; + border: none; + height: 1px; + margin: 0.5rem 0; + background: #FFF1; +} diff --git a/studio-ui/src/lib/menu.element.ts b/studio-ui/src/lib/menu.element.ts new file mode 100644 index 0000000..8b2547d --- /dev/null +++ b/studio-ui/src/lib/menu.element.ts @@ -0,0 +1,166 @@ +import { inject, on } from "@storyteller/framework"; +import { Fn } from "@storyteller/utility"; +import { LitElement, PropertyValues, css, html, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { createRef, ref, Ref } from "lit/directives/ref.js"; +import { styleMap } from "lit/directives/style-map.js"; + +import panelStyles from "./menu.element.scss?inline"; + +import { + OVERLAY_PROVIDER, + type OverlayHandle, + type OverlayProvider, +} from "./overlay-provider.element"; + +@customElement("sts-menu") +export class MenuElement extends LitElement { + static override styles = css` + :host { + visibility: hidden; + } + `; + + @property({ attribute: false }) + trigger: Ref = createRef(); + + /** Close the menu when one of its menuitems is pressed */ + @property({ type: Boolean }) + autoClose = false; + + @inject(OVERLAY_PROVIDER) + overlay!: OverlayProvider; + + #menuId = crypto.randomUUID(); + #slotRef = createRef(); + #panelRef = createRef(); + #handle?: WeakRef; + #listener?: Fn; + + protected override update(changes: PropertyValues): void { + super.update(changes); + + if (this.#handle) + this.overlay.reRender(this.#handle); + } + + protected override updated(changes: PropertyValues): void { + if (changes.has("trigger") && this.trigger.value) { + const trigger = this.trigger.value; + + trigger.setAttribute("aria-owns", this.#menuId); + trigger.ariaHasPopup = "true"; + trigger.ariaExpanded = "false"; + + if (this.#listener) + trigger.removeEventListener("click", this.#listener); + + this.#listener = () => this.show(); + trigger.addEventListener("click", this.#listener); + } + + super.updated(changes); + } + + @on("window:click") + onWindowClick(event: PointerEvent): void { + if (!this.#handle || !this.#panelRef.value) + return; + + const path = event.composedPath(); + if ( + !path.includes(this.#panelRef.value) + && (!this.trigger.value || !path.includes(this.trigger.value)) + ) { + this.hide(); + } + } + + onMenuClick(event: PointerEvent): void { + event.stopPropagation(); + + if ( + event.target + && event.target instanceof HTMLElement + && event.target.role === "menuitem" + ) { + this.hide(); + } + } + + show(): void { + const trigger = this.trigger.value; + if (!trigger) return; + + trigger.classList.toggle("active", true); + trigger.ariaExpanded = "true"; + + const rect = trigger.getBoundingClientRect(); + + this.#handle = this.overlay.attach(this, html` + + ${ + // Move the nodes assigned to the 's slot into the + // 's slot + this.#slotRef.value?.assignedNodes() + } + + `); + } + + hide(): void { + const trigger = this.trigger.value; + if (trigger) { + trigger.classList.toggle("active", false); + trigger.ariaExpanded = "false"; + } + + if (this.#panelRef.value) { + // Move the nodes assigned to the 's slot back into the + // 's slot + for (let node of this.#panelRef.value.getSlot()!.assignedNodes()) + this.appendChild(node); + } + + if (this.#handle) + this.overlay.detach(this.#handle); + + this.#handle = undefined; + } + + protected override render = () => html` + + ` +} + +@customElement("sts-menu-panel") +export class MenuPanelElement extends LitElement { + static override styles = unsafeCSS(panelStyles); + + @property({ reflect: true }) + override role = "menu"; + + #slotRef = createRef(); + getSlot(): HTMLSlotElement | undefined { return this.#slotRef.value; } + + protected override render = () => html` + + ` +} + +declare global { + interface HTMLElementTagNameMap { + "sts-menu-panel": MenuPanelElement; + } +} diff --git a/studio-ui/src/lib/overlay-provider.element.ts b/studio-ui/src/lib/overlay-provider.element.ts new file mode 100644 index 0000000..78d13e8 --- /dev/null +++ b/studio-ui/src/lib/overlay-provider.element.ts @@ -0,0 +1,110 @@ +import { UniqueToken, provide } from "@storyteller/framework"; +import { LitElement, TemplateResult, html, render } from "lit"; +import { customElement } from "lit/decorators.js"; +import { Ref, createRef, ref } from "lit/directives/ref.js"; +import { styleMap } from "lit/directives/style-map.js"; + +export const OVERLAY_PROVIDER = UniqueToken.create(); + +export interface OverlayHandle { + readonly id: number; +} + +class OverlayHandleInternal implements OverlayHandle { + #id: number; + get id() { return this.#id; } + + constructor (id: number) { + this.#id = id; + } + _updateId(id: number): void { + this.#id = id; + } +} + +export interface OverlayProvider { + attach(receiver: LitElement, template: TemplateResult): WeakRef; + detach(handle: WeakRef): void; + reRender(handle: WeakRef): void; +} + +@customElement("sts-overlay-provider") +@provide(OVERLAY_PROVIDER) +export class OverlayProviderElement + extends LitElement + implements OverlayProvider +{ + #handles: OverlayHandleInternal[] = []; + #containerRefs: Ref[] = []; + #containerTemplates: TemplateResult[] = []; + #templates: [LitElement, TemplateResult][] = []; + + attach(receiver: LitElement, template: TemplateResult): WeakRef { + const index = this.#templates.length; + const handle = new OverlayHandleInternal(index); + + this.#handles.push(handle); + this.#containerRefs.push(createRef()); + this.#containerTemplates.push(html` +
+ `); + this.#templates.push([receiver, template]); + + this.#updateLightDom(); + + return new WeakRef(handle); + } + + detach(handle: WeakRef): void { + const index = handle.deref()?.id; + if (index == null) return; + + this.#handles.splice(index, 1); + this.#containerRefs.splice(index, 1); + this.#containerTemplates.splice(index, 1); + this.#templates.splice(index, 1); + + for (let i = index; i < this.#handles.length; ++i) + this.#handles[i]._updateId(i); + + this.#updateLightDom(); + } + + reRender(handle: WeakRef): void { + const index = handle.deref()?.id; + if (index == null) return; + + const container = this.#containerRefs[index]?.value; + if (!container) return; + + const tuple = this.#templates[index]; + if (!tuple) return; + + const [receiver, template] = tuple; + render(template, container, { host: receiver }); + } + + #updateLightDom(): void { + render(this.#containerTemplates, this, { host: this }); + + for (let i = 0; i < this.#handles.length; ++i) { + const container = this.#containerRefs[i].value; + if (!container) throw new Error("Attempted to retrieve an invalid container ref!"); + + const [receiver, template] = this.#templates[i]; + render(template, container, { host: receiver }); + } + } + + protected override render = () => html` + + + `; +} diff --git a/tsconfig.base.json b/tsconfig.base.json index e4c0493..5b747bb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,7 +10,7 @@ "importHelpers": true, "target": "es2015", "module": "esnext", - "lib": ["es2020", "dom"], + "lib": ["es2021", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".", @@ -20,6 +20,8 @@ "@storyteller/studio-ui": ["studio-ui/src/index.ts"], "@storyteller/studio-ui/button": ["studio-ui/src/lib/button.element.ts"], "@storyteller/studio-ui/icon": ["studio-ui/src/lib/icon.element.ts"], + "@storyteller/studio-ui/menu": ["studio-ui/src/lib/menu.element.ts"], + "@storyteller/studio-ui/overlay-provider": ["studio-ui/src/lib/overlay-provider.element.ts"], "@storyteller/studio-ui/progress": ["studio-ui/src/lib/progress.element.ts"], "@storyteller/studio-ui/toolbar": ["studio-ui/src/lib/toolbar.element.ts"], "@storyteller/studio-ui/tree": ["studio-ui/src/lib/tree.element.ts"],