Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

## 0.3.0 - 2026-05-09

- Added shift-click node selection so multiple selected nodes can be dragged together.
- Updated layout persistence to save every explicit-id node moved by a selected group drag.

## 0.2.0 - 2026-05-09

- Added opt-in bidirectional packet travel with `connect(..., { travel: 'both' })`.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ Browser-first TypeScript library for interactive node canvases.

## Package Status

`0.2.0` is an early public release. The current API is ready for use and feedback, but it is not a finalized `1.0` contract yet.
`0.3.0` is an early public release. The current API is ready for use and feedback, but it is not a finalized `1.0` contract yet.

Supported in this alpha:

- create nodes with stable ids, labels, descriptions, colors, and shapes
- connect nodes with straight or bezier lines, arrows, labels, named ports, and animated stroke styles
- send packets across direct, shortest-path, waypoint-constrained, and bidirectional routes
- shift-click multiple nodes and drag them as a group
- dispatch serializable packet actions from external event systems
- persist dragged node positions and enable visible automatic ports

Expand Down Expand Up @@ -187,6 +188,7 @@ Use `theme.preset` for a built-in palette or `theme.tokens` to override individu

- `createNode(kind)` returns a fluent builder and `.done()` commits the node.
- `connect(...)` and `send(...)` accept either committed nodes or node ids.
- Shift-click nodes to select or deselect them, then drag any selected node to move the selected group.
- Define named ports with `.port(id, { side })`, then route connections with `sourcePort` and `targetPort`.
- `send(...)` uses the shortest available path, throws when no path exists, accepts `via` to force intermediate nodes in order, and can traverse `travel: 'both'` connections in reverse.

Expand Down
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
- Packet routing through required waypoint nodes
- Named ports and connect-to-specific-port API
- Bidirectional packet travel over a single committed connection
- Shift-click multi-node selection and group dragging
- Release preparation script and CI workflow improvements
- Documentation and usage examples

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@darbsen/cnodes",
"version": "0.2.0",
"version": "0.3.0",
"description": "Browser-first TypeScript library for interactive node canvases.",
"license": "ISC",
"type": "module",
Expand Down
76 changes: 62 additions & 14 deletions src/canvas-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class CanvasGraph {
readonly #layoutPersistence: LayoutPersistenceController | null;
readonly #onRenderStats: ((sample: CanvasRenderStatsSample) => void) | null;
readonly #renderer: CanvasRenderer;
readonly #selectedNodeIds = new Set<string>();
readonly #teardownResizeObservation: () => void;
readonly #theme = resolveCanvasTheme({});

Expand Down Expand Up @@ -285,6 +286,7 @@ export class CanvasGraph {
connectionDashOffset: this.#connectionDashOffset,
hoveredNodeId: this.#hoveredNodeId,
nodeHighlights: this.#getRenderableNodeHighlights(renderTimestamp),
selectedNodeIds: [...this.#selectedNodeIds],
});
this.#onRenderStats?.({
animatedConnections: this.#graphStore.hasAnimatedConnections(),
Expand Down Expand Up @@ -386,13 +388,41 @@ export class CanvasGraph {

if (!targetNode) {
this.#dragController.cancel();
if (this.#selectedNodeIds.size > 0) {
this.#selectedNodeIds.clear();
this.#render();
}
return;
}

if (event.shiftKey) {
if (this.#selectedNodeIds.has(targetNode.id)) {
this.#selectedNodeIds.delete(targetNode.id);
} else {
this.#selectedNodeIds.add(targetNode.id);
}

this.#hoveredNodeId = targetNode.id;
this.#render();
return;
}

const shouldRender = this.#hoveredNodeId !== targetNode.id;
const hadSelectedNodes = this.#selectedNodeIds.size > 0;
const isDraggingSelectedNode = this.#selectedNodeIds.has(targetNode.id);
const dragNodes = isDraggingSelectedNode
? this.#getSelectedDragNodes(targetNode)
: [targetNode];

if (!isDraggingSelectedNode) {
this.#selectedNodeIds.clear();
}

const shouldRender =
this.#hoveredNodeId !== targetNode.id ||
(hadSelectedNodes && !isDraggingSelectedNode);

this.#hoveredNodeId = targetNode.id;
this.#dragController.beginDrag(targetNode, point, event);
this.#dragController.beginDrag(dragNodes, point, event);

window.addEventListener('pointermove', this.#handlePointerMove);
window.addEventListener('pointerup', this.#handlePointerUp);
Expand All @@ -411,26 +441,32 @@ export class CanvasGraph {
return;
}

this.#graphStore.updateNodePosition(
positionUpdate.targetNodeId,
positionUpdate.x,
positionUpdate.y,
);
this.#hoveredNodeId = positionUpdate.targetNodeId;
for (const update of positionUpdate) {
this.#graphStore.updateNodePosition(
update.targetNodeId,
update.x,
update.y,
);
}

this.#hoveredNodeId = this.#dragController.activeNodeId;
this.#animationController.ensureRunning();
};

readonly #handlePointerUp = (event: PointerEvent): void => {
const draggedNodeId = this.#dragController.finishDrag(event);
const draggedNodeIds = this.#dragController.finishDrag(event);

if (!draggedNodeId) {
if (!draggedNodeIds) {
return;
}

this.#layoutPersistence?.persistNode(
this.#graphStore.getNode(draggedNodeId),
this.#graphStore.hasPersistentNodeId(draggedNodeId),
);
for (const draggedNodeId of draggedNodeIds) {
this.#layoutPersistence?.persistNode(
this.#graphStore.getNode(draggedNodeId),
this.#graphStore.hasPersistentNodeId(draggedNodeId),
);
}

window.removeEventListener('pointermove', this.#handlePointerMove);
window.removeEventListener('pointerup', this.#handlePointerUp);
window.removeEventListener('pointercancel', this.#handlePointerUp);
Expand Down Expand Up @@ -486,6 +522,18 @@ export class CanvasGraph {
);
}

#getSelectedDragNodes(targetNode: CanvasNode): readonly CanvasNode[] {
const selectedNodes = this.#graphStore
.getNodes()
.filter(
(node) =>
node.id !== targetNode.id &&
this.#selectedNodeIds.has(node.id),
);

return [targetNode, ...selectedNodes];
}

#resolvePacketRoute(
sourceNode: CanvasNode,
targetNode: CanvasNode,
Expand Down
55 changes: 40 additions & 15 deletions src/graph/node-drag-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ type PointerLike = Readonly<{
}>;

type DragState = Readonly<{
offsetX: number;
offsetY: number;
nodes: readonly DragNodeState[];
pointerId: number | null;
startPoint: DragPoint;
targetNodeId: string;
}>;

type DragNodeState = Readonly<{
targetNodeId: string;
x: number;
y: number;
}>;

export type DragMove = Readonly<{
targetNodeId: string;
x: number;
Expand All @@ -33,41 +39,60 @@ export class NodeDragController {
return this.#dragState !== null;
}

beginDrag(node: CanvasNode, point: DragPoint, event: PointerLike): void {
beginDrag(
nodeOrNodes: CanvasNode | readonly CanvasNode[],
point: DragPoint,
event: PointerLike,
): void {
const nodes = Array.isArray(nodeOrNodes) ? nodeOrNodes : [nodeOrNodes];
const targetNode = nodes[0];

if (!targetNode) {
this.#dragState = null;
return;
}

this.#dragState = {
offsetX: point.x - node.x,
offsetY: point.y - node.y,
nodes: nodes.map((node) => ({
targetNodeId: node.id,
x: node.x,
y: node.y,
})),
pointerId: readPointerId(event),
targetNodeId: node.id,
startPoint: point,
targetNodeId: targetNode.id,
};
}

cancel(): void {
this.#dragState = null;
}

finishDrag(event: PointerLike): string | null {
finishDrag(event: PointerLike): readonly string[] | null {
if (!this.#dragState || !matchesPointer(this.#dragState.pointerId, event)) {
return null;
}

const targetNodeId = this.#dragState.targetNodeId;
const targetNodeIds = this.#dragState.nodes.map((node) => node.targetNodeId);

this.#dragState = null;

return targetNodeId;
return targetNodeIds;
}

moveDrag(point: DragPoint, event: PointerLike): DragMove | null {
moveDrag(point: DragPoint, event: PointerLike): readonly DragMove[] | null {
if (!this.#dragState || !matchesPointer(this.#dragState.pointerId, event)) {
return null;
}

return {
targetNodeId: this.#dragState.targetNodeId,
x: point.x - this.#dragState.offsetX,
y: point.y - this.#dragState.offsetY,
};
const deltaX = point.x - this.#dragState.startPoint.x;
const deltaY = point.y - this.#dragState.startPoint.y;

return this.#dragState.nodes.map((node) => ({
targetNodeId: node.targetNodeId,
x: node.x + deltaX,
y: node.y + deltaY,
}));
}
}

Expand Down
25 changes: 20 additions & 5 deletions src/render/canvas-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const NODE_BORDER_WIDTH = 1.5;
const NODE_HOVER_BORDER_WIDTH = 2.5;
const NODE_HIGHLIGHT_LINE_WIDTH = 4;
const NODE_HIGHLIGHT_SHADOW_BLUR = 26;
const NODE_SELECTED_BORDER_COLOR = '#38bdf8';
const NODE_SELECTED_BORDER_WIDTH = 3;
const NODE_SHADOW_BLUR = 18;
const NODE_HOVER_SHADOW_BLUR = 22;
const NODE_SHADOW_OFFSET_Y = 8;
Expand All @@ -63,6 +65,7 @@ type RenderInteractionState = {
connectionDashOffset: number;
hoveredNodeId: string | null;
nodeHighlights: readonly NodeHighlight[];
selectedNodeIds: readonly string[];
};

type ConnectionLabelSpec = Readonly<{
Expand Down Expand Up @@ -227,6 +230,7 @@ export class CanvasRenderer {
highlight,
]),
);
const selectedNodeIds = new Set(interactionState.selectedNodeIds);

for (const node of nodes) {
const highlight = nodeHighlightById.get(node.id);
Expand All @@ -238,6 +242,7 @@ export class CanvasRenderer {
this.#drawNodeSurface(
node,
interactionState.hoveredNodeId === node.id,
selectedNodeIds.has(node.id),
);

this.#drawLabel(node);
Expand Down Expand Up @@ -420,12 +425,22 @@ export class CanvasRenderer {
this.#context.fill();
}

#drawNodeSurface(node: CanvasNode, isHovered: boolean): void {
#drawNodeSurface(
node: CanvasNode,
isHovered: boolean,
isSelected: boolean,
): void {
const shadowBlur = isHovered ? NODE_HOVER_SHADOW_BLUR : NODE_SHADOW_BLUR;
const strokeStyle = isHovered
? this.#theme.tokens.nodeHoverBorderColor
: this.#theme.tokens.nodeBorderColor;
const lineWidth = isHovered ? NODE_HOVER_BORDER_WIDTH : NODE_BORDER_WIDTH;
const strokeStyle = isSelected
? NODE_SELECTED_BORDER_COLOR
: isHovered
? this.#theme.tokens.nodeHoverBorderColor
: this.#theme.tokens.nodeBorderColor;
const lineWidth = isSelected
? NODE_SELECTED_BORDER_WIDTH
: isHovered
? NODE_HOVER_BORDER_WIDTH
: NODE_BORDER_WIDTH;

this.#context.save();
this.#context.fillStyle = node.color;
Expand Down
Loading
Loading