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

View File

@@ -3319,7 +3319,15 @@ export class LGraphCanvas
if (slot && linkConnector.isInputValidDrop(node, slot)) { if (slot && linkConnector.isInputValidDrop(node, slot)) {
highlightInput = 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 linkConnector.overWidget = overWidget
} }
} }
@@ -3331,7 +3339,9 @@ export class LGraphCanvas
const result = node.findInputByType(firstLink.fromSlot.type) const result = node.findInputByType(firstLink.fromSlot.type)
if (result) { if (result) {
highlightInput = result.slot highlightInput = result.slot
highlightPos = node.getInputSlotPos(result.slot) highlightPos = LiteGraph.vueNodesMode
? getSlotPosition(node, result.index, true)
: node.getInputSlotPos(result.slot)
} }
} else if ( } else if (
inputId != -1 && inputId != -1 &&
@@ -3356,7 +3366,9 @@ export class LGraphCanvas
if (inputId === -1 && outputId === -1) { if (inputId === -1 && outputId === -1) {
const result = node.findOutputByType(firstLink.fromSlot.type) const result = node.findOutputByType(firstLink.fromSlot.type)
if (result) { if (result) {
highlightPos = node.getOutputPos(result.index) highlightPos = LiteGraph.vueNodesMode
? getSlotPosition(node, result.index, false)
: node.getOutputPos(result.index)
} }
} else { } else {
// check if I have a slot below de mouse // check if I have a slot below de mouse
@@ -5730,7 +5742,9 @@ export class LGraphCanvas
if (!node) continue if (!node) continue
const startPos = firstReroute.pos 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 const endDirection = node.inputs[link.target_slot]?.dir
firstReroute._dragging = true firstReroute._dragging = true
@@ -5749,7 +5763,9 @@ export class LGraphCanvas
const node = graph.getNodeById(link.origin_id) const node = graph.getNodeById(link.origin_id)
if (!node) continue 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 endPos = reroute.pos
const startDirection = node.outputs[link.origin_slot]?.dir 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. * Gets the position of an output slot, in graph co-ordinates.
* *
* This method is preferred over the legacy {@link getConnectionPos} method. * 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 * @returns Position of the output slot
*/ */
getOutputPos(slot: number): Point { getOutputPos(outputSlotIndex: number): Point {
return calculateOutputSlotPos(this.#getSlotPositionContext(), slot) return calculateOutputSlotPos(
this.#getSlotPositionContext(),
outputSlotIndex
)
} }
/** @inheritdoc */ /** @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 { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode' import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { RerouteId } from '@/lib/litegraph/src/Reroute' 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 { 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' import { app } from '@/scripts/app'
// Keep one adapter per graph so rendering and interaction share state. // 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. * - Preserves existing Vue composable behavior.
*/ */
export class LinkConnectorAdapter { export class LinkConnectorAdapter {
readonly linkConnector: LinkConnector
constructor( constructor(
/** Network the links belong to (typically `app.canvas.graph`). */ /** Network the links belong to (typically `app.canvas.graph`). */
readonly network: LGraph readonly network: LGraph,
) { readonly linkConnector: LinkConnector
// No-op legacy setter to avoid side effects when connectors update ) {}
const setConnectingLinks: (value: ConnectingLink[]) => void = () => {}
this.linkConnector = new LinkConnector(setConnectingLinks)
}
/** /**
* The currently rendered/dragged links, typed for consumer use. * The currently rendered/dragged links, typed for consumer use.
@@ -133,6 +128,11 @@ export class LinkConnectorAdapter {
this.linkConnector.disconnectLinks() 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. */ /** Resets connector state and clears any temporary flags. */
reset(): void { reset(): void {
this.linkConnector.reset() this.linkConnector.reset()
@@ -141,11 +141,12 @@ export class LinkConnectorAdapter {
/** Convenience creator using the current app canvas graph. */ /** Convenience creator using the current app canvas graph. */
export function createLinkConnectorAdapter(): LinkConnectorAdapter | null { export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
const graph = app.canvas?.graph as LGraph | undefined const graph = app.canvas?.graph
if (!graph) return null const connector = app.canvas?.linkConnector
if (!graph || !connector) return null
let adapter = adapterByGraph.get(graph) let adapter = adapterByGraph.get(graph)
if (!adapter) { if (!adapter || adapter.linkConnector !== connector) {
adapter = new LinkConnectorAdapter(graph) adapter = new LinkConnectorAdapter(graph, connector)
adapterByGraph.set(graph, adapter) adapterByGraph.set(graph, adapter)
} }
return 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 { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point, SlotLayout } from '@/renderer/core/layout/types' 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' type SlotDragType = 'input' | 'output'
interface SlotDragSource { interface SlotDragSource {
@@ -33,6 +41,7 @@ interface SlotDragState {
source: SlotDragSource | null source: SlotDragSource | null
pointer: PointerPosition pointer: PointerPosition
candidate: SlotDropCandidate | null candidate: SlotDropCandidate | null
compatible: Map<string, boolean>
} }
const state = reactive<SlotDragState>({ const state = reactive<SlotDragState>({
@@ -43,7 +52,8 @@ const state = reactive<SlotDragState>({
client: { x: 0, y: 0 }, client: { x: 0, y: 0 },
canvas: { x: 0, y: 0 } canvas: { x: 0, y: 0 }
}, },
candidate: null candidate: null,
compatible: new Map<string, boolean>()
}) })
function updatePointerPosition( function updatePointerPosition(
@@ -67,6 +77,7 @@ function beginDrag(source: SlotDragSource, pointerId: number) {
state.source = source state.source = source
state.pointerId = pointerId state.pointerId = pointerId
state.candidate = null state.candidate = null
state.compatible.clear()
} }
function endDrag() { function endDrag() {
@@ -78,6 +89,7 @@ function endDrag() {
state.pointer.canvas.x = 0 state.pointer.canvas.x = 0
state.pointer.canvas.y = 0 state.pointer.canvas.y = 0
state.candidate = null state.candidate = null
state.compatible.clear()
} }
function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) { function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
@@ -85,13 +97,21 @@ function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) {
return layoutStore.getSlotLayout(slotKey) return layoutStore.getSlotLayout(slotKey)
} }
export function useSlotLinkDragState() { export function useSlotLinkDragUIState() {
return { return {
state: readonly(state), state: readonly(state),
beginDrag, beginDrag,
endDrag, endDrag,
updatePointerPosition, updatePointerPosition,
setCandidate, 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 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 * Update link segment layout data
*/ */

View File

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

View File

@@ -31,6 +31,8 @@ import type { ComponentPublicInstance } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors' import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph' 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 { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
@@ -103,6 +105,15 @@ const slotColor = computed(() => {
return getSlotColor(props.slotData.type) 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(() => const slotWrapperClass = computed(() =>
cn( cn(
'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6', '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', : 'pr-6 hover:bg-black/5 hover:dark:bg-white/5',
{ {
'lg-slot--connected': props.connected, '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 { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors' import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph' 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 { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
@@ -73,6 +75,15 @@ onErrorCaptured((error) => {
// Get slot color based on type // Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.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(() => const slotWrapperClass = computed(() =>
cn( cn(
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6', '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', : 'pl-6 hover:bg-black/5 hover:dark:bg-white/5',
{ {
'lg-slot--connected': props.connected, '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 { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraph } from '@/lib/litegraph/src/LGraph' 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 { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute' import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink' import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
@@ -13,15 +13,23 @@ import type {
INodeOutputSlot INodeOutputSlot
} from '@/lib/litegraph/src/interfaces' } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' 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 { createLinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter' import type { LinkConnectorAdapter } from '@/renderer/core/canvas/links/linkConnectorAdapter'
import { useSlotLinkDragState } from '@/renderer/core/canvas/links/slotLinkDragState' import {
import type { SlotDropCandidate } from '@/renderer/core/canvas/links/slotLinkDragState' 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 { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore' import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point } from '@/renderer/core/layout/types' import type { Point } from '@/renderer/core/layout/types'
import { toPoint } from '@/renderer/core/layout/utils/geometry' 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 { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch' import { createRafBatch } from '@/utils/rafBatch'
@@ -80,111 +88,54 @@ export function useSlotLinkInteraction({
index, index,
type type
}: SlotInteractionOptions): SlotInteractionHandlers { }: SlotInteractionOptions): SlotInteractionHandlers {
const { state, beginDrag, endDrag, updatePointerPosition, setCandidate } = const {
useSlotLinkDragState() state,
beginDrag,
endDrag,
updatePointerPosition,
setCandidate,
setCompatibleForKey,
clearCompatible
} = useSlotLinkDragUIState()
const conversion = useSharedCanvasPositionConversion() const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession() const pointerSession = createPointerSession()
let activeAdapter: LinkConnectorAdapter | null = null let activeAdapter: LinkConnectorAdapter | null = null
// Per-drag drag-state cache // Per-drag drag-state context (non-reactive caches + RAF batching)
const dragSession = createSlotLinkDragSession() const dragContext = createSlotLinkDragContext()
function candidateFromTarget( const resolveRenderLinkSource = (link: RenderLink): Point | null => {
target: EventTarget | null if (link.fromReroute) {
): SlotDropCandidate | null { const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
if (!(target instanceof HTMLElement)) return null if (rerouteLayout) return rerouteLayout.position
const elWithKey = target.closest<HTMLElement>('[data-slot-key]') const [x, y] = link.fromReroute.pos
const key = elWithKey?.dataset['slotKey'] return toPoint(x, y)
if (!key) return null }
const layout = layoutStore.getSlotLayout(key) const nodeId = link.node.id
if (!layout) return null 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 syncRenderLinkOrigins = () => {
const adapter = ensureActiveAdapter() if (!activeAdapter) return
if (graph && adapter) { for (const link of activeAdapter.renderLinks) {
const cached = dragSession.compatCache.get(key) const origin = resolveRenderLinkSource(link)
if (cached != null) { if (!origin) continue
candidate.compatible = cached const x = origin.x
} else { const y = origin.y
const compatible = if (link.fromPos[0] !== x || link.fromPos[1] !== y) {
layout.type === 'input' link.fromPos[0] = x
? adapter.isInputValidDrop(layout.nodeId, layout.index) link.fromPos[1] = y
: adapter.isOutputValidDrop(layout.nodeId, layout.index)
dragSession.compatCache.set(key, compatible)
candidate.compatible = compatible
} }
} }
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( function hasCanConnectToReroute(
@@ -308,12 +259,16 @@ export function useSlotLinkInteraction({
} }
const cleanupInteraction = () => { const cleanupInteraction = () => {
if (state.pointerId != null) {
clearCanvasPointerHistory(state.pointerId)
}
activeAdapter?.reset() activeAdapter?.reset()
pointerSession.clear() pointerSession.clear()
endDrag() endDrag()
activeAdapter = null activeAdapter = null
raf.cancel() raf.cancel()
dragSession.dispose() dragContext.dispose()
clearCompatible()
} }
const updatePointerState = (event: PointerEvent) => { const updatePointerState = (event: PointerEvent) => {
@@ -328,9 +283,9 @@ export function useSlotLinkInteraction({
} }
const processPointerMoveFrame = () => { const processPointerMoveFrame = () => {
const data = dragSession.pendingMove const data = dragContext.pendingPointerMove
if (!data) return if (!data) return
dragSession.pendingMove = null dragContext.pendingPointerMove = null
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([ const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
data.clientX, data.clientX,
@@ -338,34 +293,61 @@ export function useSlotLinkInteraction({
]) ])
updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY) updatePointerPosition(data.clientX, data.clientY, canvasX, canvasY)
syncRenderLinkOrigins()
let hoveredSlotKey: string | null = null let hoveredSlotKey: string | null = null
let hoveredNodeId: number | null = null let hoveredNodeId: NodeId | null = null
const target = data.target const target = data.target
if (target instanceof HTMLElement) { if (target === dragContext.lastPointerEventTarget) {
hoveredSlotKey = hoveredSlotKey = dragContext.lastPointerTargetSlotKey
target.closest<HTMLElement>('[data-slot-key]')?.dataset['slotKey'] ?? hoveredNodeId = dragContext.lastPointerTargetNodeId
null } else if (target instanceof HTMLElement) {
if (!hoveredSlotKey) { const elWithSlot = target.closest<HTMLElement>('[data-slot-key]')
const nodeIdStr = const elWithNode = elWithSlot
target.closest<HTMLElement>('[data-node-id]')?.dataset['nodeId'] ? null
hoveredNodeId = nodeIdStr != null ? Number(nodeIdStr) : 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 = const hoverChanged =
hoveredSlotKey !== dragSession.lastHoverSlotKey || hoveredSlotKey !== dragContext.lastHoverSlotKey ||
hoveredNodeId !== dragSession.lastHoverNodeId hoveredNodeId !== dragContext.lastHoverNodeId
let candidate: SlotDropCandidate | null = state.candidate let candidate: SlotDropCandidate | null = state.candidate
if (hoverChanged) { 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 const nodeCandidate = slotCandidate
? null ? null
: candidateFromNodeTarget(target) : resolveNodeSurfaceSlotCandidate(target, context)
candidate = slotCandidate ?? nodeCandidate candidate = slotCandidate ?? nodeCandidate
dragSession.lastHoverSlotKey = hoveredSlotKey dragContext.lastHoverSlotKey = hoveredSlotKey
dragSession.lastHoverNodeId = hoveredNodeId 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 const newCandidate = candidate?.compatible ? candidate : null
@@ -377,18 +359,36 @@ export function useSlotLinkInteraction({
) )
: null : null
if (newCandidateKey !== dragSession.lastCandidateKey) { const candidateChanged = newCandidateKey !== dragContext.lastCandidateKey
if (candidateChanged) {
setCandidate(newCandidate) 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 raf = createRafBatch(processPointerMoveFrame)
const handlePointerMove = (event: PointerEvent) => { const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return if (!pointerSession.matches(event)) return
dragSession.pendingMove = { dragContext.pendingPointerMove = {
clientX: event.clientX, clientX: event.clientX,
clientY: event.clientY, clientY: event.clientY,
target: event.target target: event.target
@@ -402,10 +402,10 @@ export function useSlotLinkInteraction({
): boolean => { ): boolean => {
if (!candidate?.compatible) return false if (!candidate?.compatible) return false
const graph = app.canvas?.graph const graph = app.canvas?.graph
const adapter = ensureActiveAdapter() const adapter = activeAdapter
if (!graph || !adapter) return false if (!graph || !adapter) return false
const nodeId = Number(candidate.layout.nodeId) const nodeId: NodeId = candidate.layout.nodeId
const targetNode = graph.getNodeById(nodeId) const targetNode = graph.getNodeById(nodeId)
if (!targetNode) return false if (!targetNode) return false
@@ -435,7 +435,7 @@ export function useSlotLinkInteraction({
y: state.pointer.canvas.y y: state.pointer.canvas.y
}) })
const graph = app.canvas?.graph const graph = app.canvas?.graph
const adapter = ensureActiveAdapter() const adapter = activeAdapter
if (!rerouteLayout || !graph || !adapter) return false if (!rerouteLayout || !graph || !adapter) return false
const reroute = graph.getReroute(rerouteLayout.id) const reroute = graph.getReroute(rerouteLayout.id)
@@ -483,43 +483,31 @@ export function useSlotLinkInteraction({
const finishInteraction = (event: PointerEvent) => { const finishInteraction = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return if (!pointerSession.matches(event)) return
event.preventDefault() const canvasEvent = toCanvasPointerEvent(event)
canvasEvent.preventDefault()
raf.flush()
raf.flush() raf.flush()
if (!state.source) { if (!state.source) {
cleanupInteraction() cleanupInteraction()
app.canvas?.setDirty(true) app.canvas?.setDirty(true, true)
return return
} }
// Prefer using the snapped candidate captured during hover for perf + consistency
const snappedCandidate = state.candidate?.compatible const snappedCandidate = state.candidate?.compatible
? state.candidate ? state.candidate
: null : 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 (!hasConnected) {
if (!connected) { activeAdapter?.dropOnCanvas(canvasEvent)
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()
} }
cleanupInteraction() cleanupInteraction()
app.canvas?.setDirty(true) app.canvas?.setDirty(true, true)
} }
const handlePointerUp = (event: PointerEvent) => { const handlePointerUp = (event: PointerEvent) => {
@@ -530,8 +518,37 @@ export function useSlotLinkInteraction({
if (!pointerSession.matches(event)) return if (!pointerSession.matches(event)) return
raf.flush() raf.flush()
toCanvasPointerEvent(event)
cleanupInteraction() 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) => { const onPointerDown = (event: PointerEvent) => {
@@ -543,20 +560,21 @@ export function useSlotLinkInteraction({
const graph = canvas?.graph const graph = canvas?.graph
if (!canvas || !graph) return if (!canvas || !graph) return
ensureActiveAdapter() activeAdapter = createLinkConnectorAdapter()
if (!activeAdapter) return
raf.cancel() raf.cancel()
dragSession.reset() dragContext.reset()
const layout = layoutStore.getSlotLayout( const layout = layoutStore.getSlotLayout(
getSlotKey(nodeId, index, type === 'input') getSlotKey(nodeId, index, type === 'input')
) )
if (!layout) return if (!layout) return
const numericNodeId = Number(nodeId) const localNodeId: NodeId = nodeId
const isInputSlot = type === 'input' const isInputSlot = type === 'input'
const isOutputSlot = type === 'output' const isOutputSlot = type === 'output'
const resolvedNode = graph.getNodeById(numericNodeId) const resolvedNode = graph.getNodeById(localNodeId)
const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined const inputSlot = isInputSlot ? resolvedNode?.inputs?.[index] : undefined
const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined const outputSlot = isOutputSlot ? resolvedNode?.outputs?.[index] : undefined
@@ -601,19 +619,24 @@ export function useSlotLinkInteraction({
const shouldMoveExistingInput = const shouldMoveExistingInput =
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
const adapter = ensureActiveAdapter() if (activeAdapter) {
if (adapter) {
if (isOutputSlot) { if (isOutputSlot) {
adapter.beginFromOutput(numericNodeId, index, { activeAdapter.beginFromOutput(localNodeId, index, {
moveExisting: shouldMoveExistingOutput moveExisting: shouldMoveExistingOutput
}) })
} else { } else {
adapter.beginFromInput(numericNodeId, index, { activeAdapter.beginFromInput(localNodeId, index, {
moveExisting: shouldMoveExistingInput moveExisting: shouldMoveExistingInput
}) })
} }
if (shouldMoveExistingInput && existingInputLink) {
existingInputLink._dragging = true
}
} }
syncRenderLinkOrigins()
const direction = existingAnchor?.direction ?? baseDirection const direction = existingAnchor?.direction ?? baseDirection
const startPosition = existingAnchor?.position ?? { const startPosition = existingAnchor?.position ?? {
x: layout.position.x, x: layout.position.x,
@@ -637,8 +660,16 @@ export function useSlotLinkInteraction({
pointerSession.begin(event.pointerId) pointerSession.begin(event.pointerId)
toCanvasPointerEvent(event)
updatePointerState(event) updatePointerState(event)
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [
state.pointer.canvas.x,
state.pointer.canvas.y
]
}
pointerSession.register( pointerSession.register(
useEventListener(window, 'pointermove', handlePointerMove, { useEventListener(window, 'pointermove', handlePointerMove, {
capture: true capture: true
@@ -650,7 +681,21 @@ export function useSlotLinkInteraction({
capture: true 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.preventDefault()
event.stopPropagation() event.stopPropagation()
} }