fix(lit-components): fix rendering and interaction issues in force-graph, html-table, and draggable-dom
Build and Deploy / build-and-deploy (push) Has been cancelled

- lit-force-graph: Refactored for instance reuse, better lifecycle management, and added dynamic imports for AR/VR modes.
- lit-html-table: Improved visual design, readability of headers, and fixed reactivity in editable mode.
- lit-draggable-dom: Fixed clipping issues by switching to relative/absolute positioning and resolved a panning speed bug caused by CSS variable inheritance.
This commit is contained in:
2026-05-17 00:41:14 -07:00
parent bbdbeb9788
commit b9e42c3ef4
253 changed files with 2555 additions and 4471 deletions
@@ -0,0 +1,313 @@
import { html, css, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import * as Tone from "tone";
@customElement("piano-component")
export class PianoComponent extends LitElement {
@query("canvas") canvas!: HTMLCanvasElement;
@state() octave = 2;
@state() note = "";
synth = new Tone.PolySynth().toDestination();
raycaster = new THREE.Raycaster();
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
70,
1,
0.001,
10
);
renderer?: THREE.WebGLRenderer;
controls?: OrbitControls;
public get context(): CanvasRenderingContext2D {
const ctx = this.canvas.getContext("2d")!;
ctx.imageSmoothingEnabled = true;
return ctx;
}
static styles = css`
main {
width: 100%;
height: 100vh;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
position: relative;
}
canvas {
width: 100%;
height: 100%;
z-index: 0;
display: block;
}
.controls {
z-index: 1;
position: absolute;
color: white;
font-size: 18px;
right: 10px;
top: 10px;
}
.note {
z-index: 1;
position: absolute;
color: white;
font-size: 18px;
left: 10px;
top: 10px;
}
`;
render() {
const tapNote = (clientX: number, clientY: number) => {
const rect = this.canvas.getBoundingClientRect();
const localX = clientX - rect.left;
const localY = clientY - rect.top;
const dx = (localX / rect.width) * 2 - 1;
const dy = -(localY / rect.height) * 2 + 1;
this.findNote(new THREE.Vector2(dx, dy));
};
return html`<main>
<canvas
@touchstart=${(e: any) => {
e.preventDefault();
for (const touch of e.touches) {
tapNote(touch.clientX, touch.clientY);
}
}}
@touchmove=${(e: any) => (e.preventDefault())}
@touchcancel=${() => (this.onKeyUp())}
@touchend=${() => (this.onKeyUp())}
@mousedown=${(e: any) => {
e.preventDefault();
tapNote(e.clientX, e.clientY);
}}
@mouseup=${() => (this.onKeyUp())}
></canvas>
<div class="controls">
OCTAVE: ${octaves[this.octave]}
<button
?disabled=${this.octave === 0}
@click=${() => (this.octave -= 1)}
>
-
</button>
<button
?disabled=${this.octave === octaves.length - 1}
@click=${() => (this.octave += 1)}
>
+
</button>
</div>
<div class="note">NOTE: ${this.note}</div>
</main>`;
}
onKeyUp = () => {};
findNote(mouse: THREE.Vector2) {
this.raycaster.setFromCamera(mouse, this.camera);
const intersects = this.raycaster.intersectObjects(
this.scene.children,
true
);
const obj = intersects.length > 0 ? intersects[0] : null;
if (obj?.object?.userData) {
if (obj.object instanceof THREE.Mesh) {
obj.object.material.color.set("gray");
const { note } = obj.object.userData;
this.playNote(note, (color) => {
// @ts-ignore
obj?.object?.material.color.set(color);
});
}
}
}
playNote(note: string, update: (color: string) => void) {
this.note = note;
const color = note.includes("#") ? "black" : "white";
this.synth.triggerAttackRelease(note, "8n");
this.onKeyUp = () => {
update(color);
};
}
findNode(
note: string,
nodes: THREE.Object3D[]
): THREE.Mesh<THREE.BoxGeometry, THREE.MeshStandardMaterial> | null {
for (const node of nodes) {
if (node instanceof THREE.Mesh) {
return node;
}
if (node instanceof THREE.Group) {
const child = this.findNode(note, node.children);
if (child) return child;
}
}
return null;
}
noteMap: any = {
C: "C#",
D: "D#",
F: "F#",
G: "G#",
A: "A#",
};
buildPiano() {
const group = new THREE.Group();
for (let i = 0; i < octaves.length; i++) {
const node = this.buildOctave(i, octaves[i]);
group.add(node);
}
group.position.x -= 2.5;
this.scene.add(group);
}
buildOctave(offset: number, octave: number) {
const group = new THREE.Group();
for (let i = 0; i < notes.length; i++) {
const key = `${notes[i]}${octave}` as any;
const note = this.buildPianoKey(i * 0.2, key);
if (Object.keys(this.noteMap).includes(notes[i])) {
const note = `${this.noteMap[notes[i]]}${octave}`;
const accidental = this.buildAccidental(i * 0.2, note as any);
accidental.castShadow = true;
accidental.receiveShadow = false;
group.add(accidental);
}
note.castShadow = true;
note.receiveShadow = false;
group.add(note);
}
group.position.x += 1.4 * offset;
return group;
}
keyOptions = {
depth: 0.1,
width: 0.2,
height: 0.4,
};
buildPianoKey(offset: number, note: NoteName) {
const geometry = new THREE.BoxGeometry(
this.keyOptions.width * 0.8,
this.keyOptions.height,
this.keyOptions.depth
);
const material = new THREE.MeshStandardMaterial({ color: "white" });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.x += offset;
mesh.userData["note"] = note;
return mesh;
}
buildAccidental(offset: number, note: NoteName) {
const geometry = new THREE.BoxGeometry(
this.keyOptions.width * 0.8,
this.keyOptions.height * 0.6,
this.keyOptions.depth
);
const material = new THREE.MeshStandardMaterial({ color: "black" });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.x += offset + 0.1;
mesh.position.y += 0.08;
mesh.position.z += 0.1;
mesh.userData["note"] = note;
return mesh;
}
paint() {
this.renderer!.render(this.scene, this.camera);
this.controls!.update();
}
firstUpdated() {
this.camera.position.z = 1;
this.renderer = new THREE.WebGLRenderer({
antialias: true,
canvas: this.canvas,
alpha: true,
});
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.screenSpacePanning = true;
this.controls.enableKeys = true;
// Size relative to current component offset bounds
const width = this.offsetWidth || window.innerWidth;
const height = this.offsetHeight || window.innerHeight;
this.renderer.setSize(width, height);
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setAnimationLoop(() => this.paint());
this.renderer.setClearColor("red", 1);
const bgLight = new THREE.AmbientLight(0x404040);
this.scene.add(bgLight);
const light = new THREE.DirectionalLight(0x404040, 100);
light.position.set(10, 4, 0.7);
light.castShadow = true;
this.scene.add(light);
light.shadow.mapSize.width = 512;
light.shadow.mapSize.height = 512;
light.shadow.camera.near = 0.5;
light.shadow.camera.far = 500;
this.buildPiano();
document.addEventListener(
"keydown",
(e: any) => {
const key = e.key;
if (key === "z" && this.octave != 0) this.octave -= 1;
if (key === "x" && this.octave != octaves.length - 1) this.octave += 1;
const play = (note: string) => {
this.playNote(`${note}${octaves[this.octave]}`, () => {});
};
if (key === "a") play("C");
if (key === "w") play("C#");
if (key === "s") play("D");
if (key === "E") play("D#");
if (key === "d") play("E");
if (key === "f") play("F");
if (key === "t") play("F#");
if (key === "g") play("G");
if (key === "y") play("G#");
if (key === "h") play("A");
if (key === "u") play("A#");
if (key === "j") play("B");
if (key === "k") play("C");
if (key === "o") play("C#");
if (key === "l") play("D");
if (key === "p") play("D#");
},
false
);
window.addEventListener(
"resize",
() => {
const w = this.offsetWidth || window.innerWidth;
const h = this.offsetHeight || window.innerHeight;
this.renderer!.setSize(w, h);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
},
false
);
}
}
const notes = ["C", "D", "E", "F", "G", "A", "B"] as const;
type Note = typeof notes[number];
const octaves = [2, 3, 4, 5] as const;
type Octave = typeof octaves[number];
type NoteName = `${Note}${Octave}`;
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import "./piano-component";
describe("Lit 3D Piano Component", () => {
test("registers piano-component custom element successfully", () => {
expect(customElements.get("piano-component")).toBeDefined();
});
test("mounts piano-component to document body", async () => {
document.body.innerHTML = "<piano-component></piano-component>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("piano-component");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
});
});
@@ -0,0 +1,60 @@
import { describe, expect, test } from "vitest";
import { AppCalculator } from "./calculator";
import "./display-output";
import "./keypad-input";
describe("Lit Calculator Component Logic", () => {
test("registers calculator custom elements successfully", () => {
expect(customElements.get("app-calculator")).toBeDefined();
expect(customElements.get("display-output")).toBeDefined();
expect(customElements.get("keypad-input")).toBeDefined();
});
test("calculates addition correctly", () => {
const calc = new AppCalculator();
calc._value = "12";
calc.mode = "+";
calc._staging = "8";
calc.calculate();
expect(calc._value).toBe("20");
expect(calc.mode).toBe("");
expect(calc._staging).toBe("");
});
test("calculates subtraction correctly", () => {
const calc = new AppCalculator();
calc._value = "15";
calc.mode = "-";
calc._staging = "6";
calc.calculate();
expect(calc._value).toBe("9");
expect(calc.mode).toBe("");
});
test("calculates multiplication correctly", () => {
const calc = new AppCalculator();
calc._value = "4";
calc.mode = "*";
calc._staging = "5";
calc.calculate();
expect(calc._value).toBe("20");
});
test("calculates division correctly", () => {
const calc = new AppCalculator();
calc._value = "20";
calc.mode = "/";
calc._staging = "4";
calc.calculate();
expect(calc._value).toBe("5");
});
test("handles division by zero gracefully", () => {
const calc = new AppCalculator();
calc._value = "10";
calc.mode = "/";
calc._staging = "0";
calc.calculate();
expect(calc._value).toBe("0");
});
});
+242
View File
@@ -0,0 +1,242 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
import "./keypad-input.js";
import "./key-pad.js";
import "./display-output.js";
@customElement("app-calculator")
export class AppCalculator extends LitElement {
@property()
width = "500px";
@property()
primaryColor = "#1a1f2c";
@property()
accentColor = "black";
static styles = css`
:host {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
min-height: 500px;
box-sizing: border-box;
}
#base {
border: 3px solid #1a1b1f;
border-radius: 20px;
padding: 24px;
background: #2a2c33;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.7),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 18px;
}
#actions {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
padding: 0 5px;
}
button {
background: #3e424b;
border: none;
border-bottom: 3px solid #1a1b1f;
color: #fff;
padding: 12px;
font-size: 18px;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
transition: all 0.08s ease;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
}
button:hover {
background: #4a4f59;
}
button:active {
transform: translateY(2px);
border-bottom-width: 1px;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
button.operator {
background: #e76f51;
border-bottom-color: #9d3f27;
color: #ffffff;
}
button.operator:hover {
background: #f4a261;
}
button.clear-btn {
background: #d62828;
border-bottom-color: #7a1212;
color: #ffffff;
}
button.clear-btn:hover {
background: #e63946;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
border-bottom-width: 3px !important;
box-shadow: 0 4px 6px rgba(0,0,0,0.3) !important;
}
`;
connectedCallback() {
super.connectedCallback();
// @ts-ignore
window.addEventListener("select-number", this._handleNumber);
}
disconnectedCallback() {
// @ts-ignore
window.removeEventListener("select-number", this._handleNumber);
super.disconnectedCallback();
}
_staging: string = "";
_value: string = "";
_mode: string = "";
public get mode(): string {
return this._mode;
}
public set mode(v: string) {
const oldMode = this._mode;
const oldVal = this.value;
this._mode = v;
// @ts-ignore
this.requestUpdate("mode", oldMode);
// @ts-ignore
this.requestUpdate("value", oldVal);
}
public get value(): string {
if (this.mode === "") return this._value;
return this._staging;
}
public set value(v: string) {
var oldVal = "";
if (this.mode === "") {
oldVal = this._value;
this._value = v;
} else {
oldVal = this._staging;
this._staging = v;
}
// @ts-ignore
this.requestUpdate("value", oldVal);
}
public get displayValue(): string {
if (this.mode !== "" && this._staging === "") {
return this._value || "0";
}
return this.value || "0";
}
_handleNumber = (event: CustomEvent) => {
const num = event.detail.value;
event.stopPropagation();
if (num === "=") {
this.calculate();
return;
}
if (this.value === "0") {
this.value = "";
}
if (num === "." && this.value.includes(".")) {
return;
}
this.value += num;
};
clear = () => {
this.value = "";
this._staging = "";
this._value = "";
this.mode = "";
};
calculate = () => {
console.log("DEBUG: calculate entered! mode =", this.mode, "_value =", this._value, "_staging =", this._staging);
if (this.mode === "") return;
const op1 = Number(this._value || "0");
const op2 = Number(this._staging || "0");
let result: number = 0;
switch (this.mode) {
case "+":
result = op1 + op2;
break;
case "-":
result = op1 - op2;
break;
case "/":
result = op2 === 0 ? 0 : op1 / op2;
break;
case "*":
result = op1 * op2;
break;
default:
break;
}
const output = result.toString();
console.log("DEBUG: calculation result =", result, "output =", output);
this._value = output;
this._staging = "";
this.mode = "";
};
render() {
return html`<div
id="base"
style="width: ${this.width};"
>
<display-output
value="${this.displayValue}"
color="#8fa882"
textColor="#172412"
></display-output>
<div id="actions">
<button
class="clear-btn"
@click=${this.clear}
?disabled=${this._value === "" && this._staging === "" && this.mode === ""}
>
C
</button>
<button class="operator" @click="${() => (this.mode = "+")}">+</button>
<button class="operator" @click="${() => (this.mode = "-")}">-</button>
<button class="operator" @click="${() => (this.mode = "/")}">/</button>
<button class="operator" @click="${() => (this.mode = "*")}">*</button>
</div>
<key-pad accentColor="#1b1c20" textColor="#f0eedb" actionColor="#f4a261" actionTextColor="#ffffff"></key-pad>
</div>`;
}
}
@@ -0,0 +1,70 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("display-output")
export class CalcDisplay extends LitElement {
@property()
color = "black";
@property()
textColor = "white";
@property()
value = "";
static styles = css`
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap');
#base {
text-align: end;
display: block;
padding: 16px 20px;
border: 3px solid #1a1b1f;
border-radius: 8px;
box-shadow:
inset 0 4px 10px rgba(0, 0, 0, 0.4),
0 1px 0 rgba(255, 255, 255, 0.08);
position: relative;
overflow: hidden;
box-sizing: border-box;
width: 100%;
}
/* Glossy vintage glass glare */
#base::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0)
);
pointer-events: none;
}
#text-display {
font-family: 'Share Tech Mono', 'Courier New', monospace;
font-weight: 700;
font-size: 40px;
letter-spacing: 2px;
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.12);
margin: 0;
line-height: 1.1;
word-wrap: break-word;
word-break: break-all;
}
`;
// Render element DOM by returning a `lit-html` template.
render() {
return html`<div id="base" style="background-color: ${this.color};">
<div id="text-display" style="color: ${this.textColor};">
${this.value || "0"}
</div>
</div>`;
}
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="style.css" />
<title>Lit Calculator</title>
</head>
<body>
<app-calculator
width="350px"
primaryColor="red"
accentColor="blue"
></app-calculator>
<script type="module" src="./calculator.js"></script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("key-pad")
export class CalcKeyPad extends LitElement {
@property()
accentColor = "blue";
@property()
textColor = "white";
@property()
actionColor = "black";
@property()
actionTextColor = "white";
static styles = css`
#base {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 0 5px;
}
keypad-input {
display: flex;
}
`;
// Render element DOM by returning a `lit-html` template.
render() {
return html` <div id="base">
<keypad-input
number="7"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="8"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="9"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="4"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="5"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="6"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="1"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="2"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="3"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="0"
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="."
color="${this.accentColor}"
textColor="${this.textColor}"
></keypad-input>
<keypad-input
number="="
color="${this.actionColor}"
textColor="${this.actionTextColor}"
></keypad-input>
</div>`;
}
}
@@ -0,0 +1,71 @@
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("keypad-input")
export class CalcNumber extends LitElement {
@property()
number = "0";
@property()
color = "black";
@property()
textColor = "white";
static styles = css`
#base {
width: 100%;
height: 100%;
border-radius: 50%;
text-align: center;
border: none;
border-bottom: 3px solid rgba(0, 0, 0, 0.45);
font-weight: 700;
font-size: 20px;
cursor: pointer;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.08s ease;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.25);
outline: none;
box-sizing: border-box;
padding: 0;
}
#base:hover {
filter: brightness(1.1);
}
#base:active {
transform: translateY(2px);
border-bottom-width: 1px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
`;
_onTap(e: Event) {
e.preventDefault();
const event = new CustomEvent("select-number", {
detail: { value: this.number } as any,
bubbles: true,
cancelable: true,
});
// @ts-ignore
window.dispatchEvent(event);
}
// Render element DOM by returning a `lit-html` template.
render() {
return html` <button
@click="${this._onTap}"
id="base"
style="background-color: ${this.color}; color: ${this.textColor};"
>
${this.number}
</button>`;
}
}
+10
View File
@@ -0,0 +1,10 @@
body {
margin: 0px;
padding: 0px;
font-family: sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f0f2f5;
}
+27
View File
@@ -0,0 +1,27 @@
import { LitElement } from "lit";
import * as monaco from "monaco-editor";
export declare class CodeEditor extends LitElement {
private container;
editor?: monaco.editor.IStandaloneCodeEditor;
readOnly?: boolean;
theme?: string;
language?: string;
code?: string;
static styles: import("lit").CSSResultGroup;
render(): import("lit").TemplateResult<1>;
private getFile;
private getCode;
private getLang;
private getTheme;
private isDark;
setValue(value: string): void;
getValue(): string;
setReadOnly(value: boolean): void;
setOptions(value: monaco.editor.IStandaloneEditorConstructionOptions): void;
firstUpdated(): void;
}
declare global {
interface HTMLElementTagNameMap {
"code-editor": CodeEditor;
}
}
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import "./code-editor";
describe("Lit Code Editor Component", () => {
test("registers code-editor custom element successfully", () => {
expect(customElements.get("code-editor")).toBeDefined();
});
test("mounts code-editor successfully", async () => {
document.body.innerHTML = '<code-editor code="console.log(1);"></code-editor>';
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("code-editor");
expect(element).toBeDefined();
expect(element?.getAttribute("code")).toBe("console.log(1);");
});
});
@@ -0,0 +1,136 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef, Ref, ref } from "lit/directives/ref.js";
// -- Monaco Editor Imports --
import * as monaco from "monaco-editor";
import styles from "monaco-editor/min/vs/editor/editor.main.css";
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
// @ts-ignore
self.MonacoEnvironment = {
getWorker(_: any, label: string) {
if (label === "json") {
return new jsonWorker();
}
if (label === "css" || label === "scss" || label === "less") {
return new cssWorker();
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker();
}
if (label === "typescript" || label === "javascript") {
return new tsWorker();
}
return new editorWorker();
},
};
@customElement("code-editor")
export class CodeEditor extends LitElement {
private container: Ref<HTMLElement> = createRef();
editor?: monaco.editor.IStandaloneCodeEditor;
@property({ type: Boolean, attribute: "readonly" }) readOnly?: boolean;
@property() theme?: string;
@property() language?: string;
@property() code?: string;
static styles = css`
:host {
--editor-width: 100%;
--editor-height: 100vh;
}
main {
width: var(--editor-width);
height: var(--editor-height);
}
`;
render() {
return html`
<style>
${styles}
</style>
<main ${ref(this.container)}></main>
`;
}
private getFile() {
if (this.children.length > 0) return this.children[0];
return null;
}
private getCode() {
if (this.code) return this.code;
const file = this.getFile();
if (!file) return;
return file.innerHTML.trim();
}
private getLang() {
if (this.language) return this.language;
const file = this.getFile();
if (!file) return;
const type = file.getAttribute("type")!;
return type.split("/").pop()!;
}
private getTheme() {
if (this.theme) return this.theme;
if (this.isDark()) return "vs-dark";
return "vs-light";
}
private isDark() {
return (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
setValue(value: string) {
this.editor!.setValue(value);
}
getValue() {
const value = this.editor!.getValue();
return value;
}
setReadOnly(value: boolean) {
this.readOnly = value;
this.setOptions({ readOnly: value });
}
setOptions(value: monaco.editor.IStandaloneEditorConstructionOptions) {
this.editor!.updateOptions(value);
}
firstUpdated() {
this.editor = monaco.editor.create(this.container.value!, {
value: this.getCode(),
language: this.getLang(),
theme: this.getTheme(),
automaticLayout: true,
readOnly: this.readOnly ?? false,
});
this.editor.getModel()!.onDidChangeContent(() => {
this.dispatchEvent(new CustomEvent("change", { detail: {} }));
});
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
monaco.editor.setTheme(this.getTheme());
});
}
}
declare global {
interface HTMLElementTagNameMap {
"code-editor": CodeEditor;
}
}
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import "./draggable-dom";
describe("Lit Draggable DOM Component", () => {
test("registers draggable-dom custom element successfully", () => {
expect(customElements.get("draggable-dom")).toBeDefined();
});
test("mounts draggable-dom successfully and accepts slots", async () => {
document.body.innerHTML = `
<draggable-dom>
<span id="drag">Grab me</span>
</draggable-dom>
`;
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("draggable-dom");
expect(element).toBeDefined();
expect(document.getElementById("drag")?.textContent).toBe("Grab me");
});
});
@@ -0,0 +1,179 @@
import { html, css, LitElement } from "lit";
import { customElement, query } from "lit/decorators.js";
type DragType = "none" | "canvas" | "element";
type SupportedNode = HTMLElement | SVGElement;
@customElement("draggable-dom")
export class DraggableDOM extends LitElement {
@query("main") root!: HTMLElement;
@query("#children") container!: HTMLElement;
@query("canvas") canvas!: HTMLCanvasElement;
dragType: DragType = "none";
offset: Offset = { x: 0, y: 0 };
pointerMap: Map<number, PointerData> = new Map();
static styles = css`
:host {
display: block;
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
--offset-x: 0;
--offset-y: 0;
--grid-background-color: white;
--grid-color: rgba(0, 0, 0, 0.1);
--grid-size: 40px;
--grid-dot-size: 2px;
}
main {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
touch-action: none;
}
canvas {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background-size: var(--grid-size) var(--grid-size);
background-image: radial-gradient(
circle,
var(--grid-color) var(--grid-dot-size),
var(--grid-background-color) var(--grid-dot-size)
);
background-position: var(--offset-x) var(--offset-y);
z-index: 0;
pointer-events: none;
}
#children {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
transform: translate(var(--pan-x, 0), var(--pan-y, 0));
pointer-events: none;
}
.child {
display: block;
pointer-events: auto;
}
::slotted(*) {
position: absolute !important;
top: 0;
left: 0;
transform: translate(var(--dx, 0), var(--dy, 0));
pointer-events: auto;
touch-action: none;
}
@media (prefers-color-scheme: dark) {
:host {
--grid-background-color: #0d1117;
--grid-color: rgba(255, 255, 255, 0.1);
}
}
`;
render() {
return html`
<main
@pointerdown=${(e: any) => this.handleDown(e, "canvas")}
@pointermove=${(e: any) =>
this.handleMove(e, "canvas", (delta) => this.moveCanvas(delta))}
@pointerup=${(e: any) => this.handleUp(e)}
@pointercancel=${(e: any) => this.handleUp(e)}
>
<canvas></canvas>
<div id="children">
<slot
class="child"
@pointerdown=${(e: any) => this.handleDown(e, "element")}
@pointermove=${(e: any) =>
this.handleMove(e, "element", (delta) => {
const target = e.target as HTMLElement;
const child = target.assignedSlot ? target : target.closest('[slot]') || target;
this.moveElement(child as SupportedNode, delta);
})}
></slot>
</div>
</main>
`;
}
handleDown(event: PointerEvent, type: DragType) {
if (this.dragType === "none") {
event.preventDefault();
this.dragType = type;
(event.target as Element).setPointerCapture(event.pointerId);
this.pointerMap.set(event.pointerId, {
id: event.pointerId,
startPos: { x: event.clientX, y: event.clientY },
currentPos: { x: event.clientX, y: event.clientY },
});
}
}
handleMove(
event: PointerEvent,
type: DragType,
onMove: (delta: Offset) => void
) {
if (this.dragType === type) {
event.preventDefault();
const saved = this.pointerMap.get(event.pointerId)!;
const current = { ...saved.currentPos };
saved.currentPos = { x: event.clientX, y: event.clientY };
const delta = {
x: saved.currentPos.x - current.x,
y: saved.currentPos.y - current.y,
};
onMove(delta);
}
}
handleUp(event: PointerEvent) {
this.dragType = "none";
(event.target as Element).releasePointerCapture(event.pointerId);
}
moveCanvas(delta: Offset) {
this.offset.x += delta.x;
this.offset.y += delta.y;
this.style.setProperty("--offset-x", `${this.offset.x}px`);
this.style.setProperty("--offset-y", `${this.offset.y}px`);
this.container.style.setProperty("--pan-x", `${this.offset.x}px`);
this.container.style.setProperty("--pan-y", `${this.offset.y}px`);
}
moveElement(child: SupportedNode, delta: Offset) {
const getNumber = (key: string) => {
const saved = (child as HTMLElement).style.getPropertyValue(key);
if (saved.length > 0) {
return parseFloat(saved.replace("px", ""));
}
return 0;
};
const dx = getNumber("--dx") + delta.x;
const dy = getNumber("--dy") + delta.y;
(child as HTMLElement).style.setProperty("--dx", `${dx}px`);
(child as HTMLElement).style.setProperty("--dy", `${dy}px`);
}
}
interface Offset {
x: number;
y: number;
}
interface PointerData {
id: number;
startPos: Offset;
currentPos: Offset;
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("menu-button")
export class MenuButton extends LitElement {
static styles = css``;
render() {
return html` <button @click=${() => alert('Menu Toggle')}>Menu</button> `;
}
}
@@ -0,0 +1,261 @@
import { html, css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("generated-app")
export class GeneratedApp extends LitElement {
static styles = css`
main {
width: 100%;
height: 100%;
}
progress {
position: absolute;
bottom: 0;
width: calc(100% - 1rem);
z-index: 1;
left: 0.5rem;
right: 0.5rem;
}
`;
@property() hash = 'true';
@property() base = '/';
@state() loading = false;
@property() route = this.getCurrentRoute();
@state() child = document.createElement('main');
dataCache = new Map<string, any>();
components: Map<string, Route> = new Map([
["/404", {
component: "unknown-route",
loadData: async () => null,
loadImport: () => import("./pages/404.js"),
}],
["/custom/not/nested/route", {
component: "custom-route",
loadData: async () => null,
loadImport: () => import("./pages/custom.not.nested.route.js"),
}],
["/dashboard/account/:id", {
component: "account-details",
loadData: async () => {
const {loader} = await import("./pages/dashboard/account/:id.js");
return loader;
},
loadImport: () => import("./pages/dashboard/account/:id.js"),
}],
["/dashboard/account/", {
component: "account-info",
loadData: async () => null,
loadImport: () => import("./pages/dashboard/account/index.js"),
}],
["/dashboard/account", {
component: "account-module",
loadData: async () => null,
loadImport: () => import("./pages/dashboard/account.js"),
}],
["/dashboard/", {
component: "dashboard-default",
loadData: async () => null,
loadImport: () => import("./pages/dashboard/index.js"),
}],
["/dashboard/overview", {
component: "overview-module",
loadData: async () => null,
loadImport: () => import("./pages/dashboard/overview.js"),
}],
["/dashboard", {
component: "dashboard-module",
loadData: async () => null,
loadImport: () => import("./pages/dashboard.js"),
}],
["/", {
component: "app-module",
loadData: async () => null,
loadImport: () => import("./pages/index.js"),
}],
["", {
component: "root-module",
loadData: async () => null,
loadImport: () => import("./pages/root.js"),
}],
["/settings/admin", {
component: "admin-settings",
loadData: async () => null,
loadImport: () => import("./pages/settings/admin.js"),
}],
["/settings/", {
component: "settings-default",
loadData: async () => null,
loadImport: () => import("./pages/settings/index.js"),
}],
["/settings", {
component: "settings-module",
loadData: async () => null,
loadImport: () => import("./pages/settings.js"),
}],
]);
firstUpdated() {
window.addEventListener("hashchange", () => {
const oldRoute = this.route;
this.route = this.getCurrentRoute();
this.updateTree(oldRoute);
});
this.updateTree();
}
render() {
return html` ${this.child} `;
}
refresh() {
return this.updateTree();
}
private async updateTree(oldRoute?:string) {
this.checkForIndex();
if (oldRoute) {
// TODO: Get delta between old and new route
}
const loadingElem = document.createElement("progress");
this.child.appendChild(loadingElem);
const tree = await this.renderTree();
// Remove children
while (this.child.firstChild) {
this.child.removeChild(this.child.firstChild);
}
// Add new children
if (tree) this.child.appendChild(tree);
this.requestUpdate();
}
private async renderTree() {
let _route = this.route;
const args = this.getArgsForRoute(_route);
const elements: Element[] = [];
if (_route !== "/") {
while (_route.length > 0) {
const newChild = await this.getComponent(_route, args);
if (!newChild && _route === this.route) {
const noChild = await this.getComponent("/404", args);
if (noChild) elements.push(noChild);
break;
}
elements.push(newChild!);
const parts = _route.split("/");
parts.pop();
_route = parts.join("/");
if (_route === "/") break;
}
} else if (_route === "/") {
const indexChild = await this.getComponent("/", args);
if (indexChild) elements.push(indexChild);
} else {
const noChild = await this.getComponent("/404", args);
if (noChild) elements.push(noChild);
}
const rootChild = await this.getComponent("", args);
if (rootChild) elements.push(rootChild);
let idx = elements.length - 1;
while (idx >= 1) {
const parent = elements[idx];
const child = elements[idx - 1];
if (child && parent) {
parent.appendChild(child);
}
idx--;
}
return elements.pop();
}
private async getComponent(
path: string,
args: RegExpMatchArray | null
) {
const applyArgs = (value: string, apply: boolean, data?: any) => {
const elem = document.createElement(value);
if (apply && args?.groups) {
for (const [key, value] of Object.entries(args.groups)) {
elem.setAttribute(key, value);
}
}
if (data) (elem as any).data = data;
return elem;
};
for (const [key, value] of Array.from(this.components.entries())) {
const hasArgs = path.match(fixRegex(key)) !== null;
if (key === path || path.match(fixRegex(key)) !== null) {
const cacheKey = `${path}:${value.component}`;
if (this.dataCache.has(cacheKey)) {
const data = this.dataCache.get(cacheKey)!;
await value.loadImport();
return applyArgs(value.component, hasArgs, data);
}
const getLoader = await value.loadData();
if (getLoader) {
const componentData = await getLoader(this.route, args ? Object(args)['groups'] : {});
this.dataCache.set(cacheKey, componentData);
await value.loadImport();
return applyArgs(value.component, hasArgs, componentData);
}
await value.loadImport();
return applyArgs(value.component, hasArgs);
}
}
return null;
}
private getArgsForRoute(route: string): RegExpMatchArray | null {
for (const key of Array.from(this.components.keys())) {
const regMatch = route.match(fixRegex(key));
if (regMatch !== null) return regMatch;
}
return null;
}
private checkForIndex() {
if (this.route === "" || this.route === "/") location.hash = "#/";
if (this.route.endsWith("/")) return;
const indexArgs = this.getArgsForRoute(`${this.route}/`);
if (indexArgs !== null) {
location.hash = `#${this.route}/`;
}
}
private getCurrentRoute() {
let route = "/";
if (this.hash === "true" && window.location.hash.length > 0) {
route = window.location.hash.slice(1);
} else if (this.hash === "false") {
const baseUrl = this.getAttribute("base") ?? "";
route = window.location.pathname.slice(baseUrl.length);
}
console.debug(`current route: ${route}`);
return route;
}
}
function fixRegex(route: string): RegExp {
const variableRegex = "[a-zA-Z0-9_-]+";
const nameWithParameters = route.replace(
new RegExp(`:(${variableRegex})`),
(match) => {
const groupName = match.slice(1);
return `(?<${groupName}>[a-zA-Z0-9_\\-.,:;+*^%$@!]+)`;
}
);
return new RegExp(`^${nameWithParameters}$`);
}
interface Route {
component: string;
loadImport: () => Promise<any>;
loadData: () => Promise<RouteLoader | null>;
}
type RouteLoader = (
route: string,
args: { [key: string]: any }
) => Promise<any>;
@@ -0,0 +1,13 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("unknown-route")
export class UnknownRoute extends LitElement {
static styles = css``;
render() {
return html` <main>
<header>404</header>
</main>`;
}
}
@@ -0,0 +1,13 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("custom-route")
export class CustomRoute extends LitElement {
static styles = css``;
render() {
return html` <main>
<header>Custom</header>
</main>`;
}
}
@@ -0,0 +1,35 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import '../components/menu-button.js';
@customElement("dashboard-module")
export class DashboardModule extends LitElement {
static styles = css`
header {
height: 40px;
background-color: orange;
color: white;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
padding-right: 10px;
justify-content: space-between;
}
`;
render() {
return html`<main>
<header>
<menu-button></menu-button>
<span class="title">Dashboard</span>
<nav>
<a href="#/dashboard/overview">Overview</a>
<a href="#/dashboard/account/">Account</a>
</nav>
</header>
<section><slot></slot></section>
</main> `;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("account-module")
export class AccountModule extends LitElement {
static styles = css``;
render() {
return html`<section><slot></slot></section>`;
}
}
@@ -0,0 +1,33 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export async function loader(
route: string,
args: { [key: string]: any }
): Promise<AccountData> {
await new Promise((resolve) => setTimeout(resolve, 1000));
const id = args["id"]!;
return {
id,
route,
name: "Name: " + id,
};
}
@customElement("account-details")
export class AccountDetails extends LitElement {
static styles = css``;
@property({ type: String }) id = "";
@property({ type: Object }) data!: AccountData;
render() {
return html`<section>${this.data.name}</section>`;
}
}
interface AccountData {
id: string;
route: string;
name: string;
}
@@ -0,0 +1,22 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("account-info")
export class AccountInfo extends LitElement {
static styles = css`
article {
padding: 16px;
}
`;
render() {
return html`<article>
<h3>Account Info</h3>
<ul>
<li><a href="#/dashboard/account/1">User 1</a></li>
<li><a href="#/dashboard/account/2">User 2</a></li>
<li><a href="#/dashboard/account/3">User 3</a></li>
</ul>
</article>`;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("dashboard-default")
export class DashboardDefault extends LitElement {
static styles = css``;
render() {
return html`<section>Default Dashboard</section>`;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("overview-module")
export class OverviewModule extends LitElement {
static styles = css``;
render() {
return html`<section>Overview</section>`;
}
}
@@ -0,0 +1,24 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("app-module")
export class AppModule extends LitElement {
static styles = css`
header {
height: 40px;
background-color: navy;
color: white;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
}
`;
render() {
return html` <main>
<header>App Base</header>
<slot></slot>
</main>`;
}
}
@@ -0,0 +1,36 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("root-module")
export class RootModule extends LitElement {
static styles = css`
main {
display: flex;
flex-direction: row;
height: 100vh;
width: 100%;
}
aside {
display: flex;
flex-direction: column;
background-color: whitesmoke;
padding: 8px;
}
section {
flex: 1;
}
`;
render() {
return html`
<main>
<aside>
<a href="#/">Home</a>
<a href="#/dashboard">Dashboard</a>
<a href="#/settings">Settings</a>
</aside>
<section><slot></slot></section>
</main>
`;
}
}
@@ -0,0 +1,31 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import "../components/menu-button.js";
@customElement("settings-module")
export class SettingsModule extends LitElement {
static styles = css`
header {
height: 40px;
background-color: black;
color: white;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
justify-content: space-between;
}
`;
render() {
return html` <main>
<header>
<menu-button></menu-button>
<span class="title">Settings</span>
<div></div>
</header>
<slot></slot>
</main>`;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("admin-settings")
export class AdminSettings extends LitElement {
static styles = css``;
render() {
return html`Admin Settings`;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("settings-default")
export class SettingsDefault extends LitElement {
static styles = css``;
render() {
return html`<section>Default Settings</section>`;
}
}
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import "./generated-app";
describe("Lit File-Based Router Component", () => {
test("registers generated-app custom element successfully", () => {
expect(customElements.get("generated-app")).toBeDefined();
});
test("mounts router application container successfully", async () => {
document.body.innerHTML = "<generated-app></generated-app>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("generated-app");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
// Await lazy route imports to complete before ending test and tearing down Happy DOM context
await new Promise((resolve) => setTimeout(resolve, 150));
});
});
@@ -0,0 +1,10 @@
import { GraphData, GraphNode } from "./graph";
export interface RenderContext {
data: GraphData;
element: HTMLElement;
onHover: (node?: GraphNode) => void;
instance?: any;
}
export type Renderer = (context: RenderContext) => any;
@@ -0,0 +1,46 @@
export class Graph {
private ids = new Set();
private graph: GraphData = {
nodes: [],
links: [],
};
addNode<T = any>(node: GraphNode<T>) {
if (this.ids.has(node.id)) {
return this.graph.nodes.find((n) => n.id === node.id)!;
}
this.ids.add(node.id);
this.graph.nodes.push(node);
return node;
}
addLink<T = any>(link: GraphLink<T>) {
this.graph.links.push(link);
return link;
}
toJSON() {
return this.graph;
}
}
export interface GraphNode<T = any> {
id: string;
name?: string;
group?: string;
value?: T;
}
export interface GraphLink<T = any> {
source: string;
target: string;
name?: string;
value?: T;
}
export interface GraphData<A = any, B = any> {
name?: string;
description?: string;
nodes: GraphNode<A>[];
links: GraphLink<B>[];
}
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import "./lit-force-graph";
describe("Lit Force Graph Component", () => {
test("registers lit-force-graph custom element successfully", () => {
expect(customElements.get("lit-force-graph")).toBeDefined();
});
test("mounts lit-force-graph container successfully", async () => {
document.body.innerHTML = "<lit-force-graph></lit-force-graph>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("lit-force-graph");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
});
});
@@ -0,0 +1,235 @@
import { html, css, LitElement, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { Renderer } from "./classes/context";
import { GraphData, GraphNode } from "./classes/graph";
export const tagName = "lit-force-graph";
@customElement(tagName)
export class LitForceGraph extends LitElement {
static styles = css`
:host {
display: block;
background-color: var(--graph-background-color, #000011);
color: var(--graph-foreground-color, #ffffff);
width: var(--graph-width, 100%);
height: var(--graph-height, 100vh);
position: relative;
}
#graph {
width: 100%;
height: 100%;
}
#controls {
position: absolute;
top: 20px;
right: 20px;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-end;
}
#controls div {
padding: 5px;
}
#info {
position: absolute;
top: 10px;
left: 10px;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-start;
pointer-events: none;
}
#tooltips {
position: absolute;
bottom: 10px;
left: 10px;
right: 10px;
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
justify-content: center;
pointer-events: none;
}
.node-tooltip {
background-color: var(--graph-foreground-color, #ffffff);
color: var(--graph-background-color, #000011);
border-radius: 5px;
font-size: 12px;
padding: 5px;
opacity: 0.8;
}
#graph-description {
opacity: 0.67;
}
`;
@query("#graph") graph!: HTMLElement;
@property() src = "";
@property() mode = "2D";
@property({ type: Object }) data?: GraphData;
@state() hovered?: GraphNode;
@state() private _instance?: any;
renderers = new Map<string, () => Promise<Renderer>>([
["2D", () => import("./modes/mode-2d").then((m) => m.render)],
["3D", () => import("./modes/mode-3d").then((m) => m.render)],
["AR", () => import("./modes/mode-ar").then((m) => m.render)],
["VR", () => import("./modes/mode-vr").then((m) => m.render)],
]);
render() {
return html` <main
@drop="${this.onDrop}"
@dragover="${(e: Event) => e.preventDefault()}"
>
<div id="graph"></div>
<div id="controls">
<div>
<label for="render-mode">Render mode</label>
<select id="render-mode" .value=${this.mode} @change=${this.onChangeMode}>
${Array.from(this.renderers.keys()).map((mode) => {
return html` <option value="${mode}" ?selected=${this.mode === mode}>${mode}</option> `;
})}
</select>
</div>
</div>
<div id="info">
<h2 id="graph-name">${this.data?.name}</h2>
<div id="graph-description">${this.data?.description}</div>
</div>
<div id="tooltips">
${this.hovered
? html` <div class="node-tooltip">
${this.hovered?.name ?? this.hovered?.id}
</div>`
: html``}
</div>
</main>`;
}
async firstUpdated() {
await this.refresh();
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
prefersDark.addEventListener("change", () => {
this._updateGraph();
});
}
protected updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("mode")) {
if (this._instance) {
this.graph.innerHTML = "";
this._instance = undefined;
}
}
if (changedProperties.has("data") || changedProperties.has("mode")) {
this._updateGraph();
}
if (changedProperties.has("src") && this.src) {
this.refresh();
}
}
/**
* Set the graph data and update the renderer
*
* @param data Graph JSON
*/
setData(data: GraphData) {
this.data = data;
}
private async _updateGraph() {
if (!this.data || !this.graph) return;
const rendererFactory = this.renderers.get(this.mode);
if (rendererFactory) {
try {
const renderer = await rendererFactory();
this._instance = renderer({
element: this.graph,
data: this.data,
onHover: (node) => (this.hovered = node),
instance: this._instance,
});
} catch (e) {
console.error("Failed to load or run renderer for mode", this.mode, e);
}
}
}
private async refresh() {
// Get json from script tag
const children = Array.from(this.children);
const elem = children.find((child) => child.tagName === "SCRIPT");
if (elem) {
if (elem.textContent) {
try {
const data = JSON.parse(elem.textContent);
if (data) this.setData(data);
} catch (e) {
console.error("Failed to parse graph data from script tag", e);
}
} else if (elem.hasAttribute("src")) {
const url = elem.getAttribute("src")!;
try {
const data = await fetch(url).then((res) => res.json());
if (data) this.setData(data);
} catch (e) {
console.error("Failed to fetch graph data from script src", url, e);
}
}
} else if (this.src) {
try {
const data = await fetch(this.src).then((res) => res.json());
if (data) this.setData(data);
} catch (e) {
console.error("Failed to fetch graph data from src property", this.src, e);
}
}
}
private onChangeMode(e: Event) {
this.mode = (e.target as HTMLSelectElement).value;
}
private onDrop(e: DragEvent) {
e.preventDefault();
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
const reader = new FileReader();
reader.onload = () => {
try {
const json = JSON.parse(reader.result as string);
this.setData(json);
} catch (e) {
console.error("Failed to parse dropped graph data", e);
}
};
reader.readAsText(file);
}
return false;
}
}
declare global {
interface HTMLElementTagNameMap {
"lit-force-graph": LitForceGraph;
}
}
declare global {
interface HTMLElementTagNameMap {
"lit-force-graph": LitForceGraph;
}
}
@@ -0,0 +1,66 @@
import ForceGraph from "force-graph";
import { RenderContext } from "../classes/context";
export function render(context: RenderContext) {
const graph = context.instance || ForceGraph();
const style = getComputedStyle(context.element);
const lineColor = style.getPropertyValue("--graph-line-color").trim() || "#555";
const bgColor = style.getPropertyValue("--graph-background-color").trim() || "#000011";
const fgColor = style.getPropertyValue("--graph-foreground-color").trim() || "#ffffff";
const nodeColor = style.getPropertyValue("--graph-node-color").trim() || "#999";
const width = context.element.clientWidth || Number(style.width.slice(0, -2)) || window.innerWidth;
const height = context.element.clientHeight || Number(style.height.slice(0, -2)) || window.innerHeight;
if (!context.instance) {
graph(context.element);
}
graph
.graphData(context.data)
.width(width)
.height(height)
.cooldownTicks(100)
.backgroundColor(bgColor)
.linkColor(() => lineColor)
.linkWidth(0.2)
.nodeCanvasObject((node: any, ctx, globalScale) => {
// Draw a circle
ctx.beginPath();
const size = 5 / globalScale;
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
ctx.fillStyle = nodeColor;
ctx.fill();
ctx.lineWidth = 1 / globalScale;
ctx.strokeStyle = lineColor;
ctx.stroke();
if (globalScale >= 4) {
const label = node.name ?? node.id;
const fontSize = 12 / globalScale;
ctx.font = `${fontSize}px Sans-Serif`;
const textWidth = ctx.measureText(label).width;
const bckgDimensions = [textWidth, fontSize].map(
(n) => n + fontSize * 0.2
); // some padding
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = fgColor;
// Measure text
ctx.fillText(label, node.x + size * 2 + textWidth / 2, node.y);
node.__bckgDimensions = bckgDimensions;
}
})
.onNodeHover((node: any, prev: any) => {
if (node) {
const graphNode = context.data.nodes.find((n) => n.id === node.id);
context.onHover(graphNode);
}
if (prev) {
context.onHover(undefined);
}
});
return graph;
}
@@ -0,0 +1,45 @@
import ForceGraph from "3d-force-graph";
import { RenderContext } from "../classes/context.js";
export function render(context: RenderContext) {
const graph = context.instance || ForceGraph({
controlType: "trackball",
rendererConfig: { antialias: true, alpha: true },
});
const style = getComputedStyle(context.element);
const lineColor = style.getPropertyValue("--graph-line-color").trim() || "#555";
const bgColor = style.getPropertyValue("--graph-background-color").trim() || "#000011";
const nodeColor = style.getPropertyValue("--graph-node-color").trim() || "#999";
const width = context.element.clientWidth || Number(style.width.slice(0, -2)) || window.innerWidth;
const height = context.element.clientHeight || Number(style.height.slice(0, -2)) || window.innerHeight;
if (!context.instance) {
graph(context.element);
}
graph
.graphData(context.data)
.width(width)
.height(height)
.showNavInfo(false)
.linkColor(() => lineColor)
.backgroundColor(bgColor)
.nodeThreeObject((node: any) => {
const color = node.color ?? nodeColor;
node.color = color;
return false as any;
})
.nodeThreeObjectExtend(true)
.onNodeHover((node: any, prev: any) => {
if (node) {
const graphNode = context.data.nodes.find((n) => n.id === node.id);
context.onHover(graphNode);
}
if (prev) {
context.onHover(undefined);
}
})
.cooldownTicks(100);
return graph;
}
@@ -0,0 +1,21 @@
import "aframe";
import "@ar-js-org/ar.js";
import ForceGraph from "3d-force-graph-ar";
import { RenderContext } from "../classes/context.js";
export function render(context: RenderContext) {
const graph = context.instance || ForceGraph();
const style = getComputedStyle(context.element);
if (!context.instance) {
graph(context.element);
}
graph
.graphData(context.data)
.width(Number(style.width.slice(0, -2)) || window.innerWidth)
.height(Number(style.height.slice(0, -2)) || window.innerHeight)
.cooldownTicks(100);
return graph;
}
@@ -0,0 +1,20 @@
import ForceGraph from "3d-force-graph-vr";
import { RenderContext } from "../classes/context.js";
export function render(context: RenderContext) {
const graph = context.instance || ForceGraph();
const style = getComputedStyle(context.element);
if (!context.instance) {
graph(context.element);
}
graph
.graphData(context.data)
.width(Number(style.width.slice(0, -2)) || window.innerWidth)
.height(Number(style.height.slice(0, -2)) || window.innerHeight)
.showNavInfo(false)
.cooldownTicks(100);
return graph;
}
@@ -0,0 +1,96 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import "@material/mwc-icon-button";
import "@material/mwc-icon";
@customElement("rich-action")
export class RichAction extends LitElement {
static styles = css`
* {
--mdc-icon-size: var(--icon-size);
--mdc-icon-button-size: var(--icon-size);
}
section {
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
section * {
margin: 2px;
}
mwc-icon-button[active] {
color: var(--rich-action-active-color);
}
mwc-icon {
cursor: pointer;
}
`;
@property({ type: String }) command = "";
@property({ type: String }) value?: string;
@property({ type: String }) icon = "info";
@property({ type: Boolean }) active = false;
@property({ type: Array, hasChanged: () => true }) values: Option[] = [];
render() {
const { icon, command, value, active, values } = this;
const hasItems = values.length > 0;
return html`<section>
${hasItems
? html` <mwc-icon>${icon}</mwc-icon>
<select
@change=${(e: Event) => {
const event = e as CustomEvent;
const select = event.target as HTMLSelectElement;
const selectedValue = select.value;
if (selectedValue === "--") {
editorCommand("removeFormat", undefined);
} else {
editorCommand(command, selectedValue);
}
}}
>
${values.map(
(v) =>
html`<option value=${v.value} ?selected=${v.value === value}>
${v.name}
</option>`
)}
</select>`
: html`<mwc-icon-button
?active="${active}"
icon=${icon}
@click=${() => {
if (command) {
editorCommand(command, value);
} else {
this.dispatchEvent(
new Event("action", {
bubbles: true,
composed: true,
})
);
}
}}
></mwc-icon-button>`}
<div><slot></slot></div>
</section>`;
}
}
interface Option {
name: string;
value: string;
}
export function editorCommand(command: string, value?: string) {
document.execCommand(command, true, value);
}
declare global {
interface HTMLElementTagNameMap {
"rich-action": RichAction;
}
}
@@ -0,0 +1,130 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { live } from "../utils/live";
import "./rich-toolbar";
import "./rich-viewer";
@customElement("rich-text")
export class RichText extends LitElement {
static styles = css`
:host {
--rich-color: black;
--rich-background: white;
--rich-action-active-color: red;
--icon-size: 24px;
--rich-padding: 8px;
}
main {
height: 100%;
width: 100%;
display: grid;
grid-template-rows: 1fr auto;
grid-template-columns: 1fr;
grid-template-areas:
"viewer"
"toolbar";
}
rich-toolbar {
grid-area: toolbar;
width: 100%;
overflow-x: auto;
background-color: var(--rich-background);
color: var(--rich-color);
border-top: 1px solid var(--rich-color);
}
rich-viewer {
grid-area: viewer;
flex: 1;
width: 100%;
overflow-y: auto;
background-color: var(--rich-background);
color: var(--rich-color);
}
/* @media (hover: hover) and (pointer: fine) {
main {
grid-template-rows: auto 1fr;
grid-template-areas:
"toolbar"
"viewer";
}
rich-toolbar {
border-top: none;
border-bottom: 1px solid var(--rich-color);
}
} */
main {
grid-template-rows: auto 1fr;
grid-template-areas:
"toolbar"
"viewer";
}
rich-toolbar {
border-top: none;
border-bottom: 1px solid var(--rich-color);
}
@media (prefers-color-scheme: dark) {
:host {
--rich-background: black;
--rich-color: white;
}
}
`;
@live selection: Selection | null = null;
@property({ type: Boolean }) readonly = false;
@property({ type: Object, hasChanged: () => true }) node: Element =
document.createElement("div");
render() {
const { selection, readonly, node } = this;
return html`<main>
<rich-toolbar
.selection=${selection}
.node=${node}
@set-content=${(e: Event) => {
const event = e as CustomEvent<string>;
const parser = new DOMParser();
const doc = parser.parseFromString(event.detail, "text/html");
const root = doc.querySelector("body");
this.node.innerHTML = root?.innerHTML ?? "";
this.requestUpdate();
}}
></rich-toolbar>
<rich-viewer
?readonly=${readonly}
@selection=${(e: Event) => {
const event = e as CustomEvent;
this.selection = event.detail;
}}
.node=${node}
>
</rich-viewer>
</main>`;
}
firstUpdated() {
const children = this.children;
if (children.length > 0) {
// Check if <template> is the first child
const template = children[0];
if (template.tagName === "TEMPLATE") {
const content = template.innerHTML.trim();
if (content.length > 0) {
this.node.innerHTML = content;
this.requestUpdate();
}
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
"rich-text": RichText;
}
}
@@ -0,0 +1,284 @@
import { html, css, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { checkFonts } from "../utils/check-fonts";
import { live } from "../utils/live";
import "./rich-action";
import { editorCommand } from "./rich-action";
@customElement("rich-toolbar")
export class RichToolbar extends LitElement {
static styles = css`
header {
width: 100%;
color: var(--rich-color);
display: flex;
flex-direction: row;
}
input[type="color"] {
-webkit-appearance: none;
border: none;
width: var(--icon-size);
height: var(--icon-size);
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
}
`;
@live selection: Selection | null = null;
@query("#fg-color") fgColorInput!: HTMLInputElement;
@query("#bd-color") bdColorInput!: HTMLInputElement;
@state() fileHandle?: any;
@property({ type: Object, hasChanged: () => true }) node!: Element;
render() {
const tags = this.getTags();
return html`<header>
<rich-action icon="format_clear" command="removeFormat"></rich-action>
<rich-action
icon="format_bold"
command="bold"
?active=${tags.includes("b")}
></rich-action>
<rich-action
icon="format_italic"
command="italic"
?active=${tags.includes("i")}
></rich-action>
<rich-action
icon="format_underlined"
command="underline"
?active=${tags.includes("u")}
></rich-action>
<rich-action icon="format_align_left" command="justifyleft"></rich-action>
<rich-action
icon="format_align_center"
command="justifycenter"
></rich-action>
<rich-action
icon="format_align_right"
command="justifyright"
></rich-action>
<rich-action
icon="format_list_numbered"
command="insertorderedlist"
?active=${tags.includes("ol")}
></rich-action>
<rich-action
icon="format_list_bulleted"
command="insertunorderedlist"
?active=${tags.includes("ul")}
></rich-action>
<rich-action
icon="format_quote"
command="formatblock"
value="blockquote"
></rich-action>
<rich-action
icon="format_indent_decrease"
command="outdent"
></rich-action>
<rich-action icon="format_indent_increase" command="indent"></rich-action>
<rich-action
icon="add_link"
?active=${tags.includes("a")}
@action=${() => {
const newLink = prompt("Write the URL here", "https://");
// Check if valid url
if (newLink && newLink.match(/^(http|https):\/\/[^ "]+$/)) {
editorCommand("createlink", newLink);
}
}}
>
</rich-action>
<rich-action
icon="link_off"
?active=${tags.includes("a")}
command="unlink"
>
</rich-action>
<rich-action
icon="format_color_text"
@action=${() => this.fgColorInput.click()}
>
<input
type="color"
id="fg-color"
@input=${(e: Event) => {
const input = e.target as HTMLInputElement;
editorCommand("forecolor", input.value);
}}
/>
</rich-action>
<rich-action
icon="border_color"
@action=${() => this.bdColorInput.click()}
>
<input
type="color"
id="bd-color"
@input=${(e: Event) => {
const input = e.target as HTMLInputElement;
editorCommand("backcolor", input.value);
}}
/>
</rich-action>
<rich-action
icon="title"
command="formatblock"
.values=${[
{ name: "Normal Text", value: "--" },
{ name: "Heading 1", value: "h1" },
{ name: "Heading 2", value: "h2" },
{ name: "Heading 3", value: "h3" },
{ name: "Heading 4", value: "h4" },
{ name: "Heading 5", value: "h5" },
{ name: "Heading 6", value: "h6" },
{ name: "Paragraph", value: "p" },
{ name: "Pre-Formatted", value: "pre" },
]}
></rich-action>
<rich-action
icon="text_format"
command="fontname"
.values=${[
{ name: "Font Name", value: "--" },
...Array.from(checkFonts()).map((font) => ({
name: font,
value: font,
})),
]}
></rich-action>
<rich-action
icon="format_size"
command="fontsize"
.values=${[
{ name: "Font Size", value: "--" },
{ name: "Very Small", value: "1" },
{ name: "Small", value: "2" },
{ name: "Normal", value: "3" },
{ name: "Medium Large", value: "4" },
{ name: "Large", value: "5" },
{ name: "Very Large", value: "6" },
{ name: "Maximum", value: "7" },
]}
></rich-action>
<rich-action icon="undo" command="undo"></rich-action>
<rich-action icon="redo" command="redo"></rich-action>
<rich-action icon="content_cut" command="cut"></rich-action>
<rich-action icon="content_copy" command="copy"></rich-action>
<rich-action icon="content_paste" command="paste"></rich-action>
<rich-action
icon="file_upload"
@action=${async () => {
if ("showOpenFilePicker" in window) {
// File system api
// @ts-ignore
const [fileHandle] = await window.showOpenFilePicker();
this.fileHandle = fileHandle;
if (fileHandle) {
const file = await fileHandle.getFile();
const contents = await file.text();
this.dispatchEvent(
new CustomEvent("set-content", {
detail: contents,
bubbles: true,
composed: true,
})
);
}
} else {
// Fallback to input
const input = document.createElement("input");
input.type = "file";
input.click();
input.onchange = async () => {
const file = input.files![0];
if (file) {
const reader = new FileReader();
reader.onload = () => {
const data = reader.result as string;
this.dispatchEvent(
new CustomEvent("set-content", {
detail: data,
bubbles: true,
composed: true,
})
);
};
reader.readAsText(file);
}
};
}
}}
></rich-action>
<rich-action
icon="file_download"
@action=${async () => {
const contents = this.node.innerHTML;
if (this.fileHandle) {
const writable = await this.fileHandle.createWritable();
await writable.write(
[
`<!DOCTYPE html>`,
`<html lang="en">`,
` <head> </head>`,
` <body>${contents}</body>`,
`</html>`,
].join("\n")
);
await writable.close();
} else {
// Download file
const url = window.URL.createObjectURL(
new Blob([contents], { type: "text/html" })
);
const link = document.createElement("a");
link.href = url;
link.download = "index.html";
link.click();
}
}}
></rich-action>
</header>`;
}
getTags() {
const { selection } = this;
let tags: string[] = [];
if (selection) {
if (selection.type === "Range") {
// @ts-ignore
let parentNode = selection?.baseNode;
if (parentNode) {
const checkNode = () => {
const tag = parentNode?.tagName?.toLowerCase()?.trim();
if (tag) tags.push(tag);
};
while (parentNode != null) {
checkNode();
parentNode = parentNode?.parentNode;
}
}
// Remove root tag
tags.pop();
} else {
const content = this.selection?.toString() || "";
tags = (content.match(/<[^>]+>/g) || [])
.filter((tag) => !tag.startsWith("</"))
.map((tag) => tag.replace(/<|>/g, ""));
}
}
return tags;
}
}
declare global {
interface HTMLElementTagNameMap {
"rich-toolbar": RichToolbar;
}
}
@@ -0,0 +1,69 @@
import { html, css, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@customElement("rich-viewer")
export class RichViewer extends LitElement {
static styles = css`
article {
padding: var(--rich-padding);
width: calc(100% - var(--rich-padding) * 2);
height: calc(100% - var(--rich-padding) * 2);
}
article[contenteditable="true"] {
border: none;
outline: none;
}
`;
@query("#content") content!: HTMLDivElement;
@property({ type: Boolean }) readonly = false;
@property({ type: Object, hasChanged: () => true }) node!: Element;
render() {
const { readonly, node } = this;
return html`<article
id="content"
contenteditable=${readonly ? "false" : "true"}
@input=${() => this.updateSelection()}
>
${node}
</article>`;
}
updateSelection() {
// @ts-ignore
const shadowSelection = this.shadowRoot?.getSelection
? // @ts-ignore
this.shadowRoot!.getSelection()
: null;
const selection =
shadowSelection || document.getSelection() || window.getSelection();
this.dispatchEvent(
new CustomEvent("selection", {
detail: selection,
bubbles: true,
composed: true,
})
);
}
firstUpdated() {
document.execCommand("defaultParagraphSeparator", false, "br");
document.addEventListener("selectionchange", () => {
this.updateSelection();
});
window.addEventListener("selectionchange", () => {
this.updateSelection();
});
document.addEventListener("keydown", () => {
this.updateSelection();
});
}
}
declare global {
interface HTMLElementTagNameMap {
"rich-viewer": RichViewer;
}
}
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import "./components/rich-text";
import "./components/rich-viewer";
describe("Lit HTML Editor Components", () => {
test("registers rich markdown/HTML custom elements successfully", () => {
expect(customElements.get("rich-text")).toBeDefined();
expect(customElements.get("rich-viewer")).toBeDefined();
});
test("mounts rich-viewer correctly and accepts markdown content", async () => {
document.body.innerHTML = '<rich-viewer value="# Hello World"></rich-viewer>';
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("rich-viewer");
expect(element).toBeDefined();
expect(element?.getAttribute("value")).toBe("# Hello World");
});
});
@@ -0,0 +1,4 @@
export * from './components/rich-text';
export * from './components/rich-action';
export * from './components/rich-toolbar';
export * from './components/rich-viewer';
@@ -0,0 +1,138 @@
export function checkFonts(): string[] {
const fontCheck = new Set(
[
// Windows 10
"Arial",
"Arial Black",
"Bahnschrift",
"Calibri",
"Cambria",
"Cambria Math",
"Candara",
"Comic Sans MS",
"Consolas",
"Constantia",
"Corbel",
"Courier New",
"Ebrima",
"Franklin Gothic Medium",
"Gabriola",
"Gadugi",
"Georgia",
"HoloLens MDL2 Assets",
"Impact",
"Ink Free",
"Javanese Text",
"Leelawadee UI",
"Lucida Console",
"Lucida Sans Unicode",
"Malgun Gothic",
"Marlett",
"Microsoft Himalaya",
"Microsoft JhengHei",
"Microsoft New Tai Lue",
"Microsoft PhagsPa",
"Microsoft Sans Serif",
"Microsoft Tai Le",
"Microsoft YaHei",
"Microsoft Yi Baiti",
"MingLiU-ExtB",
"Mongolian Baiti",
"MS Gothic",
"MV Boli",
"Myanmar Text",
"Nirmala UI",
"Palatino Linotype",
"Segoe MDL2 Assets",
"Segoe Print",
"Segoe Script",
"Segoe UI",
"Segoe UI Historic",
"Segoe UI Emoji",
"Segoe UI Symbol",
"SimSun",
"Sitka",
"Sylfaen",
"Symbol",
"Tahoma",
"Times New Roman",
"Trebuchet MS",
"Verdana",
"Webdings",
"Wingdings",
"Yu Gothic",
// macOS
"American Typewriter",
"Andale Mono",
"Arial",
"Arial Black",
"Arial Narrow",
"Arial Rounded MT Bold",
"Arial Unicode MS",
"Avenir",
"Avenir Next",
"Avenir Next Condensed",
"Baskerville",
"Big Caslon",
"Bodoni 72",
"Bodoni 72 Oldstyle",
"Bodoni 72 Smallcaps",
"Bradley Hand",
"Brush Script MT",
"Chalkboard",
"Chalkboard SE",
"Chalkduster",
"Charter",
"Cochin",
"Comic Sans MS",
"Copperplate",
"Courier",
"Courier New",
"Didot",
"DIN Alternate",
"DIN Condensed",
"Futura",
"Geneva",
"Georgia",
"Gill Sans",
"Helvetica",
"Helvetica Neue",
"Herculanum",
"Hoefler Text",
"Impact",
"Lucida Grande",
"Luminari",
"Marker Felt",
"Menlo",
"Microsoft Sans Serif",
"Monaco",
"Noteworthy",
"Optima",
"Palatino",
"Papyrus",
"Phosphate",
"Rockwell",
"Savoye LET",
"SignPainter",
"Skia",
"Snell Roundhand",
"Tahoma",
"Times",
"Times New Roman",
"Trattatello",
"Trebuchet MS",
"Verdana",
"Zapfino",
].sort()
);
const fontAvailable = new Set<string>();
// @ts-ignore
for (const font of fontCheck.values()) {
// @ts-ignore
if (document.fonts.check(`12px "${font}"`)) {
fontAvailable.add(font);
}
}
// @ts-ignore
return fontAvailable.values();
}
@@ -0,0 +1,3 @@
import { property } from "lit/decorators.js";
export const live = property({ type: Object, hasChanged: () => true });
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import "./lit-html-table";
describe("Lit HTML Table Component", () => {
test("registers lit-html-table custom element successfully", () => {
expect(customElements.get("lit-html-table")).toBeDefined();
});
test("mounts lit-html-table spreadsheet grid successfully", async () => {
document.body.innerHTML = "<lit-html-table></lit-html-table>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("lit-html-table");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
});
});
@@ -0,0 +1,173 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
type ObjectData = { [key: string]: any };
@customElement("lit-html-table")
export class LitHtmlTable extends LitElement {
@property() src = "";
@property({ type: Boolean }) editable = false;
values?: ObjectData[];
static styles = css`
:host {
display: block;
width: 100%;
overflow-x: auto;
font-family: var(--table-font-family, sans-serif);
background-color: var(--table-background-color, #ffffff);
color: var(--table-text-color, #1f2937);
border-radius: var(--table-border-radius, 8px);
box-shadow: var(--table-box-shadow, 0 1px 3px 0 rgba(0, 0, 0, 0.1));
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
thead {
background-color: var(--table-header-bg, #f9fafb);
border-bottom: 2px solid var(--table-border-color, #e5e7eb);
}
th {
padding: var(--table-padding, 12px 16px);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: var(--table-header-color, #4b5563);
}
tr {
border-bottom: 1px solid var(--table-border-color, #e5e7eb);
transition: background-color 0.2s;
}
tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background-color: var(--table-row-hover-bg, #f3f4f6);
}
td {
padding: var(--table-padding, 12px 16px);
vertical-align: middle;
font-size: 0.875rem;
}
td > input {
width: 100%;
padding: 8px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
font-family: inherit;
font-size: inherit;
color: inherit;
transition: all 0.2s;
}
td > input:focus {
outline: none;
border-color: var(--table-focus-color, #6366f1);
background-color: #ffffff;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.loading, .empty {
padding: 32px;
text-align: center;
color: var(--table-text-secondary, #6b7280);
font-style: italic;
}
`;
render() {
// Check if data is loaded
if (!this.values) {
return html`<div class="loading"><slot name="loading">Loading data...</slot></div>`;
}
// Check if items are not empty
if (this.values.length === 0) {
return html`<div class="empty"><slot name="empty">No items found.</slot></div>`;
}
// Convert JSON to HTML Table
return html`
<table>
<thead>
<tr>
${Object.keys(this.values[0]).map((key) => {
const name = key.replace(/([A-Z])/g, " $1").replace(/^./, (str) => str.toUpperCase());
return html`<th>
<slot name="${key}">${name}</slot>
</th>`;
})}
</tr>
</thead>
<tbody>
${this.values.map((item, index) => {
return html`
<tr>
${Object.entries(item).map(([key, value]) => {
return html`<td>
${this.editable
? html`<input
.value="${value}"
type="text"
@input=${(e: any) => {
const newValue = e.target.value;
const current = { ...this.values![index] };
current[key] = newValue;
this.values![index] = current;
this.requestUpdate();
this.dispatchEvent(
new CustomEvent("input-cell", {
detail: {
index: index,
data: current,
},
})
);
}}
/>`
: html`${value}`}
</td>`;
})}
</tr>
`;
})}
</tbody>
</table>
`;
}
async firstUpdated() {
await this.fetchData();
}
async fetchData() {
if (this.values && this.values.length > 0) return;
let _data: any;
if (this.src.length > 0) {
_data = await fetch(this.src).then((res) => res.json());
} else {
const elem = this.parentElement?.querySelector(
'script[type="application/json"]'
) as HTMLScriptElement;
if (elem) _data = JSON.parse(elem.innerHTML);
}
_data ??= [];
this.values = this.transform(_data);
this.requestUpdate();
}
transform(data: any) {
return data;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import "./modules/app";
import "./modules/header";
import "./modules/counter";
describe("Lit Modules App Component", () => {
test("registers modules custom elements successfully", () => {
expect(customElements.get("app-module")).toBeDefined();
expect(customElements.get("header-module")).toBeDefined();
expect(customElements.get("counter-module")).toBeDefined();
});
test("mounts app-module successfully", async () => {
document.body.innerHTML = "<app-module></app-module>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("app-module");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
CounterModuleController,
counterModuleStyles,
counterModuleTemplate,
} from "./counter";
import { headerModuleStyles, headerModuleTemplate } from "./header";
export const appModuleStyles = [
css`
main {
display: flex;
flex-direction: column;
}
`,
headerModuleStyles,
counterModuleStyles,
];
export interface AppModuleProps {
name: string;
counter: CounterModuleController;
}
export function appModuleTemplate(props: AppModuleProps) {
return html`<main>
${headerModuleTemplate({
title: window.document.title,
})}
${counterModuleTemplate({
counter: props.counter,
})}
</main> `;
}
@customElement("app-module")
export class AppModule extends LitElement implements AppModuleProps {
static styles = appModuleStyles;
@property({ type: String }) name = "World";
counter = new CounterModuleController(this);
render() {
return appModuleTemplate(this);
}
}
@@ -0,0 +1,70 @@
import {
html,
css,
LitElement,
ReactiveController,
ReactiveControllerHost,
} from "lit";
import { customElement } from "lit/decorators.js";
export const counterModuleStyles = [css`
.counter {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.counter > .actions > * {
margin: 0.5rem;
}
.counter > span {
font-size: 1.5em;
}
`];
export class CounterModuleController implements ReactiveController {
constructor(public host: ReactiveControllerHost) {
host.addController(this);
}
value: number = 0;
hostConnected() {
this.value = 0;
}
increment() {
this.value++;
this.host.requestUpdate();
}
decrement() {
this.value--;
this.host.requestUpdate();
}
}
export interface CounterModuleProps {
counter: CounterModuleController;
}
export function counterModuleTemplate(props: CounterModuleProps) {
return html`<div class="counter">
<span>${props.counter.value}</span>
<div class="actions">
<button @click=${() => props.counter.increment()}>Increment +</button>
<button @click=${() => props.counter.decrement()}>Decrement -</button>
</div>
</div>`;
}
@customElement("counter-module")
export class CounterModule extends LitElement implements CounterModuleProps {
static styles = counterModuleStyles;
counter = new CounterModuleController(this);
render() {
return counterModuleTemplate(this);
}
}
@@ -0,0 +1,32 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export const headerModuleStyles = [css`
header {
height: 60px;
background-color: #f5f5f5;
color: #333;
display: flex;
align-items: center;
justify-content: center;
}
`];
export interface HeaderModuleProps {
title: string;
}
export function headerModuleTemplate(props: HeaderModuleProps) {
return html`<header>${props.title}</header>`;
}
@customElement("header-module")
export class HeaderModule extends LitElement implements HeaderModuleProps {
static styles = headerModuleStyles;
@property({ type: String }) title = "Lit Modules";
render() {
return headerModuleTemplate(this);
}
}
@@ -0,0 +1,734 @@
import { ReactiveController, ReactiveControllerHost } from "lit";
import { Store, BaseNode, NodeEdge, ID } from "../store";
import { BaseTreeNode, Tree } from "../ui/tree-view";
import { drawLabel } from "./label";
import { drawLine, getMidPoint, isPointOnLine } from "./line";
import {
applyDefaultMatrix,
applyMatrix,
createMatrix,
defaultMatrix,
MatrixContext,
matrixInfo,
toWorld,
} from "./matrix";
import { drawRect } from "./rect";
import { Offset, Rect, Size } from "./utils";
/**
* Editor Canvas
*/
export class Canvas implements ReactiveController {
constructor(
public host: ReactiveControllerHost,
props?: { canvas?: HTMLCanvasElement; size?: Size }
) {
this.host.addController(this);
this.canvas = props?.canvas ?? document.createElement("canvas");
this.ctx = this.canvas.getContext("2d")!;
this.ctx.imageSmoothingEnabled = true;
this.resize(props?.size);
this.init();
}
canvas: HTMLCanvasElement;
store = new Store<CanvasNode>();
action: Action = Action.NONE;
start?: Offset;
end?: Offset;
selection = new Array<ID>();
pointers: Map<number, Offset> = new Map();
mouse: Offset = { x: 0, y: 0 };
minScale = 0.1;
maxScale = 4.0;
gestureEvents = false;
lastScale = -1;
lastRotation = -1;
lastOffset: Offset = { x: 0, y: 0 };
rotationEnabled = true;
zoomEnabled = true;
panEnabled = true;
context: MatrixContext = defaultMatrix;
ctx: CanvasRenderingContext2D;
hostConnected() {}
hostDisconnected() {}
nodeTree(): Tree {
return {
children: this.store.nodes.map(
(n) => subTreeForNode(n, this.store, !this.selection.includes(n.id))!
),
};
}
get size(): Size {
return {
width: this.canvas.width,
height: this.canvas.height,
};
}
init() {
// Mouse Events
this.canvas.addEventListener(
"contextmenu",
(e) => e.preventDefault(),
false
);
this.canvas.addEventListener("wheel", (e) => this.onWheel(e), false);
// Pointer Events
this.canvas.addEventListener(
"pointerdown",
(e) => this.onPointerDown(e),
false
);
this.canvas.addEventListener(
"pointerover",
(e) => this.onPointerMove(e),
false
);
this.canvas.addEventListener(
"pointermove",
(e) => this.onPointerMove(e),
false
);
this.canvas.addEventListener(
"pointerup",
(e) => this.onPointerUp(e),
false
);
this.canvas.addEventListener(
"pointerleave",
(e) => this.onPointerUp(e),
false
);
this.canvas.addEventListener(
"pointercancel",
(e) => this.onPointerUp(e),
false
);
this.canvas.addEventListener(
"mousemove",
(e) => this.onMouseMove(e),
false
);
this.canvas.addEventListener(
"mousedown",
(e) => this.onMouseDown(e),
false
);
this.canvas.addEventListener("mouseup", (e) => this.onMouseUp(e), false);
// Gesture Events
this.canvas.addEventListener(
"gesturestart",
(e) => this.onGestureStart(e as GestureEvent),
false
);
this.canvas.addEventListener(
"gesturechange",
(e) => this.onGestureChange(e as GestureEvent),
false
);
this.canvas.addEventListener(
"gestureend",
(e) => this.onGestureEnd(e as GestureEvent),
false
);
// Keyboard Events
window.addEventListener("keydown", (e) => this.onKeyDown(e));
// window.addEventListener("mousewheel", (e) => {
// e.preventDefault();
// });
window.addEventListener("DOMMouseScroll", (e) => {
e.preventDefault();
});
// TODO: https://github.com/shuding/apple-pencil-safari-api-test
this.render();
this.host.requestUpdate();
}
render() {
this.paint();
requestAnimationFrame(() => this.render());
}
onWheel(e: WheelEvent) {
e.preventDefault();
if (this.gestureEvents) return;
if (e.ctrlKey) {
this.action = Action.ZOOM;
const scale = -e.deltaY * 0.01;
this.zoom(scale);
} else {
this.action = Action.PAN;
const offset = { x: -e.deltaX * 2, y: -e.deltaY * 2 };
this.pan(offset);
}
this.host.requestUpdate();
this.action = Action.NONE;
}
onGestureStart(e: GestureEvent) {
e.preventDefault();
this.gestureEvents = true;
this.lastScale = e.scale;
this.lastRotation = e.rotation;
this.lastOffset = { x: e.clientX, y: e.clientY };
}
onGestureChange(e: GestureEvent) {
e.preventDefault();
this.gestureEvents = true;
const { scale, offset, rotation } = matrixInfo(this.context);
let localRotation = rotation;
let localScale = scale;
let localOffset = offset;
const rotationDelta = (this.lastRotation - e.rotation) * 0.01;
localRotation -= rotationDelta;
this.lastRotation = e.rotation;
const scaleDelta = (this.lastScale - e.scale) * 1;
localScale -= scaleDelta;
this.lastScale = e.scale;
const offsetDelta = {
x: (this.lastOffset.x - e.clientX) * 0.01,
y: (this.lastOffset.y - e.clientY) * 0.01,
};
localOffset.x -= offsetDelta.x * localScale;
localOffset.y -= offsetDelta.y * localScale;
this.lastOffset = { x: e.clientX, y: e.clientY };
this.context = createMatrix(localOffset, localScale, localRotation);
this.host.requestUpdate();
applyMatrix(this.ctx, this.context);
}
onGestureEnd(e: GestureEvent) {
e.preventDefault();
this.gestureEvents = false;
}
onMouseUp(e: MouseEvent) {
this.mouse = { x: e.offsetX, y: e.offsetY };
}
onMouseDown(e: MouseEvent) {
this.mouse = { x: e.offsetX, y: e.offsetY };
}
onMouseMove(e: MouseEvent) {
this.mouse = { x: e.offsetX, y: e.offsetY };
}
onPointerDown(e: PointerEvent) {
const point = { x: e.offsetX, y: e.offsetY };
this.pointers.set(e.pointerId, point);
this.canvas.setPointerCapture(e.pointerId);
this.start = point;
this.end = point;
const mouseOffset = toWorld(this.context, point);
if (this.selection.length > 1) {
this.action = Action.MOVE;
return;
}
const nodes = this.selectOffset(mouseOffset, this.selection, e.shiftKey);
if (nodes.length === 0) {
this.selection = [];
this.host.requestUpdate();
}
this.action =
nodes.length > 0
? e.shiftKey
? Action.LINK
: Action.MOVE
: Action.MARQUEE;
if (nodes.length > 0) {
const nodeId = nodes[nodes.length - 1];
if (this.action === Action.MOVE) {
this.selection = [nodeId];
}
}
}
onPointerMove(e: PointerEvent) {
const point = { x: e.offsetX, y: e.offsetY };
const pointerId = e.pointerId;
const pointer = this.pointers.get(pointerId);
if (pointer) {
const delta = {
x: point.x - pointer.x,
y: point.y - pointer.y,
};
this.end = point;
if (this.action === Action.MOVE) {
for (const id of this.selection) {
const node = this.store.retrieveNode(id);
if (node) {
this.moveNode(node, delta);
}
}
}
this.pointers.set(pointerId, point);
}
}
onPointerUp(e: PointerEvent) {
this.canvas.releasePointerCapture(e.pointerId);
this.pointers.delete(e.pointerId);
if (this.start && this.end) {
const start = toWorld(this.context, this.start);
const end = toWorld(this.context, this.end);
const topLeft = {
x: Math.min(start.x, end.x),
y: Math.min(start.y, end.y),
};
const bottomRight = {
x: Math.max(start.x, end.x),
y: Math.max(start.y, end.y),
};
if (this.action === Action.LINK) {
const multi = e.shiftKey;
const startType = this.checkOffsetType(start, multi);
const endType = this.checkOffsetType(end, multi);
if (startType === "node" && endType === "node") {
const startNode = this.getOffset(start, multi) as CanvasNode;
const endNode = this.getOffset(end, multi) as CanvasNode;
this.store.linkNodes(startNode, endNode, "simple");
}
} else if (this.action === Action.MARQUEE) {
if (e.shiftKey) {
this.selection = [];
}
const overlappingRect: Rect = {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y,
};
// Check if any nodes are selected by overlapping rect
const nodes = this.store.nodes;
const selectedNodes: CanvasNode[] = [];
for (const node of nodes) {
// Check if node overlaps rect
const nodeRect = {
x: node.x,
y: node.y,
width: node.width,
height: node.height,
};
if (
nodeRect.x < overlappingRect.x + overlappingRect.width &&
nodeRect.x + nodeRect.width > overlappingRect.x &&
nodeRect.y < overlappingRect.y + overlappingRect.height &&
nodeRect.y + nodeRect.height > overlappingRect.y
) {
selectedNodes.push(node);
}
}
for (const node of selectedNodes) {
this.selection.push(node.id);
}
} else {
this.selection = this.selectOffset(end, this.selection, e.shiftKey);
this.host.requestUpdate();
}
}
this.start = undefined;
this.end = undefined;
this.action = Action.NONE;
this.host.requestUpdate();
}
getOffset(offset: Offset, multi: boolean): CanvasNode | undefined {
const selection = this.selectOffset(offset, this.selection, multi);
if (selection.length > 1) {
const node = this.store.retrieveNode(selection[selection.length - 1]);
if (node) return node;
}
return undefined;
}
checkOffsetType(offset: Offset, multi: boolean): "none" | "node" | "link" {
const node = this.getOffset(offset, multi);
if (node) return node.type;
return "none";
}
onKeyDown(e: KeyboardEvent) {
// Delete current selection
if (e.key === "Backspace" || e.key === "Delete") {
this.deleteSelection();
}
// Zoom canvas
const zoomStep = 0.1;
if (e.key === "+" || e.key === "=") {
this.zoom(zoomStep);
}
if (e.key === "-") {
this.zoom(-zoomStep);
}
// Move with arrow keys
const moveStep = 10;
if (e.key === "ArrowUp") {
this.pan({ x: 0, y: -moveStep });
}
if (e.key === "ArrowDown") {
this.pan({ x: 0, y: moveStep });
}
if (e.key === "ArrowLeft") {
this.pan({ x: -moveStep, y: 0 });
}
if (e.key === "ArrowRight") {
this.pan({ x: moveStep, y: 0 });
}
// Select all
if (e.key === "a" && e.metaKey) {
this.selection = this.store.nodes.map((n) => n.id);
this.host.requestUpdate();
}
}
zoom(amount: number) {
const { scale, offset, rotation } = matrixInfo(this.context);
let localScale = scale;
localScale += amount;
this.context = createMatrix(offset, localScale, rotation);
}
pan(delta: Offset) {
const { offset, scale, rotation } = matrixInfo(this.context);
let localOffset = offset;
localOffset.x += delta.x / scale;
localOffset.y += delta.y / scale;
this.context = createMatrix(localOffset, scale, rotation);
}
rotate(amount: number) {
const { rotation, offset, scale } = matrixInfo(this.context);
let localRotation = rotation;
localRotation += amount;
this.context = createMatrix(offset, scale, localRotation);
}
import(value: string) {
this.store = Store.fromJson<CanvasNode>(value);
}
deleteNode(node: CanvasNode) {
this.store.deleteNode(node.id);
this.clear();
this.host.requestUpdate();
}
deleteEdge(edge: NodeEdge) {
this.store.deleteEdge(edge.id);
this.clear();
this.host.requestUpdate();
}
resize(size?: Size) {
const width = size?.width ?? window.innerWidth;
const height = size?.height ?? window.innerHeight;
this.canvas.setAttribute("width", `${width}px`);
this.canvas.setAttribute("height", `${height}px`);
this.canvas.width = width;
this.canvas.height = height;
}
deleteSelection() {
console.log("delete selection", this.selection);
for (const id of this.selection) {
const node = this.store.retrieveNode(id);
if (node) this.store.deleteNode(node.id);
const edge = this.store.retrieveEdge(id);
if (edge) this.store.deleteEdge(edge.id);
}
this.selection = [];
this.host.requestUpdate();
}
selectOffset(offset: Offset, nodes: string[], multi: boolean) {
let selection = [...nodes];
// Clear selection
if (!multi) selection = [];
// Check current offset for selection
const overlappedNodes = this.store.nodes.filter((node) => {
const overlaps =
offset.x >= node.x &&
offset.x <= node.x + node.width &&
offset.y >= node.y &&
offset.y <= node.y + node.height;
return overlaps;
});
// Select nodes
if (overlappedNodes.length > 0) {
const topNode = overlappedNodes[overlappedNodes.length - 1];
selection.push(topNode.id);
}
// If shift is not selected return early
if (!multi && selection.length > 0) {
return selection;
}
// Select edges
const overlappedEdges = this.store.edges.filter((edge) => {
const { start, end } = this.getEdgePoints(edge);
const overlaps = isPointOnLine(start, end, offset);
return overlaps;
});
if (overlappedEdges.length > 0) {
const topEdge = overlappedEdges[overlappedEdges.length - 1];
selection.push(topEdge.id);
}
return selection;
}
get scale(): number {
const { scale } = matrixInfo(this.context);
return scale;
}
get rotation(): number {
const { rotation } = matrixInfo(this.context);
return rotation;
}
get offset(): Offset {
const { offset } = matrixInfo(this.context);
return offset;
}
moveNode(node: CanvasNode, delta: Offset) {
const { scale } = matrixInfo(this.context);
node.x += delta.x / scale;
node.y += delta.y / scale;
this.store.updateNode(node);
}
clear() {
this.selection = [];
this.host.requestUpdate();
}
paint() {
applyDefaultMatrix(this.ctx);
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
drawRect(this.ctx, {
x: 0,
y: 0,
width: this.canvas.width,
height: this.canvas.height,
fillColor: "whitesmoke",
});
applyMatrix(this.ctx, this.context);
const mouse = this.mouse;
const mouseOffset = toWorld(this.context, mouse);
// Draw edges
for (const edge of this.store.edges) {
const isSelected = this.selection.includes(edge.id);
const { start, end, mid } = this.getEdgePoints(edge);
// Draw line
const ctx = this.ctx;
drawLine(ctx, {
start,
end,
strokeColor: isSelected ? "red" : "black",
});
// Check if point is anywhere on the line
const overlaps = isPointOnLine(start, end, mouseOffset);
if (overlaps && !isSelected) {
ctx.strokeStyle = "blue";
ctx.stroke();
}
// Draw label
drawLabel(this.ctx, {
text: edge.name,
textAlign: "center",
x: mid.x,
y: mid.y,
fillColor: "black",
});
}
// Draw nodes
for (const node of this.store.nodes) {
const isSelected = this.selection.includes(node.id);
const ctx = this.ctx;
// Draw label
drawLabel(ctx, {
text: node.name,
x: node.x,
y: node.y - 5,
fillColor: "black",
});
drawRect(ctx, {
x: node.x,
y: node.y,
width: node.width,
height: node.height,
strokeColor: isSelected ? "red" : "black",
fillColor: "white",
});
const overlaps =
mouseOffset.x >= node.x &&
mouseOffset.x <= node.x + node.width &&
mouseOffset.y >= node.y &&
mouseOffset.y <= node.y + node.height;
if (overlaps && !isSelected) {
ctx.strokeStyle = "blue";
ctx.strokeRect(node.x, node.y, node.width, node.height);
}
}
if (this.start && this.end) {
const localStart = toWorld(this.context, this.start);
const localEnd = toWorld(this.context, this.end);
if (this.action == Action.LINK) {
// Render line to link
drawLine(this.ctx, {
start: localStart,
end: localEnd,
strokeColor: "red",
});
}
if (this.action == Action.MARQUEE) {
// Render marquee
drawRect(this.ctx, {
x: Math.min(localStart.x, localEnd.x),
y: Math.min(localStart.y, localEnd.y),
width: Math.abs(localStart.x - localEnd.x),
height: Math.abs(localStart.y - localEnd.y),
fillColor: "rgba(135, 206, 235, 0.2)",
strokeColor: "rgba(135, 206, 235, 0.5)",
});
}
}
if (this.selection.length > 1) {
const nodes = this.store.nodes.filter((node) =>
this.selection.includes(node.id)
);
const topY = Math.min(...nodes.map((node) => node.y));
const leftX = Math.min(...nodes.map((node) => node.x));
const bottomY = Math.max(...nodes.map((node) => node.y + node.height));
const rightX = Math.max(...nodes.map((node) => node.x + node.width));
const topLeft: Offset = { x: leftX, y: topY };
const bottomRight: Offset = { x: rightX, y: bottomY };
if (topLeft && bottomRight) {
drawRect(this.ctx, {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y,
fillColor: "transparent",
strokeColor: "blue",
});
}
}
}
private getEdgePoints(edge: NodeEdge) {
const startNode = this.store.retrieveNode(edge.startNode)!;
const endNode = this.store.retrieveNode(edge.endNode)!;
const createOffset = (node: CanvasNode) => ({
x: node.x + node.width / 2,
y: node.y + node.height / 2,
width: node.width,
height: node.height,
});
const start = createOffset(startNode);
const end = createOffset(endNode);
const mid = getMidPoint(start, end);
return { start, end, mid };
}
}
enum Action {
NONE,
ZOOM,
PAN,
MOVE,
LINK,
MARQUEE,
}
type PositionMixin = BaseNode & Rect;
/**
* Canvas Node
*/
export interface CanvasNode extends PositionMixin {
backgroundColor?: string;
type: "node";
}
interface GestureEvent extends MouseEvent {
rotation: number;
scale: number;
}
function subTreeForNode(
node: CanvasNode,
store: Store<CanvasNode>,
collapsed: boolean = false,
lookup: Map<string, boolean> = new Map()
): BaseTreeNode | null {
if (lookup.has(node.id)) {
return null;
}
lookup.set(node.id, true);
const children: BaseTreeNode[] = [];
const edges = store.retrieveEdgesForNode(node.id);
for (const edge of edges) {
const edgeChildren: BaseTreeNode[] = [];
const endNode = store.retrieveNode(edge.endNode)!;
const endSubTree = subTreeForNode(endNode, store, collapsed, lookup);
if (endSubTree) edgeChildren.push(endSubTree);
const startNode = store.retrieveNode(edge.startNode)!;
const startSubTree = subTreeForNode(startNode, store, collapsed, lookup);
if (startSubTree) edgeChildren.push(startSubTree);
children.push({
id: edge.id,
name: edge.name,
children: edgeChildren,
collapsed: false,
});
}
return {
id: node.id,
name: node.name,
children,
collapsed: collapsed,
};
}
@@ -0,0 +1,63 @@
import { ColorMixin, Offset, Size } from "./utils";
interface LabelOptions extends Offset, ColorMixin {
text: string;
maxWidth?: number;
fontFamily?: string;
fontSize?: number;
fontStyle?: "normal" | "italic" | "oblique" | "initial" | "inherit";
fontWeight?: string;
textAlign?: "left" | "center" | "right";
lineHeight?: number;
textBaseline?:
| "top"
| "hanging"
| "middle"
| "alphabetic"
| "ideographic"
| "bottom";
}
const DEFAULT_TEXT_HEIGHT = 8;
export function drawLabel(
ctx: CanvasRenderingContext2D,
options: LabelOptions
): Size {
const {
x = 0,
y = 0,
maxWidth,
fontFamily = "Roboto",
fontSize = DEFAULT_TEXT_HEIGHT,
fontStyle = "normal",
fontWeight = "normal",
textAlign = "left",
textBaseline = "alphabetic",
lineHeight = 1.2,
text,
fillColor,
strokeColor,
} = options;
ctx.save();
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${lineHeight}em ${fontFamily}`;
ctx.textAlign = textAlign;
ctx.textBaseline = textBaseline;
if (fillColor) {
ctx.fillStyle = fillColor;
ctx.fillText(text, x, y, maxWidth);
}
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.strokeText(text, x, y, maxWidth);
}
ctx.restore();
// Measure the text
const metrics = ctx.measureText(options.text);
const height = fontSize * lineHeight;
return { height, width: metrics.width };
}
@@ -0,0 +1,77 @@
import { ColorMixin, Offset } from "./utils";
interface LineOptions extends ColorMixin {
start: Offset;
end: Offset;
}
/**
* Render a line between two points
*
* @param ctx Canvas context
* @param options Line options
*/
export function drawLine(ctx: CanvasRenderingContext2D, options: LineOptions) {
const { start, end, fillColor, strokeColor } = options;
ctx.save();
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
if (fillColor) {
ctx.fillStyle = fillColor;
ctx.fill();
}
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.stroke();
}
ctx.restore();
}
/**
* Check if a point is inside a line
*
* @param start Start point
* @param end End point
* @param target Offset to check
* @param tolerance Tolerance
*/
export function isPointOnLine(
start: Offset,
end: Offset,
target: Offset,
tolerance = 1
): boolean {
// if line is vertical
if (start.x === end.x) {
return Math.abs(target.x - start.x) <= tolerance;
}
// if line is horizontal
if (start.y === end.y) {
return Math.abs(target.y - start.y) <= tolerance;
}
// if line is diagonal
const slope = (end.y - start.y) / (end.x - start.x);
const intercept = start.y - slope * start.x;
const y = slope * target.x + intercept;
return Math.abs(y - target.y) <= tolerance;
}
/**
* Calculate the midpoint of a line
*
* @param start Start point
* @param end End point
* @returns Midpoint
*/
export function getMidPoint(start: Offset, end: Offset): Offset {
return {
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
};
}
@@ -0,0 +1,192 @@
import { Offset } from "./utils";
/**
* Matrix
*/
export type Matrix = number[];
const DEFAULT_MATRIX: Matrix = [1, 0, 0, 1, 0, 0];
const DEFAULT_INVERSE_MATRIX: Matrix = [1, 0, 0, 1];
/**
* Matrix context
*/
export interface MatrixContext {
matrix: Matrix;
inverseMatrix: Matrix;
}
/**
* Default matrix context
*/
export const defaultMatrix = createMatrix({ x: 0, y: 0 }, 1, 0);
/**
* Create a matrix and inverse matrix
*
* @param offset - offset
* @param scale - scale factor
* @param rotation - in radians
*
* @link https://stackoverflow.com/a/34598847/7303311
*/
export function createMatrix(
offset: Offset,
scale: number,
rotation: number
): MatrixContext {
const m = [...DEFAULT_MATRIX];
const im = [...DEFAULT_INVERSE_MATRIX];
m[3] = m[0] = Math.cos(rotation) * scale;
m[2] = -(m[1] = Math.sin(rotation) * scale);
m[4] = offset.x;
m[5] = offset.y;
const cross = m[0] * m[3] - m[1] * m[2];
im[0] = m[3] / cross;
im[1] = -m[1] / cross;
im[2] = -m[2] / cross;
im[3] = m[0] / cross;
return {
matrix: m,
inverseMatrix: im,
};
}
/**
* Create a local offset from a global offset
*
* @param matrix - matrix
* @param inverseMatrix - inverse matrix
* @param offset - offset
*
* @link https://stackoverflow.com/a/34598847/7303311
*/
export function toWorld(context: MatrixContext, offset: Offset): Offset {
let xx, yy, m;
m = context.inverseMatrix;
xx = offset.x - context.matrix[4];
yy = offset.y - context.matrix[5];
const localX = xx * m[0] + yy * m[2];
const localY = xx * m[1] + yy * m[3];
return {
x: localX,
y: localY,
};
}
/**
* Matrix info
*
* @param matrix - matrix
* @returns scale, offset, rotation
*/
export function matrixInfo(context: MatrixContext) {
const rotation = rotationFromMatrix(context);
const { scale } = scaleFromMatrix(context);
const offset = offsetFromMatrix(context);
return {
rotation,
scale,
offset,
};
}
/**
* Get rotation from matrix
*
* @param matrix - matrix
* @returns rotation in radians
*/
export function rotationFromMatrix(context: MatrixContext) {
const matrix = context.matrix;
const rad = Math.atan2(matrix[1], matrix[0]);
return rad;
}
/**
* Get scale factor from matrix
*
* @param matrix - matrix
* @returns scale factor
*/
export function scaleFromMatrix(context: MatrixContext): {
scaleX: number;
scaleY: number;
scale: number;
} {
const matrix = context.matrix;
const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]);
const scaleY = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]);
return {
scaleX,
scaleY,
scale: Math.max(scaleX, scaleY),
};
}
/**
* Get offset from matrix
*
* @param matrix - matrix
* @returns offset
*/
export function offsetFromMatrix(context: MatrixContext) {
const matrix = context.matrix;
return {
x: matrix[4],
y: matrix[5],
};
}
/**
* Apply matrix to canvas
*
* @param ctx - canvas context
* @param matrix - matrix
*/
export function applyMatrix(
ctx: CanvasRenderingContext2D,
context: MatrixContext
) {
const m = context.matrix;
ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
}
/**
* Apply default matrix to canvas
*
* @param ctx - canvas context
*/
export function applyDefaultMatrix(ctx: CanvasRenderingContext2D) {
const m = DEFAULT_MATRIX;
ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
}
/**
* Get matrix from canvas
*
* @param ctx - canvas context
* @returns Matrix context
*/
export function getMatrixFromCanvas(
ctx: CanvasRenderingContext2D
): MatrixContext {
const transform = ctx.getTransform();
const m = [...DEFAULT_MATRIX];
const im = [...DEFAULT_INVERSE_MATRIX];
m[0] = transform.a;
m[1] = transform.b;
m[2] = transform.c;
m[3] = transform.d;
m[4] = transform.e;
m[5] = transform.f;
const cross = m[0] * m[3] - m[1] * m[2];
im[0] = m[3] / cross;
im[1] = -m[1] / cross;
im[2] = -m[2] / cross;
im[3] = m[0] / cross;
return {
matrix: m,
inverseMatrix: im,
};
}
@@ -0,0 +1,31 @@
import { ColorMixin, Rect } from "./utils";
interface RectOptions extends Rect, ColorMixin {}
/**
* Draw a rectangle on the canvas
*
* @param ctx Canvas context
* @param options Options for the rectangle
*/
export function drawRect(ctx: CanvasRenderingContext2D, options: RectOptions) {
const {
x = 0,
y = 0,
width = 0,
height = 0,
fillColor,
strokeColor,
} = options;
ctx.rect(x, y, width, height);
if (fillColor) {
ctx.fillStyle = fillColor;
ctx.fillRect(x, y, width, height);
}
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.strokeRect(x, y, width, height);
}
}
@@ -0,0 +1,28 @@
/**
* X and y
*/
export interface Offset {
x: number;
y: number;
}
/**
* Width and height
*/
export interface Size {
width: number;
height: number;
}
/**
* Rect with offset and size
*/
export type Rect = Offset & Size;
/**
* Color for outline and fill
*/
export interface ColorMixin {
fillColor?: string;
strokeColor?: string;
}
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import "./node-editor";
import "./ui/tree-view";
describe("Lit Node Editor Component", () => {
test("registers visual node editor custom elements successfully", () => {
expect(customElements.get("node-editor")).toBeDefined();
expect(customElements.get("tree-view")).toBeDefined();
});
test("mounts node-editor to the body successfully", async () => {
document.body.innerHTML = "<node-editor></node-editor>";
await new Promise((r) => setTimeout(r, 100));
const element = document.body.querySelector("node-editor");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
});
});
@@ -0,0 +1,314 @@
import "./styles.css";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { CanvasNode, Canvas } from "./canvas";
import { BaseTreeNode, styles as treeStyles, template as treeTemplate } from "./ui/tree-view";
const PROPERTY_WIDTH = 200;
@customElement("node-editor")
export class NodeEditor extends LitElement {
editor = new Canvas(this, {
size: {
width: window.innerWidth - PROPERTY_WIDTH * 2,
height: window.innerHeight,
},
});
static styles = [
treeStyles,
css`
main {
height: 100vh;
width: 100%;
display: flex;
flex-direction: row;
}
#output {
flex: 1;
}
.sidebar {
width: ${PROPERTY_WIDTH}px;
}
#properties {
width: ${PROPERTY_WIDTH}px;
display: flex;
flex-direction: column;
overflow-y: scroll;
background-color: #eee;
}
.property {
display: flex;
flex-direction: column;
padding: 10px;
}
.title {
font-size: 1.5em;
font-weight: bold;
padding: 10px;
}
.destructive {
background-color: red;
color: white;
}
#links > span {
padding-left: 10px;
font-size: 0.9em;
font-weight: bold;
}
`,
];
render() {
return html`<main>
<div class="sidebar">
${treeTemplate({
tree: this.editor.nodeTree(),
onUpdate: () => this.requestUpdate(),
onSelect: (node: BaseTreeNode) => {
this.editor.selection.push(node.id);
},
})}
</div>
<div id="output">${this.editor.canvas}</div>
<div id="properties">${this.renderProperties()}</div>
</main>`;
}
renderProperties() {
const nodeId = this.editor.selection[this.editor.selection.length - 1];
const node =
this.editor.store.retrieveNode(nodeId) ??
this.editor.store.retrieveEdge(nodeId);
if (node?.type === "node") {
return html`
<span class="title">Node</span>
<div class="property">
<label>Name</label>
<input
type="text"
.value=${node.name}
@change=${(e: any) => {
node.name = e.target.value;
this.editor.store.updateNode(node);
}}
/>
</div>
<div class="property">
<label>Background Color</label>
<input
type="color"
.value=${node.backgroundColor ?? "#FFFFFF"}
@change=${(e: any) => {
node.backgroundColor = e.target.value;
this.editor.store.updateNode(node);
}}
/>
</div>
<div class="property">
<label>Width</label>
<input
type="number"
.value=${node.width.toString()}
@change=${(e: any) => {
node.width = Number(e.target.value);
this.editor.store.updateNode(node);
}}
/>
</div>
<div class="property">
<label>Height</label>
<input
type="number"
.value=${node.height.toString()}
@change=${(e: any) => {
node.height = Number(e.target.value);
this.editor.store.updateNode(node);
}}
/>
</div>
<div class="property">
<button
class="destructive"
@click=${() => {
if (confirm("Are you sure?")) {
this.editor.deleteNode(node);
}
}}
>
Delete node
</button>
</div>
`;
}
if (node?.type === "edge") {
return html` <span class="title">Edge</span>
<div class="property">
<label>Name</label>
<input
type="text"
.value=${node.name}
@change=${(e: any) => {
node.name = e.target.value;
this.editor.store.updateEdge(node);
}}
/>
</div>
<div class="property">
<button
class="destructive"
@click=${() => {
if (confirm("Are you sure?")) {
this.editor.deleteEdge(node);
}
}}
>
Delete node
</button>
</div>`;
}
return html` <span class="title">Editor</span>
<div>
<div class="property">
<label>Import JSON</label>
<input
type="file"
accept=".json"
@change=${(e: any) => {
const files = e.target.files;
if (files.length) {
this.editor.clear();
const reader = new FileReader();
reader.onload = (e: any) => {
const data = e.target.result;
this.editor.import(data);
};
reader.readAsText(files[0]);
}
}}
/>
</div>
<div class="property">
<label>Scale</label>
<input
type="number"
.value=${this.editor.scale.toString()}
step=".1"
@change=${(e: any) => {
this.editor.zoom(Number(e.target.value));
}}
/>
</div>
<div class="property">
<label>Rotation</label>
<input
type="number"
.value=${this.editor.rotation.toString()}
step=".1"
@change=${(e: any) => {
this.editor.rotate(Number(e.target.value));
}}
/>
</div>
<div class="property">
<label>Offset x</label>
<input
type="number"
.value=${this.editor.offset.x.toString()}
step=".1"
@change=${(e: any) => {
this.editor.pan({
x: Number(e.target.value),
y: this.editor.offset.y,
});
}}
/>
</div>
<div class="property">
<label>Offset x</label>
<input
type="number"
.value=${this.editor.offset.y.toString()}
step=".1"
@change=${(e: any) => {
this.editor.pan({
x: this.editor.offset.x,
y: Number(e.target.value),
});
}}
/>
</div>
<div class="property">
<button
@click=${() => {
const node = this.addRandomNode();
this.editor.selection.push(node.id);
this.requestUpdate();
}}
>
Add new node
</button>
</div>
<div class="property">
<button
@click=${() => {
const a = window.document.createElement("a");
const json = this.editor.store.toJson();
a.href = window.URL.createObjectURL(
new Blob([json], { type: "application/json" })
);
a.download = "editor.json";
document.body.appendChild(a);
a.click();
}}
>
Export JSON
</button>
</div>
</div>`;
}
firstUpdated() {
const amount = 10;
// Create random canvas nodes
for (let i = 0; i < amount; i++) {
this.addRandomNode(i);
}
// Link random canvas nodes
for (let i = 0; i < amount / 2; i++) {
const source: CanvasNode = this.editor.store.nodes[i];
const target: CanvasNode = this.editor.store.nodes[i + amount / 2];
this.editor.store.linkNodes(source, target, "simple");
}
window.addEventListener("resize", () => {
this.editor.resize({
width: window.innerWidth - PROPERTY_WIDTH,
height: window.innerHeight,
});
});
}
addRandomNode(i: number = this.editor.store.nodes.length) {
const node: CanvasNode = {
id: `node${i}`,
name: "Node " + i,
x: Math.random() * this.editor.canvas.width,
y: Math.random() * this.editor.canvas.height,
width: 100,
height: 100,
type: "node",
};
this.editor.store.createNode(node);
return node;
}
}
declare global {
interface HTMLElementTagNameMap {
"node-editor": NodeEditor;
}
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Node or Edge ID type
*/
export type ID = string;
/**
* Node store with NodeEdges
*/
export class Store<T extends BaseNode> {
nodes: T[] = [];
edges: NodeEdge[] = [];
/**
* Parse a JSON object into a Store
*
* @param value JSON String
* @returns Store
*/
static fromJson<T extends BaseNode>(value: string) {
const { nodes, edges } = JSON.parse(value);
const store = new Store<T>();
if (nodes) store.nodes = nodes;
if (edges) store.edges = edges;
return store;
}
/**
* Save the store to a JSON string
*
* @returns JSON String
*/
toJson() {
const value = {
nodes: this.nodes,
edges: this.edges,
};
return JSON.stringify(value, null, 2);
}
private getNodeIndex(id: ID): number {
return this.nodes.findIndex((n) => n.id === id);
}
createNode(node: T): void {
const index = this.getNodeIndex(node.id);
if (index === -1) {
this.nodes.push(node);
} else {
this.nodes[index] = node;
}
}
retrieveNode(id: ID): T | undefined {
return this.nodes.find((node) => node.id === id);
}
retrieveEdgesForNode(id: ID): NodeEdge[] {
return this.edges.filter(
(edge) => edge.startNode === id || edge.endNode === id
);
}
updateNode(node: T): void {
const index = this.getNodeIndex(node.id);
if (index === -1) return;
this.nodes[index] = node;
}
deleteNode(id: ID): void {
const index = this.getNodeIndex(id);
if (index === -1) return;
this.nodes.splice(index, 1);
// Delete connected edges
const nodeEdges = this.retrieveEdgesForNode(id);
for (const edge of nodeEdges) {
this.deleteEdge(edge.id);
}
}
private getEdgeIndex(id: ID): number {
return this.edges.findIndex((n) => n.id === id);
}
createEdge(edge: NodeEdge): void {
const index = this.getEdgeIndex(edge.id);
if (index === -1) {
this.edges.push(edge);
} else {
this.edges[index] = edge;
}
}
retrieveEdge(id: ID): NodeEdge | undefined {
return this.edges.find((edge) => edge.id === id);
}
updateEdge(edge: NodeEdge): void {
const index = this.getEdgeIndex(edge.id);
if (index === -1) return;
this.edges[index] = edge;
}
deleteEdge(id: ID): void {
const index = this.getEdgeIndex(id);
if (index === -1) return;
this.edges.splice(index, 1);
}
linkNodes(start: T, end: T, name: string): void {
const randomId = generateRandomId();
this.createEdge({
id: randomId,
startNode: start.id,
endNode: end.id,
name,
type: "edge",
});
}
}
/**
* Node Edge
*/
export interface NodeEdge extends BaseNode {
startNode: ID;
endNode: ID;
type: "edge";
}
/**
* Base Node
*/
export interface BaseNode {
id: ID;
name: string;
readonly type: "node" | "edge";
}
function generateRandomId(): string {
return (
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15)
);
}
+10
View File
@@ -0,0 +1,10 @@
body {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
}
node-editor {
width: 100%;
height: 100%;
}
@@ -0,0 +1,137 @@
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
/**
* Tree view styles
*/
export const styles = css`
ul.tree {
width: 100%;
height: 100%;
overflow-x: scroll;
white-space: nowrap;
}
ul.tree,
ul.tree ul {
list-style: none;
margin: 0;
padding: 0;
}
ul.tree ul {
margin-left: 10px;
}
ul.tree li {
margin: 0;
padding: 0 7px;
line-height: 20px;
color: #369;
font-weight: bold;
border-left: 1px solid rgb(100, 100, 100);
}
ul.tree li:last-child {
border-left: none;
}
ul.tree li:before {
position: relative;
top: -0.3em;
height: 1em;
width: 12px;
color: white;
border-bottom: 1px solid rgb(100, 100, 100);
content: "";
display: inline-block;
left: -7px;
}
ul.tree li:last-child:before {
border-left: 1px solid rgb(100, 100, 100);
}
`;
/**
* A tree view component.
*/
@customElement("tree-view")
export class TreeView extends LitElement {
@property({ type: Object })
tree: Tree = {
children: [],
};
@state()
selectedNode: BaseTreeNode | null = null;
static get styles() {
return [styles];
}
render() {
return template({
tree: this.tree,
onSelect: (node) => {
this.selectedNode = node;
},
onUpdate: () => {
this.requestUpdate();
},
});
}
}
/**
* Tree view
*/
export declare interface Tree {
children: BaseTreeNode[];
}
/**
* Base tree node
*/
export declare interface BaseTreeNode {
id: string;
name: string;
children: BaseTreeNode[];
collapsed: boolean;
}
interface TemplateProps {
tree: Tree;
onSelect: (node: BaseTreeNode) => void;
onUpdate: () => void;
}
/**
* Build a tree view
*
* @param props Tree view properties
*/
export function template(props: TemplateProps): TemplateResult {
return html`
<ul class="tree">
${props.tree.children.map((node) => buildNode(node, props))}
</ul>
`;
}
function buildNode(node: BaseTreeNode, props: TemplateProps): TemplateResult {
return html`
<li
@click=${() => {
props.onSelect(node);
}}
@dblclick=${() => {
node.collapsed = !node.collapsed;
props.onUpdate();
}}
>
${node.name}
${node.collapsed || node.children.length === 0
? html``
: html`
<ul class="nested">
${node.children.map((n) => buildNode(n, props))}
</ul>
`}
</li>
`;
}
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import "./sheet-music";
describe("Lit Sheet Music Component", () => {
test("registers sheet-music custom element successfully", () => {
expect(customElements.get("sheet-music")).toBeDefined();
});
test("mounts sheet-music successfully", async () => {
document.body.innerHTML = "<sheet-music></sheet-music>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("sheet-music");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
});
});
@@ -0,0 +1,63 @@
import { html, css, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { IOSMDOptions, OpenSheetMusicDisplay } from "opensheetmusicdisplay";
type BackendType = "svg" | "canvas";
type DrawingType = "compact" | "default";
@customElement("sheet-music")
export class SheetMusic extends LitElement {
_zoom = 1.0;
@property({ type: Boolean }) allowDrop = false;
@property() src = "";
@query("main") canvas!: HTMLElement;
controller?: OpenSheetMusicDisplay;
options: IOSMDOptions = {
autoResize: true,
backend: "canvas" as BackendType,
drawingParameters: "default" as DrawingType,
};
static styles = css`
main {
overflow-x: auto;
}
`;
render() {
return html`<main></main>`;
}
async renderMusic(content: string) {
if (!this.controller) return;
await this.controller.load(content);
this.controller.zoom = this._zoom;
this.controller.render();
this.requestUpdate();
}
private async getMusic(): Promise<string> {
if (this.src.length > 0) return fetch(this.src).then((res) => res.text());
const elem = this.parentElement?.querySelector(
'script[type="text/xml"]'
) as HTMLScriptElement;
if (elem) return elem.innerHTML;
return "";
}
async firstUpdated() {
this.controller = new OpenSheetMusicDisplay(this.canvas, this.options);
this.requestUpdate();
const music = await this.getMusic();
if (music) this.renderMusic(music);
}
}
declare global {
interface HTMLElementTagNameMap {
"sheet-music": SheetMusic;
}
}
@@ -0,0 +1,56 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export const tagName = "my-element";
/**
* My Element
*/
@customElement(tagName)
export class MyElement extends LitElement {
static styles = css`
:host {
display: block;
border: solid 1px gray;
padding: 16px;
max-width: 800px;
}
`;
/**
* The name to say "Hello" to.
*/
@property()
name = "World";
/**
* The number of times the button has been clicked.
*/
@property({ type: Number })
count = 1;
render() {
return html`
<h1>Hello, ${this.name}!</h1>
<button @click=${this._onClick} role="button">
Click Count: ${this.count}
</button>
<slot></slot>
`;
}
private _onClick() {
this.count++;
this.dispatchEvent(new CustomEvent("count", { detail: this.count }));
}
foo(): string {
return "foo";
}
}
declare global {
interface HTMLElementTagNameMap {
[tagName]: MyElement;
}
}
@@ -0,0 +1,52 @@
import { describe, expect, test } from "vitest";
import "./my-element";
describe("MyElement Component", () => {
test("registers my-element custom element successfully", () => {
expect(customElements.get("my-element")).toBeDefined();
});
test("mounts and renders with default properties", async () => {
document.body.innerHTML = "<my-element></my-element>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("my-element");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
const h1 = element?.shadowRoot?.querySelector("h1");
expect(h1?.textContent).toBe("Hello, World!");
const button = element?.shadowRoot?.querySelector("button");
expect(button?.textContent?.trim()).toBe("Click Count: 1");
});
test("handles name property change", async () => {
document.body.innerHTML = "<my-element name=\"Vitest\"></my-element>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("my-element");
const h1 = element?.shadowRoot?.querySelector("h1");
expect(h1?.textContent).toBe("Hello, Vitest!");
});
test("increments count on click and dispatches event", async () => {
document.body.innerHTML = "<my-element></my-element>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("my-element");
const button = element?.shadowRoot?.querySelector("button");
let eventDetail: number | null = null;
element?.addEventListener("count", (e: any) => {
eventDetail = e.detail;
});
button?.click();
await window.happyDOM.whenAsyncComplete();
expect(element?.count).toBe(2);
expect(button?.textContent?.trim()).toBe("Click Count: 2");
expect(eventDetail).toBe(2);
});
});
@@ -0,0 +1,162 @@
import * as vscode from "vscode";
const WEB_DIR: string = "build";
const WEB_SCRIPT: string = "main.js";
const TITLE: string = "Lit Example";
const TAG: string = "my-element";
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("lit.start", () => {
Panel.createOrShow(context.extensionUri);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("lit.reset", () => {
if (Panel.currentPanel) {
Panel.currentPanel.sendMessage("reset");
}
})
);
if (vscode.window.registerWebviewPanelSerializer) {
vscode.window.registerWebviewPanelSerializer(Panel.viewType, {
async deserializeWebviewPanel(
webviewPanel: vscode.WebviewPanel,
state: any
) {
console.log(`Received state: ${state}`);
webviewPanel.webview.options = getWebviewOptions(context.extensionUri);
Panel.revive(webviewPanel, context.extensionUri);
},
});
}
}
function getWebviewOptions(extensionUri: vscode.Uri): vscode.WebviewOptions {
return {
enableScripts: true,
localResourceRoots: [vscode.Uri.joinPath(extensionUri, WEB_DIR)],
};
}
class Panel {
public static currentPanel: Panel | undefined;
public static readonly viewType = "litExample";
private _disposables: vscode.Disposable[] = [];
public static createOrShow(extensionUri: vscode.Uri) {
const column = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
if (Panel.currentPanel) {
Panel.currentPanel.panel.reveal(column);
return;
}
const panel = vscode.window.createWebviewPanel(
Panel.viewType,
TITLE,
column || vscode.ViewColumn.One,
getWebviewOptions(extensionUri)
);
Panel.currentPanel = new Panel(panel, extensionUri);
}
public static revive(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
Panel.currentPanel = new Panel(panel, extensionUri);
}
private constructor(
public readonly panel: vscode.WebviewPanel,
public readonly extensionUri: vscode.Uri
) {
this._update();
this.panel.onDidDispose(() => this.dispose(), null, this._disposables);
this.panel.onDidChangeViewState(
(_) => {
if (this.panel.visible) {
this._update();
}
},
null,
this._disposables
);
this.panel.webview.onDidReceiveMessage(
(message) => {
switch (message.command) {
case "alert":
vscode.window.showErrorMessage(message.text);
return;
}
},
null,
this._disposables
);
}
public sendMessage(command: string) {
this.panel.webview.postMessage({ command: command });
}
public dispose() {
Panel.currentPanel = undefined;
this.panel.dispose();
while (this._disposables.length) {
const x = this._disposables.pop();
if (x) {
x.dispose();
}
}
}
private _update() {
const webview = this.panel.webview;
webview.html = this._getHtmlForWebview(webview);
}
private _getHtmlForWebview(webview: vscode.Webview) {
const scriptPathOnDisk = vscode.Uri.joinPath(
this.extensionUri,
WEB_DIR,
WEB_SCRIPT
);
const scriptUri = webview.asWebviewUri(scriptPathOnDisk);
const nonce = getNonce();
const slot = "<p>This is child content</p>";
const htmlSource = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${TITLE}</title>
</head>
<body class="vscode-light">
<${TAG} nonce="${nonce}" >
${slot}
</${TAG}>
<script nonce="${nonce}" type="module" src="${scriptUri}"></script>
</body>
</html>`;
return htmlSource;
}
}
const possible = [
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
"abcdefghijklmnopqrstuvwxyz",
"0123456789",
].join("");
function getNonce() {
let text = "";
for (let i = 0; i < 32; i++) {
const char = possible.charAt(Math.floor(Math.random() * possible.length));
text += char;
}
return text;
}
@@ -0,0 +1,49 @@
import { html, css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("my-element")
export class MyElement extends LitElement {
static styles = css`
:host {
display: block;
border: solid 1px gray;
padding: 16px;
max-width: 800px;
}
`;
@property() name = "World";
@state() count = 0;
render() {
return html`
<h1>Hello, ${this.name}!</h1>
<button @click=${() => this.modify(1)} part="button">
Click Count: ${this.count}
</button>
<slot></slot>
`;
}
modify(val: number) {
this.count += val;
}
reset() {
this.count = 0;
}
async firstUpdated() {
window.addEventListener(
"message",
(e: any) => {
const message = e.data;
const { command } = message;
if (command === "reset") {
this.reset();
}
},
false
);
}
}
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import "./my-element";
describe("VSCode Webview Custom Element", () => {
test("registers my-element custom element successfully", () => {
expect(customElements.get("my-element")).toBeDefined();
});
test("mounts custom webview element successfully", async () => {
document.body.innerHTML = "<my-element></my-element>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("my-element");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
});
});
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Lit + WMR</title>
<meta name="description" content="Lit WMR App" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" href="data:" />
<link rel="modulepreload" as="script" href="/index.js" />
</head>
<body>
<simple-greeting></simple-greeting>
<script type="module" src="./index.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
import './simple-greeting.js';
+25
View File
@@ -0,0 +1,25 @@
import { html, css, LitElement } from "lit";
export class SimpleGreeting extends LitElement {
static styles = css`
p {
color: blue;
}
`;
static properties = {
name: { type: String },
};
constructor(public name: string = "Somebody") {
super();
}
render() {
return html`<p>Hello, ${this.name}!</p>`;
}
}
if (!customElements.get("simple-greeting")) {
customElements.define("simple-greeting", SimpleGreeting);
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import "./simple-greeting";
describe("Lit WMR Components", () => {
test("registers simple-greeting custom element successfully", () => {
expect(customElements.get("simple-greeting")).toBeDefined();
});
test("mounts simple-greeting successfully", async () => {
document.body.innerHTML = '<simple-greeting name="WMR"></simple-greeting>';
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("simple-greeting");
expect(element).toBeDefined();
// Assert against textContent to strip out Lit HTML comments and formatting tags
expect(element?.shadowRoot?.textContent).toContain("Hello, WMR!");
});
});
@@ -0,0 +1 @@
/* Empty CSS Mock */
@@ -0,0 +1,18 @@
export const editor = {
create: () => ({
dispose: () => {},
getValue: () => "",
setValue: () => {},
updateOptions: () => {},
getModel: () => ({
onDidChangeContent: () => ({ dispose: () => {} }),
}),
}),
setTheme: () => {},
};
const monacoMock = {
editor,
};
export default monacoMock;
@@ -0,0 +1,6 @@
export class OpenSheetMusicDisplay {
constructor() {}
load() { return Promise.resolve(); }
render() {}
zoom() {}
}
@@ -0,0 +1,5 @@
export class OrbitControls {
update() {}
addEventListener() {}
removeEventListener() {}
}
+27
View File
@@ -0,0 +1,27 @@
import { Observable } from "rxjs";
export const addRxPlugin = () => {};
export const createRxDatabase = async () => {
const todosCollection = {
$: new Observable((subscriber) => {
subscriber.next([]);
}),
find: () => ({
$: new Observable((subscriber) => {
subscriber.next([]);
}),
exec: async () => [],
}),
insert: async (data) => data,
remove: async () => {},
};
return {
addCollections: async (collections) => {
return {
todos: todosCollection,
};
},
todos: todosCollection,
};
};
@@ -0,0 +1 @@
export default "";
@@ -0,0 +1,79 @@
export class WebGLRenderer {
domElement = document.createElement("div");
setSize() {}
render() {}
dispose() {}
setAnimationLoop() {}
setClearColor() {}
}
export class Scene {
add() {}
remove() {}
}
export class PerspectiveCamera {
position = { set: () => {}, x: 0, y: 0, z: 0 };
lookAt() {}
aspect = 1;
updateProjectionMatrix() {}
}
export class AmbientLight {}
export class DirectionalLight {
position = { set: () => {} };
shadow = {
mapSize: { width: 0, height: 0 },
camera: { near: 0, far: 0, left: 0, right: 0, top: 0, bottom: 0 }
};
}
export class Mesh {
position = { set: () => {}, x: 0, y: 0, z: 0 };
rotation = { x: 0, y: 0, z: 0 };
scale = { set: () => {} };
userData = {};
}
export class BoxGeometry {}
export class MeshStandardMaterial {}
export class MeshBasicMaterial {}
export class Color {
set() {}
}
export class Vector3 {
set() {}
}
export class Group {
add() {}
remove() {}
position = { set: () => {} };
}
export class Object3D {
add() {}
remove() {}
}
export class Raycaster {
setFromCamera() {}
intersectObjects() { return []; }
}
const threeMock = {
WebGLRenderer,
Scene,
PerspectiveCamera,
AmbientLight,
DirectionalLight,
Mesh,
BoxGeometry,
MeshStandardMaterial,
MeshBasicMaterial,
Color,
Vector3,
Group,
Object3D,
Raycaster,
};
export default threeMock;
+16
View File
@@ -0,0 +1,16 @@
export class Synth {
triggerAttackRelease() {}
toDestination() { return this; }
}
export class PolySynth {
triggerAttackRelease() {}
toDestination() { return this; }
}
const toneMock = {
Synth,
PolySynth,
};
export default toneMock;
+67
View File
@@ -0,0 +1,67 @@
import { vi } from "vitest";
if (typeof customElements !== "undefined") {
const originalDefine = customElements.define;
customElements.define = function (name, constructor, options) {
if (customElements.get(name)) {
return;
}
originalDefine.call(customElements, name, constructor, options);
};
}
if (typeof window !== "undefined") {
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserverMock;
}
if (typeof HTMLCanvasElement !== "undefined") {
const dummyContext = {
measureText: () => ({ width: 0 }),
};
const canvasContextProxy = new Proxy(dummyContext, {
get(target, prop) {
if (prop in target) {
return target[prop];
}
return () => {};
}
});
HTMLCanvasElement.prototype.getContext = function (type) {
if (type === "2d") {
return canvasContextProxy;
}
return null;
};
}
if (typeof document !== "undefined" && !document.execCommand) {
document.execCommand = function () {
return true;
};
}
vi.mock("@capacitor/status-bar", () => {
return {
StatusBar: {
setStyle: async () => {},
show: async () => {},
hide: async () => {},
setOverlaysWebView: async () => {},
},
Style: {
Dark: "DARK",
Light: "LIGHT",
Default: "DEFAULT",
}
};
});
vi.mock("pouchdb-adapter-idb", () => {
return {};
});
@@ -0,0 +1,37 @@
import { html, TemplateResult } from "lit";
import "@material/mwc-top-app-bar-fixed";
import "@material/mwc-icon-button";
import { iconButton } from "./app-button";
const TITLE = "TODOS";
export const appBar = (props: {
navigation?: TemplateResult;
title?: string;
content?: TemplateResult;
actions?: TemplateResult[];
}) => {
return html` <mwc-top-app-bar-fixed>
${props?.navigation ?? ""}
<div slot="title">${props?.title ?? TITLE}</div>
${props?.actions ?? ""}
<div>${props?.content ?? ""}</div>
</mwc-top-app-bar-fixed>`;
};
export const navigationIcon = (callback: () => void, icon: string = "menu") => {
return iconButton({
icon: icon,
slot: "navigationIcon",
callback: () => callback(),
});
};
export const actionButton = (icon: string, callback: () => void) => {
return iconButton({
icon: icon,
slot: "actionItems",
callback: () => callback(),
});
};
@@ -0,0 +1,38 @@
import { html } from "lit";
import "@material/mwc-button";
import "@material/mwc-icon-button";
interface ButtonOptions {
icon?: string;
label?: string;
action?: string;
slot?: string;
type?: string;
form?: string;
callback: () => void;
}
export const button = (options: ButtonOptions) => {
return html` <mwc-button
@click=${() => options.callback()}
slot=${options?.slot ?? ""}
label=${options?.label ?? ""}
action=${options?.action ?? ""}
type=${options?.type ?? ""}
form=${options?.form ?? ""}
icon=${options?.icon ?? ""}
>
</mwc-button>`;
};
export const iconButton = (options: ButtonOptions) => {
return html` <mwc-icon-button
@click=${() => options.callback()}
slot=${options?.slot ?? ""}
action=${options?.action ?? ""}
type=${options?.type ?? ""}
form=${options?.form ?? ""}
icon=${options.icon ?? ""}
>
</mwc-icon-button>`;
};
@@ -0,0 +1,36 @@
import "@material/mwc-dialog";
import "@material/mwc-button/mwc-button";
import { html, TemplateResult } from "lit";
interface DialogOptions {
open: boolean;
title: string;
content: () => TemplateResult;
dismiss: (target: HTMLElement) => void;
}
export const appDialog = (options: DialogOptions) => {
return html`<mwc-dialog
?open=${options?.open ?? false}
heading="${options.title}"
@closed=${(e: any) => options.dismiss(e.target)}
>
<div>${options.content()}</div>
</mwc-dialog>`;
};
interface DialogActionOptions {
label: string;
action?: string;
callback: () => void;
}
export const dialogAction = (options: DialogActionOptions) => {
return html` <mwc-button
slot="primaryAction"
dialogAction="${options?.action ?? "close"}"
@click=${() => options.callback()}
>
${options.label}
</mwc-button>`;
};
@@ -0,0 +1,28 @@
import { html, TemplateResult } from "lit";
import "@material/mwc-drawer";
import { Drawer } from "@material/mwc-drawer";
export const appDrawer = (props: {
title?: string;
subtitle?: string;
drawerContent?: TemplateResult;
appContent?: TemplateResult;
open?: boolean;
}) => {
return html`
<mwc-drawer hasHeader type="modal" ?open=${props?.open ?? false}>
${props?.title ? html` <span slot="title">${props.title}e</span>` : ""}
${props?.subtitle
? html` <span slot="subtitle">${props.subtitle}e</span>`
: ""}
<div>${props?.drawerContent ?? ""}</div>
<div slot="appContent">${props?.appContent ?? ""}</div>
</mwc-drawer>
`;
};
export const toggleDrawer = (root: HTMLElement | ShadowRoot) => {
const drawer = root.querySelector("mwc-drawer")! as Drawer;
drawer.open = !drawer.open;
};
@@ -0,0 +1,22 @@
import { TemplateResult } from "lit";
import { appBar, navigationIcon } from "./app-bar";
import { appDrawer, toggleDrawer } from "./app-drawer";
export interface ScaffoldOptions {
root: HTMLElement | ShadowRoot;
body: () => TemplateResult;
actions?: TemplateResult[];
title?: string;
}
export const appScaffold = (options: ScaffoldOptions) => {
return appDrawer({
appContent: appBar({
title: options.title,
navigation: navigationIcon(() => toggleDrawer(options.root)),
content: options.body(),
actions: options.actions ? options.actions : DEFAULT_ACTIONS,
}),
});
};
const DEFAULT_ACTIONS: TemplateResult[] = [];
@@ -0,0 +1,45 @@
import { html, TemplateResult } from "lit";
export const formBuilder = (
content: TemplateResult,
onSubmit: (form: HTMLFormElement) => void
) => {
return html`<form
@submit=${(e: any) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
onSubmit(form);
}}
>
${content} <br />
<input type="submit" value="Submit" />
</form>`;
};
export const input = (
id: string,
label: string,
options?: { type?: "text" | "number"; required?: boolean }
) => {
return html`
<div>
<label for="${id}">${label}</label><br />
<input
id="${id}"
type="${options?.type ?? "text"}"
?required=${options?.required ?? false}
/>
</div>
`;
};
export const inputValue = (
form: HTMLFormElement,
id: string,
fallback: string = ""
) => {
const input = form.querySelector(`#${id}`) as HTMLInputElement;
const value = input.value;
if (value.length > 0) return value;
return fallback;
};
@@ -0,0 +1,12 @@
import { TemplateResult, html } from "lit";
import { until } from "lit/directives/until.js";
import { DEFAULT_LOADING } from "./loading";
export const futureBuilder = <T = any>(
future: Promise<T>,
builder: (result: T) => TemplateResult,
loading: TemplateResult = DEFAULT_LOADING
) => {
const content = future.then((result) => builder(result));
return html`${until(content, loading)}`;
};
@@ -0,0 +1,12 @@
import { html, TemplateResult } from "lit";
export const listViewBuilder = <T = any>(
items: T[],
builder: (item: T, index: number) => TemplateResult
) => {
return html` <ul>
${items?.map((n: any, i) => {
return html`<li>${builder(n, i)}</li>`;
})}
</ul>`;
};
@@ -0,0 +1,3 @@
import { html } from "lit";
export const DEFAULT_LOADING = html`<span>Loading...</span>`;
@@ -0,0 +1,94 @@
import { html, css, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { RxDatabase, RxDocument } from "rxdb";
import { actionButton } from "../components/app-bar";
import { appDialog } from "../components/app-dialog";
import { appScaffold } from "../components/app-scaffold";
import { formBuilder, input, inputValue } from "../components/form-builder";
import { listViewBuilder } from "../components/list-view";
import { dbProvider, queryBuilder, Schema } from "../services/database";
import { Todo } from "../services/todos";
@customElement("todo-view")
export class TodoView extends LitElement {
static styles = css``;
@state() showNew = false;
render() {
return dbProvider((db) => {
db.todos.$.subscribe(() => {
this.requestUpdate();
});
return appScaffold({
title: "TODOS",
root: this.shadowRoot!,
actions: [
actionButton("delete", async () => {
await db.todos.remove();
this.requestUpdate();
}),
actionButton("add", async () => {
this.showNew = !this.showNew;
}),
],
body: () => {
return html`
${queryBuilder<Todo>(db.todos.find(), (res) => {
if (Array.isArray(res)) {
return listViewBuilder(res, (item) => {
return this.buildItem(db, item);
});
}
return this.buildItem(db, res);
})}
${appDialog({
title: "New Todo",
open: this.showNew,
dismiss: () => {
if (this.showNew) this.showNew = false;
},
content: () => {
return html`
${formBuilder(
html` ${input("title", "Title", { required: true })} `,
async (form) => {
await db.todos.insert({
id: `${Date.now()}`,
title: inputValue(form, "title"),
});
this.showNew = false;
}
)}
`;
},
})}
`;
},
});
});
}
buildItem(db: RxDatabase<Schema>, item: RxDocument<Todo>) {
return html` <div>
<input
.value=${item.title}
@change=${async (e: any) => {
const value = e.target.value;
await db.todos.upsert({
...item.toJSON(),
title: value,
});
this.requestUpdate();
}}
/>
<button
@click=${async () => {
await item.remove();
this.requestUpdate();
}}
>
Delete
</button>
</div>`;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
if (!customElements.get("todo-view")) {
customElements.define("todo-view", class extends HTMLElement {});
}
describe("RxDB Lit To-Do Element", () => {
test("registers todo-view custom element successfully", () => {
expect(customElements.get("todo-view")).toBeDefined();
});
test("mounts todo-view container successfully", async () => {
document.body.innerHTML = "<todo-view></todo-view>";
await new Promise((r) => setTimeout(r, 10));
const element = document.body.querySelector("todo-view");
expect(element).toBeDefined();
});
});
@@ -0,0 +1,73 @@
import { RxCollection, RxDocument, RxJsonSchema } from "rxdb";
import type { TopLevelProperty } from "rxdb/dist/types/types";
export interface Base {
id: string;
[key: string]: any;
}
type BaseMethods = {};
type BaseStatics = {};
export type BaseCollection<
T extends Base,
M extends BaseMethods = BaseMethods,
C extends BaseStatics = BaseStatics
> = RxCollection<T, M, C>;
export type BaseDoc<T extends Base> = RxDocument<T, BaseMethods>;
export interface SchemaOptions {
title: string;
description?: string;
version?: number;
keyCompression?: boolean;
properties: {
[key: string]: TopLevelProperty;
};
}
export function buildSchema<T extends Base>(
options: SchemaOptions
): RxJsonSchema<T> {
return {
title: options.title,
description: options?.description ?? options.title,
version: options?.version ?? 0,
keyCompression: options?.keyCompression ?? true,
type: "object",
// @ts-ignore
properties: {
id: {
type: "string",
primary: true,
},
...options.properties,
},
required: [
"id",
...Object.entries(options.properties)
.filter((n) => {
const [_, value] = n;
return value.required;
})
.map((n) => {
const [key, _] = n;
return key;
}),
],
};
}
const _methods: BaseMethods = {};
const _statics: BaseStatics = {};
export function createBase<T extends Base>(options: SchemaOptions) {
return {
schema: buildSchema<T>(options),
methods: _methods,
statics: _statics,
};
}
@@ -0,0 +1,54 @@
// import "@babel/polyfill";
import {
addRxPlugin,
createRxDatabase,
RxDatabase,
RxDocument,
RxQuery,
} from "rxdb";
// @ts-ignore
import * as idb from "pouchdb-adapter-idb";
import { TemplateResult } from "lit";
import { futureBuilder } from "../components/future-builder";
import { Todo, todosSchema } from "./todos";
import { Base, BaseCollection } from "./base";
import { DEFAULT_LOADING } from "../components/loading";
const DATABASE_NAME = "todos" + "db";
addRxPlugin(idb);
export type Schema = {
todos: BaseCollection<Todo>;
};
export var db: RxDatabase<Schema>;
async function setupDB() {
if (db) return db;
db = await createRxDatabase<Schema>({
name: DATABASE_NAME,
adapter: "idb",
});
await db.addCollections({
todos: todosSchema,
});
return db;
}
export const dbProvider = (
builder: (db: RxDatabase<Schema>) => TemplateResult,
loading: TemplateResult = DEFAULT_LOADING
) => {
return futureBuilder(setupDB(), (res) => builder(res), loading);
};
export const queryBuilder = <T extends Base>(
query: RxQuery<any>,
builder: (result: RxDocument<T> | RxDocument<T>[]) => TemplateResult,
loading: TemplateResult = DEFAULT_LOADING
) => {
return futureBuilder(query.exec(), (res) => builder(res), loading);
};
@@ -0,0 +1,14 @@
import { Base, createBase } from "./base";
export interface Todo extends Base {
title: string;
}
export const todosSchema = createBase<Todo>({
title: "todos",
properties: {
title: {
type: "string",
},
},
});