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

- lit-force-graph: Refactored for instance reuse, better lifecycle management, and added dynamic imports for AR/VR modes.
- lit-html-table: Improved visual design, readability of headers, and fixed reactivity in editable mode.
- lit-draggable-dom: Fixed clipping issues by switching to relative/absolute positioning and resolved a panning speed bug caused by CSS variable inheritance.
This commit is contained in:
2026-05-17 00:41:14 -07:00
parent bbdbeb9788
commit b9e42c3ef4
253 changed files with 2555 additions and 4471 deletions
@@ -0,0 +1,734 @@
import { ReactiveController, ReactiveControllerHost } from "lit";
import { Store, BaseNode, NodeEdge, ID } from "../store";
import { BaseTreeNode, Tree } from "../ui/tree-view";
import { drawLabel } from "./label";
import { drawLine, getMidPoint, isPointOnLine } from "./line";
import {
applyDefaultMatrix,
applyMatrix,
createMatrix,
defaultMatrix,
MatrixContext,
matrixInfo,
toWorld,
} from "./matrix";
import { drawRect } from "./rect";
import { Offset, Rect, Size } from "./utils";
/**
* Editor Canvas
*/
export class Canvas implements ReactiveController {
constructor(
public host: ReactiveControllerHost,
props?: { canvas?: HTMLCanvasElement; size?: Size }
) {
this.host.addController(this);
this.canvas = props?.canvas ?? document.createElement("canvas");
this.ctx = this.canvas.getContext("2d")!;
this.ctx.imageSmoothingEnabled = true;
this.resize(props?.size);
this.init();
}
canvas: HTMLCanvasElement;
store = new Store<CanvasNode>();
action: Action = Action.NONE;
start?: Offset;
end?: Offset;
selection = new Array<ID>();
pointers: Map<number, Offset> = new Map();
mouse: Offset = { x: 0, y: 0 };
minScale = 0.1;
maxScale = 4.0;
gestureEvents = false;
lastScale = -1;
lastRotation = -1;
lastOffset: Offset = { x: 0, y: 0 };
rotationEnabled = true;
zoomEnabled = true;
panEnabled = true;
context: MatrixContext = defaultMatrix;
ctx: CanvasRenderingContext2D;
hostConnected() {}
hostDisconnected() {}
nodeTree(): Tree {
return {
children: this.store.nodes.map(
(n) => subTreeForNode(n, this.store, !this.selection.includes(n.id))!
),
};
}
get size(): Size {
return {
width: this.canvas.width,
height: this.canvas.height,
};
}
init() {
// Mouse Events
this.canvas.addEventListener(
"contextmenu",
(e) => e.preventDefault(),
false
);
this.canvas.addEventListener("wheel", (e) => this.onWheel(e), false);
// Pointer Events
this.canvas.addEventListener(
"pointerdown",
(e) => this.onPointerDown(e),
false
);
this.canvas.addEventListener(
"pointerover",
(e) => this.onPointerMove(e),
false
);
this.canvas.addEventListener(
"pointermove",
(e) => this.onPointerMove(e),
false
);
this.canvas.addEventListener(
"pointerup",
(e) => this.onPointerUp(e),
false
);
this.canvas.addEventListener(
"pointerleave",
(e) => this.onPointerUp(e),
false
);
this.canvas.addEventListener(
"pointercancel",
(e) => this.onPointerUp(e),
false
);
this.canvas.addEventListener(
"mousemove",
(e) => this.onMouseMove(e),
false
);
this.canvas.addEventListener(
"mousedown",
(e) => this.onMouseDown(e),
false
);
this.canvas.addEventListener("mouseup", (e) => this.onMouseUp(e), false);
// Gesture Events
this.canvas.addEventListener(
"gesturestart",
(e) => this.onGestureStart(e as GestureEvent),
false
);
this.canvas.addEventListener(
"gesturechange",
(e) => this.onGestureChange(e as GestureEvent),
false
);
this.canvas.addEventListener(
"gestureend",
(e) => this.onGestureEnd(e as GestureEvent),
false
);
// Keyboard Events
window.addEventListener("keydown", (e) => this.onKeyDown(e));
// window.addEventListener("mousewheel", (e) => {
// e.preventDefault();
// });
window.addEventListener("DOMMouseScroll", (e) => {
e.preventDefault();
});
// TODO: https://github.com/shuding/apple-pencil-safari-api-test
this.render();
this.host.requestUpdate();
}
render() {
this.paint();
requestAnimationFrame(() => this.render());
}
onWheel(e: WheelEvent) {
e.preventDefault();
if (this.gestureEvents) return;
if (e.ctrlKey) {
this.action = Action.ZOOM;
const scale = -e.deltaY * 0.01;
this.zoom(scale);
} else {
this.action = Action.PAN;
const offset = { x: -e.deltaX * 2, y: -e.deltaY * 2 };
this.pan(offset);
}
this.host.requestUpdate();
this.action = Action.NONE;
}
onGestureStart(e: GestureEvent) {
e.preventDefault();
this.gestureEvents = true;
this.lastScale = e.scale;
this.lastRotation = e.rotation;
this.lastOffset = { x: e.clientX, y: e.clientY };
}
onGestureChange(e: GestureEvent) {
e.preventDefault();
this.gestureEvents = true;
const { scale, offset, rotation } = matrixInfo(this.context);
let localRotation = rotation;
let localScale = scale;
let localOffset = offset;
const rotationDelta = (this.lastRotation - e.rotation) * 0.01;
localRotation -= rotationDelta;
this.lastRotation = e.rotation;
const scaleDelta = (this.lastScale - e.scale) * 1;
localScale -= scaleDelta;
this.lastScale = e.scale;
const offsetDelta = {
x: (this.lastOffset.x - e.clientX) * 0.01,
y: (this.lastOffset.y - e.clientY) * 0.01,
};
localOffset.x -= offsetDelta.x * localScale;
localOffset.y -= offsetDelta.y * localScale;
this.lastOffset = { x: e.clientX, y: e.clientY };
this.context = createMatrix(localOffset, localScale, localRotation);
this.host.requestUpdate();
applyMatrix(this.ctx, this.context);
}
onGestureEnd(e: GestureEvent) {
e.preventDefault();
this.gestureEvents = false;
}
onMouseUp(e: MouseEvent) {
this.mouse = { x: e.offsetX, y: e.offsetY };
}
onMouseDown(e: MouseEvent) {
this.mouse = { x: e.offsetX, y: e.offsetY };
}
onMouseMove(e: MouseEvent) {
this.mouse = { x: e.offsetX, y: e.offsetY };
}
onPointerDown(e: PointerEvent) {
const point = { x: e.offsetX, y: e.offsetY };
this.pointers.set(e.pointerId, point);
this.canvas.setPointerCapture(e.pointerId);
this.start = point;
this.end = point;
const mouseOffset = toWorld(this.context, point);
if (this.selection.length > 1) {
this.action = Action.MOVE;
return;
}
const nodes = this.selectOffset(mouseOffset, this.selection, e.shiftKey);
if (nodes.length === 0) {
this.selection = [];
this.host.requestUpdate();
}
this.action =
nodes.length > 0
? e.shiftKey
? Action.LINK
: Action.MOVE
: Action.MARQUEE;
if (nodes.length > 0) {
const nodeId = nodes[nodes.length - 1];
if (this.action === Action.MOVE) {
this.selection = [nodeId];
}
}
}
onPointerMove(e: PointerEvent) {
const point = { x: e.offsetX, y: e.offsetY };
const pointerId = e.pointerId;
const pointer = this.pointers.get(pointerId);
if (pointer) {
const delta = {
x: point.x - pointer.x,
y: point.y - pointer.y,
};
this.end = point;
if (this.action === Action.MOVE) {
for (const id of this.selection) {
const node = this.store.retrieveNode(id);
if (node) {
this.moveNode(node, delta);
}
}
}
this.pointers.set(pointerId, point);
}
}
onPointerUp(e: PointerEvent) {
this.canvas.releasePointerCapture(e.pointerId);
this.pointers.delete(e.pointerId);
if (this.start && this.end) {
const start = toWorld(this.context, this.start);
const end = toWorld(this.context, this.end);
const topLeft = {
x: Math.min(start.x, end.x),
y: Math.min(start.y, end.y),
};
const bottomRight = {
x: Math.max(start.x, end.x),
y: Math.max(start.y, end.y),
};
if (this.action === Action.LINK) {
const multi = e.shiftKey;
const startType = this.checkOffsetType(start, multi);
const endType = this.checkOffsetType(end, multi);
if (startType === "node" && endType === "node") {
const startNode = this.getOffset(start, multi) as CanvasNode;
const endNode = this.getOffset(end, multi) as CanvasNode;
this.store.linkNodes(startNode, endNode, "simple");
}
} else if (this.action === Action.MARQUEE) {
if (e.shiftKey) {
this.selection = [];
}
const overlappingRect: Rect = {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y,
};
// Check if any nodes are selected by overlapping rect
const nodes = this.store.nodes;
const selectedNodes: CanvasNode[] = [];
for (const node of nodes) {
// Check if node overlaps rect
const nodeRect = {
x: node.x,
y: node.y,
width: node.width,
height: node.height,
};
if (
nodeRect.x < overlappingRect.x + overlappingRect.width &&
nodeRect.x + nodeRect.width > overlappingRect.x &&
nodeRect.y < overlappingRect.y + overlappingRect.height &&
nodeRect.y + nodeRect.height > overlappingRect.y
) {
selectedNodes.push(node);
}
}
for (const node of selectedNodes) {
this.selection.push(node.id);
}
} else {
this.selection = this.selectOffset(end, this.selection, e.shiftKey);
this.host.requestUpdate();
}
}
this.start = undefined;
this.end = undefined;
this.action = Action.NONE;
this.host.requestUpdate();
}
getOffset(offset: Offset, multi: boolean): CanvasNode | undefined {
const selection = this.selectOffset(offset, this.selection, multi);
if (selection.length > 1) {
const node = this.store.retrieveNode(selection[selection.length - 1]);
if (node) return node;
}
return undefined;
}
checkOffsetType(offset: Offset, multi: boolean): "none" | "node" | "link" {
const node = this.getOffset(offset, multi);
if (node) return node.type;
return "none";
}
onKeyDown(e: KeyboardEvent) {
// Delete current selection
if (e.key === "Backspace" || e.key === "Delete") {
this.deleteSelection();
}
// Zoom canvas
const zoomStep = 0.1;
if (e.key === "+" || e.key === "=") {
this.zoom(zoomStep);
}
if (e.key === "-") {
this.zoom(-zoomStep);
}
// Move with arrow keys
const moveStep = 10;
if (e.key === "ArrowUp") {
this.pan({ x: 0, y: -moveStep });
}
if (e.key === "ArrowDown") {
this.pan({ x: 0, y: moveStep });
}
if (e.key === "ArrowLeft") {
this.pan({ x: -moveStep, y: 0 });
}
if (e.key === "ArrowRight") {
this.pan({ x: moveStep, y: 0 });
}
// Select all
if (e.key === "a" && e.metaKey) {
this.selection = this.store.nodes.map((n) => n.id);
this.host.requestUpdate();
}
}
zoom(amount: number) {
const { scale, offset, rotation } = matrixInfo(this.context);
let localScale = scale;
localScale += amount;
this.context = createMatrix(offset, localScale, rotation);
}
pan(delta: Offset) {
const { offset, scale, rotation } = matrixInfo(this.context);
let localOffset = offset;
localOffset.x += delta.x / scale;
localOffset.y += delta.y / scale;
this.context = createMatrix(localOffset, scale, rotation);
}
rotate(amount: number) {
const { rotation, offset, scale } = matrixInfo(this.context);
let localRotation = rotation;
localRotation += amount;
this.context = createMatrix(offset, scale, localRotation);
}
import(value: string) {
this.store = Store.fromJson<CanvasNode>(value);
}
deleteNode(node: CanvasNode) {
this.store.deleteNode(node.id);
this.clear();
this.host.requestUpdate();
}
deleteEdge(edge: NodeEdge) {
this.store.deleteEdge(edge.id);
this.clear();
this.host.requestUpdate();
}
resize(size?: Size) {
const width = size?.width ?? window.innerWidth;
const height = size?.height ?? window.innerHeight;
this.canvas.setAttribute("width", `${width}px`);
this.canvas.setAttribute("height", `${height}px`);
this.canvas.width = width;
this.canvas.height = height;
}
deleteSelection() {
console.log("delete selection", this.selection);
for (const id of this.selection) {
const node = this.store.retrieveNode(id);
if (node) this.store.deleteNode(node.id);
const edge = this.store.retrieveEdge(id);
if (edge) this.store.deleteEdge(edge.id);
}
this.selection = [];
this.host.requestUpdate();
}
selectOffset(offset: Offset, nodes: string[], multi: boolean) {
let selection = [...nodes];
// Clear selection
if (!multi) selection = [];
// Check current offset for selection
const overlappedNodes = this.store.nodes.filter((node) => {
const overlaps =
offset.x >= node.x &&
offset.x <= node.x + node.width &&
offset.y >= node.y &&
offset.y <= node.y + node.height;
return overlaps;
});
// Select nodes
if (overlappedNodes.length > 0) {
const topNode = overlappedNodes[overlappedNodes.length - 1];
selection.push(topNode.id);
}
// If shift is not selected return early
if (!multi && selection.length > 0) {
return selection;
}
// Select edges
const overlappedEdges = this.store.edges.filter((edge) => {
const { start, end } = this.getEdgePoints(edge);
const overlaps = isPointOnLine(start, end, offset);
return overlaps;
});
if (overlappedEdges.length > 0) {
const topEdge = overlappedEdges[overlappedEdges.length - 1];
selection.push(topEdge.id);
}
return selection;
}
get scale(): number {
const { scale } = matrixInfo(this.context);
return scale;
}
get rotation(): number {
const { rotation } = matrixInfo(this.context);
return rotation;
}
get offset(): Offset {
const { offset } = matrixInfo(this.context);
return offset;
}
moveNode(node: CanvasNode, delta: Offset) {
const { scale } = matrixInfo(this.context);
node.x += delta.x / scale;
node.y += delta.y / scale;
this.store.updateNode(node);
}
clear() {
this.selection = [];
this.host.requestUpdate();
}
paint() {
applyDefaultMatrix(this.ctx);
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
drawRect(this.ctx, {
x: 0,
y: 0,
width: this.canvas.width,
height: this.canvas.height,
fillColor: "whitesmoke",
});
applyMatrix(this.ctx, this.context);
const mouse = this.mouse;
const mouseOffset = toWorld(this.context, mouse);
// Draw edges
for (const edge of this.store.edges) {
const isSelected = this.selection.includes(edge.id);
const { start, end, mid } = this.getEdgePoints(edge);
// Draw line
const ctx = this.ctx;
drawLine(ctx, {
start,
end,
strokeColor: isSelected ? "red" : "black",
});
// Check if point is anywhere on the line
const overlaps = isPointOnLine(start, end, mouseOffset);
if (overlaps && !isSelected) {
ctx.strokeStyle = "blue";
ctx.stroke();
}
// Draw label
drawLabel(this.ctx, {
text: edge.name,
textAlign: "center",
x: mid.x,
y: mid.y,
fillColor: "black",
});
}
// Draw nodes
for (const node of this.store.nodes) {
const isSelected = this.selection.includes(node.id);
const ctx = this.ctx;
// Draw label
drawLabel(ctx, {
text: node.name,
x: node.x,
y: node.y - 5,
fillColor: "black",
});
drawRect(ctx, {
x: node.x,
y: node.y,
width: node.width,
height: node.height,
strokeColor: isSelected ? "red" : "black",
fillColor: "white",
});
const overlaps =
mouseOffset.x >= node.x &&
mouseOffset.x <= node.x + node.width &&
mouseOffset.y >= node.y &&
mouseOffset.y <= node.y + node.height;
if (overlaps && !isSelected) {
ctx.strokeStyle = "blue";
ctx.strokeRect(node.x, node.y, node.width, node.height);
}
}
if (this.start && this.end) {
const localStart = toWorld(this.context, this.start);
const localEnd = toWorld(this.context, this.end);
if (this.action == Action.LINK) {
// Render line to link
drawLine(this.ctx, {
start: localStart,
end: localEnd,
strokeColor: "red",
});
}
if (this.action == Action.MARQUEE) {
// Render marquee
drawRect(this.ctx, {
x: Math.min(localStart.x, localEnd.x),
y: Math.min(localStart.y, localEnd.y),
width: Math.abs(localStart.x - localEnd.x),
height: Math.abs(localStart.y - localEnd.y),
fillColor: "rgba(135, 206, 235, 0.2)",
strokeColor: "rgba(135, 206, 235, 0.5)",
});
}
}
if (this.selection.length > 1) {
const nodes = this.store.nodes.filter((node) =>
this.selection.includes(node.id)
);
const topY = Math.min(...nodes.map((node) => node.y));
const leftX = Math.min(...nodes.map((node) => node.x));
const bottomY = Math.max(...nodes.map((node) => node.y + node.height));
const rightX = Math.max(...nodes.map((node) => node.x + node.width));
const topLeft: Offset = { x: leftX, y: topY };
const bottomRight: Offset = { x: rightX, y: bottomY };
if (topLeft && bottomRight) {
drawRect(this.ctx, {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y,
fillColor: "transparent",
strokeColor: "blue",
});
}
}
}
private getEdgePoints(edge: NodeEdge) {
const startNode = this.store.retrieveNode(edge.startNode)!;
const endNode = this.store.retrieveNode(edge.endNode)!;
const createOffset = (node: CanvasNode) => ({
x: node.x + node.width / 2,
y: node.y + node.height / 2,
width: node.width,
height: node.height,
});
const start = createOffset(startNode);
const end = createOffset(endNode);
const mid = getMidPoint(start, end);
return { start, end, mid };
}
}
enum Action {
NONE,
ZOOM,
PAN,
MOVE,
LINK,
MARQUEE,
}
type PositionMixin = BaseNode & Rect;
/**
* Canvas Node
*/
export interface CanvasNode extends PositionMixin {
backgroundColor?: string;
type: "node";
}
interface GestureEvent extends MouseEvent {
rotation: number;
scale: number;
}
function subTreeForNode(
node: CanvasNode,
store: Store<CanvasNode>,
collapsed: boolean = false,
lookup: Map<string, boolean> = new Map()
): BaseTreeNode | null {
if (lookup.has(node.id)) {
return null;
}
lookup.set(node.id, true);
const children: BaseTreeNode[] = [];
const edges = store.retrieveEdgesForNode(node.id);
for (const edge of edges) {
const edgeChildren: BaseTreeNode[] = [];
const endNode = store.retrieveNode(edge.endNode)!;
const endSubTree = subTreeForNode(endNode, store, collapsed, lookup);
if (endSubTree) edgeChildren.push(endSubTree);
const startNode = store.retrieveNode(edge.startNode)!;
const startSubTree = subTreeForNode(startNode, store, collapsed, lookup);
if (startSubTree) edgeChildren.push(startSubTree);
children.push({
id: edge.id,
name: edge.name,
children: edgeChildren,
collapsed: false,
});
}
return {
id: node.id,
name: node.name,
children,
collapsed: collapsed,
};
}
@@ -0,0 +1,63 @@
import { ColorMixin, Offset, Size } from "./utils";
interface LabelOptions extends Offset, ColorMixin {
text: string;
maxWidth?: number;
fontFamily?: string;
fontSize?: number;
fontStyle?: "normal" | "italic" | "oblique" | "initial" | "inherit";
fontWeight?: string;
textAlign?: "left" | "center" | "right";
lineHeight?: number;
textBaseline?:
| "top"
| "hanging"
| "middle"
| "alphabetic"
| "ideographic"
| "bottom";
}
const DEFAULT_TEXT_HEIGHT = 8;
export function drawLabel(
ctx: CanvasRenderingContext2D,
options: LabelOptions
): Size {
const {
x = 0,
y = 0,
maxWidth,
fontFamily = "Roboto",
fontSize = DEFAULT_TEXT_HEIGHT,
fontStyle = "normal",
fontWeight = "normal",
textAlign = "left",
textBaseline = "alphabetic",
lineHeight = 1.2,
text,
fillColor,
strokeColor,
} = options;
ctx.save();
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${lineHeight}em ${fontFamily}`;
ctx.textAlign = textAlign;
ctx.textBaseline = textBaseline;
if (fillColor) {
ctx.fillStyle = fillColor;
ctx.fillText(text, x, y, maxWidth);
}
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.strokeText(text, x, y, maxWidth);
}
ctx.restore();
// Measure the text
const metrics = ctx.measureText(options.text);
const height = fontSize * lineHeight;
return { height, width: metrics.width };
}
@@ -0,0 +1,77 @@
import { ColorMixin, Offset } from "./utils";
interface LineOptions extends ColorMixin {
start: Offset;
end: Offset;
}
/**
* Render a line between two points
*
* @param ctx Canvas context
* @param options Line options
*/
export function drawLine(ctx: CanvasRenderingContext2D, options: LineOptions) {
const { start, end, fillColor, strokeColor } = options;
ctx.save();
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
if (fillColor) {
ctx.fillStyle = fillColor;
ctx.fill();
}
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.stroke();
}
ctx.restore();
}
/**
* Check if a point is inside a line
*
* @param start Start point
* @param end End point
* @param target Offset to check
* @param tolerance Tolerance
*/
export function isPointOnLine(
start: Offset,
end: Offset,
target: Offset,
tolerance = 1
): boolean {
// if line is vertical
if (start.x === end.x) {
return Math.abs(target.x - start.x) <= tolerance;
}
// if line is horizontal
if (start.y === end.y) {
return Math.abs(target.y - start.y) <= tolerance;
}
// if line is diagonal
const slope = (end.y - start.y) / (end.x - start.x);
const intercept = start.y - slope * start.x;
const y = slope * target.x + intercept;
return Math.abs(y - target.y) <= tolerance;
}
/**
* Calculate the midpoint of a line
*
* @param start Start point
* @param end End point
* @returns Midpoint
*/
export function getMidPoint(start: Offset, end: Offset): Offset {
return {
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
};
}
@@ -0,0 +1,192 @@
import { Offset } from "./utils";
/**
* Matrix
*/
export type Matrix = number[];
const DEFAULT_MATRIX: Matrix = [1, 0, 0, 1, 0, 0];
const DEFAULT_INVERSE_MATRIX: Matrix = [1, 0, 0, 1];
/**
* Matrix context
*/
export interface MatrixContext {
matrix: Matrix;
inverseMatrix: Matrix;
}
/**
* Default matrix context
*/
export const defaultMatrix = createMatrix({ x: 0, y: 0 }, 1, 0);
/**
* Create a matrix and inverse matrix
*
* @param offset - offset
* @param scale - scale factor
* @param rotation - in radians
*
* @link https://stackoverflow.com/a/34598847/7303311
*/
export function createMatrix(
offset: Offset,
scale: number,
rotation: number
): MatrixContext {
const m = [...DEFAULT_MATRIX];
const im = [...DEFAULT_INVERSE_MATRIX];
m[3] = m[0] = Math.cos(rotation) * scale;
m[2] = -(m[1] = Math.sin(rotation) * scale);
m[4] = offset.x;
m[5] = offset.y;
const cross = m[0] * m[3] - m[1] * m[2];
im[0] = m[3] / cross;
im[1] = -m[1] / cross;
im[2] = -m[2] / cross;
im[3] = m[0] / cross;
return {
matrix: m,
inverseMatrix: im,
};
}
/**
* Create a local offset from a global offset
*
* @param matrix - matrix
* @param inverseMatrix - inverse matrix
* @param offset - offset
*
* @link https://stackoverflow.com/a/34598847/7303311
*/
export function toWorld(context: MatrixContext, offset: Offset): Offset {
let xx, yy, m;
m = context.inverseMatrix;
xx = offset.x - context.matrix[4];
yy = offset.y - context.matrix[5];
const localX = xx * m[0] + yy * m[2];
const localY = xx * m[1] + yy * m[3];
return {
x: localX,
y: localY,
};
}
/**
* Matrix info
*
* @param matrix - matrix
* @returns scale, offset, rotation
*/
export function matrixInfo(context: MatrixContext) {
const rotation = rotationFromMatrix(context);
const { scale } = scaleFromMatrix(context);
const offset = offsetFromMatrix(context);
return {
rotation,
scale,
offset,
};
}
/**
* Get rotation from matrix
*
* @param matrix - matrix
* @returns rotation in radians
*/
export function rotationFromMatrix(context: MatrixContext) {
const matrix = context.matrix;
const rad = Math.atan2(matrix[1], matrix[0]);
return rad;
}
/**
* Get scale factor from matrix
*
* @param matrix - matrix
* @returns scale factor
*/
export function scaleFromMatrix(context: MatrixContext): {
scaleX: number;
scaleY: number;
scale: number;
} {
const matrix = context.matrix;
const scaleX = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]);
const scaleY = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]);
return {
scaleX,
scaleY,
scale: Math.max(scaleX, scaleY),
};
}
/**
* Get offset from matrix
*
* @param matrix - matrix
* @returns offset
*/
export function offsetFromMatrix(context: MatrixContext) {
const matrix = context.matrix;
return {
x: matrix[4],
y: matrix[5],
};
}
/**
* Apply matrix to canvas
*
* @param ctx - canvas context
* @param matrix - matrix
*/
export function applyMatrix(
ctx: CanvasRenderingContext2D,
context: MatrixContext
) {
const m = context.matrix;
ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
}
/**
* Apply default matrix to canvas
*
* @param ctx - canvas context
*/
export function applyDefaultMatrix(ctx: CanvasRenderingContext2D) {
const m = DEFAULT_MATRIX;
ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
}
/**
* Get matrix from canvas
*
* @param ctx - canvas context
* @returns Matrix context
*/
export function getMatrixFromCanvas(
ctx: CanvasRenderingContext2D
): MatrixContext {
const transform = ctx.getTransform();
const m = [...DEFAULT_MATRIX];
const im = [...DEFAULT_INVERSE_MATRIX];
m[0] = transform.a;
m[1] = transform.b;
m[2] = transform.c;
m[3] = transform.d;
m[4] = transform.e;
m[5] = transform.f;
const cross = m[0] * m[3] - m[1] * m[2];
im[0] = m[3] / cross;
im[1] = -m[1] / cross;
im[2] = -m[2] / cross;
im[3] = m[0] / cross;
return {
matrix: m,
inverseMatrix: im,
};
}
@@ -0,0 +1,31 @@
import { ColorMixin, Rect } from "./utils";
interface RectOptions extends Rect, ColorMixin {}
/**
* Draw a rectangle on the canvas
*
* @param ctx Canvas context
* @param options Options for the rectangle
*/
export function drawRect(ctx: CanvasRenderingContext2D, options: RectOptions) {
const {
x = 0,
y = 0,
width = 0,
height = 0,
fillColor,
strokeColor,
} = options;
ctx.rect(x, y, width, height);
if (fillColor) {
ctx.fillStyle = fillColor;
ctx.fillRect(x, y, width, height);
}
if (strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.strokeRect(x, y, width, height);
}
}
@@ -0,0 +1,28 @@
/**
* X and y
*/
export interface Offset {
x: number;
y: number;
}
/**
* Width and height
*/
export interface Size {
width: number;
height: number;
}
/**
* Rect with offset and size
*/
export type Rect = Offset & Size;
/**
* Color for outline and fill
*/
export interface ColorMixin {
fillColor?: string;
strokeColor?: string;
}
@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import "./node-editor";
import "./ui/tree-view";
describe("Lit Node Editor Component", () => {
test("registers visual node editor custom elements successfully", () => {
expect(customElements.get("node-editor")).toBeDefined();
expect(customElements.get("tree-view")).toBeDefined();
});
test("mounts node-editor to the body successfully", async () => {
document.body.innerHTML = "<node-editor></node-editor>";
await new Promise((r) => setTimeout(r, 100));
const element = document.body.querySelector("node-editor");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
});
});
@@ -0,0 +1,314 @@
import "./styles.css";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { CanvasNode, Canvas } from "./canvas";
import { BaseTreeNode, styles as treeStyles, template as treeTemplate } from "./ui/tree-view";
const PROPERTY_WIDTH = 200;
@customElement("node-editor")
export class NodeEditor extends LitElement {
editor = new Canvas(this, {
size: {
width: window.innerWidth - PROPERTY_WIDTH * 2,
height: window.innerHeight,
},
});
static styles = [
treeStyles,
css`
main {
height: 100vh;
width: 100%;
display: flex;
flex-direction: row;
}
#output {
flex: 1;
}
.sidebar {
width: ${PROPERTY_WIDTH}px;
}
#properties {
width: ${PROPERTY_WIDTH}px;
display: flex;
flex-direction: column;
overflow-y: scroll;
background-color: #eee;
}
.property {
display: flex;
flex-direction: column;
padding: 10px;
}
.title {
font-size: 1.5em;
font-weight: bold;
padding: 10px;
}
.destructive {
background-color: red;
color: white;
}
#links > span {
padding-left: 10px;
font-size: 0.9em;
font-weight: bold;
}
`,
];
render() {
return html`<main>
<div class="sidebar">
${treeTemplate({
tree: this.editor.nodeTree(),
onUpdate: () => this.requestUpdate(),
onSelect: (node: BaseTreeNode) => {
this.editor.selection.push(node.id);
},
})}
</div>
<div id="output">${this.editor.canvas}</div>
<div id="properties">${this.renderProperties()}</div>
</main>`;
}
renderProperties() {
const nodeId = this.editor.selection[this.editor.selection.length - 1];
const node =
this.editor.store.retrieveNode(nodeId) ??
this.editor.store.retrieveEdge(nodeId);
if (node?.type === "node") {
return html`
<span class="title">Node</span>
<div class="property">
<label>Name</label>
<input
type="text"
.value=${node.name}
@change=${(e: any) => {
node.name = e.target.value;
this.editor.store.updateNode(node);
}}
/>
</div>
<div class="property">
<label>Background Color</label>
<input
type="color"
.value=${node.backgroundColor ?? "#FFFFFF"}
@change=${(e: any) => {
node.backgroundColor = e.target.value;
this.editor.store.updateNode(node);
}}
/>
</div>
<div class="property">
<label>Width</label>
<input
type="number"
.value=${node.width.toString()}
@change=${(e: any) => {
node.width = Number(e.target.value);
this.editor.store.updateNode(node);
}}
/>
</div>
<div class="property">
<label>Height</label>
<input
type="number"
.value=${node.height.toString()}
@change=${(e: any) => {
node.height = Number(e.target.value);
this.editor.store.updateNode(node);
}}
/>
</div>
<div class="property">
<button
class="destructive"
@click=${() => {
if (confirm("Are you sure?")) {
this.editor.deleteNode(node);
}
}}
>
Delete node
</button>
</div>
`;
}
if (node?.type === "edge") {
return html` <span class="title">Edge</span>
<div class="property">
<label>Name</label>
<input
type="text"
.value=${node.name}
@change=${(e: any) => {
node.name = e.target.value;
this.editor.store.updateEdge(node);
}}
/>
</div>
<div class="property">
<button
class="destructive"
@click=${() => {
if (confirm("Are you sure?")) {
this.editor.deleteEdge(node);
}
}}
>
Delete node
</button>
</div>`;
}
return html` <span class="title">Editor</span>
<div>
<div class="property">
<label>Import JSON</label>
<input
type="file"
accept=".json"
@change=${(e: any) => {
const files = e.target.files;
if (files.length) {
this.editor.clear();
const reader = new FileReader();
reader.onload = (e: any) => {
const data = e.target.result;
this.editor.import(data);
};
reader.readAsText(files[0]);
}
}}
/>
</div>
<div class="property">
<label>Scale</label>
<input
type="number"
.value=${this.editor.scale.toString()}
step=".1"
@change=${(e: any) => {
this.editor.zoom(Number(e.target.value));
}}
/>
</div>
<div class="property">
<label>Rotation</label>
<input
type="number"
.value=${this.editor.rotation.toString()}
step=".1"
@change=${(e: any) => {
this.editor.rotate(Number(e.target.value));
}}
/>
</div>
<div class="property">
<label>Offset x</label>
<input
type="number"
.value=${this.editor.offset.x.toString()}
step=".1"
@change=${(e: any) => {
this.editor.pan({
x: Number(e.target.value),
y: this.editor.offset.y,
});
}}
/>
</div>
<div class="property">
<label>Offset x</label>
<input
type="number"
.value=${this.editor.offset.y.toString()}
step=".1"
@change=${(e: any) => {
this.editor.pan({
x: this.editor.offset.x,
y: Number(e.target.value),
});
}}
/>
</div>
<div class="property">
<button
@click=${() => {
const node = this.addRandomNode();
this.editor.selection.push(node.id);
this.requestUpdate();
}}
>
Add new node
</button>
</div>
<div class="property">
<button
@click=${() => {
const a = window.document.createElement("a");
const json = this.editor.store.toJson();
a.href = window.URL.createObjectURL(
new Blob([json], { type: "application/json" })
);
a.download = "editor.json";
document.body.appendChild(a);
a.click();
}}
>
Export JSON
</button>
</div>
</div>`;
}
firstUpdated() {
const amount = 10;
// Create random canvas nodes
for (let i = 0; i < amount; i++) {
this.addRandomNode(i);
}
// Link random canvas nodes
for (let i = 0; i < amount / 2; i++) {
const source: CanvasNode = this.editor.store.nodes[i];
const target: CanvasNode = this.editor.store.nodes[i + amount / 2];
this.editor.store.linkNodes(source, target, "simple");
}
window.addEventListener("resize", () => {
this.editor.resize({
width: window.innerWidth - PROPERTY_WIDTH,
height: window.innerHeight,
});
});
}
addRandomNode(i: number = this.editor.store.nodes.length) {
const node: CanvasNode = {
id: `node${i}`,
name: "Node " + i,
x: Math.random() * this.editor.canvas.width,
y: Math.random() * this.editor.canvas.height,
width: 100,
height: 100,
type: "node",
};
this.editor.store.createNode(node);
return node;
}
}
declare global {
interface HTMLElementTagNameMap {
"node-editor": NodeEditor;
}
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Node or Edge ID type
*/
export type ID = string;
/**
* Node store with NodeEdges
*/
export class Store<T extends BaseNode> {
nodes: T[] = [];
edges: NodeEdge[] = [];
/**
* Parse a JSON object into a Store
*
* @param value JSON String
* @returns Store
*/
static fromJson<T extends BaseNode>(value: string) {
const { nodes, edges } = JSON.parse(value);
const store = new Store<T>();
if (nodes) store.nodes = nodes;
if (edges) store.edges = edges;
return store;
}
/**
* Save the store to a JSON string
*
* @returns JSON String
*/
toJson() {
const value = {
nodes: this.nodes,
edges: this.edges,
};
return JSON.stringify(value, null, 2);
}
private getNodeIndex(id: ID): number {
return this.nodes.findIndex((n) => n.id === id);
}
createNode(node: T): void {
const index = this.getNodeIndex(node.id);
if (index === -1) {
this.nodes.push(node);
} else {
this.nodes[index] = node;
}
}
retrieveNode(id: ID): T | undefined {
return this.nodes.find((node) => node.id === id);
}
retrieveEdgesForNode(id: ID): NodeEdge[] {
return this.edges.filter(
(edge) => edge.startNode === id || edge.endNode === id
);
}
updateNode(node: T): void {
const index = this.getNodeIndex(node.id);
if (index === -1) return;
this.nodes[index] = node;
}
deleteNode(id: ID): void {
const index = this.getNodeIndex(id);
if (index === -1) return;
this.nodes.splice(index, 1);
// Delete connected edges
const nodeEdges = this.retrieveEdgesForNode(id);
for (const edge of nodeEdges) {
this.deleteEdge(edge.id);
}
}
private getEdgeIndex(id: ID): number {
return this.edges.findIndex((n) => n.id === id);
}
createEdge(edge: NodeEdge): void {
const index = this.getEdgeIndex(edge.id);
if (index === -1) {
this.edges.push(edge);
} else {
this.edges[index] = edge;
}
}
retrieveEdge(id: ID): NodeEdge | undefined {
return this.edges.find((edge) => edge.id === id);
}
updateEdge(edge: NodeEdge): void {
const index = this.getEdgeIndex(edge.id);
if (index === -1) return;
this.edges[index] = edge;
}
deleteEdge(id: ID): void {
const index = this.getEdgeIndex(id);
if (index === -1) return;
this.edges.splice(index, 1);
}
linkNodes(start: T, end: T, name: string): void {
const randomId = generateRandomId();
this.createEdge({
id: randomId,
startNode: start.id,
endNode: end.id,
name,
type: "edge",
});
}
}
/**
* Node Edge
*/
export interface NodeEdge extends BaseNode {
startNode: ID;
endNode: ID;
type: "edge";
}
/**
* Base Node
*/
export interface BaseNode {
id: ID;
name: string;
readonly type: "node" | "edge";
}
function generateRandomId(): string {
return (
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15)
);
}
+10
View File
@@ -0,0 +1,10 @@
body {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
}
node-editor {
width: 100%;
height: 100%;
}
@@ -0,0 +1,137 @@
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
/**
* Tree view styles
*/
export const styles = css`
ul.tree {
width: 100%;
height: 100%;
overflow-x: scroll;
white-space: nowrap;
}
ul.tree,
ul.tree ul {
list-style: none;
margin: 0;
padding: 0;
}
ul.tree ul {
margin-left: 10px;
}
ul.tree li {
margin: 0;
padding: 0 7px;
line-height: 20px;
color: #369;
font-weight: bold;
border-left: 1px solid rgb(100, 100, 100);
}
ul.tree li:last-child {
border-left: none;
}
ul.tree li:before {
position: relative;
top: -0.3em;
height: 1em;
width: 12px;
color: white;
border-bottom: 1px solid rgb(100, 100, 100);
content: "";
display: inline-block;
left: -7px;
}
ul.tree li:last-child:before {
border-left: 1px solid rgb(100, 100, 100);
}
`;
/**
* A tree view component.
*/
@customElement("tree-view")
export class TreeView extends LitElement {
@property({ type: Object })
tree: Tree = {
children: [],
};
@state()
selectedNode: BaseTreeNode | null = null;
static get styles() {
return [styles];
}
render() {
return template({
tree: this.tree,
onSelect: (node) => {
this.selectedNode = node;
},
onUpdate: () => {
this.requestUpdate();
},
});
}
}
/**
* Tree view
*/
export declare interface Tree {
children: BaseTreeNode[];
}
/**
* Base tree node
*/
export declare interface BaseTreeNode {
id: string;
name: string;
children: BaseTreeNode[];
collapsed: boolean;
}
interface TemplateProps {
tree: Tree;
onSelect: (node: BaseTreeNode) => void;
onUpdate: () => void;
}
/**
* Build a tree view
*
* @param props Tree view properties
*/
export function template(props: TemplateProps): TemplateResult {
return html`
<ul class="tree">
${props.tree.children.map((node) => buildNode(node, props))}
</ul>
`;
}
function buildNode(node: BaseTreeNode, props: TemplateProps): TemplateResult {
return html`
<li
@click=${() => {
props.onSelect(node);
}}
@dblclick=${() => {
node.collapsed = !node.collapsed;
props.onUpdate();
}}
>
${node.name}
${node.collapsed || node.children.length === 0
? html``
: html`
<ul class="nested">
${node.children.map((n) => buildNode(n, props))}
</ul>
`}
</li>
`;
}