Consolidate Lit repositories: figma-to-lit, figma_lit_example, lit-3d-piano, lit-calculator, lit-code-editor, lit-draggable-dom, lit-file-based-routing, lit-force-graph, lit-html-editor, lit-html-table, lit-modules, lit-native, lit-node-editor, lit-sheet-music, lit-starter-ts, lit-vscode-extension, lit-wmr, vite-lit-capacitor, vite-lit-element-starter, vite-rxdb-lit
This commit is contained in:
@@ -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,20 @@
|
||||
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z"
|
||||
fill="url(#paint0_linear)" />
|
||||
<path
|
||||
d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z"
|
||||
fill="url(#paint1_linear)" />
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF" />
|
||||
<stop offset="1" stop-color="#BD34FE" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83" />
|
||||
<stop offset="0.0833333" stop-color="#FFDD35" />
|
||||
<stop offset="1" stop-color="#FFA800" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
`;
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user