From 03f87b25446abe2a6655828d080bc5b066e87c63 Mon Sep 17 00:00:00 2001 From: Danny McGee Date: Fri, 15 Mar 2024 13:30:06 -0400 Subject: [PATCH] "Add to Scene" menu and Media Library dialog (#78) This PR adds a new "Add to Scene" dropdown menu for adding elements to the current scene, and a new Media Library dialog for importing and/or adding Storyteller Engine assets (characters, scenes, animations, etc.) associated with the user's Storyteller.ai account. The scope of this PR is limited to the UI — actual asset importing behavior, and adding elements other than primitive shapes to the current scene, will be implemented in follow-up PRs. ### Technical Details In order to facilitate these changes, a new "overlay" abstraction has been added to `@storyteller/studio-ui` (loosely modeled after [`@angular/cdk/overlay`](https://material.angular.io/cdk/overlay/overview)) for managing DOM elements which should render in a self-contained stacking context "above" the rest of the application. Two new base UI components — `` and `` — have also been added to make use of this overlay system, but it's worth noting that these new components have not yet been exhaustively tested or thoroughly fleshed out, so they may require significant changes in order to accommodate future use cases or address unforeseen bugs. ### Testing The Media Library makes some API calls which will run into CORS errors in a normal browser. This can be bypassed by running Chrome with the following command-line flags: ``` --disable-web-security --ignore-certificate-errors --user-data-dir=path/to/some/temporary/directory ``` Verify all of the following: * The "Add to Scene" menu opens and closes as expected * The primitive-shape menu items (Cube, Sphere, etc.) add the corresponding elements to the scene * The "Media Library..." menu item correctly opens the Media Library dialog * The Media Library's search bar and category filters work as expected * A single asset can be selected by clicking on it * Multiple assets can be selected by holding the "Ctrl" or "Cmd" key * The selection can be cleared by clicking in an empty space in the results grid * An "Add to Scene" button appears if (and only if) one or more assets are selected * The Media Library dialog can be closed by: - Clicking the "X" in the upper right-hand corner - Pressing the "Esc" key - Clicking anywhere outside of the dialog boundaries Additionally, ensure that the Media Library dialog handles all of the following situations gracefully: * User is not logged in to fakeyou.com * User is logged in but does not have any engine-compatible assets uploaded (this can be tested by selecting any of the categories from the sidebar, since the database has not yet been migrated to use the new `media_file_class` field, so all of those categories should currently yield an empty list) * User is logged in and there are engine-compatible assets to list (this can be tested by pressing the "Save" button in the main application toolbar to save the current scene to your account, and selecting the "Home" entry from the Media Library sidebar) --- package-lock.json | 32 +- package.json | 4 +- studio-frontend/src/app/app.element.scss | 6 + studio-frontend/src/app/app.element.ts | 75 +-- studio-frontend/src/app/blocking.element.scss | 8 - studio-frontend/src/app/blocking.element.ts | 41 -- studio-frontend/tsconfig.json | 2 +- 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 | 3 + studio-ui/src/lib/button.element.scss | 1 + studio-ui/src/lib/button.element.ts | 14 +- studio-ui/src/lib/dialog.element.ts | 191 ++++++++ .../src/lib/forms/text-field.element.scss | 15 +- studio-ui/src/lib/forms/text-field.element.ts | 29 +- studio-ui/src/lib/icon.element.ts | 33 +- studio-ui/src/lib/menu.element.scss | 35 ++ studio-ui/src/lib/menu.element.ts | 213 +++++++++ .../src/lib/overlay-provider.element.scss | 4 + studio-ui/src/lib/overlay-provider.element.ts | 262 +++++++++++ studio-web/src/index.ts | 4 + .../src/lib/add-to-scene-menu.element.ts | 65 +++ .../src/lib/media-library-dialog.element.scss | 266 +++++++++++ .../src/lib/media-library-dialog.element.ts | 348 ++++++++++++++ studio-web/src/lib/storyteller-api.ts | 436 ++++++++++++++++++ tsconfig.base.json | 7 +- utility/src/index.ts | 1 + utility/src/lib/assert.ts | 8 + utility/src/lib/types.ts | 3 + 30 files changed, 2004 insertions(+), 118 deletions(-) delete mode 100644 studio-frontend/src/app/blocking.element.scss delete mode 100644 studio-frontend/src/app/blocking.element.ts 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/dialog.element.ts 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.scss create mode 100644 studio-ui/src/lib/overlay-provider.element.ts create mode 100644 studio-web/src/lib/add-to-scene-menu.element.ts create mode 100644 studio-web/src/lib/media-library-dialog.element.scss create mode 100644 studio-web/src/lib/media-library-dialog.element.ts create mode 100644 studio-web/src/lib/storyteller-api.ts create mode 100644 utility/src/lib/assert.ts diff --git a/package-lock.json b/package-lock.json index 9d13de8..024ae5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,12 @@ "license": "MIT", "dependencies": { "@fortawesome/fontawesome-pro": "^6.4.2", + "axios": "^1.6.7", "lit": "^2.8.0", "lodash": "^4.17.21", "react": "18.2.0", "react-dom": "18.2.0", - "tslib": "^2.3.0", - "uuid": "^9.0.1" + "tslib": "^2.3.0" }, "devDependencies": { "@nx/js": "16.9.1", @@ -4976,8 +4976,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/available-typed-arrays": { "version": "1.0.5", @@ -4992,12 +4991,11 @@ } }, "node_modules/axios": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.1.tgz", - "integrity": "sha512-Q28iYCWzNHjAm+yEAot5QaAMxhMghWLFVf7rRdwhUI+c2jix2DUXjAHXVi+s1ibs3mjPO/cCgbA++3BjD0vP/A==", - "dev": true, + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -5606,7 +5604,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5950,7 +5947,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -6531,10 +6527,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", - "dev": true, + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", "funding": [ { "type": "individual", @@ -6591,7 +6586,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -8041,7 +8035,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -8050,7 +8043,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -8834,8 +8826,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/psl": { "version": "1.9.0", @@ -10202,6 +10193,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index 65ddb8a..e2e5c35 100644 --- a/package.json +++ b/package.json @@ -42,11 +42,11 @@ }, "dependencies": { "@fortawesome/fontawesome-pro": "^6.4.2", + "axios": "^1.6.7", "lit": "^2.8.0", "lodash": "^4.17.21", "react": "18.2.0", "react-dom": "18.2.0", - "tslib": "^2.3.0", - "uuid": "^9.0.1" + "tslib": "^2.3.0" } } 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..1c574be 100644 --- a/studio-frontend/src/app/app.element.ts +++ b/studio-frontend/src/app/app.element.ts @@ -1,4 +1,4 @@ -import { drag, on } from "@storyteller/framework"; +import { drag, on, provide } from "@storyteller/framework"; import * as studio from "@storyteller/studio"; import { type SceneSavedEvent, @@ -6,8 +6,11 @@ import { SceneState, StudioMode, } from "@storyteller/studio"; +import { ButtonElement } from "@storyteller/studio-ui/button"; +import { DialogElement } from "@storyteller/studio-ui/dialog"; import { SceneHierarchyElement, + StorytellerApi, StudioURLParams, } from "@storyteller/studio-web"; import { match } from "@storyteller/utility"; @@ -16,27 +19,32 @@ 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 { v4 as uuid } from "uuid"; import "@storyteller/studio-ui/button"; +import "@storyteller/studio-ui/dialog"; import "@storyteller/studio-ui/icon"; +import "@storyteller/studio-ui/overlay-provider"; import "@storyteller/studio-ui/toolbar"; import "@storyteller/studio-ui/tree"; +import "@storyteller/studio-web/add-to-scene-menu"; import "@storyteller/studio-web/canvas-provider"; import "@storyteller/studio-web/entity-inspector"; import "@storyteller/studio-web/iframe-messaging-bridge"; import "@storyteller/studio-web/loading"; +import "@storyteller/studio-web/media-library-dialog"; import "@storyteller/studio-web/scene-hierarchy"; import "@storyteller/studio-web/studio"; import "@storyteller/studio-web/transform-toolbar"; import "./anim-editor.element"; import "./help.element"; -import "./blocking.element"; - import "./app.element.scss"; @customElement("app-root") +@provide({ + token: StorytellerApi, + provide() { return this.api; } +}) export class AppElement extends LitElement { @state() showHelp = false; @state() showInspector = false; @@ -46,8 +54,11 @@ export class AppElement extends LitElement { @state() inspectorWidth = 320; @state() hierarchyHeight?: number; + private api = new StorytellerApi(); #enableAnimTimeline = false; // TODO + #addToSceneButtonRef = createRef(); + #mediaLibraryRef = createRef(); #inspectorRef = createRef(); #hierarchyRef = createRef(); @@ -59,34 +70,15 @@ export class AppElement extends LitElement { @on("scene-saved") async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise { - const file = new File([scene], "scene.scn.ron", { type: "text/ron" }); - const formData = new FormData(); - - formData.append("uuid_idempotency_token", uuid()); - formData.append("file", file); - try { - const response = await fetch("https://api.fakeyou.com/v1/engine/create_scene", { - method: "POST", - credentials: "include", - headers: { "Accept": "text/plain" }, - body: formData, - }); + const file = new File([scene], "scene.scn.ron", { type: "text/ron" }); + const token = await this.api.createScene(file); - if (response.ok) { - const resultJson: { - success: boolean; - media_file_token: string; - } = await response.json(); - - if (resultJson.success) { - this.dispatchEvent(new CustomEvent("scene-uploaded", { - detail: resultJson.media_file_token, - bubbles: true, - composed: true, - })); - } - } + this.dispatchEvent(new CustomEvent("scene-uploaded", { + detail: token, + bubbles: true, + composed: true, + })); } catch (err) { console.error(err); @@ -145,6 +137,7 @@ export class AppElement extends LitElement { } protected override render = () => html` + @@ -174,6 +167,16 @@ export class AppElement extends LitElement { > Save + + Add to Scene + + ` : nothing} ${this.#params.mode === StudioMode.Editor ? html` - + { + this.#mediaLibraryRef.value?.open(); + }} + > + @@ -276,5 +288,6 @@ export class AppElement extends LitElement { ` : nothing} + `; } diff --git a/studio-frontend/src/app/blocking.element.scss b/studio-frontend/src/app/blocking.element.scss deleted file mode 100644 index 19a9a3d..0000000 --- a/studio-frontend/src/app/blocking.element.scss +++ /dev/null @@ -1,8 +0,0 @@ -:host { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - position: absolute; - top: 60px; - left: 16px; -} \ No newline at end of file diff --git a/studio-frontend/src/app/blocking.element.ts b/studio-frontend/src/app/blocking.element.ts deleted file mode 100644 index 9b88573..0000000 --- a/studio-frontend/src/app/blocking.element.ts +++ /dev/null @@ -1,41 +0,0 @@ - -import { LitElement, html, nothing, unsafeCSS } from "lit"; - -import styles from "./blocking.element.scss?inline"; -import { customElement } from "lit/decorators.js"; -import * as studio from "@storyteller/studio"; - -@customElement("sts-blocking") -export class BlockingElement extends LitElement { - static override styles = unsafeCSS(styles); - - override render = () => html` - { - studio.dispatchBlockingCommand(studio.BlockingCommand.Cube); - }}> - Cube - - - { - studio.dispatchBlockingCommand(studio.BlockingCommand.Sphere); - }}> - Sphere - - - { - studio.dispatchBlockingCommand(studio.BlockingCommand.Cylinder); - }}> - Cylinder - - - { - studio.dispatchBlockingCommand(studio.BlockingCommand.Torus); - }}> - Torus - - `; -} \ No newline at end of file diff --git a/studio-frontend/tsconfig.json b/studio-frontend/tsconfig.json index 724ccfc..a8f3853 100644 --- a/studio-frontend/tsconfig.json +++ b/studio-frontend/tsconfig.json @@ -6,7 +6,7 @@ "experimentalDecorators": true, "useDefineForClassFields": false, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2021", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "Bundler", 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..b306d48 100644 --- a/studio-ui/src/index.ts +++ b/studio-ui/src/index.ts @@ -1,5 +1,8 @@ export * from "./lib/button.element"; +export * from "./lib/dialog.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.scss b/studio-ui/src/lib/button.element.scss index a171b45..42c2d83 100644 --- a/studio-ui/src/lib/button.element.scss +++ b/studio-ui/src/lib/button.element.scss @@ -35,6 +35,7 @@ :host(.disabled:hover) { background: #0003; color: #FFF6; + cursor: default; } .icon { diff --git a/studio-ui/src/lib/button.element.ts b/studio-ui/src/lib/button.element.ts index 73d6f5b..2a532e0 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 { @@ -35,9 +37,13 @@ export class ButtonElement extends LitElement { } protected override render = () => html` - ${this.icon - ? html`` - : nothing} + ${this.icon ? html` + + ` : nothing} ` } diff --git a/studio-ui/src/lib/dialog.element.ts b/studio-ui/src/lib/dialog.element.ts new file mode 100644 index 0000000..43326c8 --- /dev/null +++ b/studio-ui/src/lib/dialog.element.ts @@ -0,0 +1,191 @@ +import { inject, on } from "@storyteller/framework"; +import { LitElement, type PropertyValues } 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"; +import { createRef, ref } from "lit/directives/ref.js"; +import { + OVERLAY_PROVIDER, + type OverlayHandle, + type OverlayProvider, +} from "./overlay-provider.element"; + +/** + * A simple, WIP [dialog widget](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) + * implementation. + * + * @warning + * This component should be considered experimental! Its API is still a work-in- + * progress, potential use cases have not been thoroughly explored, and it may + * not behave as expected in all circumstances. + * + * @example + * ``` + * \@customElement("my-dialog") + * class MyDialogElement extends DialogBaseElement { + * render = () => html` + *
+ *

My Dialog

+ * this.dispatchEvent(new CustomEvent("close"))} + * > + *
+ *
+ *

Hello, world!

+ *
+ * ` + * } + * ``` + * ### Trigger-based implementation + * ``` + * \@customElement("my-app") + * class MyApp extends LitElement { + * dialogTriggerRef = createRef(); + * + * render = () => html` + * + * + * Open Dialog + * + * + * + * + * ` + * } + * ``` + * ### Manual-open implementation + * ``` + * \@customElement("my-app") + * class MyApp extends LitElement { + * dialogRef = createRef(); + * + * render = () => html` + * + * { + * this.dialogRef.value?.open(); + * }} + * > + * Open Dialog + * + * + * + * + * ` + * } + * ``` + */ +@customElement("sts-dialog") +export class DialogElement extends LitElement { + /** + * Tag-name of a custom element inheriting from {@linkcode DialogBaseElement}` + */ + @property() tag!: string; + + /** ARIA role to pass to the custom element defined by `tag`. */ + @property() dialogRole?: "dialog" | "alert"; + + /** + * @optional + * Ref of the HTML element which should open the dialog when clicked. + */ + @property({ attribute: false }) + trigger = createRef(); + + @inject(OVERLAY_PROVIDER) + _overlay!: OverlayProvider; + + #overlayHandle?: WeakRef; + #dialogRef = createRef(); + + open(): void { + this.#overlayHandle = this._overlay.attach(this, html` + <${unsafeStatic(this.tag)} + ${ref(this.#dialogRef)} + role=${ifDefined(this.dialogRole)} + @close=${() => this.close()} + > + `, { + containerStyles: { + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + }); + } + + close(): void { + if (this.#overlayHandle) + this._overlay.detach(this.#overlayHandle); + } + + override connectedCallback(): void { + if (!this.tag) + throw new Error("DialogElement requires a static `tag` attribute!"); + + super.connectedCallback(); + } + + protected override updated(changes: PropertyValues): void { + if (changes.has("tag") && changes.get("tag") != null) { + throw new Error(`DialogElement's "tag" attribute should not be changed after rendering!`); + } + + if (changes.has("trigger")) { + const prevTrigger = changes.get("trigger"); + if (prevTrigger?.value) { + prevTrigger.value.removeEventListener("click", this.onTriggerClick); + } + + const trigger = this.trigger.value; + if (!trigger) return; + + trigger.addEventListener("click", this.onTriggerClick); + } + + super.updated(changes); + } + + override disconnectedCallback(): void { + this.trigger.value?.removeEventListener("click", this.onTriggerClick); + super.disconnectedCallback(); + } + + onTriggerClick = (): void => { + this.open(); + } +} + +export class DialogBaseElement extends LitElement { + @property({ reflect: true }) + override role = "dialog"; + + override tabIndex = -1; + + override connectedCallback(): void { + this.focus(); + super.connectedCallback(); + } + + @on("window:click") + onWindowClick(event: PointerEvent): void { + if (!event.composedPath().includes(this)) + this.dispatchEvent(new CustomEvent("close")); + } + + @on("keydown") + onKeydown(event: KeyboardEvent): void { + if (/^(Escape|Shift)$/.test(event.key)) + event.stopPropagation(); + + if (event.key === "Escape") + this.dispatchEvent(new CustomEvent("close")); + } +} diff --git a/studio-ui/src/lib/forms/text-field.element.scss b/studio-ui/src/lib/forms/text-field.element.scss index 27f954b..0236a5a 100644 --- a/studio-ui/src/lib/forms/text-field.element.scss +++ b/studio-ui/src/lib/forms/text-field.element.scss @@ -2,10 +2,12 @@ :host { box-sizing: border-box; - display: block; + display: flex; + align-items: center; width: 100%; padding: 0.5rem 0.75rem; border-radius: 0.25rem; + gap: 0.75rem; background: #0004; border: 1px solid #FFF2; @@ -22,6 +24,10 @@ } } +[part^="icon-"] { + color: #FFF6; +} + :host(:focus) { outline: none; border-color: #FFF4; @@ -30,11 +36,16 @@ } .input { - width: 100%; + flex: 1 0 0; appearance: none; background: transparent; border: none; font: inherit; color: inherit; outline: none !important; + + &::placeholder { + font: inherit; + color: #FFF6; + } } diff --git a/studio-ui/src/lib/forms/text-field.element.ts b/studio-ui/src/lib/forms/text-field.element.ts index 95b4fe9..5df4053 100644 --- a/studio-ui/src/lib/forms/text-field.element.ts +++ b/studio-ui/src/lib/forms/text-field.element.ts @@ -1,6 +1,7 @@ import { on } from "@storyteller/framework"; -import { html, unsafeCSS } from "lit"; +import { html, nothing, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { FormFieldBaseElement } from "./form-field-base.element"; @@ -14,8 +15,18 @@ export class TextFieldElement extends FormFieldBaseElement { delegatesFocus: true, } + @property({ + attribute: "type", + reflect: true, + }) + fieldType = "text"; + + @property() placeholder?: string; @property() value = ""; + @property({ attribute: "icon-pre" }) iconPre?: string; + @property({ attribute: "icon-post" }) iconPost?: string; + @on("keydown") @on("keyup") blockKeypressBubbling(event: KeyboardEvent): void { @@ -24,18 +35,24 @@ export class TextFieldElement extends FormFieldBaseElement { } #onInput(event: InputEvent): void { - const updated = (event.target as HTMLInputElement).value; - console.log("updated:", updated); - - this.dispatchEvent(new CustomEvent("value-change", { detail: updated })); + this.dispatchEvent(new CustomEvent("value-change", { + detail: (event.target as HTMLInputElement).value, + })); } protected override render = () => html` + ${this.iconPre ? html` + + ` : nothing} + ${this.iconPost ? html` + + ` : nothing} `; } diff --git a/studio-ui/src/lib/icon.element.ts b/studio-ui/src/lib/icon.element.ts index 7e6b556..4a503b6 100644 --- a/studio-ui/src/lib/icon.element.ts +++ b/studio-ui/src/lib/icon.element.ts @@ -31,6 +31,21 @@ 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 farVolume from "@fortawesome/fontawesome-pro/svgs/regular/volume.svg?raw"; +import farFilm 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 fasHouse from "@fortawesome/fontawesome-pro/svgs/solid/house.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,35 +76,49 @@ 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, + "figure": () => fasPersonRunning, + "file-import": () => farFileImport, + "film": () => farFilm, "floppy-disk": () => fasFloppyDisk, "fwd-fast": () => fasFwdFast, "fwd-step": () => fasFwdStep, + "home": () => fasHouse, + "import": () => farArrowRightToBracket, "key": () => fasKey, "light": () => fasBrightness, "list-tree": () => fasListTree, "minus": () => fasMinus, "globe": () => farGlobe, + "photo-film-music": () => farPhotoFilmMusic, "play": () => fasPlay, + "plus-large": () => fasPlusLarge, "question": () => fasQuestion, "rotate": () => farRotate, "scale": () => farScale, + "scene": () => farMountainSun, + "search": () => farMagnifyingGlass, "skeleton": () => farSkeleton, + "sphere": () => customSphere, + "torus": () => customTorus, "up-down-left-right": () => farUpDownLeftRight, "video": () => farVideo, + "volume": () => farVolume, "xmark": () => fasXmark, _: () => null, }); 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..6c51893 --- /dev/null +++ b/studio-ui/src/lib/menu.element.ts @@ -0,0 +1,213 @@ +import { inject, on } from "@storyteller/framework"; +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, type Ref } from "lit/directives/ref.js"; +import { styleMap } from "lit/directives/style-map.js"; +import { v4 as uuid } from "uuid"; + +import { + OVERLAY_PROVIDER, + type OverlayHandle, + type OverlayProvider, +} from "./overlay-provider.element"; + +import panelStyles from "./menu.element.scss?inline"; + +/** + * A simple, WIP [menu widget](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) + * implementation. + * + * @note + * This should _not_ be used for a `