From 70fbfd0f5e1659305070761d6a4a0c0f597651df Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 20 Aug 2025 16:25:13 -0400 Subject: [PATCH] feat: Add slot registration and spatial indexing for hit detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement slot registration for all nodes (Vue and LiteGraph) - Add spatial indexes for slots and reroutes to improve hit detection performance - Register slots when nodes are drawn via new registerSlots() method - Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n) Resolves #5125 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/litegraph/src/LGraph.ts | 3 + src/lib/litegraph/src/LGraphCanvas.ts | 87 ++++-- src/lib/litegraph/src/LGraphNode.ts | 10 +- src/lib/litegraph/src/LLink.ts | 5 + .../canvas/litegraph/LitegraphLinkAdapter.ts | 73 +++++ .../core/canvas/litegraph/SlotCalculations.ts | 59 ++++ src/renderer/core/layout/store/LayoutStore.ts | 266 +++++++++++++++++- src/renderer/core/layout/types.ts | 53 +++- .../vueNodes/layout/useNodeLayout.ts | 32 +++ 9 files changed, 562 insertions(+), 26 deletions(-) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 1aba5881e..66448776c 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -4,6 +4,7 @@ import { } from '@/lib/litegraph/src/constants' import type { UUID } from '@/lib/litegraph/src/utils/uuid' import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid' +import { layoutStore } from '@/renderer/core/layout/store/LayoutStore' import type { DragAndScaleState } from './DragAndScale' import { LGraphCanvas } from './LGraphCanvas' @@ -1970,6 +1971,8 @@ export class LGraph // Drop broken links, and ignore reroutes with no valid links if (!reroute.validateLinks(this._links, this.floatingLinks)) { this.reroutes.delete(reroute.id) + // Clean up layout store + layoutStore.deleteRerouteLayout(String(reroute.id)) } } diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 9cfa57f33..e80e69e8e 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -3,6 +3,7 @@ import { type LinkRenderContext, LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/LitegraphLinkAdapter' +import { layoutStore } from '@/renderer/core/layout/store/LayoutStore' import { CanvasPointer } from './CanvasPointer' import type { ContextMenu } from './ContextMenu' @@ -2194,11 +2195,14 @@ export class LGraphCanvas this.processSelect(node, e, true) } else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) { // Reroutes - const reroute = graph.getRerouteOnPos( - e.canvasX, - e.canvasY, - this.#visibleReroutes - ) + // Try layout store first, fallback to old method + const rerouteLayout = layoutStore.queryRerouteAtPoint({ + x: e.canvasX, + y: e.canvasY + }) + const reroute = rerouteLayout + ? graph.getReroute(Number(rerouteLayout.id)) + : graph.getRerouteOnPos(e.canvasX, e.canvasY, this.#visibleReroutes) if (reroute) { if (e.altKey) { pointer.onClick = (upEvent) => { @@ -2397,16 +2401,21 @@ export class LGraphCanvas this.ctx.lineWidth = this.connections_width + 7 const dpi = window?.devicePixelRatio || 1 + // Try layout store for link hit testing first + const hitLinkId = layoutStore.queryLinkAtPoint({ x, y }, this.ctx) + for (const linkSegment of this.renderedPaths) { const centre = linkSegment._pos if (!centre) continue + // Check if this link was hit (using layout store or fallback to old method) + const isLinkHit = hitLinkId + ? String(linkSegment.id) === hitLinkId + : linkSegment.path && + this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi) + // If we shift click on a link then start a link from that input - if ( - (e.shiftKey || e.altKey) && - linkSegment.path && - this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi) - ) { + if ((e.shiftKey || e.altKey) && isLinkHit) { this.ctx.lineWidth = lineWidth if (e.shiftKey && !e.altKey) { @@ -3103,8 +3112,27 @@ export class LGraphCanvas // For input/output hovering // to store the output of isOverNodeInput const pos: Point = [0, 0] - const inputId = isOverNodeInput(node, x, y, pos) - const outputId = isOverNodeOutput(node, x, y, pos) + + // Try to use layout store for hit testing first, fallback to old method + let inputId: number = -1 + let outputId: number = -1 + + const slotLayout = layoutStore.querySlotAtPoint({ x, y }) + if (slotLayout && slotLayout.nodeId === String(node.id)) { + if (slotLayout.type === 'input') { + inputId = slotLayout.index + pos[0] = slotLayout.position.x + pos[1] = slotLayout.position.y + } else { + outputId = slotLayout.index + pos[0] = slotLayout.position.x + pos[1] = slotLayout.position.y + } + } else { + // Fallback to old method + inputId = isOverNodeInput(node, x, y, pos) + outputId = isOverNodeOutput(node, x, y, pos) + } const overWidget = node.getWidgetOnPos(x, y, true) ?? undefined if (!node.mouseOver) { @@ -4974,6 +5002,7 @@ export class LGraphCanvas node._setConcreteSlots() if (!node.collapsed) { node.arrange() + node.registerSlots() // Register slots for hit detection } // Skip all node body/widget/title rendering. Vue overlay handles visuals. return @@ -5065,6 +5094,7 @@ export class LGraphCanvas node._setConcreteSlots() if (!node.collapsed) { node.arrange() + node.registerSlots() // Register slots for hit detection node.drawSlots(ctx, { fromSlot: this.linkConnector.renderLinks[0]?.fromSlot as | INodeOutputSlot @@ -5079,6 +5109,7 @@ export class LGraphCanvas this.drawNodeWidgets(node, null, ctx) } else if (this.render_collapsed_slots) { + node.registerSlots() // Register slots for collapsed nodes too node.drawCollapsedSlots(ctx) } @@ -5470,6 +5501,19 @@ export class LGraphCanvas } reroute.draw(ctx, this._pattern) + // Register reroute layout with layout store for hit testing + layoutStore.updateRerouteLayout(String(reroute.id), { + id: String(reroute.id), + position: { x: reroute.pos[0], y: reroute.pos[1] }, + radius: 8, // Reroute.radius + bounds: { + x: reroute.pos[0] - 8, + y: reroute.pos[1] - 8, + width: 16, + height: 16 + } + }) + // Never draw slots when the pointer is down if (!this.pointer.isDown) reroute.drawSlots(ctx) } @@ -5982,6 +6026,8 @@ export class LGraphCanvas case 'Delete': graph.removeLink(segment.id) + // Clean up layout store + layoutStore.deleteLinkLayout(String(segment.id)) break default: } @@ -8026,11 +8072,18 @@ export class LGraphCanvas // Check for reroutes if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) { - const reroute = this.graph.getRerouteOnPos( - event.canvasX, - event.canvasY, - this.#visibleReroutes - ) + // Try layout store first, fallback to old method + const rerouteLayout = layoutStore.queryRerouteAtPoint({ + x: event.canvasX, + y: event.canvasY + }) + const reroute = rerouteLayout + ? this.graph.getReroute(Number(rerouteLayout.id)) + : this.graph.getRerouteOnPos( + event.canvasX, + event.canvasY, + this.#visibleReroutes + ) if (reroute) { menu_info.unshift( { diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 36691dcff..04c16291e 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -3,7 +3,8 @@ import { type SlotPositionContext, calculateInputSlotPos, calculateInputSlotPosFromSlot, - calculateOutputSlotPos + calculateOutputSlotPos, + registerNodeSlots } from '@/renderer/core/canvas/litegraph/SlotCalculations' import type { DragAndScale } from './DragAndScale' @@ -4098,6 +4099,13 @@ export class LGraphNode this.#arrangeWidgetInputSlots() } + /** + * Register all slots with the layout store for hit detection + */ + registerSlots(): void { + registerNodeSlots(String(this.id), this.#getSlotPositionContext()) + } + /** * Draws a progress bar on the node. * @param ctx The canvas context to draw on diff --git a/src/lib/litegraph/src/LLink.ts b/src/lib/litegraph/src/LLink.ts index c493a27a1..55f97dc6d 100644 --- a/src/lib/litegraph/src/LLink.ts +++ b/src/lib/litegraph/src/LLink.ts @@ -2,6 +2,7 @@ import { SUBGRAPH_INPUT_ID, SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants' +import { layoutStore } from '@/renderer/core/layout/store/LayoutStore' import type { LGraphNode, NodeId } from './LGraphNode' import type { Reroute, RerouteId } from './Reroute' @@ -447,9 +448,13 @@ export class LLink implements LinkSegment, Serialisable { reroute.linkIds.delete(this.id) if (!keepReroutes && !reroute.totalLinks) { network.reroutes.delete(reroute.id) + // Clean up layout store + layoutStore.deleteRerouteLayout(String(reroute.id)) } } network.links.delete(this.id) + // Clean up layout store + layoutStore.deleteLinkLayout(String(this.id)) } /** diff --git a/src/renderer/core/canvas/litegraph/LitegraphLinkAdapter.ts b/src/renderer/core/canvas/litegraph/LitegraphLinkAdapter.ts index a5a28e72f..ea22fa1f6 100644 --- a/src/renderer/core/canvas/litegraph/LitegraphLinkAdapter.ts +++ b/src/renderer/core/canvas/litegraph/LitegraphLinkAdapter.ts @@ -38,6 +38,7 @@ import { calculateOutputSlotPos } from '@/renderer/core/canvas/litegraph/SlotCalculations' import { layoutStore } from '@/renderer/core/layout/store/LayoutStore' +import type { LinkLayout } from '@/renderer/core/layout/types' export interface LinkRenderContext { // Canvas settings @@ -139,6 +140,25 @@ export class LitegraphLinkAdapter { // Store path for hit detection link.path = path + + // Register link layout with layout store + if (path && linkData.centerPos) { + const linkLayout: LinkLayout = { + id: String(link.id), + path, + bounds: this.calculateLinkBounds( + linkData.startPoint, + linkData.endPoint, + linkData.controlPoints + ), + centerPos: linkData.centerPos, + sourceNodeId: String(link.origin_id), + targetNodeId: String(link.target_id), + sourceSlot: link.origin_slot, + targetSlot: link.target_slot + } + layoutStore.updateLinkLayout(String(link.id), linkLayout) + } } /** @@ -434,6 +454,25 @@ export class LitegraphLinkAdapter { linkSegment._centreAngle = linkData.centerAngle } } + + // Register link layout with layout store if this is a real link + if (link && path && linkData.centerPos) { + const linkLayout: LinkLayout = { + id: String(link.id), + path, + bounds: this.calculateLinkBounds( + linkData.startPoint, + linkData.endPoint, + linkData.controlPoints + ), + centerPos: linkData.centerPos, + sourceNodeId: String(link.origin_id), + targetNodeId: String(link.target_id), + sourceSlot: link.origin_slot, + targetSlot: link.target_slot + } + layoutStore.updateLinkLayout(String(link.id), linkLayout) + } } } @@ -522,4 +561,38 @@ export class LitegraphLinkAdapter { // Render using pure renderer this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext) } + + /** + * Calculate bounding box for a link + */ + private calculateLinkBounds( + startPoint: Point, + endPoint: Point, + controlPoints?: Point[] + ): { x: number; y: number; width: number; height: number } { + // Start with endpoints + let minX = Math.min(startPoint.x, endPoint.x) + let maxX = Math.max(startPoint.x, endPoint.x) + let minY = Math.min(startPoint.y, endPoint.y) + let maxY = Math.max(startPoint.y, endPoint.y) + + // Include control points if present (for spline links) + if (controlPoints) { + for (const cp of controlPoints) { + minX = Math.min(minX, cp.x) + maxX = Math.max(maxX, cp.x) + minY = Math.min(minY, cp.y) + maxY = Math.max(maxY, cp.y) + } + } + + // Add some padding for hit detection + const padding = 10 + return { + x: minX - padding, + y: minY - padding, + width: maxX - minX + 2 * padding, + height: maxY - minY + 2 * padding + } + } } diff --git a/src/renderer/core/canvas/litegraph/SlotCalculations.ts b/src/renderer/core/canvas/litegraph/SlotCalculations.ts index 5084028c5..ca08ae685 100644 --- a/src/renderer/core/canvas/litegraph/SlotCalculations.ts +++ b/src/renderer/core/canvas/litegraph/SlotCalculations.ts @@ -13,6 +13,8 @@ import type { } from '@/lib/litegraph/src/interfaces' import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils' +import { layoutStore } from '@/renderer/core/layout/store/LayoutStore' +import type { SlotLayout } from '@/renderer/core/layout/types' export interface SlotPositionContext { /** Node's X position in graph coordinates */ @@ -229,3 +231,60 @@ function calculateVueSlotPosition( return [nodeX + slotCenterX, nodeY + slotCenterY] } + +/** + * Register slot layout with the layout store for hit testing + * @param nodeId The node ID + * @param slotIndex The slot index + * @param isInput Whether this is an input slot + * @param position The slot position in graph coordinates + */ +export function registerSlotLayout( + nodeId: string, + slotIndex: number, + isInput: boolean, + position: Point +): void { + const slotKey = `${nodeId}-${isInput ? 'in' : 'out'}-${slotIndex}` + + // Calculate bounds for the slot (20x20 area around center) + const slotSize = 20 + const halfSize = slotSize / 2 + + const slotLayout: SlotLayout = { + nodeId, + index: slotIndex, + type: isInput ? 'input' : 'output', + position: { x: position[0], y: position[1] }, + bounds: { + x: position[0] - halfSize, + y: position[1] - halfSize, + width: slotSize, + height: slotSize + } + } + + layoutStore.updateSlotLayout(slotKey, slotLayout) +} + +/** + * Register all slots for a node + * @param nodeId The node ID + * @param context The slot position context + */ +export function registerNodeSlots( + nodeId: string, + context: SlotPositionContext +): void { + // Register input slots + context.inputs.forEach((_, index) => { + const position = calculateInputSlotPos(context, index) + registerSlotLayout(nodeId, index, true, position) + }) + + // Register output slots + context.outputs.forEach((_, index) => { + const position = calculateOutputSlotPos(context, index) + registerSlotLayout(nodeId, index, false, position) + }) +} diff --git a/src/renderer/core/layout/store/LayoutStore.ts b/src/renderer/core/layout/store/LayoutStore.ts index c3c46fd9a..2bf931ea2 100644 --- a/src/renderer/core/layout/store/LayoutStore.ts +++ b/src/renderer/core/layout/store/LayoutStore.ts @@ -20,9 +20,14 @@ import type { Bounds, LayoutChange, LayoutStore, + LinkId, + LinkLayout, NodeId, NodeLayout, - Point + Point, + RerouteId, + RerouteLayout, + SlotLayout } from '@/renderer/core/layout/types' import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex' @@ -38,7 +43,7 @@ class LayoutStoreImpl implements LayoutStore { ACTOR_CONFIG.DEFAULT_SOURCE private currentActor = `${ACTOR_CONFIG.USER_PREFIX}${Math.random() .toString(36) - .substr(2, ACTOR_CONFIG.ID_LENGTH)}` + .substring(2, 2 + ACTOR_CONFIG.ID_LENGTH)}` // Change listeners private changeListeners = new Set<(change: LayoutChange) => void>() @@ -47,16 +52,27 @@ class LayoutStoreImpl implements LayoutStore { private nodeRefs = new Map>() private nodeTriggers = new Map void>() - // Spatial index manager - private spatialIndex: SpatialIndexManager + // New data structures for hit testing + private linkLayouts = new Map() + private slotLayouts = new Map() + private rerouteLayouts = new Map() + + // Spatial index managers + private spatialIndex: SpatialIndexManager // For nodes + private linkSpatialIndex: SpatialIndexManager // For links + private slotSpatialIndex: SpatialIndexManager // For slots + private rerouteSpatialIndex: SpatialIndexManager // For reroutes constructor() { // Initialize Yjs data structures this.ynodes = this.ydoc.getMap('nodes') this.yoperations = this.ydoc.getArray('operations') - // Initialize spatial index manager + // Initialize spatial index managers this.spatialIndex = new SpatialIndexManager() + this.linkSpatialIndex = new SpatialIndexManager() + this.slotSpatialIndex = new SpatialIndexManager() + this.rerouteSpatialIndex = new SpatialIndexManager() // Listen for Yjs changes and trigger Vue reactivity this.ynodes.observe((event) => { @@ -259,6 +275,237 @@ class LayoutStoreImpl implements LayoutStore { return this.spatialIndex.query(bounds) } + /** + * Update link layout data + */ + updateLinkLayout(linkId: LinkId, layout: LinkLayout): void { + const existing = this.linkLayouts.get(linkId) + + if (existing) { + // Update spatial index + this.linkSpatialIndex.update(linkId, layout.bounds) + } else { + // Insert into spatial index + this.linkSpatialIndex.insert(linkId, layout.bounds) + } + + this.linkLayouts.set(linkId, layout) + } + + /** + * Delete link layout data + */ + deleteLinkLayout(linkId: LinkId): void { + const deleted = this.linkLayouts.delete(linkId) + if (deleted) { + // Remove from spatial index + this.linkSpatialIndex.remove(linkId) + } + } + + /** + * Update slot layout data + */ + updateSlotLayout(key: string, layout: SlotLayout): void { + const existing = this.slotLayouts.get(key) + + if (existing) { + // Update spatial index + this.slotSpatialIndex.update(key, layout.bounds) + } else { + // Insert into spatial index + this.slotSpatialIndex.insert(key, layout.bounds) + } + + this.slotLayouts.set(key, layout) + } + + /** + * Delete slot layout data + */ + deleteSlotLayout(key: string): void { + const deleted = this.slotLayouts.delete(key) + if (deleted) { + // Remove from spatial index + this.slotSpatialIndex.remove(key) + } + } + + /** + * Delete all slot layouts for a node + */ + deleteNodeSlotLayouts(nodeId: NodeId): void { + const keysToDelete: string[] = [] + for (const [key, layout] of this.slotLayouts) { + if (layout.nodeId === nodeId) { + keysToDelete.push(key) + } + } + for (const key of keysToDelete) { + this.slotLayouts.delete(key) + // Remove from spatial index + this.slotSpatialIndex.remove(key) + } + } + + /** + * Update reroute layout data + */ + updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void { + const existing = this.rerouteLayouts.get(rerouteId) + + if (existing) { + // Update spatial index + this.rerouteSpatialIndex.update(rerouteId, layout.bounds) + } else { + // Insert into spatial index + this.rerouteSpatialIndex.insert(rerouteId, layout.bounds) + } + + this.rerouteLayouts.set(rerouteId, layout) + } + + /** + * Delete reroute layout data + */ + deleteRerouteLayout(rerouteId: RerouteId): void { + const deleted = this.rerouteLayouts.delete(rerouteId) + if (deleted) { + // Remove from spatial index + this.rerouteSpatialIndex.remove(rerouteId) + } + } + + /** + * Get link layout data + */ + getLinkLayout(linkId: LinkId): LinkLayout | null { + return this.linkLayouts.get(linkId) || null + } + + /** + * Get slot layout data + */ + getSlotLayout(key: string): SlotLayout | null { + return this.slotLayouts.get(key) || null + } + + /** + * Get reroute layout data + */ + getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null { + return this.rerouteLayouts.get(rerouteId) || null + } + + /** + * Query link at point + */ + queryLinkAtPoint( + point: Point, + ctx?: CanvasRenderingContext2D + ): LinkId | null { + // Use spatial index to get candidate links + const searchArea = { + x: point.x - 10, // Tolerance for line width + y: point.y - 10, + width: 20, + height: 20 + } + const candidateLinkIds = this.linkSpatialIndex.query(searchArea) + + // Precise hit test only on candidates + for (const linkId of candidateLinkIds) { + const linkLayout = this.linkLayouts.get(linkId) + if (!linkLayout) continue + + if (ctx && linkLayout.path) { + // Save and set appropriate line width for hit testing + const oldLineWidth = ctx.lineWidth + ctx.lineWidth = 10 // Hit test tolerance + + const hit = ctx.isPointInStroke(linkLayout.path, point.x, point.y) + ctx.lineWidth = oldLineWidth + + if (hit) return linkId + } else if (this.pointInBounds(point, linkLayout.bounds)) { + // Fallback to bounding box test + return linkId + } + } + + return null + } + + /** + * Query slot at point + */ + querySlotAtPoint(point: Point): SlotLayout | null { + // Use spatial index to get candidate slots + const searchArea = { + x: point.x - 10, // Tolerance for slot size + y: point.y - 10, + width: 20, + height: 20 + } + const candidateSlotKeys = this.slotSpatialIndex.query(searchArea) + + // Check precise bounds for candidates + for (const key of candidateSlotKeys) { + const slotLayout = this.slotLayouts.get(key) + if (slotLayout && this.pointInBounds(point, slotLayout.bounds)) { + return slotLayout + } + } + return null + } + + /** + * Query reroute at point + */ + queryRerouteAtPoint(point: Point): RerouteLayout | null { + // Use spatial index to get candidate reroutes + const maxRadius = 20 // Maximum expected reroute radius + const searchArea = { + x: point.x - maxRadius, + y: point.y - maxRadius, + width: maxRadius * 2, + height: maxRadius * 2 + } + const candidateRerouteIds = this.rerouteSpatialIndex.query(searchArea) + + // Check precise distance for candidates + for (const rerouteId of candidateRerouteIds) { + const rerouteLayout = this.rerouteLayouts.get(rerouteId) + if (rerouteLayout) { + const dx = point.x - rerouteLayout.position.x + const dy = point.y - rerouteLayout.position.y + const distance = Math.sqrt(dx * dx + dy * dy) + + if (distance <= rerouteLayout.radius) { + return rerouteLayout + } + } + } + return null + } + + /** + * Query all items in bounds + */ + queryItemsInBounds(bounds: Bounds): { + nodes: NodeId[] + links: LinkId[] + slots: string[] + reroutes: RerouteId[] + } { + return { + nodes: this.queryNodesInBounds(bounds), + links: this.linkSpatialIndex.query(bounds), // Use spatial index for links + slots: this.slotSpatialIndex.query(bounds), // Use spatial index for slots + reroutes: this.rerouteSpatialIndex.query(bounds) // Use spatial index for reroutes + } + } + /** * Apply a layout operation using Yjs transactions */ @@ -378,6 +625,12 @@ class LayoutStoreImpl implements LayoutStore { this.nodeRefs.clear() this.nodeTriggers.clear() this.spatialIndex.clear() + this.linkSpatialIndex.clear() + this.slotSpatialIndex.clear() + this.rerouteSpatialIndex.clear() + this.linkLayouts.clear() + this.slotLayouts.clear() + this.rerouteLayouts.clear() nodes.forEach((node, index) => { const layout: NodeLayout = { @@ -487,6 +740,9 @@ class LayoutStoreImpl implements LayoutStore { // Remove from spatial index this.spatialIndex.remove(operation.nodeId) + // Clean up associated slot layouts + this.deleteNodeSlotLayouts(operation.nodeId) + change.type = 'delete' change.nodeIds.push(operation.nodeId) } diff --git a/src/renderer/core/layout/types.ts b/src/renderer/core/layout/types.ts index c8c5da763..617b8a33e 100644 --- a/src/renderer/core/layout/types.ts +++ b/src/renderer/core/layout/types.ts @@ -28,6 +28,8 @@ export interface Bounds { export type NodeId = string export type SlotId = string export type ConnectionId = string +export type LinkId = string +export type RerouteId = string // Layout data structures export interface NodeLayout { @@ -41,11 +43,29 @@ export interface NodeLayout { } export interface SlotLayout { - id: SlotId nodeId: NodeId - position: Point // Relative to node - type: 'input' | 'output' index: number + type: 'input' | 'output' + position: Point + bounds: Bounds +} + +export interface LinkLayout { + id: string + path: Path2D + bounds: Bounds + centerPos: Point + sourceNodeId: NodeId + targetNodeId: NodeId + sourceSlot: number + targetSlot: number +} + +export interface RerouteLayout { + id: RerouteId + position: Point + radius: number + bounds: Bounds } export interface ConnectionLayout { @@ -300,6 +320,33 @@ export interface LayoutStore { queryNodeAtPoint(point: Point): NodeId | null queryNodesInBounds(bounds: Bounds): NodeId[] + // Hit testing queries for links, slots, and reroutes + queryLinkAtPoint(point: Point, ctx?: CanvasRenderingContext2D): LinkId | null + querySlotAtPoint(point: Point): SlotLayout | null + queryRerouteAtPoint(point: Point): RerouteLayout | null + queryItemsInBounds(bounds: Bounds): { + nodes: NodeId[] + links: LinkId[] + slots: string[] + reroutes: RerouteId[] + } + + // Update methods for link, slot, and reroute layouts + updateLinkLayout(linkId: LinkId, layout: LinkLayout): void + updateSlotLayout(key: string, layout: SlotLayout): void + updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void + + // Delete methods for cleanup + deleteLinkLayout(linkId: LinkId): void + deleteSlotLayout(key: string): void + deleteNodeSlotLayouts(nodeId: NodeId): void + deleteRerouteLayout(rerouteId: RerouteId): void + + // Get layout data + getLinkLayout(linkId: LinkId): LinkLayout | null + getSlotLayout(key: string): SlotLayout | null + getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null + // Direct mutation API (CRDT-ready) applyOperation(operation: LayoutOperation): void diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts index ff77c040c..b197bc441 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts @@ -6,9 +6,12 @@ */ import { computed, inject } from 'vue' +import { registerNodeSlots } from '@/renderer/core/canvas/litegraph/SlotCalculations' +import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/SlotCalculations' import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations' import { layoutStore } from '@/renderer/core/layout/store/LayoutStore' import type { Point } from '@/renderer/core/layout/types' +import { app } from '@/scripts/app' /** * Composable for individual Vue node components @@ -136,6 +139,34 @@ export function useNodeLayout(nodeId: string) { mutations.resizeNode(nodeId, newSize) } + /** + * Update slot layouts for this node + * Should be called whenever the node's slots or position changes + */ + function updateSlotLayouts() { + if (!layoutRef.value || !app.graph) return + + const node = app.graph.getNodeById(Number(nodeId)) + if (!node) return + + // Create context for slot position calculation + const context: SlotPositionContext = { + nodeX: layoutRef.value.position.x, + nodeY: layoutRef.value.position.y, + nodeWidth: layoutRef.value.size.width, + nodeHeight: layoutRef.value.size.height, + collapsed: node.flags?.collapsed || false, + collapsedWidth: node._collapsed_width, + slotStartY: node.constructor.slot_start_y, + inputs: node.inputs || [], + outputs: node.outputs || [], + widgets: node.widgets + } + + // Register all slots for this node + registerNodeSlots(nodeId, context) + } + return { // Reactive state (via customRef) layoutRef, @@ -148,6 +179,7 @@ export function useNodeLayout(nodeId: string) { // Mutations moveTo, resize, + updateSlotLayouts, // Drag handlers startDrag,