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,
};
}