mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-13 23:52:55 +00:00
03f87b2544
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 — `<sts-menu>` and `<sts-dialog>` — 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)
214 lines
5.6 KiB
TypeScript
214 lines
5.6 KiB
TypeScript
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 `<select />` style combo-box widget, as it
|
|
* does not implement the correct ARIA roles/behavior for that use case.
|
|
*
|
|
* @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-app")
|
|
* class MyApp extends LitElement {
|
|
* menuTriggerRef = createRef<ButtonElement>();
|
|
*
|
|
* render = () => html`
|
|
* <sts-overlay-provider>
|
|
* <sts-button ${ref(this.menuTriggerRef)}>
|
|
* Open Menu
|
|
* </sts-button>
|
|
*
|
|
* <sts-menu
|
|
* autoClose
|
|
* .trigger=${this.menuTriggerRef}
|
|
* >
|
|
* <sts-button role="menuitem">Lorem</sts-button>
|
|
* <sts-button role="menuitem">Ipsum</sts-button>
|
|
* <sts-button role="menuitem">Dolor</sts-button>
|
|
* <sts-button role="menuitem">Sit Amet</sts-button>
|
|
* <hr />
|
|
* <sts-button role="menuitem">Consectetur</sts-button>
|
|
* <sts-button role="menuitem">Adipiscing Elit</sts-button>
|
|
* </sts-menu>
|
|
* </sts-overlay-provider>
|
|
* `
|
|
* }
|
|
* ```
|
|
*/
|
|
@customElement("sts-menu")
|
|
export class MenuElement extends LitElement {
|
|
static override styles = css`
|
|
:host {
|
|
visibility: hidden;
|
|
}
|
|
`;
|
|
|
|
/**
|
|
* A {@linkcode Ref} of the button that should open this menu when pressed.
|
|
*/
|
|
@property({ attribute: false })
|
|
trigger = createRef<HTMLElement>();
|
|
|
|
/** Close the menu when one of its menuitems is pressed */
|
|
@property({ type: Boolean })
|
|
autoClose = false;
|
|
|
|
@inject(OVERLAY_PROVIDER)
|
|
_overlay!: OverlayProvider;
|
|
|
|
#menuId = uuid();
|
|
#slotRef = createRef<HTMLSlotElement>();
|
|
#panelRef = createRef<MenuPanelElement>();
|
|
#handle?: WeakRef<OverlayHandle>;
|
|
|
|
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`
|
|
<sts-menu-panel
|
|
${ref(this.#panelRef)}
|
|
id=${this.#menuId}
|
|
class=${ifDefined(this.className)}
|
|
style=${styleMap({
|
|
position: "absolute",
|
|
top: `${rect.bottom + 4}px`,
|
|
left: `${rect.left}px`,
|
|
})}
|
|
?autoClose=${this.autoClose}
|
|
@hide=${this.hide}
|
|
>${
|
|
// Move the nodes assigned to the <sts-menu>'s slot into the
|
|
// <sts-menu-panel>'s slot
|
|
this.#slotRef.value?.assignedNodes()
|
|
}</sts-menu-panel>
|
|
`);
|
|
}
|
|
|
|
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 <sts-menu-panel>'s slot back into the
|
|
// <sts-menu>'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 update(changes: PropertyValues<this>): void {
|
|
super.update(changes);
|
|
|
|
if (this.#handle)
|
|
this._overlay.reRender(this.#handle);
|
|
}
|
|
|
|
protected override updated(changes: PropertyValues<this>): void {
|
|
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.setAttribute("aria-owns", this.#menuId);
|
|
trigger.ariaHasPopup = "true";
|
|
trigger.ariaExpanded = "false";
|
|
|
|
trigger.addEventListener("click", this.#onTriggerClick);
|
|
}
|
|
|
|
super.updated(changes);
|
|
}
|
|
|
|
override disconnectedCallback(): void {
|
|
this.trigger.value?.removeEventListener("click", this.#onTriggerClick);
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
#onTriggerClick = (event: Event): void => {
|
|
event.stopPropagation();
|
|
this.show();
|
|
}
|
|
|
|
protected override render = () => html`
|
|
<slot ${ref(this.#slotRef)}></slot>
|
|
`
|
|
}
|
|
|
|
// TODO: ARIA keyboard navigation: https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
|
|
@customElement("sts-menu-panel")
|
|
export class MenuPanelElement extends LitElement {
|
|
static override styles = unsafeCSS(panelStyles);
|
|
|
|
@property({ reflect: true })
|
|
override role = "menu";
|
|
|
|
@property({ type: Boolean })
|
|
autoClose = false;
|
|
|
|
#slotRef = createRef<HTMLSlotElement>();
|
|
getSlot(): HTMLSlotElement | undefined { return this.#slotRef.value; }
|
|
|
|
@on("click")
|
|
onClick(event: PointerEvent): void {
|
|
event.stopPropagation();
|
|
|
|
if (
|
|
this.autoClose
|
|
&& event.target
|
|
&& event.target instanceof HTMLElement
|
|
&& event.target.role === "menuitem"
|
|
) {
|
|
this.dispatchEvent(new CustomEvent("hide"));
|
|
}
|
|
}
|
|
|
|
@on("window:click")
|
|
onWindowClick(event: PointerEvent): void {
|
|
if (!event.composedPath().includes(this))
|
|
this.dispatchEvent(new CustomEvent("hide"));
|
|
}
|
|
|
|
protected override render = () => html`
|
|
<slot ${ref(this.#slotRef)}></slot>
|
|
`
|
|
}
|