diff --git a/CHANGELOG.md b/CHANGELOG.md index bf2aa3f..b96be5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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' })`. diff --git a/README.md b/README.md index 7b1bb03..bb00ec2 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 3408fe0..415e393 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 95cb282..4d06e2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cnodes", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cnodes", - "version": "0.2.0", + "version": "0.3.0", "license": "ISC", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 17c9c03..a1efd6d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/canvas-graph.ts b/src/canvas-graph.ts index 4b0259e..7914ba4 100644 --- a/src/canvas-graph.ts +++ b/src/canvas-graph.ts @@ -50,6 +50,7 @@ export class CanvasGraph { readonly #layoutPersistence: LayoutPersistenceController | null; readonly #onRenderStats: ((sample: CanvasRenderStatsSample) => void) | null; readonly #renderer: CanvasRenderer; + readonly #selectedNodeIds = new Set(); readonly #teardownResizeObservation: () => void; readonly #theme = resolveCanvasTheme({}); @@ -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(), @@ -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); @@ -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); @@ -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, diff --git a/src/graph/node-drag-controller.ts b/src/graph/node-drag-controller.ts index 836186c..3f5567d 100644 --- a/src/graph/node-drag-controller.ts +++ b/src/graph/node-drag-controller.ts @@ -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; @@ -33,12 +39,28 @@ 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, }; } @@ -46,28 +68,31 @@ export class NodeDragController { 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, + })); } } diff --git a/src/render/canvas-renderer.ts b/src/render/canvas-renderer.ts index 3f0072d..58ca7e8 100644 --- a/src/render/canvas-renderer.ts +++ b/src/render/canvas-renderer.ts @@ -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; @@ -63,6 +65,7 @@ type RenderInteractionState = { connectionDashOffset: number; hoveredNodeId: string | null; nodeHighlights: readonly NodeHighlight[]; + selectedNodeIds: readonly string[]; }; type ConnectionLabelSpec = Readonly<{ @@ -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); @@ -238,6 +242,7 @@ export class CanvasRenderer { this.#drawNodeSurface( node, interactionState.hoveredNodeId === node.id, + selectedNodeIds.has(node.id), ); this.#drawLabel(node); @@ -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; diff --git a/test/graph/node-drag-controller.test.ts b/test/graph/node-drag-controller.test.ts index 4868973..3fb3c19 100644 --- a/test/graph/node-drag-controller.test.ts +++ b/test/graph/node-drag-controller.test.ts @@ -14,11 +14,42 @@ describe('NodeDragController', () => { dragController.beginDrag(source, { x: 110, y: 135 }, { pointerId: 7 }); - expect(dragController.moveDrag({ x: 190, y: 215 }, { pointerId: 7 })).toEqual({ - targetNodeId: 'source', - x: 180, - y: 200, + expect(dragController.moveDrag({ x: 190, y: 215 }, { pointerId: 7 })).toEqual([ + { + targetNodeId: 'source', + x: 180, + y: 200, + }, + ]); + }); + + it('returns updated coordinates for every node in a drag group', () => { + const dragController = new NodeDragController(); + const source = createNode({ + id: 'source', + x: 100, + y: 120, + }); + const target = createNode({ + id: 'target', + x: 280, + y: 240, }); + + dragController.beginDrag([source, target], { x: 110, y: 135 }, { pointerId: 7 }); + + expect(dragController.moveDrag({ x: 190, y: 215 }, { pointerId: 7 })).toEqual([ + { + targetNodeId: 'source', + x: 180, + y: 200, + }, + { + targetNodeId: 'target', + x: 360, + y: 320, + }, + ]); }); it('ignores pointer moves from a different pointer id', () => { @@ -35,7 +66,7 @@ describe('NodeDragController', () => { dragController.beginDrag(createNode({ id: 'source' }), { x: 100, y: 120 }, { pointerId: 7 }); expect(dragController.finishDrag({ pointerId: 8 })).toBeNull(); - expect(dragController.finishDrag({ pointerId: 7 })).toBe('source'); + expect(dragController.finishDrag({ pointerId: 7 })).toEqual(['source']); expect(dragController.isDragging).toBe(false); }); }); diff --git a/test/interaction.test.ts b/test/interaction.test.ts index 608af9e..964ff2a 100644 --- a/test/interaction.test.ts +++ b/test/interaction.test.ts @@ -75,6 +75,47 @@ describe('interaction', () => { expect(context.strokeRecords).toHaveLength(previousStrokeCount); }); + it('toggles node selection with shift-click and renders selected styling', () => { + const context = createMockContext(); + stubCanvasContext(context); + + const graph = new CanvasGraph('app'); + const canvas = getCanvas(); + + graph.createNode('source').at(100, 120).done(); + stubCanvasRect(canvas); + context.strokeRecords.length = 0; + + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 120, + shiftKey: true, + })); + + expect(context.strokeRecords).toContainEqual( + expect.objectContaining({ + lineWidth: 3, + strokeStyle: '#38bdf8', + }), + ); + + context.strokeRecords.length = 0; + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 120, + shiftKey: true, + })); + + expect(context.strokeRecords).not.toContainEqual( + expect.objectContaining({ + lineWidth: 3, + strokeStyle: '#38bdf8', + }), + ); + }); + it('starts dragging a node on pointer down and updates its coordinates on pointer move', () => { const context = createMockContext(); const animation = stubAnimationFrame(); @@ -116,6 +157,117 @@ describe('interaction', () => { expect(context.roundRect).toHaveBeenLastCalledWith(100, 128, 80, 64, 18); }); + it('moves all selected nodes when dragging one selected node', () => { + const context = createMockContext(); + const animation = stubAnimationFrame(); + stubCanvasContext(context); + + const graph = new CanvasGraph('app'); + const source = graph.createNode('source').at(100, 120).done(); + const target = graph.createNode('target').at(280, 120).done(); + const canvas = getCanvas(); + + stubCanvasRect(canvas); + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 120, + shiftKey: true, + })); + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 280, + clientY: 120, + shiftKey: true, + })); + + canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 120 })); + window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 180, clientY: 200 })); + animation.step(16); + + expect(source.x).toBe(180); + expect(source.y).toBe(200); + expect(target.x).toBe(360); + expect(target.y).toBe(200); + }); + + it('clears selected nodes when dragging an unselected node', () => { + const context = createMockContext(); + const animation = stubAnimationFrame(); + stubCanvasContext(context); + + const graph = new CanvasGraph('app'); + const source = graph.createNode('source').at(100, 120).done(); + const target = graph.createNode('target').at(280, 120).done(); + const other = graph.createNode('other').at(460, 120).done(); + const canvas = getCanvas(); + + stubCanvasRect(canvas); + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 120, + shiftKey: true, + })); + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 280, + clientY: 120, + shiftKey: true, + })); + + canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 460, clientY: 120 })); + window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 540, clientY: 200 })); + animation.step(16); + window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 540, clientY: 200 })); + + canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 120 })); + window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 140, clientY: 160 })); + animation.step(32); + + expect(source.x).toBe(140); + expect(source.y).toBe(160); + expect(target.x).toBe(280); + expect(target.y).toBe(120); + expect(other.x).toBe(540); + expect(other.y).toBe(200); + }); + + it('clears selected nodes when clicking empty canvas space', () => { + const context = createMockContext(); + const animation = stubAnimationFrame(); + stubCanvasContext(context); + + const graph = new CanvasGraph('app'); + const source = graph.createNode('source').at(100, 120).done(); + const target = graph.createNode('target').at(280, 120).done(); + const canvas = getCanvas(); + + stubCanvasRect(canvas); + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 120, + shiftKey: true, + })); + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 280, + clientY: 120, + shiftKey: true, + })); + canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 520, clientY: 360 })); + + canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 120 })); + window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 140, clientY: 160 })); + animation.step(16); + + expect(source.x).toBe(140); + expect(source.y).toBe(160); + expect(target.x).toBe(280); + expect(target.y).toBe(120); + }); + it('restores persisted node coordinates when layout persistence is enabled', () => { const context = createMockContext(); stubCanvasContext(context); @@ -318,6 +470,49 @@ describe('interaction', () => { ); }); + it('persists every explicit-id node moved by a selected group drag', () => { + const context = createMockContext(); + const animation = stubAnimationFrame(); + stubCanvasContext(context); + const storage = createStorage(); + + const graph = new CanvasGraph('app', { + layoutPersistence: { + enabled: true, + storage, + }, + }); + graph.createNode('source').id('source').at(100, 120).done(); + graph.createNode('target').id('target').at(280, 120).done(); + const canvas = getCanvas(); + + stubCanvasRect(canvas); + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 100, + clientY: 120, + shiftKey: true, + })); + canvas.dispatchEvent(new MouseEvent('pointerdown', { + bubbles: true, + clientX: 280, + clientY: 120, + shiftKey: true, + })); + canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 120 })); + window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 180, clientY: 200 })); + animation.step(16); + window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 180, clientY: 200 })); + + expect(storage.setItem).toHaveBeenLastCalledWith( + 'cnodes:layout:app', + JSON.stringify({ + source: { x: 180, y: 200 }, + target: { x: 360, y: 200 }, + }), + ); + }); + it('does not persist generated runtime ids on drag', () => { const context = createMockContext(); stubCanvasContext(context);