feat: Add slot registration and spatial indexing for hit detection

- 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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Lu
2025-08-20 16:25:13 -04:00
parent 3a52c46a04
commit 70fbfd0f5e
9 changed files with 562 additions and 26 deletions

View File

@@ -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))
}
}

View File

@@ -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(
{

View File

@@ -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

View File

@@ -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<SerialisableLLink> {
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))
}
/**

View File

@@ -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
}
}
}

View File

@@ -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)
})
}

View File

@@ -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<NodeId, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<NodeId, () => void>()
// Spatial index manager
private spatialIndex: SpatialIndexManager
// New data structures for hit testing
private linkLayouts = new Map<LinkId, LinkLayout>()
private slotLayouts = new Map<string, SlotLayout>()
private rerouteLayouts = new Map<RerouteId, RerouteLayout>()
// 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)
}

View File

@@ -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

View File

@@ -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,