28 KiB
SUBTASK-003: Vertical Snap + Push
Parent Task: TASK-000J Canvas Organization System
Estimate: 12-16 hours
Priority: 3
Dependencies: None (can be implemented independently)
Overview
A system for vertically aligning and attaching nodes so that when one expands, nodes below it automatically push down, maintaining spacing and preventing overlap.
The Problem
When nodes expand vertically (due to new ports being added):
- They overlap nodes below them
- Carefully arranged vertical stacks become messy
- Users must manually reposition nodes after every change
- The "flow" of logic gets disrupted
The Solution
Allow users to vertically attach nodes into stacks:
- Attached nodes maintain spacing when moved
- When a node expands, nodes below push down automatically
- Visual feedback shows when nodes can be attached
- Easy to detach when needed
Why Vertical Only?
Horizontal attachment would interfere with connection lines:
- Connections flow left-to-right (outputs → inputs)
- Nodes need horizontal spacing for connection visibility
- Horizontal "snapping" would cover connection endpoints
Vertical stacking works naturally because:
- Many parallel logic paths are arranged vertically
- Vertical expansion is the main layout-breaking problem
- Doesn't interfere with connection rendering
Feature Capabilities
| Capability | Description |
|---|---|
| Edge proximity detection | Visual feedback when dragging near attachable edges |
| Attachment creation | Drop on highlighted edge to attach |
| Push on expand | When node grows, attached nodes below shift down |
| Chain insertion | Drop between attached nodes to insert into chain |
| Detachment | Context menu option to remove from stack |
| Alignment guides | Visual guides when edges align (even without attachment) |
Visual Design
Attachment Visualization
Proximity Detection (during drag):
┌────────────┐
│ Node A │
└────────────┘ ← bottom edge glows when Node X is near
[Node X] ← being dragged
┌────────────┐
│ Node B │ ← top edge glows when Node X is near
└────────────┘
Attached State:
┌────────────┐
│ Node A │
└────────────┘
┃ ← subtle vertical line indicating attachment
┃
┌────────────┐
│ Node B │
└────────────┘
┃
┃
┌────────────┐
│ Node C │
└────────────┘
Push Behavior:
Before: After Node A expands:
┌────────────┐ ┌────────────┐
│ Node A │ │ Node A │
└────────────┘ │ (grew) │
┃ │ │
┌────────────┐ └────────────┘
│ Node B │ ┃
└────────────┘ ┌────────────┐
┃ │ Node B │ ← pushed down
┌────────────┐ └────────────┘
│ Node C │ ┃
└────────────┘ ┌────────────┐
│ Node C │ ← also pushed down
└────────────┘
Edge Highlight Style
- Glow effect: Box shadow or gradient
- Color: Accent color (e.g., blue #3b82f6) at 50% opacity
- Width: Extends slightly beyond node edges
- Height: ~4px
.edge-highlight-bottom {
position: absolute;
bottom: -2px;
left: -4px;
right: -4px;
height: 4px;
background: linear-gradient(to bottom, rgba(59, 130, 246, 0.5), transparent);
border-radius: 2px;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
}
Alignment Guide Style
- Color: Light gray or accent color at 30% opacity
- Style: Dashed line
- Extends: Full canvas width (or reasonable extent)
Data Model
Attachment Storage
interface VerticalAttachment {
id: string; // Unique attachment ID
topNodeId: string; // Node on top
bottomNodeId: string; // Node on bottom
spacing: number; // Pixel gap between nodes
}
// Storage options:
// Option A: Separate AttachmentsModel
class AttachmentsModel {
private attachments: Map<string, VerticalAttachment>;
createAttachment(topId: string, bottomId: string, spacing: number): void;
removeAttachment(attachmentId: string): void;
getAttachedBelow(nodeId: string): string | null;
getAttachedAbove(nodeId: string): string | null;
getAttachmentChain(nodeId: string): string[];
getAttachmentBetween(topId: string, bottomId: string): VerticalAttachment | null;
}
// Option B: Store on NodeGraphNode model
interface NodeGraphNode {
// ... existing fields
attachedAbove?: string; // ID of node this is attached below
attachedBelow?: string; // ID of node attached below this
attachmentSpacing?: number;
}
Recommendation: Use Option A (separate AttachmentsModel) for cleaner separation of concerns and easier debugging.
Persistence
Attachments should persist with the project:
// In component save/load
{
"nodes": [...],
"connections": [...],
"comments": [...],
"attachments": [
{ "id": "att_1", "topNodeId": "node_a", "bottomNodeId": "node_b", "spacing": 20 },
{ "id": "att_2", "topNodeId": "node_b", "bottomNodeId": "node_c", "spacing": 20 }
]
}
Implementation Sessions
Session 3.1: Attachment Data Model (2 hours)
Goal: Create AttachmentsModel and wire up persistence.
Tasks:
- Create
AttachmentsModelclass:// packages/noodl-editor/src/editor/src/models/attachmentsmodel.ts import { EventEmitter } from '@noodl-utils/eventemitter'; interface VerticalAttachment { id: string; topNodeId: string; bottomNodeId: string; spacing: number; } export class AttachmentsModel extends EventEmitter { private attachments: Map<string, VerticalAttachment> = new Map(); createAttachment(topId: string, bottomId: string, spacing: number): VerticalAttachment { // Check for circular dependencies if (this.wouldCreateCycle(topId, bottomId)) { throw new Error('Cannot create circular attachment'); } const id = `att_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const attachment: VerticalAttachment = { id, topNodeId: topId, bottomNodeId: bottomId, spacing }; this.attachments.set(id, attachment); this.emit('attachmentCreated', attachment); return attachment; } removeAttachment(attachmentId: string): void { const attachment = this.attachments.get(attachmentId); if (attachment) { this.attachments.delete(attachmentId); this.emit('attachmentRemoved', attachment); } } getAttachedBelow(nodeId: string): string | null { for (const att of this.attachments.values()) { if (att.topNodeId === nodeId) return att.bottomNodeId; } return null; } getAttachedAbove(nodeId: string): string | null { for (const att of this.attachments.values()) { if (att.bottomNodeId === nodeId) return att.topNodeId; } return null; } getAttachmentChain(nodeId: string): string[] { const chain: string[] = []; // Go up to find the top let current = nodeId; while (this.getAttachedAbove(current)) { current = this.getAttachedAbove(current)!; } // Now go down to build the chain chain.push(current); while (this.getAttachedBelow(current)) { current = this.getAttachedBelow(current)!; chain.push(current); } return chain; } private wouldCreateCycle(topId: string, bottomId: string): boolean { // Check if bottomId is already above topId in any chain let current = topId; while (this.getAttachedAbove(current)) { current = this.getAttachedAbove(current)!; if (current === bottomId) return true; } return false; } // Serialization toJSON(): VerticalAttachment[] { return Array.from(this.attachments.values()); } fromJSON(data: VerticalAttachment[]): void { this.attachments.clear(); for (const att of data) { this.attachments.set(att.id, att); } } } - Integrate with NodeGraphModel for persistence
- Write unit tests for chain detection and cycle prevention
Files to create:
packages/noodl-editor/src/editor/src/models/attachmentsmodel.ts
Files to modify:
packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts(add attachments to save/load)
Success criteria:
- AttachmentsModel class implemented
- Cycle detection works
- Chain traversal works
- Attachments persist with project
Session 3.2: Edge Proximity Detection (2-3 hours)
Goal: Detect when a dragged node is near another node's top or bottom edge.
Tasks:
- Define proximity threshold constant:
const ATTACHMENT_THRESHOLD = 20; // pixels - In
nodegrapheditor.ts, during node drag:private detectEdgeProximity(draggingNode: NodeGraphEditorNode): EdgeProximity | null { const dragBounds = { x: draggingNode.global.x, y: draggingNode.global.y, width: draggingNode.nodeSize.width, height: draggingNode.nodeSize.height }; let closest: EdgeProximity | null = null; let closestDistance = ATTACHMENT_THRESHOLD; this.forEachNode((node) => { if (node === draggingNode) return; const nodeBounds = { x: node.global.x, y: node.global.y, width: node.nodeSize.width, height: node.nodeSize.height }; // Check horizontal overlap (nodes should be roughly aligned) const horizontalOverlap = dragBounds.x < nodeBounds.x + nodeBounds.width && dragBounds.x + dragBounds.width > nodeBounds.x; if (!horizontalOverlap) return; // Check distance from dragging node's bottom to target's top const distToTop = Math.abs( (dragBounds.y + dragBounds.height) - nodeBounds.y ); if (distToTop < closestDistance) { closestDistance = distToTop; closest = { targetNode: node, edge: 'top', distance: distToTop }; } // Check distance from dragging node's top to target's bottom const distToBottom = Math.abs( dragBounds.y - (nodeBounds.y + nodeBounds.height) ); if (distToBottom < closestDistance) { closestDistance = distToBottom; closest = { targetNode: node, edge: 'bottom', distance: distToBottom }; } }); return closest; } - Track proximity state during drag:
private currentProximity: EdgeProximity | null = null; // In drag move handler this.currentProximity = this.detectEdgeProximity(draggingNode); this.repaint(); // Trigger repaint to show highlight - Clear proximity on drag end
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
Success criteria:
- Proximity detected when node near top edge
- Proximity detected when node near bottom edge
- Only detects when nodes horizontally overlap
- Nearest edge prioritized if multiple options
Session 3.3: Visual Feedback (2 hours)
Goal: Show visual glow on edges that can be attached to.
Tasks:
- Add highlighted edge state to NodeGraphEditorNode:
// In NodeGraphEditorNode.ts public highlightedEdge: 'top' | 'bottom' | null = null; - Modify paint() to render highlight:
paint(ctx: CanvasRenderingContext2D, paintRect: Rect) { // ... existing paint code // Draw edge highlight if active if (this.highlightedEdge) { ctx.save(); const highlightColor = 'rgba(59, 130, 246, 0.5)'; const glowColor = 'rgba(59, 130, 246, 0.3)'; const highlightHeight = 4; const extend = 4; // Extend beyond node edges if (this.highlightedEdge === 'bottom') { const y = this.global.y + this.nodeSize.height; // Glow ctx.shadowColor = glowColor; ctx.shadowBlur = 8; ctx.shadowOffsetY = 2; // Highlight bar ctx.fillStyle = highlightColor; ctx.fillRect( this.global.x - extend, y - 2, this.nodeSize.width + extend * 2, highlightHeight ); } else if (this.highlightedEdge === 'top') { const y = this.global.y; ctx.shadowColor = glowColor; ctx.shadowBlur = 8; ctx.shadowOffsetY = -2; ctx.fillStyle = highlightColor; ctx.fillRect( this.global.x - extend, y - highlightHeight + 2, this.nodeSize.width + extend * 2, highlightHeight ); } ctx.restore(); } // Continue with rest of painting... } - Update highlights based on proximity during drag:
// In nodegrapheditor.ts drag handler // Clear previous highlights this.forEachNode((node) => { node.highlightedEdge = null; }); // Set new highlight if (this.currentProximity) { const { targetNode, edge } = this.currentProximity; targetNode.highlightedEdge = edge; } - Clear all highlights on drag end
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorNode.tspackages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
Success criteria:
- Bottom edge glows when dragging node above
- Top edge glows when dragging node below
- Glow has nice shadow/blur effect
- Highlights clear after drag
Session 3.4: Attachment Creation (2-3 hours)
Goal: Create attachments when dropping on highlighted edges.
Tasks:
- On drag end, check for active proximity:
// In drag end handler if (this.currentProximity) { const { targetNode, edge } = this.currentProximity; const draggingNode = this.draggingNodes[0]; // Assuming single node drag // Determine top and bottom based on edge let topNodeId: string, bottomNodeId: string; if (edge === 'top') { // Dragging node is above target topNodeId = draggingNode.model.id; bottomNodeId = targetNode.model.id; } else { // Dragging node is below target topNodeId = targetNode.model.id; bottomNodeId = draggingNode.model.id; } // Calculate spacing const topNode = edge === 'top' ? draggingNode : targetNode; const bottomNode = edge === 'top' ? targetNode : draggingNode; const spacing = bottomNode.global.y - (topNode.global.y + topNode.nodeSize.height); // Create attachment this.attachmentsModel.createAttachment(topNodeId, bottomNodeId, Math.max(spacing, 10)); // Snap node to exact position this.snapToAttachment(draggingNode, topNodeId, bottomNodeId); } - Handle insertion between existing attached nodes:
private handleChainInsertion( draggingNode: NodeGraphEditorNode, targetNode: NodeGraphEditorNode, edge: 'top' | 'bottom' ): void { if (edge === 'top') { // Check if target has something attached above const aboveId = this.attachmentsModel.getAttachedAbove(targetNode.model.id); if (aboveId) { // Remove existing attachment const existingAtt = this.attachmentsModel.getAttachmentBetween(aboveId, targetNode.model.id); if (existingAtt) { this.attachmentsModel.removeAttachment(existingAtt.id); } // Insert new node: above -> dragging -> target this.attachmentsModel.createAttachment(aboveId, draggingNode.model.id, existingAtt?.spacing || 20); } // Attach dragging to target this.attachmentsModel.createAttachment(draggingNode.model.id, targetNode.model.id, 20); } // Similar logic for 'bottom' edge... } - Add undo support for attachment creation
- Show visual confirmation (brief flash or toast)
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
Success criteria:
- Dropping on highlighted edge creates attachment
- Correct top/bottom assignment based on edge
- Spacing calculated from actual positions
- Insertion between attached nodes works
- Undo removes attachment
Session 3.5: Push Calculation (2-3 hours)
Goal: When a node resizes, push attached nodes down.
Tasks:
- Subscribe to node size changes:
// In nodegrapheditor.ts or component initialization EventDispatcher.instance.on( ['Model.portAdded', 'Model.portRemoved'], (args) => this.handleNodeSizeChange(args.model), this ); - Implement push calculation:
private handleNodeSizeChange(nodeModel: NodeGraphNode): void { const node = this.findNodeWithId(nodeModel.id); if (!node) return; // Get all nodes attached below this one const chain = this.attachmentsModel.getAttachmentChain(nodeModel.id); const nodeIndex = chain.indexOf(nodeModel.id); if (nodeIndex === -1 || nodeIndex === chain.length - 1) return; // Calculate expected position for next node const attachment = this.attachmentsModel.getAttachmentBetween( nodeModel.id, chain[nodeIndex + 1] ); if (!attachment) return; const expectedY = node.global.y + node.nodeSize.height + attachment.spacing; const nextNode = this.findNodeWithId(chain[nodeIndex + 1]); if (!nextNode) return; const deltaY = expectedY - nextNode.global.y; if (Math.abs(deltaY) < 1) return; // No significant change // Push all nodes below for (let i = nodeIndex + 1; i < chain.length; i++) { const pushNode = this.findNodeWithId(chain[i]); if (pushNode) { pushNode.model.setPosition(pushNode.x, pushNode.y + deltaY, { undo: false }); pushNode.setPosition(pushNode.x, pushNode.y + deltaY); } } this.relayout(); this.repaint(); } - Handle recursive push (pushing a node that has nodes attached below it)
- Add debouncing to prevent excessive updates during rapid changes
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
Success criteria:
- Adding port pushes attached nodes down
- Removing port pulls attached nodes up (closer)
- Full chain pushes correctly (A→B→C, A grows, B and C both move)
- No infinite loops or excessive recalculation
Session 3.6: Detachment (2 hours)
Goal: Allow users to remove nodes from attachment chains.
Tasks:
- Add context menu item:
// In node context menu creation if (this.attachmentsModel.getAttachedAbove(node.model.id) || this.attachmentsModel.getAttachedBelow(node.model.id)) { menuItems.push({ label: 'Detach from Stack', onClick: () => this.detachNode(node) }); } - Implement detach logic:
private detachNode(node: NodeGraphEditorNode): void { const nodeId = node.model.id; const aboveId = this.attachmentsModel.getAttachedAbove(nodeId); const belowId = this.attachmentsModel.getAttachedBelow(nodeId); // Remove attachment above (if exists) if (aboveId) { const att = this.attachmentsModel.getAttachmentBetween(aboveId, nodeId); if (att) this.attachmentsModel.removeAttachment(att.id); } // Remove attachment below (if exists) if (belowId) { const att = this.attachmentsModel.getAttachmentBetween(nodeId, belowId); if (att) this.attachmentsModel.removeAttachment(att.id); } // Reconnect above and below if both existed if (aboveId && belowId) { const aboveNode = this.findNodeWithId(aboveId); const belowNode = this.findNodeWithId(belowId); if (aboveNode && belowNode) { // Calculate new spacing (closing the gap) const spacing = belowNode.global.y - (aboveNode.global.y + aboveNode.nodeSize.height); this.attachmentsModel.createAttachment(aboveId, belowId, spacing); // Move below node up to close gap const targetY = aboveNode.global.y + aboveNode.nodeSize.height + 20; // Default spacing const deltaY = targetY - belowNode.global.y; // Move entire sub-chain const chain = this.attachmentsModel.getAttachmentChain(belowId); const startIndex = chain.indexOf(belowId); for (let i = startIndex; i < chain.length; i++) { const moveNode = this.findNodeWithId(chain[i]); if (moveNode) { moveNode.model.setPosition(moveNode.x, moveNode.y + deltaY, { undo: false }); moveNode.setPosition(moveNode.x, moveNode.y + deltaY); } } } } this.relayout(); this.repaint(); } - Add undo support for detachment
- Consider animation for gap closing (optional)
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
Success criteria:
- Context menu shows "Detach from Stack" for attached nodes
- Detaching middle node reconnects above and below
- Gap closes after detachment
- Detaching end node removes single attachment
- Undo restores attachment and positions
Session 3.7: Alignment Guides (2 hours, optional)
Goal: Show alignment guides when dragging near aligned edges.
Tasks:
- Detect aligned edges during drag:
private detectAlignedEdges(draggingNode: NodeGraphEditorNode): AlignmentGuide[] { const guides: AlignmentGuide[] = []; const tolerance = 5; // pixels const dragBounds = { left: draggingNode.global.x, right: draggingNode.global.x + draggingNode.nodeSize.width, top: draggingNode.global.y, bottom: draggingNode.global.y + draggingNode.nodeSize.height }; this.forEachNode((node) => { if (node === draggingNode) return; const nodeBounds = { left: node.global.x, right: node.global.x + node.nodeSize.width, top: node.global.y, bottom: node.global.y + node.nodeSize.height }; // Check left edges align if (Math.abs(dragBounds.left - nodeBounds.left) < tolerance) { guides.push({ type: 'vertical', position: nodeBounds.left }); } // Check right edges align if (Math.abs(dragBounds.right - nodeBounds.right) < tolerance) { guides.push({ type: 'vertical', position: nodeBounds.right }); } // Check top edges align if (Math.abs(dragBounds.top - nodeBounds.top) < tolerance) { guides.push({ type: 'horizontal', position: nodeBounds.top }); } // Check bottom edges align if (Math.abs(dragBounds.bottom - nodeBounds.bottom) < tolerance) { guides.push({ type: 'horizontal', position: nodeBounds.bottom }); } }); return guides; } - Render guides in paint():
private paintAlignmentGuides(ctx: CanvasRenderingContext2D): void { if (!this.alignmentGuides?.length) return; ctx.save(); ctx.strokeStyle = 'rgba(59, 130, 246, 0.4)'; ctx.lineWidth = 1; ctx.setLineDash([5, 5]); for (const guide of this.alignmentGuides) { ctx.beginPath(); if (guide.type === 'vertical') { ctx.moveTo(guide.position, this.graphAABB.minY - 100); ctx.lineTo(guide.position, this.graphAABB.maxY + 100); } else { ctx.moveTo(this.graphAABB.minX - 100, guide.position); ctx.lineTo(this.graphAABB.maxX + 100, guide.position); } ctx.stroke(); } ctx.restore(); } - Clear guides on drag end
Files to modify:
packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
Success criteria:
- Vertical guides appear when left/right edges align
- Horizontal guides appear when top/bottom edges align
- Guides visually distinct from attachment highlights
- Guides clear after drag
Testing Checklist
Attachment Creation
- Drag node near another's bottom edge → edge highlights
- Drag node near another's top edge → edge highlights
- Drop on highlighted edge → attachment created
- Attachment stored in model
- Attachment persists after save/reload
Chain Behavior
- Create chain of 3+ nodes
- Moving top node moves all attached nodes
- Expanding top node pushes all down
- Expanding middle node pushes nodes below
Insertion
- Drag node between two attached nodes
- Both edges highlight (or nearest one)
- Drop inserts node into chain
- Original chain reconnected through new node
Detachment
- Context menu shows "Detach from Stack" for attached nodes
- Detach middle node → chain reconnects
- Detach top node → remaining chain intact
- Detach bottom node → remaining chain intact
- Gap closes after detachment
Undo/Redo
- Undo attachment creation → attachment removed
- Redo → attachment restored
- Undo detachment → attachment restored
- Undo push → positions restored
Edge Cases
- Circular attachment prevented (A→B→C→A impossible)
- Deleting attached node removes from chain
- Very long chain (10+ nodes) works correctly
- Node in Smart Frame can still be attached
- Copy/paste of attached node creates independent node
Alignment Guides (if implemented)
- Vertical guide shows when left edges align
- Vertical guide shows when right edges align
- Horizontal guide shows when top edges align
- Horizontal guide shows when bottom edges align
- Multiple guides can show simultaneously
- Guides clear after drag ends
Files Summary
Create
packages/noodl-editor/src/editor/src/models/attachmentsmodel.ts
Modify
packages/noodl-editor/src/editor/src/models/nodegraphmodel.ts
packages/noodl-editor/src/editor/src/views/nodegrapheditor.ts
packages/noodl-editor/src/editor/src/views/nodegrapheditor/NodeGraphEditorNode.ts
Performance Considerations
Proximity Detection
- Only check nearby nodes (use spatial partitioning for large graphs)
- Cache node bounds during drag
- Don't recalculate on every mouse move (throttle to ~30fps)
// Throttled proximity check
const checkProximity = throttle(() => {
this.currentProximity = this.detectEdgeProximity(draggingNode);
this.repaint();
}, 33); // ~30fps
Push Calculation
- Debounce size change handlers
- Only recalculate affected chain, not all attachments
- Cache chain lookups
// Debounced size change handler
const handleSizeChange = debounce((nodeModel) => {
this.pushAttachedNodes(nodeModel);
}, 100);
Alignment Guides
- Limit to nodes within viewport
- Use Set to deduplicate guides at same position
- Don't render guides that extend far off-screen
Design Decisions
Why Not Auto-Attach on Overlap?
Users may intentionally overlap nodes temporarily. Requiring drop on highlighted edge gives user control and prevents unwanted attachments.
Why Fixed Spacing?
Spacing is captured at attachment creation time. This preserves the user's intentional layout while maintaining relative positions during push operations.
Could make spacing adjustable in future (drag to resize gap).
Why Reconnect on Detach?
If A→B→C and B is detached, users usually want A→C (close the gap) rather than leaving A and C unattached. This matches mental model of "removing from the middle".
Users can manually detach A from C afterward if they want separation.