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,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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user