"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 &mdash; `<sts-menu>` and `<sts-dialog>`
&mdash; 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)
This commit is contained in:
Danny McGee
2024-03-15 13:30:06 -04:00
committed by GitHub
parent 17b0e14dc8
commit 03f87b2544
30 changed files with 2004 additions and 118 deletions
+12 -20
View File
@@ -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"
+2 -2
View File
@@ -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"
}
}
+6
View File
@@ -165,3 +165,9 @@ sts-button.active {
}
}
}
// TODO: Move this
.menu-button-icon {
font-size: 12px;
opacity: 0.75;
}
+44 -31
View File
@@ -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<ButtonElement>();
#mediaLibraryRef = createRef<DialogElement>();
#inspectorRef = createRef<HTMLDivElement>();
#hierarchyRef = createRef<SceneHierarchyElement>();
@@ -59,34 +70,15 @@ export class AppElement extends LitElement {
@on("scene-saved")
async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise<void> {
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`
<sts-overlay-provider>
<sts-canvas-provider class="app-root">
<sts-scene-data-provider>
<sts-iframe-messaging-bridge></sts-iframe-messaging-bridge>
@@ -174,6 +167,16 @@ export class AppElement extends LitElement {
>
Save
</sts-button>
<sts-button
${ref(this.#addToSceneButtonRef)}
icon="plus-large"
>
Add to Scene
<sts-icon
class="menu-button-icon"
icon="chevron-down"
></sts-icon>
</sts-button>
` : nothing}
<sts-button
class=${classMap({ active: this.showHelp })}
@@ -187,7 +190,16 @@ export class AppElement extends LitElement {
<!-- Studio-mode-only elements -->
${this.#params.mode === StudioMode.Editor ? html`
<sts-blocking></sts-blocking>
<sts-add-to-scene-menu
.trigger=${this.#addToSceneButtonRef}
@open-media-library=${() => {
this.#mediaLibraryRef.value?.open();
}}
></sts-add-to-scene-menu>
<sts-dialog
${ref(this.#mediaLibraryRef)}
tag="sts-media-library-dialog"
></sts-dialog>
<sts-transform-toolbar class="v-start h-center"></sts-transform-toolbar>
@@ -276,5 +288,6 @@ export class AppElement extends LitElement {
` : nothing}
</sts-scene-data-provider>
</sts-canvas-provider>
</sts-overlay-provider>
`;
}
@@ -1,8 +0,0 @@
:host {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
position: absolute;
top: 60px;
left: 16px;
}
@@ -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`
<sts-button
@click=${() => {
studio.dispatchBlockingCommand(studio.BlockingCommand.Cube);
}}>
Cube
</sts-button>
<sts-button
@click=${() => {
studio.dispatchBlockingCommand(studio.BlockingCommand.Sphere);
}}>
Sphere
</sts-button>
<sts-button
@click=${() => {
studio.dispatchBlockingCommand(studio.BlockingCommand.Cylinder);
}}>
Cylinder
</sts-button>
<sts-button
@click=${() => {
studio.dispatchBlockingCommand(studio.BlockingCommand.Torus);
}}>
Torus
</sts-button>
`;
}
+1 -1
View File
@@ -6,7 +6,7 @@
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill-rule="evenodd" clip-rule="evenodd" d="M256 0C201.996 0 151.917 10.8937 114.452 29.6264C78.4224 47.6412 47 76.9689 47 116.5C47 116.667 47.0006 116.834 47.0017 117H47V394.5C47 434.031 78.4224 463.359 114.452 481.374C151.917 500.106 201.996 511 256 511C310.004 511 360.083 500.106 397.548 481.374C433.578 463.359 465 434.031 465 394.5V117H464.998C464.999 116.834 465 116.667 465 116.5C465 76.9689 433.578 47.6412 397.548 29.6264C360.083 10.8937 310.004 0 256 0ZM95 116.5C95 104.945 104.991 88.0224 135.918 72.5589C165.41 57.8131 207.832 48 256 48C304.168 48 346.59 57.8131 376.082 72.5589C407.009 88.0224 417 104.945 417 116.5C417 128.055 407.009 144.978 376.082 160.441C346.59 175.187 304.168 185 256 185C207.832 185 165.41 175.187 135.918 160.441C104.991 144.978 95 128.055 95 116.5ZM417 192.155C410.79 196.281 404.249 200.023 397.548 203.374C360.083 222.106 310.004 233 256 233C201.996 233 151.917 222.106 114.452 203.374C107.751 200.023 101.21 196.281 95 192.155V394.5C95 406.055 104.991 422.978 135.918 438.441C165.41 453.187 207.832 463 256 463C304.168 463 346.59 453.187 376.082 438.441C407.009 422.978 417 406.055 417 394.5V192.155Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs><style>.fa-secondary{opacity:.4}</style></defs>
<path class="fa-primary" fill-rule="evenodd" clip-rule="evenodd" d="M512 256C512 397.385 397.385 512 256 512C114.615 512 0 397.385 0 256C0 114.615 114.615 0 256 0C397.385 0 512 114.615 512 256ZM186.928 426.524C189.796 436.76 193.011 446.466 196.6 455.395C129.489 435.432 76.5679 382.511 56.6051 315.4C65.5341 318.989 75.24 322.204 85.4764 325.072C109.953 331.93 139.137 337.259 171.326 340.674C174.741 372.863 180.07 402.047 186.928 426.524ZM220.032 344.246C223.188 370.597 227.691 394.094 233.148 413.574C238.705 433.409 244.882 447.719 250.708 456.515C254.094 461.627 256.33 463.434 257.191 463.997C350.905 463.471 429.941 400.971 455.395 315.4C446.466 318.989 436.76 322.204 426.524 325.072C381.892 337.577 321.611 345 256 345C243.805 345 231.793 344.743 220.032 344.246ZM463.997 254.809C463.434 255.67 461.627 257.906 456.515 261.292C447.719 267.118 433.409 273.295 413.574 278.852C374.238 289.873 318.519 297 256 297C242.304 297 228.935 296.658 215.992 296.008C215.342 283.065 215 269.696 215 256C215 193.481 222.127 137.762 233.148 98.426C238.705 78.5909 244.882 64.2806 250.708 55.4854C254.094 50.3726 256.33 48.5664 257.191 48.0033C371.121 48.6421 463.358 140.879 463.997 254.809ZM48.0033 254.809C48.5664 255.67 50.3726 257.906 55.4854 261.292C64.2806 267.118 78.5909 273.295 98.426 278.852C117.906 284.31 141.403 288.812 167.754 291.968C167.256 280.207 167 268.195 167 256C167 190.389 174.423 130.108 186.928 85.4764C189.796 75.24 193.011 65.5341 196.6 56.6051C111.029 82.0589 48.5287 161.095 48.0033 254.809Z"/>
<g class="fa-secondary">
<path d="M315.4 56.6051C296.941 51.1141 277.407 48.1167 257.191 48.0034C256.924 48.1779 256.525 48.4721 256 48.9472C257.167 50.0045 258.956 51.958 261.292 55.4854C267.118 64.2806 273.295 78.591 278.852 98.4261C284.31 117.906 288.812 141.403 291.968 167.754C280.207 167.256 268.195 167 256 167C243.805 167 231.793 167.256 220.032 167.754C218.212 182.958 216.84 199.112 215.992 215.992C228.935 215.342 242.304 215 256 215C269.696 215 283.065 215.342 296.008 215.992C296.658 228.935 297 242.304 297 256C297 269.696 296.658 283.065 296.008 296.008C312.888 295.16 329.042 293.788 344.246 291.968C344.744 280.207 345 268.195 345 256C345 243.805 344.744 231.793 344.246 220.032C370.597 223.188 394.094 227.691 413.574 233.148C433.409 238.705 447.719 244.882 456.515 250.708C460.042 253.044 461.996 254.833 463.053 256C463.528 255.475 463.822 255.076 463.997 254.809C463.883 234.593 460.886 215.059 455.395 196.6C446.466 193.011 436.76 189.796 426.524 186.928C402.047 180.07 372.863 174.741 340.674 171.326C337.259 139.137 331.93 109.953 325.072 85.4764C322.204 75.24 318.989 65.5341 315.4 56.6051Z"/>
<path d="M340.674 340.674C325.08 342.329 308.779 343.534 291.968 344.246C288.812 370.597 284.31 394.094 278.852 413.574C273.295 433.409 267.118 447.719 261.292 456.515C258.956 460.042 257.167 461.996 256 463.053C256.525 463.528 256.924 463.822 257.191 463.997C277.407 463.883 296.941 460.886 315.4 455.395C318.989 446.466 322.204 436.76 325.072 426.524C331.93 402.047 337.259 372.863 340.674 340.674Z"/>
<path d="M167.754 220.032C168.466 203.221 169.671 186.921 171.326 171.326C139.137 174.741 109.953 180.07 85.4764 186.928C75.24 189.796 65.5341 193.011 56.6051 196.6C51.1141 215.059 48.1167 234.593 48.0034 254.809C48.1779 255.076 48.4721 255.475 48.9472 256C50.0045 254.833 51.958 253.044 55.4854 250.708C64.2806 244.882 78.591 238.705 98.4261 233.148C117.906 227.691 141.403 223.188 167.754 220.032Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill-rule="evenodd" clip-rule="evenodd" d="M414.075 374.952C451.433 350.649 464 324.197 464 306V206C464 187.706 451.322 160.985 413.919 136.446C377.543 112.58 322.985 95 256 95C189.015 95 134.457 112.58 98.0807 136.446C60.6776 160.985 48 187.706 48 206V306C48 324.197 60.5666 350.649 97.9251 374.952C134.288 398.608 188.89 416 256 416C323.11 416 377.712 398.608 414.075 374.952ZM256 464C406 464 512 386.5 512 306V206C512 125.5 406 47 256 47C106 47 0 125.5 0 206V306C0 386.5 106 464 256 464Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M322.721 287.56C303.166 297.338 279.509 302 256 302C232.491 302 208.834 297.338 189.279 287.56C172.595 279.218 144 258.812 144 222C144 185.188 172.595 164.782 189.279 156.44C208.834 146.662 232.491 142 256 142C279.509 142 303.166 146.662 322.721 156.44C339.405 164.782 368 185.188 368 222C368 258.812 339.405 279.218 322.721 287.56ZM256 254C291.346 254 320 239.673 320 222C320 204.327 291.346 190 256 190C220.654 190 192 204.327 192 222C192 239.673 220.654 254 256 254Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -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";
+1
View File
@@ -35,6 +35,7 @@
:host(.disabled:hover) {
background: #0003;
color: #FFF6;
cursor: default;
}
.icon {
+10 -4
View File
@@ -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<this>): void {
@@ -35,9 +37,13 @@ export class ButtonElement extends LitElement {
}
protected override render = () => html`
${this.icon
? html`<sts-icon icon=${this.icon} aria-hidden="true"></sts-icon>`
: nothing}
${this.icon ? html`
<sts-icon
part="icon"
icon=${this.icon}
aria-hidden="true"
></sts-icon>
` : nothing}
<slot></slot>
`
}
+191
View File
@@ -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`
* <header>
* <h4>My Dialog</h4>
* <sts-icon
* icon="xmark"
* \@click=${() => this.dispatchEvent(new CustomEvent("close"))}
* ></sts-icon>
* </header>
* <section>
* <p>Hello, world!</p>
* </section>
* `
* }
* ```
* ### Trigger-based implementation
* ```
* \@customElement("my-app")
* class MyApp extends LitElement {
* dialogTriggerRef = createRef<ButtonElement>();
*
* render = () => html`
* <sts-overlay-provider>
* <sts-button ${ref(this.dialogTriggerRef)}>
* Open Dialog
* </sts-button>
*
* <sts-dialog
* tag="my-dialog"
* .trigger=${this.dialogTriggerRef}
* ></sts-dialog>
* </sts-overlay-provider>
* `
* }
* ```
* ### Manual-open implementation
* ```
* \@customElement("my-app")
* class MyApp extends LitElement {
* dialogRef = createRef<DialogElement>();
*
* render = () => html`
* <sts-overlay-provider>
* <sts-button
* \@click=${() => {
* this.dialogRef.value?.open();
* }}
* >
* Open Dialog
* </sts-button>
*
* <sts-dialog
* ${ref(this.dialogRef)}
* tag="my-dialog"
* ></sts-dialog>
* </sts-overlay-provider>
* `
* }
* ```
*/
@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<HTMLElement>();
@inject(OVERLAY_PROVIDER)
_overlay!: OverlayProvider;
#overlayHandle?: WeakRef<OverlayHandle>;
#dialogRef = createRef<DialogBaseElement>();
open(): void {
this.#overlayHandle = this._overlay.attach(this, html`
<${unsafeStatic(this.tag)}
${ref(this.#dialogRef)}
role=${ifDefined(this.dialogRole)}
@close=${() => this.close()}
></${unsafeStatic(this.tag)}>
`, {
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<this>): 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"));
}
}
@@ -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;
}
}
+23 -6
View File
@@ -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`
<sts-icon part="icon-pre" .icon=${this.iconPre}></sts-icon>
` : nothing}
<input
class="input"
type="text"
.type=${this.fieldType}
placeholder=${ifDefined(this.placeholder)}
.value=${this.value}
@input=${this.#onInput}
/>
${this.iconPost ? html`
<sts-icon part="icon-post" .icon=${this.iconPost}></sts-icon>
` : nothing}
`;
}
+31 -2
View File
@@ -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(.+?)>(.+?)<\/svg>/)!;
const [, attrsString, body] = svgSrc.match(/<svg(.+?)>(.+?)<\/svg>/s)!;
const attrs = Object.fromEntries(
Array.from(attrsString.matchAll(/([-:_a-zA-Z0-9]+)(?:=['"]?([^'"]*)['"]?)/g))
.map(([, attr, value]) => [attr, value])
+35
View File
@@ -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;
}
+213
View File
@@ -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 `<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>
`
}
@@ -0,0 +1,4 @@
.overlay-stack {
isolation: isolate;
z-index: 99999;
}
@@ -0,0 +1,262 @@
import { UniqueToken, provide } from "@storyteller/framework";
import { LitElement, TemplateResult, html, render, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import { StyleInfo, styleMap } from "lit/directives/style-map.js";
import styles from "./overlay-provider.element.scss?inline";
export const OVERLAY_PROVIDER = UniqueToken.create<OverlayProvider>();
/**
* A low-level utility for rendering "overlay" elements to the DOM. Overlays are
* rendered in a separate [stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context)
* which sits "on top" of the rest of the DOM, which is useful for creating
* things like modals and pop-up menus.
*
* To use this utility, first ensure that your entire application is wrapped
* with an `<sts-overlay-provider>` element, then inject the
* {@linkcode OVERLAY_PROVIDER} token to access this interface.
*
* @example
* ```
* class MyCustomOverlay extends LitElement {
* \@inject(OVERLAY_PROVIDER)
* _overlay?: OverlayProvider;
*
* #handle?: WeakRef<OverlayHandle>;
*
* openOverlay(): void {
* this.#handle = this._overlay.attach(this, html`
* <div>
* <p>This is a custom overlay!</p>
* <sts-button \@click=${this.closeOverlay}>
* Close Overlay
* </sts-button>
* </div>
* `);
* }
*
* closeOverlay(): void {
* if (this.#handle)
* this._overlay.detach(this.#handle);
*
* this.#handle = undefined;
* }
*
* render = () => html`
* <sts-button \@click=${this.openOverlay}>
* Open Overlay
* </sts-button>
* `
* }
* ```
*/
export interface OverlayProvider {
/**
* Attach a template to the overlay stack.
*
* @param host
* This is the instance that will be the receiver (i.e. the `this` argument)
* for any event-handlers that are bound in the provided `template`.
*
* @param template
* The Lit template to render as an overlay, usually created with Lit's
* `html` tagged template function.
*
* @param options
* Additional options to customize the overlay. See
* {@linkcode AttachOptions} for details.
*/
attach(
host: LitElement,
template: TemplateResult,
options?: AttachOptions,
): WeakRef<OverlayHandle>;
/**
* Detach a currently-attached overlay from the DOM.
*
* After invoking this method, the provided `handle` will no longer be valid
* and should be discarded by the caller.
*/
detach(handle: WeakRef<OverlayHandle>): void;
/** Re-render a currently-attached overlay. */
reRender(handle: WeakRef<OverlayHandle>): void;
}
export interface AttachOptions {
/** Custom styles for the container that will wrap the overlay template. */
containerStyles?: Readonly<StyleInfo>;
}
/** An opaque handle to an attached overlay template. */
export interface OverlayHandle {
readonly id: number;
}
/**
* @internal
*
* A simple "handle" to an attached overlay instance. The `id` property tracks
* the current index of its associated parts in the `OverlayProviderElement`'s
* set of arrays.
*
* When entries are inserted, removed, or moved within the arrays, these handles
* must be updated with the correct indices by calling `_updateId`. This ensures
* that existing `OverlayHandle`s that have been passed out to consumers will
* remain valid for looking up their associated templates, overlay containers,
* etc.
*/
class OverlayHandleInternal implements OverlayHandle {
#id: number;
get id() { return this.#id; }
constructor (id: number) {
this.#id = id;
}
_updateId(id: number): void {
this.#id = id;
}
}
@customElement("sts-overlay-provider")
@provide(OVERLAY_PROVIDER)
export class OverlayProviderElement
extends LitElement
implements OverlayProvider
{
static override styles = unsafeCSS(styles);
/**
* Handles for each currently attached overlay instance. The `id` of these
* handles must always match their index within this array.
*/
#handles: OverlayHandleInternal[] = [];
/**
* Refs for the container elements of each currently attached overlay
* instance. The order and length of entries in this array must be kept in
* sync with their corresponding `#handles`.
*/
#containerRefs: Ref<HTMLElement>[] = [];
/**
* The Lit templates for the container elements of each currently attached
* overlay instance. The order and length of entries in this array must be
* kept in sync with their corresponding `#handles`.
*/
#containerTemplates: TemplateResult[] = [];
/**
* The Lit templates (and their hosts/event receivers) for each currently
* attached overlay instance. The order and length of entries in this array
* must be kept in sync with their corresponding `#handles`.
*/
#templates: [LitElement, TemplateResult][] = [];
attach(
host: LitElement,
template: TemplateResult,
options: AttachOptions = {},
): WeakRef<OverlayHandle> {
// Create and append a new handle for the overlay
const index = this.#handles.length;
const handle = new OverlayHandleInternal(index);
this.#handles.push(handle);
// Create and append the new container ref and template
const containerStyles = options.containerStyles ?? {};
this.#containerRefs.push(createRef());
this.#containerTemplates.push(html`
<div
${ref(this.#containerRefs[index])}
slot="overlay"
style=${styleMap({
position: "fixed",
inset: 0,
...containerStyles,
})}
></div>
`);
// Append the provided host and template
this.#templates.push([host, template]);
// (Re-)render the overlay stack and return the handle to the consumer
this.#updateLightDom();
return new WeakRef(handle);
}
detach(handle: WeakRef<OverlayHandle>): void {
// Find the index of the overlay to remove
const index = handle.deref()?.id;
if (index == null || index === -1) return;
// Remove the corresponding entry from each array
const [removedHandle] = this.#handles.splice(index, 1);
this.#containerRefs.splice(index, 1);
this.#containerTemplates.splice(index, 1);
this.#templates.splice(index, 1);
// Update the handle IDs to match their new indices
removedHandle._updateId(-1);
for (let i = index; i < this.#handles.length; ++i)
this.#handles[i]._updateId(i);
// Rerender the overlay stack
this.#updateLightDom();
}
// TODO: I'm not actually sure whether this serves a necessary function.
// Needs further testing.
reRender(handle: WeakRef<OverlayHandle>): void {
const index = handle.deref()?.id;
if (index == null || index === -1)
return;
const container = this.#containerRefs[index]?.value;
if (!container) return;
const tuple = this.#templates[index];
if (!tuple) return;
const [host, template] = tuple;
render(template, container, { host });
}
#updateLightDom(): void {
// First, render the set of container templates to this element's "light
// DOM" container (i.e. the publicly-accessible portion of the DOM which
// sits outside of the `#shadow-root`).
//
// Because each container template bears a `slot="overlay"` attribute,
// they should be automatically assigned to our shadow root's
// `slot[name="overlay"]`, which will position the containers in a
// separate stacking context above the other DOM nodes passed into the
// `<sts-overlay-provider>`.
//
// After this step (and _only_ after this step), the corresponding refs in
// the `#containerRefs` array should have valid, non-null `value`s.
render(this.#containerTemplates, this, { host: this });
for (let i = 0; i < this.#handles.length; ++i) {
// Render each overlay template into its corresponding container
const container = this.#containerRefs[i].value;
if (!container) throw new Error("Attempted to retrieve an invalid container ref!");
const [host, template] = this.#templates[i];
render(template, container, { host });
}
}
protected override render = () => html`
<slot></slot>
<div class="overlay-stack">
<slot name="overlay"></slot>
</div>
`;
}
+4
View File
@@ -1,8 +1,12 @@
export * from "./lib/storyteller-api";
export * from "./lib/add-to-scene-menu.element";
export * from "./lib/canvas-provider.element";
export * from "./lib/iframe-messaging-bridge.element";
export * from "./lib/inspector/entity-inspector.element";
export * from "./lib/inspector/inspector.types";
export * from "./lib/loading.element";
export * from "./lib/media-library-dialog.element";
export * from "./lib/scene-data-provider.element";
export * from "./lib/scene-hierarchy.element";
export * from "./lib/studio.element";
@@ -0,0 +1,65 @@
import * as studio from "@storyteller/studio";
import { BlockingCommand } from "@storyteller/studio";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef } from "lit/directives/ref.js"
import "@storyteller/studio-ui/button";
import "@storyteller/studio-ui/menu";
@customElement("sts-add-to-scene-menu")
export class AddToSceneMenuElement extends LitElement {
@property({ attribute: false })
trigger = createRef<HTMLElement>();
protected override render = () => html`
<sts-menu autoClose .trigger=${this.trigger}>
<sts-button
role="menuitem"
icon="cube"
@click=${() => {
studio.dispatchBlockingCommand(BlockingCommand.Cube);
}}
>
Cube
</sts-button>
<sts-button
role="menuitem"
icon="sphere"
@click=${() => {
studio.dispatchBlockingCommand(BlockingCommand.Sphere);
}}
>
Sphere
</sts-button>
<sts-button
role="menuitem"
icon="torus"
@click=${() => {
studio.dispatchBlockingCommand(BlockingCommand.Torus);
}}
>
Torus
</sts-button>
<sts-button
role="menuitem"
icon="cylinder"
@click=${() => {
studio.dispatchBlockingCommand(BlockingCommand.Cylinder);
}}
>
Cylinder
</sts-button>
<hr />
<sts-button
role="menuitem"
icon="photo-film-music"
@click=${() => {
this.dispatchEvent(new CustomEvent("open-media-library"));
}}
>
Media library...
</sts-button>
</sts-menu>
`;
}
@@ -0,0 +1,266 @@
@use "sass:math";
*, *::before, *::after {
font-family: "Fira Sans", sans-serif;
box-sizing: border-box;
}
:host {
display: flex;
flex-direction: column;
width: 1024px;
height: 640px;
max-width: 80%;
max-height: 80%;
background: #0006;
color: #FFFC;
backdrop-filter: blur(32px);
border-radius: 0.5rem;
overflow: hidden;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem;
padding: 0 {
inline-start: 1rem;
}
background: #0006;
}
.title {
display: flex;
align-items: center;
gap: math.div(12, 16) * 1rem;
sts-icon {
color: #FFFC;
opacity: 0.75;
}
h3 {
color: #FFFC;
font: {
weight: 500;
size: math.div(15, 16) * 1rem;
}
}
}
.close {
width: 3rem;
height: 3rem;
font-size: 1rem;
color: #FFFC;
opacity: 0.75;
&:hover {
color: #FFFF;
opacity: 1;
}
}
.main {
display: flex;
flex: 1 0 0;
max-height: calc(80vh - 3rem);
}
.sidebar {
flex: 0 0 256px;
padding: 1rem;
background: #0004;
&__list {
list-style: none;
list-style-type: none;
margin: 0;
padding: 0;
}
&__listitem {
margin: 0;
padding: 0;
sts-button {
width: 100%;
background: transparent;
backdrop-filter: none;
&:not(.disabled):hover {
background: #0003;
}
&.active, &.active:hover {
background: #FFF2;
}
}
}
}
.content {
display: flex;
flex-direction: column;
flex: 0 0 auto;
width: calc(100% - 256px);
gap: 1rem;
border: {
inline-end: 0.5rem solid #0004;
block-end: 0.5rem solid #0004;
}
padding: 1rem;
position: relative;
&__ui {
display: flex;
gap: 0.5rem;
.search-field {
border: none;
border-radius: 0.5rem;
&::part(icon-pre) {
font-size: math.div(18, 16) * 1rem;
}
}
}
&__results {
flex: 1 0 0;
padding-block-end: 3rem;
&--centered {
display: flex;
flex: 1 0 0;
align-items: center;
justify-content: center;
}
&--grid {
display: grid;
grid-template: {
columns: 1fr 1fr 1fr;
rows: min-content;
}
gap: 1rem;
overflow-y: auto;
}
&::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
&-thumb {
border-radius: 2px;
background: #FFF2;
&:hover {
background: #FFF3;
}
}
&-corner {
background: transparent;
}
}
}
&__media-file {
display: flex;
flex-direction: column;
justify-content: flex-end;
height: math.div(174, 16) * 1rem;
padding: 1rem;
border-radius: 0.5rem;
overflow: hidden;
background: #FFF1;
&.selected {
border: 2px solid #E66462;
padding: calc(1rem - 2px);
}
.last-updated,
.name {
line-height: 1.5;
user-select: none;
}
.last-updated {
font: {
size: 0.75rem;
weight: 400;
}
color: #FFFC;
}
.name {
margin: 0;
max-width: 100%;
overflow: hidden;
color: #FFFF;
font: {
size: 1rem;
weight: 500;
}
white-space: nowrap;
text-overflow: ellipsis;
}
}
&__add-to-scene {
position: absolute;
bottom: 1rem;
right: 1rem;
}
}
.error, .placeholder {
display: flex;
flex-direction: column;
align-items: center;
aling-self: center;
.heading, p {
text-align: center;
}
.heading {
margin: 0;
}
p {
margin: 1em 0;
font-size: 1.25rem;
line-height: 1.5;
}
}
.btn-primary {
backdrop-filter: none;
&:not(.disabled) {
background: #E66462;
color: #FFFF;
&:hover {
background: #EA7A78;
color: #FFFF;
}
}
}
.btn-secondary {
backdrop-filter: none;
&:not(:disabled) {
background: #44445B;
&:hover {
background: #39394C;
}
}
}
@@ -0,0 +1,348 @@
import { bind, inject } from "@storyteller/framework";
import { DialogBaseElement } from "@storyteller/studio-ui/dialog";
import {
type MediaFile as IMediaFile,
MediaFileClass,
MediaFileSubtype,
MediaFileToken,
MediaFileType,
StorytellerApi,
Visibility,
} from "@storyteller/studio-web";
import { match, type Pred } from "@storyteller/utility";
import {
html,
nothing,
type PropertyValues,
TemplateResult,
unsafeCSS,
} from "lit";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import "@storyteller/studio-ui/button";
import "@storyteller/studio-ui/icon";
import "@storyteller/studio-ui/forms/text-field";
import styles from "./media-library-dialog.element.scss?inline";
@customElement("sts-media-library-dialog")
export class MediaLibraryDialogElement extends DialogBaseElement {
static override styles = unsafeCSS(styles);
@state() _mediaFiles?: MediaFile[];
@state() _searchTerm = "";
@state() _selected: MediaFileToken[] = [];
@state() _activeCategory: MediaFileCategory = MEDIA_FILE_CATEGORIES[0];
@state() _filters: Pred<MediaFile>[] = [];
@state() _apiError?: Error;
@inject(StorytellerApi)
_api!: StorytellerApi;
#dateFmt = new Intl.DateTimeFormat("en-us");
get #mediaFiles() {
return this._mediaFiles?.filter(it =>
this._filters.every(pred => pred(it))
);
}
get #resultsStatus() {
if (this._apiError)
return ResultsStatus.Error;
if (!this.#mediaFiles)
return ResultsStatus.Loading;
if (!this.#mediaFiles.length)
return ResultsStatus.Empty;
return ResultsStatus.NonEmpty;
}
override connectedCallback(): void {
this.loadResults();
super.connectedCallback();
}
protected override willUpdate(changes: PropertyValues<this>): void {
if (changes.has("_activeCategory") || changes.has("_searchTerm")) {
this._filters = [
this._activeCategory.predicate,
it => it.displayName.includes(this._searchTerm),
];
}
super.willUpdate(changes);
}
async loadResults(): Promise<void> {
this._mediaFiles = undefined;
this._apiError = undefined;
try {
const session = await this._api.session();
if (!session.logged_in || !session.user?.username)
throw new Error("Not logged in");
const response = await this._api.listMediaFiles({
username: session.user.username,
filterMediaType: [
MediaFileType.Bvh,
MediaFileType.Glb,
MediaFileType.Gltf,
MediaFileType.ScnRon,
],
});
const results = await Promise.all(
response.results.map(({ token }) => this._api.mediaFile(token))
);
this._mediaFiles = results.map(info => new MediaFile(info));
}
catch (err) {
if (typeof err === "object" && err !== null && err instanceof Error) {
this._apiError = err;
} else {
this._apiError = new Error(String(err));
}
}
}
onSelect(token: MediaFileToken) {
return (event: PointerEvent) => {
event.stopPropagation();
if (event.getModifierState("Control") || event.getModifierState("Shift"))
this.toggleSelected(token);
else
this.select(token);
}
}
select(token: MediaFileToken): void {
this._selected = [token];
}
toggleSelected(token: MediaFileToken): void {
if (this._selected.includes(token))
this._selected = this._selected.filter(it => it !== token);
else
this._selected = this._selected.concat(token);
}
clearSelection(): void {
this._selected = [];
}
protected override render = (): TemplateResult => html`
<header part="header">
<div class="title">
<sts-icon icon="photo-film-music"></sts-icon>
<h3>Media Library</h3>
</div>
${/*
Note: "Close" button is intentionally hidden from the a11y tree
because because the Esc key is the ARIA-standard interaction for
closing a dialog.
*/ nothing}
<sts-icon
class="close"
icon="xmark"
@click=${() => this.dispatchEvent(new CustomEvent("close"))}
></sts-icon>
</header>
<section class="main">
<nav class="sidebar">
<ul class="sidebar__list">
${MEDIA_FILE_CATEGORIES.map(cat => html`
<li class="sidebar__listitem">
<sts-button
class=${classMap({
active: cat === this._activeCategory,
})}
.icon=${cat.icon}
title=${ifDefined(cat.tooltip)}
?disabled=${cat.disabled}
@click=${() => {
if (!cat.disabled)
this._activeCategory = cat;
}}
>
${cat.name}
</sts-button>
</li>
`)}
</ul>
</nav>
<section class="content">
<div class="content__ui">
<sts-button
class="btn-secondary"
icon="import"
>
Import...
</sts-button>
<sts-text-field
class="search-field"
type="search"
placeholder="Search"
icon-pre="search"
.value=${bind(this, "_searchTerm")}
></sts-text-field>
</div>
<div
class=${classMap({
"content__results": true,
"content__results--centered": this.#resultsStatus !== ResultsStatus.NonEmpty,
"content__results--grid": this.#resultsStatus === ResultsStatus.NonEmpty,
})}
@click=${this.clearSelection}
>
${match (this.#resultsStatus, {
[ResultsStatus.Loading]: () => html`
<span>Loading...</span>
`,
[ResultsStatus.Empty]: () => html`
<div class="placeholder">
<h2 class="heading">No results found</h2>
<p>Import a new asset to get started!</p>
<sts-button
class="btn-secondary"
icon="import"
>
Import...
</sts-button>
</div>
`,
[ResultsStatus.Error]: () => html`
<div class="error">
<h2 class="heading">Error</h2>
<p>${this._apiError!.message}</p>
<sts-button
class="btn-secondary"
icon="rotate"
@click=${this.loadResults}
>
Retry
</sts-button>
</div>
`,
[ResultsStatus.NonEmpty]: () => this.#mediaFiles!.map(file => html`
<div
class=${classMap({
"content__media-file": true,
"selected": this._selected.includes(file.token),
})}
@click=${this.onSelect(file.token)}
tabindex="0"
>
<span class="last-updated">
${this.#dateFmt.format(file.updated)}
</span>
<h4 class="name">
${file.displayName}
</h4>
</div>
`),
})}
</div>
${this._selected.length > 0 ? html`
<sts-button
class="btn-primary content__add-to-scene"
icon="plus-large"
>
Add to Scene
</sts-button>
` : nothing}
</section>
</section>
`;
}
interface MediaFileCategory {
name: string;
icon: string;
disabled?: boolean;
tooltip?: string;
predicate: Pred<MediaFile>;
}
const MEDIA_FILE_CATEGORIES: readonly MediaFileCategory[] = [
{
name: "Home",
icon: "home",
predicate: () => true,
},
{
name: "Animation",
icon: "film",
predicate: it => it.mediaClass === MediaFileClass.Animation,
},
{
name: "Audio",
icon: "volume",
disabled: true,
tooltip: "Coming soon!",
predicate: it => it.mediaClass === MediaFileClass.Audio,
},
{
name: "Characters",
icon: "figure",
predicate: it => it.mediaClass === MediaFileClass.Character,
},
{
name: "Props",
icon: "cube",
predicate: it => it.mediaClass === MediaFileClass.Prop,
},
{
name: "Scenes",
icon: "scene",
predicate: it => it.mediaClass === MediaFileClass.Scene,
},
];
enum ResultsStatus {
Loading,
Empty,
NonEmpty,
Error,
}
class MediaFile {
token: MediaFileToken;
type: MediaFileType;
subtype?: MediaFileSubtype;
mediaClass: MediaFileClass;
title?: string;
filename?: string;
visibility: Visibility;
bucketPath: string;
created: Date;
updated: Date;
get displayName(): string {
return this.title ?? this.filename ?? this.token;
}
constructor (info: IMediaFile) {
this.token = info.token;
this.type = info.media_type;
this.subtype = info.maybe_media_subtype ?? undefined;
this.mediaClass = info.media_class;
this.title = info.maybe_title ?? undefined;
this.filename = info.maybe_original_filename ?? undefined;
this.visibility = info.creator_set_visibility;
this.bucketPath = info.public_bucket_path;
this.created = new Date(info.created_at);
this.updated = new Date(info.updated_at);
}
}
+436
View File
@@ -0,0 +1,436 @@
import axios, { AxiosResponse } from "axios";
import { v4 as uuid } from "uuid";
export class StorytellerApi {
#api = axios.create({
baseURL: "https://api.fakeyou.com",
withCredentials: true,
});
async createScene(file: File): Promise<MediaFileToken> {
const response = await this.#api.postForm<MediaUploadResponse>(
"v1/engine/create_scene",
{
uuid_idempotency_token: uuid(),
file,
}, {
headers: { Accept: "text/plain" },
},
);
this.#validateResponse(response);
return response.data.media_file_token;
}
async session(): Promise<Session> {
const response = await this.#api.get<Session>("v1/session");
this.#validateResponse(response);
return response.data;
}
async listMediaFiles(options: ListMediaFilesOptions = {}): Promise<ListMediaFilesResult> {
const params = {} as any;
if (options.filterMediaType)
params.filter_media_type = options.filterMediaType.join(",");
if (options.filterMediaClasses)
params.filter_media_classes = options.filterMediaClasses.join(",");
if (options.sortAscending != null)
params.soer_ascending = options.sortAscending;
if (options.pageSize != null)
params.page_size = options.pageSize;
if (options.pageIndex != null)
params.page_index = options.pageIndex;
const endpoint = options.username
? `v1/media_files/list/user/${options.username}`
: "v1/media_files/list";
const response = await this.#api.get<ListMediaFilesResult>(endpoint, { params });
this.#validateResponse(response);
return response.data;
}
async uploadMediaFile(
file: File,
{ subtype, mediaClass }: UploadMediaFileOptions,
): Promise<MediaFileToken> {
const response = await this.#api.postForm<MediaUploadResponse>(
"v1/media_files/upload/engine_asset",
{
uuid_idempotency_token: uuid(),
file,
media_file_subtype: subtype,
media_file_class: mediaClass,
},
{
headers: { Accept: file.type },
},
);
this.#validateResponse(response);
return response.data.media_file_token;
}
async mediaFile(token: MediaFileToken): Promise<MediaFile> {
const response = await this.#api.get<MediaFileResult>(`v1/media_files/file/${token}`);
this.#validateResponse(response);
return response.data.media_file;
}
#validateResponse<R extends AxiosResponse>(response: R): asserts response is OkResponse<R> {
if (response.status !== 200)
throw new Error(`API responded with status ${response.status}: "${response.statusText}"`);
if (
response.data
&& "success" in response.data
&& response.data.success === false
) {
throw new ApiError(response);
}
}
}
export class ApiError<T extends AxiosResponse = AxiosResponse> extends Error {
detail: T;
constructor (detail: T) {
super("API Error");
this.detail = detail;
}
}
type OkResponse<T extends AxiosResponse> = T & { status: 200 }
export interface ListMediaFilesOptions {
username?: string;
filterMediaType?: MediaFileType[];
filterMediaClasses?: MediaFileClass[];
pageSize?: number;
pageIndex?: number;
sortAscending?: boolean;
}
export interface UploadMediaFileOptions {
subtype: MediaFileSubtype;
mediaClass: MediaFileClass;
}
export interface ListMediaFilesResult {
pagination: PaginationPage;
results: MediaFileForUserListItem[];
success: boolean;
}
export interface MediaUploadResponse {
success: boolean;
media_file_token: MediaFileToken;
}
/**
* Pagination by page id.
*
* This type of pagination is by "page id" and "page count". This should never
* be used to walk the entire public database as competitors and investors can
* use it to glean information about the scale of our service. This is best used
* for user profiles and scoped down results.
*/
export interface PaginationPage {
current: number;
total_page_count: number;
}
export interface MediaFileResult {
success: boolean;
media_file: MediaFile;
}
export interface MediaFile {
token: MediaFileToken;
media_type: MediaFileType;
maybe_media_subtype?: MediaFileSubtype;
media_class: MediaFileClass;
maybe_batch_token?: unknown;
public_bucket_path: string;
maybe_model_weight_info?: unknown;
maybe_creator_user?: UserDetailsLight;
creator_set_visibility: Visibility;
maybe_text_transcript?: string;
maybe_prompt_token?: unknown;
maybe_title?: string;
maybe_original_filename?: string;
is_emulated_media_file: boolean;
stats: Stats;
/** Date-time string */
created_at: string;
/** Date-time string */
updated_at: string;
}
export interface MediaFileListItem {
/** Date-time string */
created_at: string;
creator_set_visibility: Visibility;
maybe_creator?: UserDetailsLight;
/** @deprecated */
maybe_origin_model_token?: string;
maybe_origin_model_type?: MediaFileOriginModelType;
/** Text transcripts for TTS, etc. */
maybe_text_transcript?: string;
media_type: MediaFileType;
maybe_media_subtype?: MediaFileSubtype;
origin: MediaFileOriginDetails;
origin_category: MediaFileOriginCategory;
origin_product_category: MediaFileOriginProductCategory;
/** URL to the media file. */
public_bucket_path: string;
/** The primary key for Media Files */
token: MediaFileToken;
/** Date-time string */
updated_at: string;
}
export interface Stats {
positive_rating_count: number;
bookmark_count: number;
}
export type MediaFileForUserListItem = Omit<MediaFileListItem, "maybe_creator">;
export enum Visibility {
Public = "public",
Hidden = "hidden",
Private = "private",
}
/**
* Used in the `media_files` table in a `VARCHAR` field.
*
* DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
*/
export enum MediaFileOriginModelType {
RvcV2 = "rvc_v2",
SadTalker = "sad_talker",
SoVitsSvc = "so_vits_svc",
Tacotron2 = "tacotron2",
VallEX = "vall_e_x",
Rerender = "rerender",
MocapNet = "mocap_net",
ComfyUi = "comfy_ui",
StyleTts2 = "styletts2",
StableDiffusion_1_5 = "stable_diffusion_1_5",
}
/**
* Used in the `media_files` table in a `VARCHAR(16)` field.
*
* DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
*/
export enum MediaFileType {
Audio = "audio",
Image = "image",
Video = "video",
Mocap = "mocap",
Bvh = "bvh",
Fbx = "fbx",
Glb = "glb",
Gltf = "gltf",
ScnRon = "scene_ron",
}
export enum MediaFileSubtype {
/**
* Animation file from Mixamo.
* Primarily used for FBX and GLB.
*/
Mixamo = "mixamo",
/**
* Animation file from MocapNet.
* Primarily used for BVH.
*/
MocapNet = "mocap_net",
/**
* Generic animation case.
* Used for BVH files, but can also pertain to animation-only files of other
* types.
*/
AnimationOnly = "animation_only",
/**
* @deprecated Use `SceneImport` instead.
*/
Scene = "scene",
/**
* Generic 3D scene file.
* Can pertain to glTF, glB, FBX, etc.
*/
SceneImport = "scene_import",
/**
* Native Storyteller scene format.
* Typically stored in a `.scn.ron` file.
*/
StorytellerScene = "storyteller_scene",
}
/**
* Used in the `media_files` table in a `VARCHAR(16)` field.
*
* DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
*/
export enum MediaFileClass {
/** This will be present until we migrate all old files. */
Unknown = "unknown",
/** Audio files: wav, mp3, etc. */
Audio = "audio",
/** Image files: png, jpeg, etc. */
Image = "image",
/** Video files: mp4, etc. */
Video = "video",
/** Engine "animations" */
Animation = "animation",
/** Engine "characters" */
Character = "character",
/** Engine "prop" items */
Prop = "prop",
/** Engine scenes (internal and external scenes) */
Scene = "scene",
}
/** Fields useful for enriching media file listings */
export interface MediaFileOriginDetails {
maybe_model?: Model;
origin_category: MediaFileOriginCategory;
product_category: MediaFileOriginProductCategory;
}
export interface Model {
model_type: MediaFileOriginModelType;
/**
* The model title (typically only populated for model_weights models, not
* legacy tables such as tts_models.)
*/
title?: string;
/** The primary key for the "model_weights" table. */
token?: string;
}
/**
* Used in the `media_files` table in a `VARCHAR` field.
*
* DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
*/
export enum MediaFileOriginCategory {
Inference = "inference",
Processed = "processed",
Upload = "upload",
DeviceApi = "device_api",
StoryEngine = "story_engine",
}
/**
* Used in the `media_files` table in `VARCHAR(16)` field
* `origin_product_category`.
*
* This value indicates what product originally created the media file. (Not the
* ML model or user upload process.) This will let us scope media files to the
* product that generated them and filter them out of unrelated products if
* necessary (eg. a user probably doesn't want "Voice Designer" dataset samples
* in a video generation flow.)
*
* DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
*/
export enum MediaFileOriginProductCategory {
Unknown = "unknown",
FaceAnimator = "face_animator",
Tts = "tts",
VoiceConversion = "voice_conversion",
ZsVoice = "zs_voice",
VideoFilter = "video_filter",
Mocap = "mocap",
ImageGen = "image_gen",
Workflow = "workflow",
}
export type MediaFileToken = string;
export interface Session {
logged_in: boolean;
success: boolean;
user?: {
can_access_studio: boolean;
can_approve_w21_templates: boolean;
can_ban_users: boolean;
can_delete_other_users_tts_models: boolean;
can_delete_other_users_tts_results: boolean;
can_delete_other_users_w2l_results: boolean;
can_delete_other_users_w2l_templates: boolean;
can_delete_own_account: boolean;
can_delete_own_tts_models: boolean;
can_delete_own_tts_results: boolean;
can_delete_own_w2l_results: boolean;
can_delete_own_w2l_templates: boolean;
can_delete_users: boolean;
can_edit_other_users_profiles: boolean;
can_edit_other_users_tts_models: boolean;
can_edit_other_users_w2l_templates: boolean;
can_upload_tts_models: boolean;
can_upload_w2l_templates: boolean;
can_use_tts: boolean;
can_use_w2l: boolean;
core_info: UserDetailsLight;
display_name: string;
email_gravatar_hash: string;
fakeyou_plan: FakeYouPlan;
maybe_feature_flags: unknown;
storyteller_stream_plan: StorytellerStreamPlan;
user_token: string;
username: string;
};
}
/**
* Everything we need to refer to a user on the public web interface.
*/
export interface UserDetailsLight {
default_avatar: DefaultAvatarInfo;
/**
* As of 2023-08-23, this is the username with capitalization (In the future,
* a display name can be customized by the user.)
*/
display_name: string;
/**
* Email hash for Gravatar Always set for now since login is email/username +
* password. In the future this will need to become an optional OR be filled
* with a bogus hash.
*/
gravatar_hash: string;
/** The primary key for users. */
user_token: string;
/**
* The unique username someone logs in with. As of 2023-08-23, this is always
* lowercase.
*/
username: string;
}
export interface DefaultAvatarInfo {
color_index: number;
image_index: number;
}
export enum FakeYouPlan {
Free = "free",
Basic = "basic",
Standard = "standard",
Pro = "pro",
}
export enum StorytellerStreamPlan {
Free = "free",
Basic = "basic",
Standard = "standard",
Pro = "pro",
}
+6 -1
View File
@@ -10,7 +10,7 @@
"importHelpers": true,
"target": "es2015",
"module": "esnext",
"lib": ["es2020", "dom"],
"lib": ["es2021", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": ".",
@@ -19,16 +19,21 @@
"@storyteller/studio": ["dist/studio"],
"@storyteller/studio-ui": ["studio-ui/src/index.ts"],
"@storyteller/studio-ui/button": ["studio-ui/src/lib/button.element.ts"],
"@storyteller/studio-ui/dialog": ["studio-ui/src/lib/dialog.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"],
"@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"],
"@storyteller/studio-web/entity-inspector": ["studio-web/src/lib/inspector/entity-inspector.element.ts"],
"@storyteller/studio-web/iframe-messaging-bridge": ["studio-web/src/lib/iframe-messaging-bridge.element.ts"],
"@storyteller/studio-web/loading": ["studio-web/src/lib/loading.element.ts"],
"@storyteller/studio-web/media-library-dialog": ["studio-web/src/lib/media-library-dialog.element.ts"],
"@storyteller/studio-web/scene-data-provider": ["studio-web/src/lib/scene-data-provider.element.ts"],
"@storyteller/studio-web/scene-hierarchy": ["studio-web/src/lib/scene-hierarchy.element.ts"],
"@storyteller/studio-web/studio": ["studio-web/src/lib/studio.element.ts"],
+1
View File
@@ -1,3 +1,4 @@
export * from "./lib/assert";
export * from "./lib/exists";
export * from "./lib/match";
export * from "./lib/types";
+8
View File
@@ -0,0 +1,8 @@
export function assert(expression: unknown, message?: string): asserts expression {
if (!expression) {
let err = "Assertion failed";
if (message) err += `: ${message}`;
throw new Error(err);
}
}
+3
View File
@@ -16,6 +16,9 @@ export interface Method<T, Args extends any[] = any[], R = any> {
(this: T, ...args: Args): R;
}
export type Pred<T> = Fn<[T], boolean>;
export type IndexedPred<T> = Fn<[T, number], boolean>;
export type Opt<T> = T | null | undefined;
export type WithOpt<T, K extends keyof T>