From 16ddd4d481d0cce7aa5aea582bc4a3e699d80e68 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 17 Sep 2025 14:03:23 -0700 Subject: [PATCH] allow dragging out links and creating connections --- src/components/graph/GraphCanvas.vue | 2 + src/lib/litegraph/src/LGraphCanvas.ts | 6 +- src/lib/litegraph/src/utils/linkColors.ts | 13 ++ .../canvas/litegraph/litegraphLinkAdapter.ts | 80 +++---- .../linkInteractions/slotLinkCompatibility.ts | 83 +++++++ .../linkInteractions/slotLinkDragState.ts | 89 ++++++++ .../slotLinkPreviewRenderer.ts | 96 +++++++++ .../components/InputOutputSlot.test.ts | 89 -------- .../vueNodes/components/InputSlot.vue | 12 +- .../vueNodes/components/OutputSlot.vue | 12 +- .../composables/useSlotElementTracking.ts | 8 +- .../composables/useSlotLinkInteraction.ts | 203 ++++++++++++++++++ 12 files changed, 543 insertions(+), 150 deletions(-) create mode 100644 src/lib/litegraph/src/utils/linkColors.ts create mode 100644 src/renderer/core/linkInteractions/slotLinkCompatibility.ts create mode 100644 src/renderer/core/linkInteractions/slotLinkDragState.ts create mode 100644 src/renderer/core/linkInteractions/slotLinkPreviewRenderer.ts delete mode 100644 src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts create mode 100644 src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 49f325c0c..22148d079 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -119,6 +119,7 @@ import { useWorkflowPersistence } from '@/platform/workflow/persistence/composab import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys' import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue' +import { attachSlotLinkPreviewRenderer } from '@/renderer/core/linkInteractions/slotLinkPreviewRenderer' import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue' import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue' import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers' @@ -404,6 +405,7 @@ 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 diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index fade868c2..d4f8c5b75 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -87,6 +87,7 @@ import type { PickNevers } from './types/utility' import type { IBaseWidget } from './types/widgets' import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange' import { findFirstNode, getAllNestedItems } from './utils/collections' +import { resolveConnectingLinkColor } from './utils/linkColors' import type { UUID } from './utils/uuid' import { BaseWidget } from './widgets/BaseWidget' import { toConcreteWidget } from './widgets/widgetMap' @@ -4717,10 +4718,7 @@ export class LGraphCanvas const connShape = fromSlot.shape const connType = fromSlot.type - const colour = - connType === LiteGraph.EVENT - ? LiteGraph.EVENT_LINK_COLOR - : LiteGraph.CONNECTING_LINK_COLOR + const colour = resolveConnectingLinkColor(connType) // the connection being dragged by the mouse if (this.linkRenderer) { diff --git a/src/lib/litegraph/src/utils/linkColors.ts b/src/lib/litegraph/src/utils/linkColors.ts new file mode 100644 index 000000000..e6e966133 --- /dev/null +++ b/src/lib/litegraph/src/utils/linkColors.ts @@ -0,0 +1,13 @@ +import type { CanvasColour, ISlotType } from '../interfaces' +import { LiteGraph } from '../litegraph' + +/** + * Resolve the colour used while rendering or previewing a connection of a given slot type. + */ +export function resolveConnectingLinkColor( + type: ISlotType | undefined +): CanvasColour { + return type === LiteGraph.EVENT + ? LiteGraph.EVENT_LINK_COLOR + : LiteGraph.CONNECTING_LINK_COLOR +} diff --git a/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts b/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts index 349ad903b..377ba95c6 100644 --- a/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts +++ b/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts @@ -7,13 +7,10 @@ * Maintains backward compatibility with existing litegraph integration. */ import type { LGraph } from '@/lib/litegraph/src/LGraph' -import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { LLink } from '@/lib/litegraph/src/LLink' import type { Reroute } from '@/lib/litegraph/src/Reroute' import type { CanvasColour, - INodeInputSlot, - INodeOutputSlot, ReadOnlyPoint } from '@/lib/litegraph/src/interfaces' import { LiteGraph } from '@/lib/litegraph/src/litegraph' @@ -27,7 +24,6 @@ import { type ArrowShape, CanvasPathRenderer, type Direction, - type DragLinkData, type LinkRenderData, type RenderContext as PathRenderContext, type Point, @@ -356,8 +352,8 @@ export class LitegraphLinkAdapter { } = {} ): void { // Apply same defaults as original renderLink - const startDir = start_dir || LinkDirection.RIGHT - const endDir = end_dir || LinkDirection.LEFT + const startDir = start_dir ?? LinkDirection.RIGHT + const endDir = end_dir ?? LinkDirection.LEFT // Convert flow to boolean const flowBool = flow === true || (typeof flow === 'number' && flow > 0) @@ -502,57 +498,33 @@ export class LitegraphLinkAdapter { } } - /** - * Render a link being dragged from a slot to mouse position - * Used during link creation/reconnection - */ - renderDraggingLink( + renderDragPreview( ctx: CanvasRenderingContext2D, - fromNode: LGraphNode | null, - fromSlot: INodeOutputSlot | INodeInputSlot, - fromSlotIndex: number, - toPosition: ReadOnlyPoint, - context: LinkRenderContext, - options: { - fromInput?: boolean - color?: CanvasColour - disabled?: boolean - } = {} + from: ReadOnlyPoint, + to: ReadOnlyPoint, + colour: CanvasColour, + startDir: LinkDirection, + endDir: LinkDirection, + context: LinkRenderContext ): void { - if (!fromNode) return - - // Get slot position using layout tree if available - const slotPos = getSlotPosition( - fromNode, - fromSlotIndex, - options.fromInput || false + this.renderLinkDirect( + ctx, + from, + to, + null, + false, + null, + colour, + startDir, + endDir, + { + ...context, + linkMarkerShape: LinkMarkerShape.None + }, + { + disabled: false + } ) - if (!slotPos) return - - // Get slot direction - const slotDir = - fromSlot.dir || - (options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT) - - // Create drag data - const dragData: DragLinkData = { - fixedPoint: { x: slotPos[0], y: slotPos[1] }, - fixedDirection: this.convertDirection(slotDir), - dragPoint: { x: toPosition[0], y: toPosition[1] }, - color: options.color ? String(options.color) : undefined, - type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined, - disabled: options.disabled || false, - fromInput: options.fromInput || false - } - - // Convert context - const pathContext = this.convertToPathRenderContext(context) - - // Hide center marker when dragging links - pathContext.style.showCenterMarker = false - - // Render using pure renderer - this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext) } /** diff --git a/src/renderer/core/linkInteractions/slotLinkCompatibility.ts b/src/renderer/core/linkInteractions/slotLinkCompatibility.ts new file mode 100644 index 000000000..f3db1135a --- /dev/null +++ b/src/renderer/core/linkInteractions/slotLinkCompatibility.ts @@ -0,0 +1,83 @@ +import type { + INodeInputSlot, + INodeOutputSlot +} from '@/lib/litegraph/src/interfaces' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { + SlotDragSource, + SlotDropCandidate +} from '@/renderer/core/linkInteractions/slotLinkDragState' +import { app } from '@/scripts/app' + +export interface CompatibilityResult { + allowable: boolean + targetNode?: LGraphNode + targetSlot?: INodeInputSlot | INodeOutputSlot +} + +function resolveNode(nodeId: string | number) { + const canvas = app.canvas + const graph = canvas?.graph + if (!graph) return null + const id = typeof nodeId === 'string' ? Number(nodeId) : nodeId + if (Number.isNaN(id)) return null + return graph.getNodeById(id) +} + +export function evaluateCompatibility( + source: SlotDragSource, + candidate: SlotDropCandidate +): CompatibilityResult { + if ( + candidate.layout.nodeId === source.nodeId && + candidate.layout.index === source.slotIndex + ) { + return { allowable: false } + } + + const isOutputToInput = + source.type === 'output' && candidate.layout.type === 'input' + const isInputToOutput = + source.type === 'input' && candidate.layout.type === 'output' + + if (!isOutputToInput && !isInputToOutput) { + return { allowable: false } + } + + const sourceNode = resolveNode(source.nodeId) + const targetNode = resolveNode(candidate.layout.nodeId) + if (!sourceNode || !targetNode) { + return { allowable: false } + } + + const sourceSlot = isOutputToInput + ? sourceNode.outputs?.[source.slotIndex] + : sourceNode.inputs?.[source.slotIndex] + const targetSlot = isOutputToInput + ? targetNode.inputs?.[candidate.layout.index] + : targetNode.outputs?.[candidate.layout.index] + + if (!sourceSlot || !targetSlot) { + return { allowable: false } + } + + if (isOutputToInput) { + const outputSlot = sourceSlot as INodeOutputSlot | undefined + const inputSlot = targetSlot as INodeInputSlot | undefined + if (!outputSlot || !inputSlot) { + return { allowable: false } + } + + const allowable = sourceNode.canConnectTo(targetNode, inputSlot, outputSlot) + return { allowable, targetNode, targetSlot: inputSlot } + } + + const inputSlot = sourceSlot as INodeInputSlot | undefined + const outputSlot = targetSlot as INodeOutputSlot | undefined + if (!inputSlot || !outputSlot) { + return { allowable: false } + } + + const allowable = targetNode.canConnectTo(sourceNode, inputSlot, outputSlot) + return { allowable, targetNode, targetSlot: outputSlot } +} diff --git a/src/renderer/core/linkInteractions/slotLinkDragState.ts b/src/renderer/core/linkInteractions/slotLinkDragState.ts new file mode 100644 index 000000000..41c2c15f2 --- /dev/null +++ b/src/renderer/core/linkInteractions/slotLinkDragState.ts @@ -0,0 +1,89 @@ +import { reactive, readonly, shallowReactive } from 'vue' + +import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' +import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import type { SlotLayout } from '@/renderer/core/layout/types' + +export type SlotDragType = 'input' | 'output' + +export interface SlotDragSource { + nodeId: string + slotIndex: number + type: SlotDragType + direction: LinkDirection + position: Readonly<{ x: number; y: number }> +} + +export interface SlotDropCandidate { + layout: SlotLayout + compatible: boolean +} + +export interface PointerPosition { + client: Readonly<{ x: number; y: number }> + canvas: Readonly<{ x: number; y: number }> +} + +export interface SlotDragState { + active: boolean + pointerId: number | null + source: SlotDragSource | null + pointer: PointerPosition + candidate: SlotDropCandidate | null +} + +const defaultPointer: PointerPosition = Object.freeze({ + client: { x: 0, y: 0 }, + canvas: { x: 0, y: 0 } +}) + +const state = reactive({ + active: false, + pointerId: null, + source: null, + pointer: defaultPointer, + candidate: null +}) + +function updatePointerPosition(position: PointerPosition) { + state.pointer = shallowReactive({ + client: position.client, + canvas: position.canvas + }) +} + +function setCandidate(candidate: SlotDropCandidate | null) { + state.candidate = candidate +} + +function beginDrag(source: SlotDragSource, pointerId: number) { + state.active = true + state.source = source + state.pointerId = pointerId + state.candidate = null +} + +function endDrag() { + state.active = false + state.pointerId = null + state.source = null + state.pointer = defaultPointer + state.candidate = null +} + +function getSlotLayout(nodeId: string, slotIndex: number, isInput: boolean) { + const slotKey = getSlotKey(nodeId, slotIndex, isInput) + return layoutStore.getSlotLayout(slotKey) +} + +export function useSlotLinkDragState() { + return { + state: readonly(state), + beginDrag, + endDrag, + updatePointerPosition, + setCandidate, + getSlotLayout + } +} diff --git a/src/renderer/core/linkInteractions/slotLinkPreviewRenderer.ts b/src/renderer/core/linkInteractions/slotLinkPreviewRenderer.ts new file mode 100644 index 000000000..9a5934671 --- /dev/null +++ b/src/renderer/core/linkInteractions/slotLinkPreviewRenderer.ts @@ -0,0 +1,96 @@ +import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' +import type { + INodeInputSlot, + INodeOutputSlot, + ReadOnlyPoint +} from '@/lib/litegraph/src/interfaces' +import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' +import { resolveConnectingLinkColor } from '@/lib/litegraph/src/utils/linkColors' +import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter' +import { + type SlotDragSource, + useSlotLinkDragState +} from '@/renderer/core/linkInteractions/slotLinkDragState' + +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 originalOnRender = canvas.onRender?.bind(canvas) + const patched = ( + canvasElement: HTMLCanvasElement, + ctx: CanvasRenderingContext2D + ) => { + originalOnRender?.(canvasElement, ctx) + + const { state } = useSlotLinkDragState() + if (!state.active || !state.source) return + + const { pointer, source } = state + const start = source.position + const sourceSlot = resolveSourceSlot(canvas, source) + + const linkRenderer = canvas.linkRenderer + if (!linkRenderer) return + + const context = buildContext(canvas) + + const from: ReadOnlyPoint = [start.x, start.y] + const to: ReadOnlyPoint = [pointer.canvas.x, pointer.canvas.y] + + const startDir = source.direction ?? LinkDirection.RIGHT + const endDir = LinkDirection.NONE + + const colour = resolveConnectingLinkColor(sourceSlot?.type) + + ctx.save() + canvas.ds.toCanvasContext(ctx) + + linkRenderer.renderDragPreview( + ctx, + from, + to, + colour, + startDir, + endDir, + context + ) + + ctx.restore() + } + + canvas.onRender = patched +} + +function resolveSourceSlot( + canvas: LGraphCanvas, + source: SlotDragSource +): INodeInputSlot | INodeOutputSlot | undefined { + const graph = canvas.graph + if (!graph) return undefined + + const nodeId = Number(source.nodeId) + if (!Number.isFinite(nodeId)) return undefined + + const node = graph.getNodeById(nodeId) + if (!node) return undefined + + return source.type === 'output' + ? node.outputs?.[source.slotIndex] + : node.inputs?.[source.slotIndex] +} diff --git a/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts b/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts deleted file mode 100644 index d1c886905..000000000 --- a/src/renderer/extensions/vueNodes/components/InputOutputSlot.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { type ComponentMountingOptions, mount } from '@vue/test-utils' -import { createPinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createI18n } from 'vue-i18n' - -import enMessages from '@/locales/en/main.json' -import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' - -import InputSlot from './InputSlot.vue' -import OutputSlot from './OutputSlot.vue' - -// Mock composable used by InputSlot/OutputSlot so we can assert call params -vi.mock( - '@/renderer/extensions/vueNodes/composables/useSlotElementTracking', - () => ({ - useSlotElementTracking: vi.fn(() => ({ stop: vi.fn() })) - }) -) - -type InputSlotProps = ComponentMountingOptions['props'] -type OutputSlotProps = ComponentMountingOptions['props'] - -const mountInputSlot = (props: InputSlotProps) => - mount(InputSlot, { - global: { - plugins: [ - createI18n({ - legacy: false, - locale: 'en', - messages: { en: enMessages } - }), - createPinia() - ] - }, - props - }) - -const mountOutputSlot = (props: OutputSlotProps) => - mount(OutputSlot, { - global: { - plugins: [ - createI18n({ - legacy: false, - locale: 'en', - messages: { en: enMessages } - }), - createPinia() - ] - }, - props - }) - -describe('InputSlot/OutputSlot', () => { - beforeEach(() => { - vi.mocked(useSlotElementTracking).mockClear() - }) - - it('InputSlot registers with correct options', () => { - mountInputSlot({ - nodeId: 'node-1', - index: 3, - slotData: { name: 'A', type: 'any', boundingRect: [0, 0, 0, 0] } - }) - - expect(useSlotElementTracking).toHaveBeenLastCalledWith( - expect.objectContaining({ - nodeId: 'node-1', - index: 3, - type: 'input' - }) - ) - }) - - it('OutputSlot registers with correct options', () => { - mountOutputSlot({ - nodeId: 'node-2', - index: 1, - slotData: { name: 'B', type: 'any', boundingRect: [0, 0, 0, 0] } - }) - - expect(useSlotElementTracking).toHaveBeenLastCalledWith( - expect.objectContaining({ - nodeId: 'node-2', - index: 1, - type: 'output' - }) - ) - }) -}) diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue index d7424b082..2e0ddd5a2 100644 --- a/src/renderer/extensions/vueNodes/components/InputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue @@ -2,8 +2,10 @@
⚠️
@@ -41,6 +44,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { getSlotColor } from '@/constants/slotColors' import { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' +import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' import SlotConnectionDot from './SlotConnectionDot.vue' @@ -88,4 +92,10 @@ useSlotElementTracking({ type: 'input', element: slotElRef }) + +const { onPointerDown } = useSlotLinkInteraction({ + nodeId: props.nodeId ?? '', + index: props.index, + type: 'input' +}) diff --git a/src/renderer/extensions/vueNodes/components/OutputSlot.vue b/src/renderer/extensions/vueNodes/components/OutputSlot.vue index ae8d4c741..317d57e4f 100644 --- a/src/renderer/extensions/vueNodes/components/OutputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/OutputSlot.vue @@ -2,8 +2,10 @@
⚠️
@@ -42,6 +45,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { getSlotColor } from '@/constants/slotColors' import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' +import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' import SlotConnectionDot from './SlotConnectionDot.vue' @@ -90,4 +94,10 @@ useSlotElementTracking({ type: 'output', element: slotElRef }) + +const { onPointerDown } = useSlotLinkInteraction({ + nodeId: props.nodeId ?? '', + index: props.index, + type: 'output' +}) diff --git a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts index a57077db8..7686b04f0 100644 --- a/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useSlotElementTracking.ts @@ -184,6 +184,8 @@ export function useSlotElementTracking(options: { // Register slot const slotKey = getSlotKey(nodeId, index, type === 'input') + + el.dataset.slotKey = slotKey node.slots.set(slotKey, { el, index, type }) // Seed initial sync from DOM @@ -203,7 +205,11 @@ export function useSlotElementTracking(options: { // Remove this slot from registry and layout const slotKey = getSlotKey(nodeId, index, type === 'input') - node.slots.delete(slotKey) + const entry = node.slots.get(slotKey) + if (entry) { + delete entry.el.dataset.slotKey + node.slots.delete(slotKey) + } layoutStore.deleteSlotLayout(slotKey) // If node has no more slots, clean up diff --git a/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts new file mode 100644 index 000000000..e0c74f05b --- /dev/null +++ b/src/renderer/extensions/vueNodes/composables/useSlotLinkInteraction.ts @@ -0,0 +1,203 @@ +import { onBeforeUnmount } from 'vue' + +import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' +import type { + INodeInputSlot, + INodeOutputSlot +} from '@/lib/litegraph/src/interfaces' +import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums' +import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier' +import { layoutStore } from '@/renderer/core/layout/store/layoutStore' +import type { SlotLayout } from '@/renderer/core/layout/types' +import { evaluateCompatibility } from '@/renderer/core/linkInteractions/slotLinkCompatibility' +import { + type SlotDropCandidate, + useSlotLinkDragState +} from '@/renderer/core/linkInteractions/slotLinkDragState' +import { app } from '@/scripts/app' + +interface SlotInteractionOptions { + nodeId: string + index: number + type: 'input' | 'output' + readonly?: boolean +} + +export function useSlotLinkInteraction({ + nodeId, + index, + type, + readonly +}: SlotInteractionOptions) { + const { state, beginDrag, endDrag, updatePointerPosition } = + useSlotLinkDragState() + + function candidateFromTarget( + target: EventTarget | null + ): SlotDropCandidate | null { + if (!(target instanceof HTMLElement)) return null + const key = target.dataset['slotKey'] + if (!key) return null + + const layout = layoutStore.getSlotLayout(key) + if (!layout) return null + + return { layout, compatible: true } + } + + const conversion = useSharedCanvasPositionConversion() + + let activePointerId: number | null = null + + const cleanupListeners = () => { + window.removeEventListener('pointermove', handlePointerMove, true) + window.removeEventListener('pointerup', handlePointerUp, true) + window.removeEventListener('pointercancel', handlePointerCancel, true) + activePointerId = null + endDrag() + } + + const updatePointerState = (event: PointerEvent) => { + const client = { x: event.clientX, y: event.clientY } + const [canvasX, canvasY] = conversion.clientPosToCanvasPos([ + client.x, + client.y + ]) + + updatePointerPosition({ + client, + canvas: { x: canvasX, y: canvasY } + }) + } + + const handlePointerMove = (event: PointerEvent) => { + if (event.pointerId !== activePointerId) return + updatePointerState(event) + app.canvas?.setDirty(true) + } + + const connectSlots = (slotLayout: SlotLayout) => { + const canvas = app.canvas + const graph = canvas?.graph + const source = state.source + if (!canvas || !graph || !source) return + + const sourceNode = graph.getNodeById(Number(source.nodeId)) + const targetNode = graph.getNodeById(Number(slotLayout.nodeId)) + if (!sourceNode || !targetNode) return + + const sourceSlot = + source.type === 'output' + ? sourceNode.outputs?.[source.slotIndex] + : sourceNode.inputs?.[source.slotIndex] + const targetSlot = + slotLayout.type === 'input' + ? targetNode.inputs?.[slotLayout.index] + : targetNode.outputs?.[slotLayout.index] + + if (!sourceSlot || !targetSlot) return + + if (source.type === 'output' && slotLayout.type === 'input') { + const outputSlot = sourceSlot as INodeOutputSlot | undefined + const inputSlot = targetSlot as INodeInputSlot | undefined + if (!outputSlot || !inputSlot) return + graph.beforeChange() + sourceNode.connectSlots(outputSlot, targetNode, inputSlot, undefined) + return + } + + if (source.type === 'input' && slotLayout.type === 'output') { + const inputSlot = sourceSlot as INodeInputSlot | undefined + const outputSlot = targetSlot as INodeOutputSlot | undefined + if (!inputSlot || !outputSlot) return + graph.beforeChange() + sourceNode.disconnectInput(source.slotIndex, true) + targetNode.connectSlots(outputSlot, sourceNode, inputSlot, undefined) + } + } + + const finishInteraction = (event: PointerEvent) => { + if (event.pointerId !== activePointerId) return + event.preventDefault() + + if (state.source) { + const candidate = candidateFromTarget(event.target) + if (candidate) { + const result = evaluateCompatibility(state.source, candidate) + if (result.allowable) { + connectSlots(candidate.layout) + } + } + } + + cleanupListeners() + app.canvas?.setDirty(true) + } + + const handlePointerUp = (event: PointerEvent) => { + finishInteraction(event) + } + + const handlePointerCancel = (event: PointerEvent) => { + if (event.pointerId !== activePointerId) return + cleanupListeners() + app.canvas?.setDirty(true, true) + } + + const onPointerDown = (event: PointerEvent) => { + if (readonly) return + if (event.button !== 0) return + if (!nodeId) return + if (activePointerId !== null) return + + const canvas = app.canvas + const graph = canvas?.graph + if (!canvas || !graph) return + + const layout = layoutStore.getSlotLayout( + getSlotKey(nodeId, index, type === 'input') + ) + if (!layout) return + + const resolvedNode = graph.getNodeById(Number(nodeId)) + const slot = + type === 'input' + ? resolvedNode?.inputs?.[index] + : resolvedNode?.outputs?.[index] + + const direction = + slot?.dir ?? (type === 'input' ? LinkDirection.LEFT : LinkDirection.RIGHT) + + beginDrag( + { + nodeId, + slotIndex: index, + type, + direction, + position: layout.position + }, + event.pointerId + ) + + activePointerId = event.pointerId + + updatePointerState(event) + + window.addEventListener('pointermove', handlePointerMove, true) + window.addEventListener('pointerup', handlePointerUp, true) + window.addEventListener('pointercancel', handlePointerCancel, true) + app.canvas?.setDirty(true, true) + event.preventDefault() + event.stopPropagation() + } + + onBeforeUnmount(() => { + if (activePointerId !== null) { + cleanupListeners() + } + }) + + return { + onPointerDown + } +}