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>
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
74
src/renderer/core/canvas/interaction/canvasPointerEvent.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
117
src/renderer/core/canvas/links/linkDropOrchestrator.ts
Normal 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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||