mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-17 10:57:34 +00:00
Compare commits
1 Commits
refactor/e
...
quick-conn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76373a6eea |
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
154
src/renderer/extensions/vueNodes/components/SlotContextMenu.vue
Normal file
154
src/renderer/extensions/vueNodes/components/SlotContextMenu.vue
Normal 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>
|
||||
179
src/renderer/extensions/vueNodes/composables/useAutoPan.test.ts
Normal file
179
src/renderer/extensions/vueNodes/composables/useAutoPan.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
116
src/renderer/extensions/vueNodes/composables/useAutoPan.ts
Normal file
116
src/renderer/extensions/vueNodes/composables/useAutoPan.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user