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
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:
@@ -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}`;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
import './simple-greeting.js';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user