Compare commits

...

1 Commits

Author SHA1 Message Date
bymyself
76373a6eea feat: slot context menu 'Connect to...' and auto-pan during link drag
Add right-click context menu on slot dots with 'Connect to...' submenu
listing compatible existing nodes. Uses Vue/PrimeVue ContextMenu pattern
matching NodeContextMenu.vue. Finds compatible nodes via
LiteGraph.isValidConnection, filters wildcards/bypassed/connected inputs,
sorts by Y position, caps at 15 results.

Add auto-panning when dragging links near canvas edges. Integrated into
useSlotLinkInteraction via useAutoPan composable. Velocity-based rAF
panning that recomputes canvas coordinates after each offset change.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3056-108e-7248-9c30-7d236197edcc
2026-02-06 01:22:35 -08:00
9 changed files with 987 additions and 0 deletions

View File

@@ -43,6 +43,7 @@
</Transition>
</div>
<NodeContextMenu />
<SlotContextMenu />
</template>
<script setup lang="ts">
@@ -70,6 +71,7 @@ import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import NodeContextMenu from './NodeContextMenu.vue'
import SlotContextMenu from '@/renderer/extensions/vueNodes/components/SlotContextMenu.vue'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'

View File

@@ -31,6 +31,7 @@
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
@contextmenu.stop.prevent="onSlotContextMenu"
/>
<!-- Slot Name -->
@@ -65,6 +66,7 @@ import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDra
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 { showSlotMenu } from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
@@ -147,4 +149,13 @@ const { onClick, onDoubleClick, onPointerDown } = useSlotLinkInteraction({
index: props.index,
type: 'input'
})
function onSlotContextMenu(event: MouseEvent) {
if (!props.nodeId) return
showSlotMenu(event, {
nodeId: props.nodeId,
slotIndex: props.index,
isInput: true
})
}
</script>

View File

@@ -13,6 +13,7 @@
class="w-3 translate-x-1/2"
:slot-data
@pointerdown="onPointerDown"
@contextmenu.stop.prevent="onSlotContextMenu"
/>
</div>
</template>
@@ -26,6 +27,7 @@ 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 { showSlotMenu } from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil'
@@ -112,4 +114,13 @@ const { onPointerDown } = useSlotLinkInteraction({
index: props.index,
type: 'output'
})
function onSlotContextMenu(event: MouseEvent) {
if (!props.nodeId) return
showSlotMenu(event, {
nodeId: props.nodeId,
slotIndex: props.index,
isInput: false
})
}
</script>

View File

@@ -0,0 +1,154 @@
<template>
<ContextMenu
ref="contextMenu"
:model="menuItems"
class="max-h-[80vh] overflow-y-auto"
@show="onMenuShow"
@hide="onMenuHide"
>
<template #item="{ item, props: itemProps, hasSubmenu }">
<a v-bind="itemProps.action" class="flex items-center gap-2 px-3 py-1.5">
<span class="flex-1">{{ item.label }}</span>
<i
v-if="hasSubmenu"
class="icon-[lucide--chevron-right] size-4 opacity-60"
/>
</a>
</template>
</ContextMenu>
</template>
<script setup lang="ts">
import { useElementBounding, useRafFn } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
connectSlots,
findCompatibleTargets,
registerSlotMenuInstance
} from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
import type { SlotMenuContext } from '@/renderer/extensions/vueNodes/composables/useSlotContextMenu'
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const isOpen = ref(false)
const activeContext = ref<SlotMenuContext | null>(null)
const canvasStore = useCanvasStore()
const lgCanvas = canvasStore.getCanvas()
const { left: canvasLeft, top: canvasTop } = useElementBounding(lgCanvas.canvas)
const worldPosition = ref({ x: 0, y: 0 })
let lastScale = 0
let lastOffsetX = 0
let lastOffsetY = 0
function updateMenuPosition() {
if (!isOpen.value) return
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const menuEl = menuInstance?.container
if (!menuEl) return
const { scale, offset } = lgCanvas.ds
if (
scale === lastScale &&
offset[0] === lastOffsetX &&
offset[1] === lastOffsetY
) {
return
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
const screenX = (worldPosition.value.x + offset[0]) * scale + canvasLeft.value
const screenY = (worldPosition.value.y + offset[1]) * scale + canvasTop.value
menuEl.style.left = `${screenX}px`
menuEl.style.top = `${screenY}px`
}
const { resume: startSync, pause: stopSync } = useRafFn(updateMenuPosition, {
immediate: false
})
watchEffect(() => {
if (isOpen.value) {
startSync()
} else {
stopSync()
}
})
const menuItems = computed<MenuItem[]>(() => {
const ctx = activeContext.value
if (!ctx) return []
const targets = findCompatibleTargets(ctx)
if (targets.length === 0) {
return [{ label: 'No compatible nodes', disabled: true }]
}
return [
{
label: 'Connect to...',
items: targets.map((target) => ({
label: `${target.slotInfo.name} @ ${target.node.title || target.node.type}`,
command: () => {
connectSlots(ctx, target)
hide()
}
}))
}
]
})
function show(event: MouseEvent, context: SlotMenuContext) {
activeContext.value = context
const screenX = event.clientX - canvasLeft.value
const screenY = event.clientY - canvasTop.value
const { scale, offset } = lgCanvas.ds
worldPosition.value = {
x: screenX / scale - offset[0],
y: screenY / scale - offset[1]
}
lastScale = scale
lastOffsetX = offset[0]
lastOffsetY = offset[1]
isOpen.value = true
contextMenu.value?.show(event)
}
function hide() {
contextMenu.value?.hide()
}
function onMenuShow() {
isOpen.value = true
}
function onMenuHide() {
isOpen.value = false
activeContext.value = null
}
defineExpose({ show, hide, isOpen })
onMounted(() => {
registerSlotMenuInstance({ show, hide, isOpen })
})
onUnmounted(() => {
registerSlotMenuInstance(null)
})
</script>

View File

@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDs, mockSetDirty } = vi.hoisted(() => {
const mockDs = { offset: [0, 0] as number[], scale: 1 }
const mockSetDirty = vi.fn()
return { mockDs, mockSetDirty }
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
canvas: {
getBoundingClientRect: () => ({
left: 0,
right: 800,
top: 0,
bottom: 600,
width: 800,
height: 600
})
},
ds: mockDs,
setDirty: mockSetDirty
}
}
}))
import { useAutoPan } from './useAutoPan'
describe('useAutoPan', () => {
let rafCallbacks: Array<(timestamp: number) => void>
beforeEach(() => {
vi.clearAllMocks()
mockDs.offset = [0, 0]
mockDs.scale = 1
rafCallbacks = []
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallbacks.push(cb as (timestamp: number) => void)
return rafCallbacks.length
})
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation(() => {})
vi.spyOn(performance, 'now').mockReturnValue(0)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('does not start panning when pointer is in the center', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(400, 300)
expect(rafCallbacks).toHaveLength(0)
})
it('starts panning when pointer enters left edge zone', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
expect(rafCallbacks).toHaveLength(1)
})
it('starts panning when pointer enters right edge zone', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(790, 300)
expect(rafCallbacks).toHaveLength(1)
})
it('starts panning when pointer enters top edge zone', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(400, 10)
expect(rafCallbacks).toHaveLength(1)
})
it('starts panning when pointer enters bottom edge zone', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(400, 590)
expect(rafCallbacks).toHaveLength(1)
})
it('stops panning when stop() is called', () => {
const onPan = vi.fn()
const { updatePointer, stop } = useAutoPan(onPan)
updatePointer(10, 300)
expect(rafCallbacks).toHaveLength(1)
stop()
rafCallbacks[0](100)
expect(onPan).not.toHaveBeenCalled()
})
it('calls onPan callback with canvas-space deltas', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
expect(rafCallbacks).toHaveLength(1)
rafCallbacks[0](100)
expect(onPan).toHaveBeenCalledTimes(1)
const [dx, dy] = onPan.mock.calls[0]
expect(dx).toBeGreaterThan(0)
expect(dy).toBe(0)
})
it('modifies ds.offset when panning', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
rafCallbacks[0](100)
expect(mockDs.offset[0]).toBeGreaterThan(0)
expect(mockDs.offset[1]).toBe(0)
})
it('speed scales with proximity to edge', () => {
const onPanClose = vi.fn()
const controlsClose = useAutoPan(onPanClose)
controlsClose.updatePointer(5, 300)
rafCallbacks[0](100)
controlsClose.stop()
const dxClose = onPanClose.mock.calls[0][0]
mockDs.offset = [0, 0]
rafCallbacks = []
const onPanFar = vi.fn()
const controlsFar = useAutoPan(onPanFar)
controlsFar.updatePointer(40, 300)
rafCallbacks[0](100)
controlsFar.stop()
const dxFar = onPanFar.mock.calls[0][0]
expect(Math.abs(dxClose)).toBeGreaterThan(Math.abs(dxFar))
})
it('marks canvas as dirty when panning', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
rafCallbacks[0](100)
expect(mockSetDirty).toHaveBeenCalledWith(true, true)
})
it('does not call onPan when velocity is zero', () => {
const onPan = vi.fn()
const { updatePointer } = useAutoPan(onPan)
updatePointer(10, 300)
updatePointer(400, 300)
rafCallbacks[0](100)
expect(onPan).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,116 @@
import { app } from '@/scripts/app'
const EDGE_PX = 48
const MAX_SPEED = 900
interface AutoPanState {
active: boolean
rafId: number | null
lastTime: number
velocityX: number
velocityY: number
lastClientX: number
lastClientY: number
}
interface AutoPanControls {
updatePointer: (clientX: number, clientY: number) => void
stop: () => void
}
export function useAutoPan(
onPan: (dxCanvas: number, dyCanvas: number) => void
): AutoPanControls {
const state: AutoPanState = {
active: false,
rafId: null,
lastTime: 0,
velocityX: 0,
velocityY: 0,
lastClientX: 0,
lastClientY: 0
}
function computeVelocity(clientX: number, clientY: number): [number, number] {
const canvas = app.canvas?.canvas
if (!canvas) return [0, 0]
const rect = canvas.getBoundingClientRect()
let vx = 0
let vy = 0
const distLeft = clientX - rect.left
const distRight = rect.right - clientX
const distTop = clientY - rect.top
const distBottom = rect.bottom - clientY
if (distLeft < EDGE_PX) vx = ((EDGE_PX - distLeft) / EDGE_PX) * MAX_SPEED
else if (distRight < EDGE_PX)
vx = -(((EDGE_PX - distRight) / EDGE_PX) * MAX_SPEED)
if (distTop < EDGE_PX) vy = ((EDGE_PX - distTop) / EDGE_PX) * MAX_SPEED
else if (distBottom < EDGE_PX)
vy = -(((EDGE_PX - distBottom) / EDGE_PX) * MAX_SPEED)
return [vx, vy]
}
function tick(timestamp: number): void {
if (!state.active) return
const [vx, vy] = computeVelocity(state.lastClientX, state.lastClientY)
state.velocityX = vx
state.velocityY = vy
if (vx === 0 && vy === 0) {
state.rafId = requestAnimationFrame(tick)
return
}
const ds = app.canvas?.ds
if (!ds) {
stop()
return
}
const dt = Math.min((timestamp - state.lastTime) / 1000, 0.1)
state.lastTime = timestamp
const dxCanvas = (vx * dt) / ds.scale
const dyCanvas = (vy * dt) / ds.scale
ds.offset[0] += dxCanvas
ds.offset[1] += dyCanvas
app.canvas?.setDirty(true, true)
onPan(dxCanvas, dyCanvas)
state.rafId = requestAnimationFrame(tick)
}
function updatePointer(clientX: number, clientY: number): void {
state.lastClientX = clientX
state.lastClientY = clientY
if (!state.active) {
const [vx, vy] = computeVelocity(clientX, clientY)
if (vx !== 0 || vy !== 0) {
state.active = true
state.lastTime = performance.now()
state.rafId = requestAnimationFrame(tick)
}
}
}
function stop(): void {
state.active = false
if (state.rafId !== null) {
cancelAnimationFrame(state.rafId)
state.rafId = null
}
state.velocityX = 0
state.velocityY = 0
}
return { updatePointer, stop }
}

View File

@@ -0,0 +1,376 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
const { mockGraph, mockCanvas } = vi.hoisted(() => {
const mockGraph = {
_nodes: [] as any[],
getNodeById: vi.fn(),
beforeChange: vi.fn(),
afterChange: vi.fn()
}
const mockCanvas = {
graph: mockGraph as any,
setDirty: vi.fn()
}
return { mockGraph, mockCanvas }
})
vi.mock('@/scripts/app', () => ({
app: { canvas: mockCanvas }
}))
vi.mock('@/lib/litegraph/src/litegraph', () => ({
LiteGraph: {
isValidConnection: vi.fn((a: unknown, b: unknown) => a === b)
}
}))
import { connectSlots, findCompatibleTargets } from './useSlotContextMenu'
function createMockNode(overrides: Record<string, unknown> = {}) {
return {
id: overrides.id ?? '1',
pos: overrides.pos ?? [0, 0],
title: overrides.title ?? 'TestNode',
type: overrides.type ?? 'TestType',
mode: overrides.mode ?? LGraphEventMode.ALWAYS,
inputs: overrides.inputs ?? [],
outputs: overrides.outputs ?? [],
connect: vi.fn(),
...overrides
} as unknown as LGraphNode & { connect: ReturnType<typeof vi.fn> }
}
describe('findCompatibleTargets', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGraph._nodes = []
mockCanvas.graph = mockGraph
})
it('returns empty array when graph is null', () => {
mockCanvas.graph = null as any
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('returns empty array when source node is not found', () => {
mockGraph.getNodeById.mockReturnValue(null)
const result = findCompatibleTargets({
nodeId: '99',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('returns empty array when source slot has wildcard type "*"', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: '*', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('returns empty array when source slot has wildcard type ""', () => {
const source = createMockNode({
id: '1',
outputs: [{ type: '', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: false
})
expect(result).toEqual([])
})
it('returns empty array when source slot has wildcard type 0', () => {
const source = createMockNode({
id: '1',
outputs: [{ type: 0, links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: false
})
expect(result).toEqual([])
})
it('finds compatible output nodes when source is input', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const candidate = createMockNode({
id: '2',
outputs: [{ type: 'IMAGE', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, candidate]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toHaveLength(1)
expect(result[0].node).toBe(candidate)
expect(result[0].slotIndex).toBe(0)
})
it('finds compatible input nodes when source is output', () => {
const source = createMockNode({
id: '1',
outputs: [{ type: 'MODEL', links: [] }]
})
const candidate = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, candidate]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: false
})
expect(result).toHaveLength(1)
expect(result[0].node).toBe(candidate)
expect(result[0].slotIndex).toBe(0)
})
it('skips bypassed nodes', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const bypassed = createMockNode({
id: '2',
mode: LGraphEventMode.NEVER,
outputs: [{ type: 'IMAGE', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, bypassed]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('skips already-connected inputs', () => {
const source = createMockNode({
id: '1',
outputs: [{ type: 'MODEL', links: [] }]
})
const connected = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: 42 }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, connected]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: false
})
expect(result).toEqual([])
})
it('skips wildcard-typed candidate slots', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const wildcardCandidate = createMockNode({
id: '2',
outputs: [{ type: '*', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, wildcardCandidate]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
it('sorts results by node Y position', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const nodeHigh = createMockNode({
id: '2',
pos: [0, 300],
outputs: [{ type: 'IMAGE', links: [] }]
})
const nodeLow = createMockNode({
id: '3',
pos: [0, 100],
outputs: [{ type: 'IMAGE', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, nodeHigh, nodeLow]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toHaveLength(2)
expect(result[0].node).toBe(nodeLow)
expect(result[1].node).toBe(nodeHigh)
})
it('limits results to maxResults', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const candidates = Array.from({ length: 5 }, (_, i) =>
createMockNode({
id: String(i + 2),
pos: [0, i * 100],
outputs: [{ type: 'IMAGE', links: [] }]
})
)
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source, ...candidates]
const result = findCompatibleTargets(
{ nodeId: '1', slotIndex: 0, isInput: true },
3
)
expect(result).toHaveLength(3)
})
it('does not include the source node itself', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }],
outputs: [{ type: 'IMAGE', links: [] }]
})
mockGraph.getNodeById.mockReturnValue(source)
mockGraph._nodes = [source]
const result = findCompatibleTargets({
nodeId: '1',
slotIndex: 0,
isInput: true
})
expect(result).toEqual([])
})
})
describe('connectSlots', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanvas.graph = mockGraph
})
it('calls graph.beforeChange and afterChange', () => {
const source = createMockNode({ id: '1', outputs: [{ type: 'MODEL' }] })
const target = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: false },
{ node: target, slotIndex: 0, slotInfo: target.inputs[0] }
)
expect(mockGraph.beforeChange).toHaveBeenCalled()
expect(mockGraph.afterChange).toHaveBeenCalled()
})
it('connects source output to target input', () => {
const source = createMockNode({ id: '1', outputs: [{ type: 'MODEL' }] })
const target = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: false },
{ node: target, slotIndex: 0, slotInfo: target.inputs[0] }
)
expect(source.connect).toHaveBeenCalledWith(0, target, 0)
})
it('connects target output to source input when source is input', () => {
const source = createMockNode({
id: '1',
inputs: [{ type: 'IMAGE', link: null }]
})
const target = createMockNode({ id: '2', outputs: [{ type: 'IMAGE' }] })
mockGraph.getNodeById.mockReturnValue(source)
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: true },
{ node: target, slotIndex: 0, slotInfo: target.outputs[0] }
)
expect(target.connect).toHaveBeenCalledWith(0, source, 0)
})
it('does nothing when graph is null', () => {
mockCanvas.graph = null as any
const target = createMockNode({ id: '2' })
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: false },
{ node: target, slotIndex: 0, slotInfo: {} as any }
)
expect(target.connect).not.toHaveBeenCalled()
})
it('marks canvas as dirty after connecting', () => {
const source = createMockNode({ id: '1', outputs: [{ type: 'MODEL' }] })
const target = createMockNode({
id: '2',
inputs: [{ type: 'MODEL', link: null }]
})
mockGraph.getNodeById.mockReturnValue(source)
connectSlots(
{ nodeId: '1', slotIndex: 0, isInput: false },
{ node: target, slotIndex: 0, slotInfo: target.inputs[0] }
)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
})

View File

@@ -0,0 +1,120 @@
import type { Ref } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
interface SlotMenuContext {
nodeId: NodeId
slotIndex: number
isInput: boolean
}
interface CompatibleTarget {
node: LGraphNode
slotIndex: number
slotInfo: INodeInputSlot | INodeOutputSlot
}
interface SlotMenuInstance {
show: (event: MouseEvent, context: SlotMenuContext) => void
hide: () => void
isOpen: Ref<boolean>
}
let slotMenuInstance: SlotMenuInstance | null = null
export function registerSlotMenuInstance(
instance: SlotMenuInstance | null
): void {
slotMenuInstance = instance
}
export function showSlotMenu(
event: MouseEvent,
context: SlotMenuContext
): void {
slotMenuInstance?.show(event, context)
}
function isWildcardType(type: unknown): boolean {
return type === '*' || type === '' || type === 0
}
export function findCompatibleTargets(
context: SlotMenuContext,
maxResults: number = 15
): CompatibleTarget[] {
const graph = app.canvas?.graph
if (!graph) return []
const sourceNode = graph.getNodeById(context.nodeId)
if (!sourceNode) return []
const sourceSlot = context.isInput
? sourceNode.inputs?.[context.slotIndex]
: sourceNode.outputs?.[context.slotIndex]
if (!sourceSlot) return []
if (isWildcardType(sourceSlot.type)) return []
const results: CompatibleTarget[] = []
for (const candidate of graph._nodes) {
if (candidate.id === sourceNode.id) continue
if (candidate.mode === LGraphEventMode.NEVER) continue
if (context.isInput) {
if (!candidate.outputs) continue
for (let i = 0; i < candidate.outputs.length; i++) {
const output = candidate.outputs[i]
if (isWildcardType(output.type)) continue
if (LiteGraph.isValidConnection(output.type, sourceSlot.type)) {
results.push({ node: candidate, slotIndex: i, slotInfo: output })
}
}
} else {
if (!candidate.inputs) continue
for (let i = 0; i < candidate.inputs.length; i++) {
const input = candidate.inputs[i]
if (input.link != null) continue
if (isWildcardType(input.type)) continue
if (LiteGraph.isValidConnection(sourceSlot.type, input.type)) {
results.push({ node: candidate, slotIndex: i, slotInfo: input })
}
}
}
}
results.sort((a, b) => a.node.pos[1] - b.node.pos[1])
return results.slice(0, maxResults)
}
export function connectSlots(
context: SlotMenuContext,
target: CompatibleTarget
): void {
const graph = app.canvas?.graph
if (!graph) return
const sourceNode = graph.getNodeById(context.nodeId)
if (!sourceNode) return
graph.beforeChange()
if (context.isInput) {
target.node.connect(target.slotIndex, sourceNode, context.slotIndex)
} else {
sourceNode.connect(context.slotIndex, target.node, target.slotIndex)
}
graph.afterChange()
app.canvas?.setDirty(true, true)
}
export type { SlotMenuContext }

View File

@@ -29,6 +29,7 @@ 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 { createSlotLinkDragContext } from '@/renderer/extensions/vueNodes/composables/slotLinkDragContext'
import { useAutoPan } from '@/renderer/extensions/vueNodes/composables/useAutoPan'
import { augmentToCanvasPointerEvent } from '@/renderer/extensions/vueNodes/utils/eventUtils'
import { app } from '@/scripts/app'
import { createRafBatch } from '@/utils/rafBatch'
@@ -128,6 +129,21 @@ export function useSlotLinkInteraction({
// Per-drag drag-state context (non-reactive caches + RAF batching)
const dragContext = createSlotLinkDragContext()
const autoPan = useAutoPan(() => {
const data = dragContext.pendingPointerMove
const clientX = data?.clientX ?? state.pointer.client.x
const clientY = data?.clientY ?? state.pointer.client.y
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
clientX,
clientY
])
updatePointerPosition(clientX, clientY, canvasX, canvasY)
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [canvasX, canvasY]
app.canvas?.setDirty(true, true)
}
})
const resolveRenderLinkSource = (link: RenderLink): Point | null => {
if (link.fromReroute) {
const rerouteLayout = layoutStore.getRerouteLayout(link.fromReroute.id)
@@ -286,6 +302,7 @@ export function useSlotLinkInteraction({
if (state.pointerId != null) {
clearCanvasPointerHistory(state.pointerId)
}
autoPan.stop()
activeAdapter?.reset()
pointerSession.clear()
endDrag()
@@ -416,6 +433,7 @@ export function useSlotLinkInteraction({
clientY: event.clientY,
target: event.target
}
autoPan.updatePointer(event.clientX, event.clientY)
raf.schedule()
}