UI infrastructure and simple "Help" dialog (#3)

Sets up some WIP infrastructure for building out the UI, and adds a simple popup
to show the navigation controls for the app.
This commit is contained in:
Danny McGee
2023-10-09 22:19:58 -04:00
committed by GitHub
parent 57d64f08f0
commit c84fd31387
40 changed files with 1608 additions and 590 deletions
+3
View File
@@ -1,3 +1,6 @@
{
"rust-analyzer.showUnlinkedFileNotification": false,
"explorer.fileNesting.patterns": {
"*.element.ts": "${capture}.element.scss, ${capture}.element.spec.ts",
},
}
+11
View File
@@ -0,0 +1,11 @@
# framework
This library was generated with [Nx](https://nx.dev).
## Building
Run `nx build framework` to build the library.
## Running unit tests
Run `nx test framework` to execute the unit tests via [Jest](https://jestjs.io).
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@storyteller/framework",
"version": "0.0.1",
"dependencies": {},
"main": "./index.js",
"module": "./index.mjs",
"typings": "./index.d.ts"
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "framework",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "framework/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/vite:build",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/framework"
}
},
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"passWithNoTests": true,
"reportsDirectory": "../coverage/framework"
}
}
},
"tags": []
}
+1
View File
@@ -0,0 +1 @@
export * from "./lib/decorators/on";
+33
View File
@@ -0,0 +1,33 @@
import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { on } from "./on";
@customElement("test-counter")
class TestCounter extends LitElement {
@state() count = 0;
@on("click")
incrementCount(): void {
this.count += 1;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-counter": TestCounter;
}
}
describe("@on(eventName)", () => {
it("binds a Custom Element method as an event handler", () => {
const counter = document.createElement("test-counter");
document.body.appendChild(counter);
counter.click();
counter.click();
counter.click();
expect(counter.count).toBe(3);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { match } from "@storyteller/utility";
import type { LitElement } from "lit";
/**
* Adds the decorated method as an event listener for the given event name, and
* automatically cleans up the listener when the element is removed from the
* DOM.
*
* The event name can be prefixed with `window:` or `document:` to add the
* listener to one of those targets instead of the custom element itself.
*/
export function on(eventSelector: string) {
const [targetName, eventName] = eventSelector.includes(":")
? eventSelector.split(":")
: [null, eventSelector];
let target = match (targetName, {
"window": () => window as EventTarget,
"document": () => document as EventTarget,
_: () => null,
});
return <T extends LitElement>(proto: T, propName: keyof T, desc: PropertyDescriptor) => {
const handler = desc?.value ?? proto[propName] ?? (() => {});
const connectedCb = proto.connectedCallback;
const disconnectedCb = proto.disconnectedCallback;
Object.defineProperty(proto, "connectedCallback", {
value: function (this: HTMLElement) {
target ??= this;
target.addEventListener(eventName, handler);
connectedCb.call(this);
},
configurable: true,
writable: true,
});
Object.defineProperty(proto, "disconnectedCallback", {
value: function (this: HTMLElement) {
target ??= this;
target.removeEventListener(eventName, handler);
disconnectedCb.call(this);
},
configurable: true,
writable: true,
});
return desc;
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc",
"declaration": true,
"types": ["node", "vite/client"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}
+50
View File
@@ -0,0 +1,50 @@
/// <reference types='vitest' />
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import * as path from "path";
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
export default defineConfig({
cacheDir: "../node_modules/.vite/framework",
plugins: [
nxViteTsPaths(),
dts({
entryRoot: "src",
tsConfigFilePath: path.join(__dirname, "tsconfig.lib.json"),
skipDiagnostics: true,
}),
],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: "src/index.ts",
name: "framework",
fileName: "index",
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ["es", "cjs"],
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: [],
},
},
test: {
globals: true,
cache: {
dir: "../node_modules/.vitest",
},
environment: "jsdom",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
},
});
+1
View File
@@ -35,6 +35,7 @@
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"useDaemonProcess": false,
"cacheableOperations": [
"build",
"lint",
+858 -556
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -13,6 +13,7 @@
"@swc/core": "~1.3.85",
"@types/lodash": "^4.14.199",
"@types/node": "18.7.1",
"@vitest/coverage-c8": "~0.32.0",
"@vitest/ui": "~0.32.0",
"chalk": "^5.3.0",
"glob": "^10.3.10",
@@ -23,12 +24,14 @@
"swc-loader": "0.1.15",
"typescript": "~5.1.3",
"vite": "~4.3.9",
"vite-plugin-dts": "~2.3.0",
"vite-plugin-top-level-await": "^1.3.1",
"vite-plugin-wasm": "^3.2.2",
"vitest": "~0.32.0",
"wasm-pack": "^0.12.1"
},
"dependencies": {
"lit": "^2.8.0",
"lodash": "^4.17.21",
"tslib": "^2.3.0"
}
-5
View File
@@ -1,5 +0,0 @@
{
"presets": [
"@nx/js/babel"
]
}
+1 -1
View File
@@ -3,6 +3,6 @@
"parser": {
"syntax": "typescript"
},
"target": "es2016"
"target": "ES2020"
}
}
+3
View File
@@ -8,6 +8,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="stylesheet" href="/src/styles.scss" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500;700&display=swap" rel="stylesheet">
</head>
<body>
<app-root></app-root>
+17
View File
@@ -0,0 +1,17 @@
:host {
position: fixed;
inset: 0;
pointer-events: none;
}
.btn-help {
position: absolute;
top: 16px;
right: 16px;
}
.help {
position: absolute;
top: 60px;
right: 16px;
}
+44 -15
View File
@@ -1,13 +1,40 @@
import { on } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import { ViewportSize } from "@storyteller/studio";
import { throttle } from "lodash";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./app.element.scss";
import "./button.element";
import "./help.element";
import "./icons/fa-circle-info.element";
export class AppElement extends HTMLElement {
static observedAttributes = [];
import styles from "./app.element.scss?inline";
connectedCallback(): void {
@customElement("app-root")
export class AppElement extends LitElement {
static override styles = unsafeCSS(styles);
@state() showHelp = false;
#studioCanvas?: HTMLCanvasElement | null;
override connectedCallback(): void {
this.#studioCanvas = this.#initStudio();
super.connectedCallback();
}
@on("window:resize")
onWindowResize(): void {
const width = window.innerWidth;
const height = window.innerHeight;
studio.resize(new ViewportSize(width, height));
}
toggleHelp(): void {
this.showHelp = !this.showHelp;
}
#initStudio(): HTMLCanvasElement | null {
const width = window.innerWidth;
const height = window.innerHeight;
try {
@@ -17,17 +44,19 @@ export class AppElement extends HTMLElement {
throw err;
}
window.addEventListener("resize", this.onResize);
return document.querySelector<HTMLCanvasElement>(`canvas[alt="Storyteller Studio"]`);
}
disconnectedCallback(): void {
window.removeEventListener("resize", this.onResize);
}
protected override render = () => html`
<sts-button class="btn-help"
icon="circle-info"
@click=${this.toggleHelp}
>
Help
</sts-button>
onResize = throttle(() => {
const width = window.innerWidth;
const height = window.innerHeight;
studio.resize(new ViewportSize(width, height));
}, 16.666);
${this.showHelp ? html`
<sts-help class="help"></sts-help>
` : null}
`;
}
customElements.define("app-root", AppElement);
@@ -0,0 +1,31 @@
:host {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 10px 20px;
border-radius: 0.5rem;
font: {
family: "Fira Sans", sans-serif;
size: 15px;
weight: 500;
}
line-height: 1;
pointer-events: auto;
cursor: pointer;
user-select: none;
background: #0006;
color: #FFF;
backdrop-filter: blur(16px);
transition: background 0.1s linear;
}
:host(:hover) {
background: #0008;
}
.icon {
fill: currentColor;
}
+30
View File
@@ -0,0 +1,30 @@
import { match } from "@storyteller/utility";
import { on } from "@storyteller/framework";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement, property } from "lit/decorators.js";
import styles from "./button.element.scss?inline";
@customElement("sts-button")
export class ButtonElement extends LitElement {
static override styles = unsafeCSS(styles);
@property() icon?: string;
override role = "button";
override tabIndex = 0;
@on("keydown")
onKeyDown({ key }: KeyboardEvent) {
if (/^( |Enter)$/.test(key))
this.click();
}
protected override render = () => html`
${match (this.icon, {
"circle-info": () => html`<fa-circle-info class="icon"></fa-circle-info>`,
_: () => null,
})}
<slot></slot>
`
}
+32
View File
@@ -0,0 +1,32 @@
:host {
padding: 20px 30px;
border-radius: 0.5rem;
font: {
family: "Fira Sans", sans-serif;
size: 15px;
weight: 400;
}
background: #0006;
backdrop-filter: blur(16px);
pointer-events: auto;
}
th {
text-align: start;
color: #FFF;
font-weight: 700;
line-height: 2;
}
td {
color: #FFFA;
vertical-align: top;
line-height: 1.4;
&:has(+ td) {
padding-right: 30px;
}
}
+49
View File
@@ -0,0 +1,49 @@
import { LitElement, html, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import styles from "./help.element.scss?inline";
@customElement("sts-help")
export class HelpElement extends LitElement {
static override styles = unsafeCSS(styles);
protected override render = () => html`
<table>
<thead>
<tr>
<th>Action</th>
<th>Controls</th>
<th>Alternate</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pan view</td>
<td colspan="2">MMB</td>
</tr>
<tr>
<td>Orbit around point</td>
<td colspan="2">Alt + LMB</td>
</tr>
<tr>
<td>Zoom</td>
<td>Alt + RMB</td>
<td>Mouse Wheel</td>
</tr>
<tr>
<td>Free fly</td>
<td colspan="2">W, A, S, D</td>
</tr>
<tr>
<td style="padding-left: 20px">Up / Down</td>
<td colspan="2">E, Q</td>
</tr>
<tr>
<td>Free look</td>
<td>RMB</td>
<td>Shift</td>
</tr>
</tbody>
</table>
`;
}
@@ -0,0 +1,20 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("fa-circle-info")
export class CircleInfoIcon extends LitElement {
override render = () => html`
<svg xmlns="http://www.w3.org/2000/svg"
height="1em"
viewBox="0 0 512 512"
>
<!--!
Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com
License - https://fontawesome.com/license (Commercial License)
Copyright 2023 Fonticons, Inc.
-->
<path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"
/>
</svg>
`;
}
+8 -2
View File
@@ -4,6 +4,12 @@
"outDir": "../dist/out-tsc",
"types": ["node"]
},
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts"
],
"include": [
"src/**/*.ts",
"typings/*.d.ts"
]
}
+16 -9
View File
@@ -2,20 +2,27 @@
"extends": "../tsconfig.base.json",
"files": [],
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"target": "ES2020",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitAny": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src"],
+4
View File
@@ -0,0 +1,4 @@
declare module "*.scss?inline" {
const styles: import("lit").CSSResult;
export default styles;
}
+1
View File
@@ -30,6 +30,7 @@ pub fn init(viewport_size: ViewportSize) {
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
resolution: viewport_size.into(),
title: "Storyteller Studio".into(),
..default()
}),
..default()
+1 -1
View File
@@ -30,8 +30,8 @@ fn layout(mut cmd: Commands) {
.with_text_alignment(TextAlignment::Right)
.with_style(Style {
position_type: PositionType::Absolute,
top: Val::Px(16.),
right: Val::Px(16.),
bottom: Val::Px(16.),
..default()
}),
));
+3 -1
View File
@@ -15,7 +15,9 @@
"skipDefaultLibCheck": true,
"baseUrl": ".",
"paths": {
"@storyteller/studio": ["dist/studio"]
"@storyteller/framework": ["framework/src/index.ts"],
"@storyteller/studio": ["dist/studio"],
"@storyteller/utility": ["utility/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp"]
+11
View File
@@ -0,0 +1,11 @@
# utility
This library was generated with [Nx](https://nx.dev).
## Building
Run `nx build utility` to build the library.
## Running unit tests
Run `nx test utility` to execute the unit tests via [Jest](https://jestjs.io).
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@storyteller/utility",
"version": "0.0.1",
"dependencies": {},
"main": "./index.js",
"module": "./index.mjs",
"typings": "./index.d.ts"
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "utility",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "utility/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/vite:build",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/utility"
}
},
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"passWithNoTests": true,
"reportsDirectory": "../coverage/utility"
}
}
},
"tags": []
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./lib/match";
export * from "./lib/types";
+93
View File
@@ -0,0 +1,93 @@
import { Fn } from "./types";
type Key = string | number;
type ExhaustiveMatcher<Union extends Key>
= Record<Union, () => any>
;
type MatcherWithFallback<Union extends Key>
= Partial<Record<Union, () => any>>
& { _: () => any }
;
type Matcher<Union extends Key>
= ExhaustiveMatcher<Union>
| MatcherWithFallback<Union>
;
type MatchReturn<K extends Key, T extends Matcher<K>>
// if K is a known, specific key of T, infer the matching function's return type
= T[K] extends Fn<[], infer R> ? R
// if K is unknown at comptime, infer the union of all matcher return types
: T[keyof T] extends Fn<[], infer R> ? R
// Unreachable
: never
;
/**
* Behaves like a simple [switch expression](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/switch-expression)
* for JavaScript.
*
* @example
* ```typescript
* enum Direction {
* Forward,
* Left,
* Back,
* Right,
* }
*
* function getDirection(event: KeyboardEvent): Direction {
* return match (event.key, {
* "W": () => Direction.Forward,
* "A": () => Direction.Left,
* "S": () => Direction.Back,
* "D": () => Direction.Right,
* _: () => {
* throw new Error(`${event.key} is not bound to a direction!`);
* },
* });
* }
* ```
* An enum can also be used as the subject, as long as its enumerators are
* assigned to values that can be used as JavaScript object keys (i.e. `string`,
* `number`, or `symbol`):
*
* @example
* ```typescript
* enum Direction {
* Up = "up",
* Down = "down",
* Right = "right",
* Left = "left",
* }
*
* enum Orientation {
* North,
* South,
* East,
* West,
* }
*
* function toOrientation(direction: Direction): Orientation {
* // Note that if the `matcher` argument is exhaustive,
* // you don't need to provide a `_` fallback.
* return match (direction, {
* [Direction.Up]: () => Orientation.North,
* [Direction.Right]: () => Orientation.East,
* [Direction.Down]: () => Orientation.South,
* [Direction.Left]: () => Orientation.West,
* });
* }
* ```
*/
export function match<K extends Key, T extends Matcher<K>>(
subject: K | null | undefined,
matcher: T,
): MatchReturn<K, T> {
if (subject && subject in matcher)
return matcher[subject]!();
if ("_" in matcher)
return matcher._();
throw new Error(`No match found for subject \`${subject}\` in matcher`);
}
+3
View File
@@ -0,0 +1,3 @@
export interface Fn<Args extends any[] = [], R = void> {
(...args: Args): R;
}
+22
View File
@@ -0,0 +1,22 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc",
"declaration": true,
"types": ["node", "vite/client"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}
+50
View File
@@ -0,0 +1,50 @@
/// <reference types='vitest' />
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import * as path from "node:path";
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
export default defineConfig({
cacheDir: "../node_modules/.vite/utility",
plugins: [
nxViteTsPaths(),
dts({
entryRoot: "src",
tsConfigFilePath: path.join(__dirname, "tsconfig.lib.json"),
skipDiagnostics: true,
}),
],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: "src/index.ts",
name: "utility",
fileName: "index",
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ["es", "cjs"],
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: [],
},
},
test: {
globals: true,
cache: {
dir: "../node_modules/.vitest",
},
environment: "node",
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
},
});