mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user