Implement drop-on-canvas + linkconnectoradapter consolidation (#5898)

Implements droponcanvas functionality and a linkconnectoradapter
refactor.

- Drop on canvas (Shift and default) integrated via LinkConnector
‘dropped-on-canvas’ with proper CanvasPointerEvent.
- LinkConnector adapter: now wraps the live canvas linkConnector (no
duplicate state); added dropOnCanvas() helper.
- Tests: Playwright scenarios for Shift-drop context menu/searchbox,
pinned endpoint, type prefilter, and post-selection auto-connect
(browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts).

There are some followup PRs that will fix/refactor some more noncritical
things, like the terrible slotid, the number/string nodeid confusion,
etc.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/5780 (snapping) <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5898 (drop on canvas
+ linkconnectoradapter refactor) <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/5903 (fix reroute
snapping)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Benjamin Lu
2025-10-09 20:55:30 -07:00
committed by GitHub
parent 4cb03cf052
commit 4404c0461d
24 changed files with 720 additions and 340 deletions

View File

@@ -788,4 +788,171 @@ test.describe('Vue Node Link Interaction', () => {
targetSlot: 2
})
})
test.describe('Release actions (Shift-drop)', () => {
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Context menu should be visible
const contextMenu = comfyPage.page.locator('.litecontextmenu')
await expect(contextMenu).toBeVisible()
// Pinned endpoint should not change with mouse movement while menu is open
const before = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(before).not.toBeNull()
// Move mouse elsewhere and verify snap position is unchanged
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
const after = await comfyPage.page.evaluate(() => {
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
return Array.isArray(snap) ? [snap[0], snap[1]] : null
})
expect(after).toEqual(before)
})
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting(
'Comfy.LinkRelease.ActionShift',
'context menu'
)
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 120 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Open Search from the context menu
await comfyPage.clickContextMenuItem('Search')
// Search box opens with prefilled type filter based on link type (LATENT)
await expect(comfyPage.searchBox.input).toBeVisible()
const chips = comfyPage.searchBox.filterChips
// Ensure at least one filter chip exists and it matches the link type
const chipCount = await chips.count()
expect(chipCount).toBeGreaterThan(0)
await expect(chips.first()).toContainText('LATENT')
// Choose a compatible node and verify it auto-connects
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
await comfyPage.nextFrame()
// KSampler output should now have an outgoing link
const samplerOutput = await samplerNode.getOutput(0)
expect(await samplerOutput.getLinkCount()).toBe(1)
// One of the VAEDecode nodes should have an incoming link on input[0]
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
let linked = false
for (const vae of vaeNodes) {
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
if (details) {
expect(details.originId).toBe(samplerNode.id)
linked = true
break
}
}
expect(linked).toBe(true)
})
test('Search box opens on Shift-drop and connects after selection', async ({
comfyPage,
comfyMouse
}) => {
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
expect(samplerNode).toBeTruthy()
const outputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,
0,
false
)
const dropPos = { x: outputCenter.x + 140, y: outputCenter.y - 100 }
await comfyMouse.move(outputCenter)
await comfyPage.page.keyboard.down('Shift')
try {
await comfyMouse.drag(dropPos)
await comfyMouse.drop()
} finally {
await comfyPage.page.keyboard.up('Shift').catch(() => {})
}
// Search box should open directly
await expect(comfyPage.searchBox.input).toBeVisible()
await expect(comfyPage.searchBox.filterChips.first()).toContainText(
'LATENT'
)
// Select a compatible node and verify connection
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
await comfyPage.nextFrame()
const samplerOutput = await samplerNode.getOutput(0)
expect(await samplerOutput.getLinkCount()).toBe(1)
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
let linked = false
for (const vae of vaeNodes) {
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
if (details) {
expect(details.originId).toBe(samplerNode.id)
linked = true
break
}
}
expect(linked).toBe(true)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -113,7 +113,6 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
@@ -401,7 +400,6 @@ onMounted(async () => {
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
attachSlotLinkPreviewRenderer(comfyApp.canvas)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false

View File

@@ -3319,7 +3319,15 @@ export class LGraphCanvas
if (slot && linkConnector.isInputValidDrop(node, slot)) {
highlightInput = slot
highlightPos = node.getInputSlotPos(slot)
if (LiteGraph.vueNodesMode) {
const idx = node.inputs.indexOf(slot)
highlightPos =
idx !== -1
? getSlotPosition(node, idx, true)
: node.getInputSlotPos(slot)
} else {
highlightPos = node.getInputSlotPos(slot)
}
linkConnector.overWidget = overWidget
}
}
@@ -3331,7 +3339,9 @@ export class LGraphCanvas
const result = node.findInputByType(firstLink.fromSlot.type)
if (result) {
highlightInput = result.slot
highlightPos = node.getInputSlotPos(result.slot)
highlightPos = LiteGraph.vueNodesMode
? getSlotPosition(node, result.index, true)
: node.getInputSlotPos(result.slot)
}
} else if (
inputId != -1 &&
@@ -3356,7 +3366,9 @@ export class LGraphCanvas
if (inputId === -1 && outputId === -1) {
const result = node.findOutputByType(firstLink.fromSlot.type)
if (result) {
highlightPos = node.getOutputPos(result.index)
highlightPos = LiteGraph.vueNodesMode
? getSlotPosition(node, result.index, false)
: node.getOutputPos(result.index)
}
} else {
// check if I have a slot below de mouse
@@ -5730,7 +5742,9 @@ export class LGraphCanvas
if (!node) continue
const startPos = firstReroute.pos
const endPos = node.getInputPos(link.target_slot)
const endPos: Point = LiteGraph.vueNodesMode
? getSlotPosition(node, link.target_slot, true)
: node.getInputPos(link.target_slot)
const endDirection = node.inputs[link.target_slot]?.dir
firstReroute._dragging = true
@@ -5749,7 +5763,9 @@ export class LGraphCanvas
const node = graph.getNodeById(link.origin_id)
if (!node) continue
const startPos = node.getOutputPos(link.origin_slot)
const startPos: Point = LiteGraph.vueNodesMode
? getSlotPosition(node, link.origin_slot, false)
: node.getOutputPos(link.origin_slot)
const endPos = reroute.pos
const startDirection = node.outputs[link.origin_slot]?.dir

View File

@@ -3326,11 +3326,14 @@ export class LGraphNode
* Gets the position of an output slot, in graph co-ordinates.
*
* This method is preferred over the legacy {@link getConnectionPos} method.
* @param slot Output slot index
* @param outputSlotIndex Output slot index
* @returns Position of the output slot
*/
getOutputPos(slot: number): Point {
return calculateOutputSlotPos(this.#getSlotPositionContext(), slot)
getOutputPos(outputSlotIndex: number): Point {
return calculateOutputSlotPos(
this.#getSlotPositionContext(),
outputSlotIndex
)
}
/** @inheritdoc */

View File

@@ -0,0 +1,74 @@
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type {
CanvasPointerEvent,
CanvasPointerExtensions
} from '@/lib/litegraph/src/types/events'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
type PointerOffsets = {
x: number
y: number
}
const pointerHistory = new Map<number, PointerOffsets>()
const defineEnhancements = (
event: PointerEvent,
enhancement: CanvasPointerExtensions
) => {
Object.defineProperties(event, {
canvasX: { value: enhancement.canvasX, configurable: true, writable: true },
canvasY: { value: enhancement.canvasY, configurable: true, writable: true },
deltaX: { value: enhancement.deltaX, configurable: true, writable: true },
deltaY: { value: enhancement.deltaY, configurable: true, writable: true },
safeOffsetX: {
value: enhancement.safeOffsetX,
configurable: true,
writable: true
},
safeOffsetY: {
value: enhancement.safeOffsetY,
configurable: true,
writable: true
}
})
}
const createEnhancement = (event: PointerEvent): CanvasPointerExtensions => {
const conversion = useSharedCanvasPositionConversion()
conversion.update()
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
event.clientX,
event.clientY
])
const canvas = useCanvasStore().getCanvas()
const { offset, scale } = canvas.ds
const [originClientX, originClientY] = conversion.canvasPosToClientPos([0, 0])
const left = originClientX - offset[0] * scale
const top = originClientY - offset[1] * scale
const safeOffsetX = event.clientX - left
const safeOffsetY = event.clientY - top
const previous = pointerHistory.get(event.pointerId)
const deltaX = previous ? safeOffsetX - previous.x : 0
const deltaY = previous ? safeOffsetY - previous.y : 0
pointerHistory.set(event.pointerId, { x: safeOffsetX, y: safeOffsetY })
return { canvasX, canvasY, deltaX, deltaY, safeOffsetX, safeOffsetY }
}
export const toCanvasPointerEvent = <T extends PointerEvent>(
event: T
): T & CanvasPointerEvent => {
const enhancement = createEnhancement(event)
defineEnhancements(event, enhancement)
return event as T & CanvasPointerEvent
}
export const clearCanvasPointerHistory = (pointerId: number) => {
pointerHistory.delete(pointerId)
}

View File

@@ -1,9 +1,9 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { app } from '@/scripts/app'
// Keep one adapter per graph so rendering and interaction share state.
@@ -17,16 +17,11 @@ const adapterByGraph = new WeakMap<LGraph, LinkConnectorAdapter>()
* - Preserves existing Vue composable behavior.
*/
export class LinkConnectorAdapter {
readonly linkConnector: LinkConnector
constructor(
/** Network the links belong to (typically `app.canvas.graph`). */
readonly network: LGraph
) {
// No-op legacy setter to avoid side effects when connectors update
const setConnectingLinks: (value: ConnectingLink[]) => void = () => {}
this.linkConnector = new LinkConnector(setConnectingLinks)
}
readonly network: LGraph,
readonly linkConnector: LinkConnector
) {}
/**
* The currently rendered/dragged links, typed for consumer use.
@@ -133,6 +128,11 @@ export class LinkConnectorAdapter {
this.linkConnector.disconnectLinks()
}
/** Drops moving links onto the canvas (no target). */
dropOnCanvas(event: CanvasPointerEvent): void {
this.linkConnector.dropOnNothing(event)
}
/** Resets connector state and clears any temporary flags. */
reset(): void {
this.linkConnector.reset()
@@ -141,11 +141,12 @@ export class LinkConnectorAdapter {
/** Convenience creator using the current app canvas graph. */
export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
const graph = app.canvas?.graph as LGraph | undefined
if (!graph) return null
const graph = app.canvas?.graph
const connector = app.canvas?.linkConnector
if (!graph || !connector) return null
let adapter = adapterByGraph.get(graph)
if (!adapter) {
adapter = new LinkConnectorAdapter(graph)
if (!adapter || adapter.linkConnector !== connector) {
adapter = new LinkConnectorAdapter(graph, connector)
adapterByGraph.set(graph, adapter)
}
return adapter

View File

@@ -0,0 +1,117 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
interface DropResolutionContext {
adapter: LinkConnectorAdapter | null
graph: LGraph | null
session: SlotLinkDragContext
}
export const resolveSlotTargetCandidate = (
target: EventTarget | null,
{ adapter, graph }: DropResolutionContext
): SlotDropCandidate | null => {
const { state: dragState, setCompatibleForKey } = useSlotLinkDragUIState()
if (!(target instanceof HTMLElement)) return null
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
const key = elWithKey?.dataset['slotKey']
if (!key) return null
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
const candidate: SlotDropCandidate = { layout, compatible: false }
if (adapter && graph) {
const cached = dragState.compatible.get(key)
if (cached != null) {
candidate.compatible = cached
} else {
const nodeId: NodeId = layout.nodeId
const compatible =
layout.type === 'input'
? adapter.isInputValidDrop(nodeId, layout.index)
: adapter.isOutputValidDrop(nodeId, layout.index)
setCompatibleForKey(key, compatible)
candidate.compatible = compatible
}
}
return candidate
}
export const resolveNodeSurfaceSlotCandidate = (
target: EventTarget | null,
{ adapter, graph, session }: DropResolutionContext
): SlotDropCandidate | null => {
const { setCompatibleForKey } = useSlotLinkDragUIState()
if (!(target instanceof HTMLElement)) return null
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
const nodeIdAttr = elWithNode?.dataset['nodeId']
if (!nodeIdAttr) return null
if (!adapter || !graph) return null
const nodeId: NodeId = nodeIdAttr
const cachedPreferredSlotForNode = session.preferredSlotForNode.get(nodeId)
if (cachedPreferredSlotForNode !== undefined) {
return cachedPreferredSlotForNode
? { layout: cachedPreferredSlotForNode.layout, compatible: true }
: null
}
const node = graph.getNodeById(nodeId)
if (!node) return null
const firstLink = adapter.renderLinks[0]
if (!firstLink) return null
const connectingTo = adapter.linkConnector.state.connectingTo
if (connectingTo !== 'input' && connectingTo !== 'output') return null
const isInput = connectingTo === 'input'
const slotType = firstLink.fromSlot.type
const result = isInput
? node.findInputByType(slotType)
: node.findOutputByType(slotType)
const index = result?.index
if (index == null) {
session.preferredSlotForNode.set(nodeId, null)
return null
}
const key = getSlotKey(String(nodeId), index, isInput)
const layout = layoutStore.getSlotLayout(key)
if (!layout) {
session.preferredSlotForNode.set(nodeId, null)
return null
}
const compatible = isInput
? adapter.isInputValidDrop(nodeId, index)
: adapter.isOutputValidDrop(nodeId, index)
setCompatibleForKey(key, compatible)
if (!compatible) {
session.preferredSlotForNode.set(nodeId, null)
return null
}
const preferred = { index, key, layout }
session.preferredSlotForNode.set(nodeId, preferred)
return { layout, compatible: true }
}

View File

@@ -5,6 +5,14 @@ import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point, SlotLayout } from '@/renderer/core/layout/types'
/**
* Slot link drag UI state
*
* Reactive, shared state for a single drag interaction that UI components subscribe to.
* Tracks pointer position, source slot, and resolved drop candidate. Also exposes
* a compatibility map used to dim incompatible slots during drag.
*/
type SlotDragType = 'input' | 'output'
interface SlotDragSource {
@@ -33,6 +41,7 @@ interface SlotDragState {
source: SlotDragSource | null
pointer: PointerPosition
candidate: SlotDropCandidate | null
compatible: Map<string, boolean>
}
const state = reactive<SlotDragState>({
@@ -43,7 +52,8 @@ const state = reactive<SlotDragState>({
client: { x: 0, y: 0 },
canvas: { x: 0, y: 0 }
},
candidate: null
candidate: null,
compatible: new Map<string, boolean>()
})
function updatePointerPosition(
@@ -67,6 +77,7 @@ function beginDrag(source: SlotDragSource, pointerId: number) {
state.source = source
state.pointerId = pointerId
state.candidate = null
state.compatible.clear()
}
function endDrag() {
@@ -78,6 +89,7 @@ function endDrag() {
state.pointer.canvas.x = 0
state.pointer.canvas.y = 0
state.candidate = null
state.compatible.clear()
}
function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
@@ -85,13 +97,21 @@ function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
return layoutStore.getSlotLayout(slotKey)
}
export function useSlotLinkDragState() {
export function useSlotLinkDragUIState() {
return {
state: readonly(state),
beginDrag,
endDrag,
updatePointerPosition,
setCandidate,
getSlotLayout
getSlotLayout,
setCompatibleMap: (entries: Iterable<[string, boolean]>) => {
state.compatible.clear()
for (const [key, value] of entries) state.compatible.set(key, value)
},
setCompatibleForKey: (key: string, value: boolean) => {
state.compatible.set(key, value)
},
clearCompatible: () => state.compatible.clear()
}
}

View File

@@ -1,110 +0,0 @@
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type { Point } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
function buildContext(canvas: LGraphCanvas): LinkRenderContext {
return {
renderMode: canvas.links_render_mode,
connectionWidth: canvas.connections_width,
renderBorder: canvas.render_connections_border,
lowQuality: canvas.low_quality,
highQualityRender: canvas.highquality_render,
scale: canvas.ds.scale,
linkMarkerShape: canvas.linkMarkerShape,
renderConnectionArrows: canvas.render_connection_arrows,
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
defaultLinkColor: canvas.default_link_color,
linkTypeColors: (canvas.constructor as typeof LGraphCanvas)
.link_type_colors,
disabledPattern: canvas._pattern
}
}
export function attachSlotLinkPreviewRenderer(canvas: LGraphCanvas) {
const originalOnDrawForeground = canvas.onDrawForeground?.bind(canvas)
const patched = (
ctx: CanvasRenderingContext2D,
area: LGraphCanvas['visible_area']
) => {
originalOnDrawForeground?.(ctx, area)
const { state } = useSlotLinkDragState()
// If LiteGraph's own connector is active, let it handle rendering to avoid double-draw
if (canvas.linkConnector?.isConnecting) return
if (!state.active || !state.source) return
const { pointer } = state
const linkRenderer = canvas.linkRenderer
if (!linkRenderer) return
const context = buildContext(canvas)
const renderLinks = createLinkConnectorAdapter()?.renderLinks
if (!renderLinks || renderLinks.length === 0) return
const to: Readonly<Point> = state.candidate?.compatible
? [state.candidate.layout.position.x, state.candidate.layout.position.y]
: [pointer.canvas.x, pointer.canvas.y]
ctx.save()
for (const link of renderLinks) {
const startDir = link.fromDirection ?? LinkDirection.RIGHT
const endDir = link.dragDirection ?? LinkDirection.CENTER
const colour = resolveConnectingLinkColor(link.fromSlot.type)
const fromPoint = resolveRenderLinkOrigin(link)
linkRenderer.renderDraggingLink(
ctx,
fromPoint,
to,
colour,
startDir,
endDir,
context
)
}
ctx.restore()
}
canvas.onDrawForeground = patched
}
function resolveRenderLinkOrigin(link: RenderLink): Readonly<Point> {
if (link.fromReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
if (rerouteLayout) {
return [rerouteLayout.position.x, rerouteLayout.position.y]
}
const [x, y] = link.fromReroute.pos
return [x, y]
}
const nodeId = getRenderLinkNodeId(link)
if (nodeId != null) {
const isInputFrom = link.toType === 'output'
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
const layout = layoutStore.getSlotLayout(key)
if (layout) {
return [layout.position.x, layout.position.y]
}
}
return link.fromPos
}
function getRenderLinkNodeId(link: RenderLink): number | null {
const node = link.node
if (typeof node === 'object' && node !== null && 'id' in node) {
const maybeId = node.id
if (typeof maybeId === 'number') return maybeId
}
return null
}

View File

@@ -577,6 +577,14 @@ class LayoutStoreImpl implements LayoutStore {
return this.rerouteLayouts.get(rerouteId) || null
}
/**
* Returns all slot layout keys currently tracked by the store.
* Useful for global passes without relying on spatial queries.
*/
getAllSlotKeys(): string[] {
return Array.from(this.slotLayouts.keys())
}
/**
* Update link segment layout data
*/

View File

@@ -309,6 +309,9 @@ export interface LayoutStore {
getSlotLayout(key: string): SlotLayout | null
getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null
// Returns all slot layout keys currently tracked by the store
getAllSlotKeys(): string[]
// Direct mutation API (CRDT-ready)
applyOperation(operation: LayoutOperation): void

View File

@@ -31,6 +31,8 @@ import type { ComponentPublicInstance } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
@@ -103,6 +105,15 @@ const slotColor = computed(() => {
return getSlotColor(props.slotData.type)
})
const { state: dragState } = useSlotLinkDragUIState()
const slotKey = computed(() =>
getSlotKey(props.nodeId ?? '', props.index, true)
)
const shouldDim = computed(() => {
if (!dragState.active) return false
return !dragState.compatible.get(slotKey.value)
})
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6',
@@ -112,7 +123,8 @@ const slotWrapperClass = computed(() =>
: 'pr-6 hover:bg-black/5 hover:dark:bg-white/5',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible
'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim.value
}
)
)

View File

@@ -28,6 +28,8 @@ import type { ComponentPublicInstance } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
@@ -73,6 +75,15 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const { state: dragState } = useSlotLinkDragUIState()
const slotKey = computed(() =>
getSlotKey(props.nodeId ?? '', props.index, false)
)
const shouldDim = computed(() => {
if (!dragState.active) return false
return !dragState.compatible.get(slotKey.value)
})
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
@@ -82,7 +93,8 @@ const slotWrapperClass = computed(() =>
: 'pl-6 hover:bg-black/5 hover:dark:bg-white/5',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible
'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim.value
}
)
)

View File

@@ -0,0 +1,59 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { SlotLayout } from '@/renderer/core/layout/types'
/**
* Slot link drag context
*
* Non-reactive, per-drag ephemeral caches and RAF batching used during
* link drag interactions. Keeps high-churn data out of the reactive UI state.
*/
interface PendingPointerMoveData {
clientX: number
clientY: number
target: EventTarget | null
}
export interface SlotLinkDragContext {
preferredSlotForNode: Map<
NodeId,
{ index: number; key: string; layout: SlotLayout } | null
>
lastHoverSlotKey: string | null
lastHoverNodeId: NodeId | null
lastCandidateKey: string | null
pendingPointerMove: PendingPointerMoveData | null
lastPointerEventTarget: EventTarget | null
lastPointerTargetSlotKey: string | null
lastPointerTargetNodeId: NodeId | null
reset: () => void
dispose: () => void
}
export function createSlotLinkDragContext(): SlotLinkDragContext {
const state: SlotLinkDragContext = {
preferredSlotForNode: new Map(),
lastHoverSlotKey: null,
lastHoverNodeId: null,
lastCandidateKey: null,
pendingPointerMove: null,
lastPointerEventTarget: null,
lastPointerTargetSlotKey: null,
lastPointerTargetNodeId: null,
reset: () => {
state.preferredSlotForNode = new Map()
state.lastHoverSlotKey = null
state.lastHoverNodeId = null
state.lastCandidateKey = null
state.pendingPointerMove = null
state.lastPointerEventTarget = null
state.lastPointerTargetSlotKey = null
state.lastPointerTargetNodeId = null
},
dispose: () => {
state.reset()
}
}
return state
}

View File

@@ -1,45 +0,0 @@
import type { SlotLayout } from '@/renderer/core/layout/types'
interface PendingMoveData {
clientX: number
clientY: number
target: EventTarget | null
}
interface SlotLinkDragSession {
compatCache: Map<string, boolean>
nodePreferred: Map<
number,
{ index: number; key: string; layout: SlotLayout } | null
>
lastHoverSlotKey: string | null
lastHoverNodeId: number | null
lastCandidateKey: string | null
pendingMove: PendingMoveData | null
reset: () => void
dispose: () => void
}
export function createSlotLinkDragSession(): SlotLinkDragSession {
const state: SlotLinkDragSession = {
compatCache: new Map(),
nodePreferred: new Map(),
lastHoverSlotKey: null,
lastHoverNodeId: null,
lastCandidateKey: null,
pendingMove: null,
reset: () => {
state.compatCache = new Map()
state.nodePreferred = new Map()
state.lastHoverSlotKey = null
state.lastHoverNodeId = null
state.lastCandidateKey = null
state.pendingMove = null
},
dispose: () => {
state.reset()
}
}
return state
}

View File

@@ -4,7 +4,7 @@ import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
@@ -13,15 +13,23 @@ import type {
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import {
clearCanvasPointerHistory,
toCanvasPointerEvent
} from '@/renderer/core/canvas/interaction/canvasPointerEvent'
import { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragState'
import {
resolveNodeSurfaceSlotCandidate,
resolveSlotTargetCandidate
} from '@/renderer/core/canvas/links/linkDropOrchestrator'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragUIState'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point } from '@/renderer/core/layout/types'
import { toPoint } from '@/renderer/core/layout/utils/geometry'
import { createSlotLinkDragSession } from '@/renderer/extensions/vueNodes/composables/slotLinkDragSession'
import { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
@@ -80,111 +88,54 @@ export function useSlotLinkInteraction({
index,
type
}: SlotInteractionOptions): SlotInteractionHandlers {
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } =
useSlotLinkDragState()
const {
state,
beginDrag,
endDrag,
updatePointerPosition,
setCandidate,
setCompatibleForKey,
clearCompatible
} = useSlotLinkDragUIState()
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null
// Per-drag drag-state cache
const dragSession = createSlotLinkDragSession()
// Per-drag drag-state context (non-reactive caches + RAF batching)
const dragContext = createSlotLinkDragContext()
function candidateFromTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const elWithKey = target.closest<HTMLElement>('[data-slot-key]')
const key = elWithKey?.dataset['slotKey']
if (!key) return null
const resolveRenderLinkSource = (link: RenderLink): Point | null => {
if (link.fromReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
if (rerouteLayout) return rerouteLayout.position
const [x, y] = link.fromReroute.pos
return toPoint(x, y)
}
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
const nodeId = link.node.id
if (nodeId != null) {
const isInputFrom = link.toType === 'output'
const key = getSlotKey(String(nodeId), link.fromSlotIndex, isInputFrom)
const layout = layoutStore.getSlotLayout(key)
if (layout) return layout.position
}
const candidate: SlotDropCandidate = { layout, compatible: false }
const pos = link.fromPos
return toPoint(pos[0], pos[1])
}
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
if (graph && adapter) {
const cached = dragSession.compatCache.get(key)
if (cached != null) {
candidate.compatible = cached
} else {
const compatible =
layout.type === 'input'
? adapter.isInputValidDrop(layout.nodeId, layout.index)
: adapter.isOutputValidDrop(layout.nodeId, layout.index)
dragSession.compatCache.set(key, compatible)
candidate.compatible = compatible
const syncRenderLinkOrigins = () => {
if (!activeAdapter) return
for (const link of activeAdapter.renderLinks) {
const origin = resolveRenderLinkSource(link)
if (!origin) continue
const x = origin.x
const y = origin.y
if (link.fromPos[0] !== x || link.fromPos[1] !== y) {
link.fromPos[0] = x
link.fromPos[1] = y
}
}
return candidate
}
function candidateFromNodeTarget(
target: EventTarget | null
): SlotDropCandidate | null {
if (!(target instanceof HTMLElement)) return null
const elWithNode = target.closest<HTMLElement>('[data-node-id]')
const nodeIdStr = elWithNode?.dataset['nodeId']
if (!nodeIdStr) return null
const adapter = ensureActiveAdapter()
const graph = app.canvas?.graph
if (!adapter || !graph) return null
const nodeId = Number(nodeIdStr)
// Cached preferred slot for this node within this drag
const cachedPreferred = dragSession.nodePreferred.get(nodeId)
if (cachedPreferred !== undefined) {
return cachedPreferred
? { layout: cachedPreferred.layout, compatible: true }
: null
}
const node = graph.getNodeById(nodeId)
if (!node) return null
const firstLink = adapter.renderLinks[0]
if (!firstLink) return null
const connectingTo = adapter.linkConnector.state.connectingTo
if (connectingTo !== 'input' && connectingTo !== 'output') return null
const isInput = connectingTo === 'input'
const slotType = firstLink.fromSlot.type
const res = isInput
? node.findInputByType(slotType)
: node.findOutputByType(slotType)
const index = res?.index
if (index == null) return null
const key = getSlotKey(String(nodeId), index, isInput)
const layout = layoutStore.getSlotLayout(key)
if (!layout) return null
const compatible = isInput
? adapter.isInputValidDrop(nodeId, index)
: adapter.isOutputValidDrop(nodeId, index)
if (compatible) {
dragSession.compatCache.set(key, true)
const preferred = { index, key, layout }
dragSession.nodePreferred.set(nodeId, preferred)
return { layout, compatible: true }
} else {
dragSession.compatCache.set(key, false)
dragSession.nodePreferred.set(nodeId, null)
return null
}
}
const ensureActiveAdapter = (): LinkConnectorAdapter | null => {
if (!activeAdapter) activeAdapter = createLinkConnectorAdapter()
return activeAdapter
}
function hasCanConnectToReroute(
@@ -308,12 +259,16 @@ export function useSlotLinkInteraction({
}
const cleanupInteraction = () => {
if (state.pointerId != null) {
clearCanvasPointerHistory(state.pointerId)
}
activeAdapter?.reset()
pointerSession.clear()
endDrag()
activeAdapter = null
raf.cancel()
dragSession.dispose()
dragContext.dispose()
clearCompatible()
}
const updatePointerState = (event: PointerEvent) => {
@@ -328,9 +283,9 @@ export function useSlotLinkInteraction({
}
const processPointerMoveFrame = () => {
const data = dragSession.pendingMove
const data = dragContext.pendingPointerMove
if (!data) return
dragSession.pendingMove = null
dragContext.pendingPointerMove = null
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
data.clientX,
@@ -338,34 +293,61 @@ export function useSlotLinkInteraction({
])
updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY)
syncRenderLinkOrigins()
let hoveredSlotKey: string | null = null
let hoveredNodeId: number | null = null
let hoveredNodeId: NodeId | null = null
const target = data.target
if (target instanceof HTMLElement) {
hoveredSlotKey =
target.closest<HTMLElement>('[data-slot-key]')?.dataset['slotKey'] ??
null
if (!hoveredSlotKey) {
const nodeIdStr =
target.closest<HTMLElement>('[data-node-id]')?.dataset['nodeId']
hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : null
}
if (target === dragContext.lastPointerEventTarget) {
hoveredSlotKey = dragContext.lastPointerTargetSlotKey
hoveredNodeId = dragContext.lastPointerTargetNodeId
} else if (target instanceof HTMLElement) {
const elWithSlot = target.closest<HTMLElement>('[data-slot-key]')
const elWithNode = elWithSlot
? null
: target.closest<HTMLElement>('[data-node-id]')
hoveredSlotKey = elWithSlot?.dataset['slotKey'] ?? null
hoveredNodeId = hoveredSlotKey
? null
: elWithNode?.dataset['nodeId'] ?? null
dragContext.lastPointerEventTarget = target
dragContext.lastPointerTargetSlotKey = hoveredSlotKey
dragContext.lastPointerTargetNodeId = hoveredNodeId
}
const hoverChanged =
hoveredSlotKey !== dragSession.lastHoverSlotKey ||
hoveredNodeId !== dragSession.lastHoverNodeId
hoveredSlotKey !== dragContext.lastHoverSlotKey ||
hoveredNodeId !== dragContext.lastHoverNodeId
let candidate: SlotDropCandidate | null = state.candidate
if (hoverChanged) {
const slotCandidate = candidateFromTarget(target)
const adapter = activeAdapter
const graph = app.canvas?.graph ?? null
const context = { adapter, graph, session: dragContext }
const slotCandidate = resolveSlotTargetCandidate(target, context)
const nodeCandidate = slotCandidate
? null
: candidateFromNodeTarget(target)
: resolveNodeSurfaceSlotCandidate(target, context)
candidate = slotCandidate ?? nodeCandidate
dragSession.lastHoverSlotKey = hoveredSlotKey
dragSession.lastHoverNodeId = hoveredNodeId
dragContext.lastHoverSlotKey = hoveredSlotKey
dragContext.lastHoverNodeId = hoveredNodeId
if (slotCandidate) {
const key = getSlotKey(
slotCandidate.layout.nodeId,
slotCandidate.layout.index,
slotCandidate.layout.type === 'input'
)
setCompatibleForKey(key, !!slotCandidate.compatible)
} else if (nodeCandidate) {
const key = getSlotKey(
nodeCandidate.layout.nodeId,
nodeCandidate.layout.index,
nodeCandidate.layout.type === 'input'
)
setCompatibleForKey(key, !!nodeCandidate.compatible)
}
}
const newCandidate = candidate?.compatible ? candidate : null
@@ -377,18 +359,36 @@ export function useSlotLinkInteraction({
)
: null
if (newCandidateKey !== dragSession.lastCandidateKey) {
const candidateChanged = newCandidateKey !== dragContext.lastCandidateKey
if (candidateChanged) {
setCandidate(newCandidate)
dragSession.lastCandidateKey = newCandidateKey
dragContext.lastCandidateKey = newCandidateKey
}
app.canvas?.setDirty(true)
let snapPosChanged = false
if (activeAdapter) {
const snapX = newCandidate
? newCandidate.layout.position.x
: state.pointer.canvas.x
const snapY = newCandidate
? newCandidate.layout.position.y
: state.pointer.canvas.y
const currentSnap = activeAdapter.linkConnector.state.snapLinksPos
snapPosChanged =
!currentSnap || currentSnap[0] !== snapX || currentSnap[1] !== snapY
if (snapPosChanged) {
activeAdapter.linkConnector.state.snapLinksPos = [snapX, snapY]
}
}
const shouldRedraw = candidateChanged || snapPosChanged
if (shouldRedraw) app.canvas?.setDirty(true, true)
}
const raf = createRafBatch(processPointerMoveFrame)
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
dragSession.pendingMove = {
dragContext.pendingPointerMove = {
clientX: event.clientX,
clientY: event.clientY,
target: event.target
@@ -402,10 +402,10 @@ export function useSlotLinkInteraction({
): boolean => {
if (!candidate?.compatible) return false
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
const adapter = activeAdapter
if (!graph || !adapter) return false
const nodeId = Number(candidate.layout.nodeId)
const nodeId: NodeId = candidate.layout.nodeId
const targetNode = graph.getNodeById(nodeId)
if (!targetNode) return false
@@ -435,7 +435,7 @@ export function useSlotLinkInteraction({
y: state.pointer.canvas.y
})
const graph = app.canvas?.graph
const adapter = ensureActiveAdapter()
const adapter = activeAdapter
if (!rerouteLayout || !graph || !adapter) return false
const reroute = graph.getReroute(rerouteLayout.id)
@@ -483,43 +483,31 @@ export function useSlotLinkInteraction({
const finishInteraction = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
event.preventDefault()
const canvasEvent = toCanvasPointerEvent(event)
canvasEvent.preventDefault()
raf.flush()
raf.flush()
if (!state.source) {
cleanupInteraction()
app.canvas?.setDirty(true)
app.canvas?.setDirty(true, true)
return
}
// Prefer using the snapped candidate captured during hover for perf + consistency
const snappedCandidate = state.candidate?.compatible
? state.candidate
: null
let connected = tryConnectToCandidate(snappedCandidate)
const hasConnected = connectByPriority(canvasEvent.target, snappedCandidate)
// Fallback to DOM slot under pointer (if any), then node fallback, then reroute
if (!connected) {
const domCandidate = candidateFromTarget(event.target)
connected = tryConnectToCandidate(domCandidate)
}
if (!connected) {
const nodeCandidate = candidateFromNodeTarget(event.target)
connected = tryConnectToCandidate(nodeCandidate)
}
if (!connected) connected = tryConnectViaRerouteAtPointer() || connected
// Drop on canvas: disconnect moving input link(s)
if (!connected && !snappedCandidate && state.source.type === 'input') {
ensureActiveAdapter()?.disconnectMovingLinks()
if (!hasConnected) {
activeAdapter?.dropOnCanvas(canvasEvent)
}
cleanupInteraction()
app.canvas?.setDirty(true)
app.canvas?.setDirty(true, true)
}
const handlePointerUp = (event: PointerEvent) => {
@@ -530,8 +518,37 @@ export function useSlotLinkInteraction({
if (!pointerSession.matches(event)) return
raf.flush()
toCanvasPointerEvent(event)
cleanupInteraction()
app.canvas?.setDirty(true)
app.canvas?.setDirty(true, true)
}
function connectByPriority(
target: EventTarget | null,
snappedCandidate: SlotDropCandidate | null
): boolean {
const adapter = activeAdapter
const graph = app.canvas?.graph ?? null
const context = { adapter, graph, session: dragContext }
const attemptSnapped = () => tryConnectToCandidate(snappedCandidate)
const domSlotCandidate = resolveSlotTargetCandidate(target, context)
const attemptDomSlot = () => tryConnectToCandidate(domSlotCandidate)
const nodeSurfaceSlotCandidate = resolveNodeSurfaceSlotCandidate(
target,
context
)
const attemptNodeSurface = () =>
tryConnectToCandidate(nodeSurfaceSlotCandidate)
const attemptReroute = () => tryConnectViaRerouteAtPointer()
if (attemptSnapped()) return true
if (attemptDomSlot()) return true
if (attemptNodeSurface()) return true
if (attemptReroute()) return true
return false
}
const onPointerDown = (event: PointerEvent) => {
@@ -543,20 +560,21 @@ export function useSlotLinkInteraction({
const graph = canvas?.graph
if (!canvas || !graph) return
ensureActiveAdapter()
activeAdapter = createLinkConnectorAdapter()
if (!activeAdapter) return
raf.cancel()
dragSession.reset()
dragContext.reset()
const layout = layoutStore.getSlotLayout(
getSlotKey(nodeId, index, type === 'input')
)
if (!layout) return
const numericNodeId = Number(nodeId)
const localNodeId: NodeId = nodeId
const isInputSlot = type === 'input'
const isOutputSlot = type === 'output'
const resolvedNode = graph.getNodeById(numericNodeId)
const resolvedNode = graph.getNodeById(localNodeId)
const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined
const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined
@@ -601,19 +619,24 @@ export function useSlotLinkInteraction({
const shouldMoveExistingInput =
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
const adapter = ensureActiveAdapter()
if (adapter) {
if (activeAdapter) {
if (isOutputSlot) {
adapter.beginFromOutput(numericNodeId, index, {
activeAdapter.beginFromOutput(localNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
adapter.beginFromInput(numericNodeId, index, {
activeAdapter.beginFromInput(localNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
if (shouldMoveExistingInput && existingInputLink) {
existingInputLink._dragging = true
}
}
syncRenderLinkOrigins()
const direction = existingAnchor?.direction ?? baseDirection
const startPosition = existingAnchor?.position ?? {
x: layout.position.x,
@@ -637,8 +660,16 @@ export function useSlotLinkInteraction({
pointerSession.begin(event.pointerId)
toCanvasPointerEvent(event)
updatePointerState(event)
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [
state.pointer.canvas.x,
state.pointer.canvas.y
]
}
pointerSession.register(
useEventListener(window, 'pointermove', handlePointerMove, {
capture: true
@@ -650,7 +681,21 @@ export function useSlotLinkInteraction({
capture: true
})
)
app.canvas?.setDirty(true)
const targetType: 'input' | 'output' = type === 'input' ? 'output' : 'input'
const allKeys = layoutStore.getAllSlotKeys()
clearCompatible()
for (const key of allKeys) {
const slotLayout = layoutStore.getSlotLayout(key)
if (!slotLayout) continue
if (slotLayout.type !== targetType) continue
const idx = slotLayout.index
const ok =
targetType === 'input'
? activeAdapter.isInputValidDrop(slotLayout.nodeId, idx)
: activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx)
setCompatibleForKey(key, ok)
}
app.canvas?.setDirty(true, true)
event.preventDefault()
event.stopPropagation()
}