333 lines
8.7 KiB
TypeScript
333 lines
8.7 KiB
TypeScript
import { html, css, LitElement } from "lit";
|
|
import { customElement, query } 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,
|
|
},
|
|
});
|
|
|
|
@query("#output")
|
|
outputContainer!: HTMLDivElement;
|
|
|
|
private _resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const { width, height } = entry.contentRect;
|
|
this.editor.resize({ width, height });
|
|
}
|
|
});
|
|
|
|
static styles = [
|
|
treeStyles,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
main {
|
|
height: 100%;
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: row;
|
|
}
|
|
#output {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
.sidebar {
|
|
width: ${PROPERTY_WIDTH}px;
|
|
overflow-y: auto;
|
|
background-color: #f5f5f5;
|
|
border-right: 1px solid #ddd;
|
|
}
|
|
#properties {
|
|
width: ${PROPERTY_WIDTH}px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
background-color: #eee;
|
|
border-left: 1px solid #ddd;
|
|
}
|
|
.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() {
|
|
this._resizeObserver.observe(this.outputContainer);
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this._resizeObserver.disconnect();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|