Slot functionality for vue nodes (#5628)

Allows for simple slot functionality in vue nodes mode.

Has:
- Drag new link from slot
- Connect new link from dropping on slot

Now:
- Tests

After:
- Drop on reroute
- Correct link color on connect
- Drop on node
- Hover effects

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5628-Slot-functionality-for-vue-nodes-2716d73d365081c59a3cef7c8a5e539e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Benjamin Lu
2025-09-18 19:35:15 -07:00
committed by GitHub
parent 1f88925144
commit 2ff0d951ed
13 changed files with 609 additions and 179 deletions

View File

@@ -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<typeof InputSlot>['props']
type OutputSlotProps = ComponentMountingOptions<typeof OutputSlot>['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'
})
)
})
})

View File

@@ -1,21 +1,12 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg h-6"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
'lg-slot--dot-only': dotOnly,
'pr-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly
}"
>
<div v-else :class="slotWrapperClass">
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
:color="slotColor"
class="-translate-x-1/2"
v-on="readonly ? {} : { pointerdown: onPointerDown }"
/>
<!-- Slot Name -->
@@ -41,6 +32,8 @@ 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 { cn } from '@/utils/tailwindUtil'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -70,6 +63,20 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--input flex items-center group rounded-r-lg h-6',
props.readonly ? 'cursor-default opacity-70' : 'cursor-crosshair',
props.dotOnly
? 'lg-slot--dot-only'
: 'pr-6 hover:bg-black/5 hover:dark:bg-white/5',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible
}
)
)
const connectionDotRef = ref<ComponentPublicInstance<{
slotElRef: HTMLElement | undefined
}> | null>(null)
@@ -88,4 +95,11 @@ useSlotElementTracking({
type: 'input',
element: slotElRef
})
const { onPointerDown } = useSlotLinkInteraction({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'input',
readonly: props.readonly
})
</script>

View File

@@ -1,17 +1,6 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg h-6"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
'lg-slot--dot-only': dotOnly,
'pl-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly,
'justify-center': dotOnly
}"
>
<div v-else :class="slotWrapperClass">
<!-- Slot Name -->
<span
v-if="!dotOnly"
@@ -25,6 +14,7 @@
ref="connectionDotRef"
:color="slotColor"
class="translate-x-1/2"
v-on="readonly ? {} : { pointerdown: onPointerDown }"
/>
</div>
</template>
@@ -42,6 +32,8 @@ 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 { cn } from '@/utils/tailwindUtil'
import SlotConnectionDot from './SlotConnectionDot.vue'
@@ -72,6 +64,20 @@ onErrorCaptured((error) => {
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
props.readonly ? 'cursor-default opacity-70' : 'cursor-crosshair',
props.dotOnly
? 'lg-slot--dot-only justify-center'
: 'pl-6 hover:bg-black/5 hover:dark:bg-white/5',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible
}
)
)
const connectionDotRef = ref<ComponentPublicInstance<{
slotElRef: HTMLElement | undefined
}> | null>(null)
@@ -90,4 +96,11 @@ useSlotElementTracking({
type: 'output',
element: slotElRef
})
const { onPointerDown } = useSlotLinkInteraction({
nodeId: props.nodeId ?? '',
index: props.index,
type: 'output',
readonly: props.readonly
})
</script>

View File

@@ -150,7 +150,6 @@ export function useSlotElementTracking(options: {
(el) => {
if (!el) return
// Ensure node entry
const node = nodeSlotRegistryStore.ensureNode(nodeId)
if (!node.stopWatch) {
@@ -184,6 +183,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,12 +204,15 @@ 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
if (node.slots.size === 0) {
// Stop the node-level watcher when the last slot is gone
if (node.stopWatch) node.stopWatch()
nodeSlotRegistryStore.deleteNode(nodeId)
}

View File

@@ -0,0 +1,247 @@
import { type Fn, useEventListener } from '@vueuse/core'
import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { evaluateCompatibility } from '@/renderer/core/canvas/links/slotLinkCompatibility'
import {
type SlotDropCandidate,
useSlotLinkDragState
} from '@/renderer/core/canvas/links/slotLinkDragState'
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 { app } from '@/scripts/app'
interface SlotInteractionOptions {
nodeId: string
index: number
type: 'input' | 'output'
readonly?: boolean
}
interface SlotInteractionHandlers {
onPointerDown: (event: PointerEvent) => void
}
interface PointerSession {
begin: (pointerId: number) => void
register: (...stops: Array<Fn | null | undefined>) => void
matches: (event: PointerEvent) => boolean
isActive: () => boolean
clear: () => void
}
function createPointerSession(): PointerSession {
let pointerId: number | null = null
let stops: Fn[] = []
const begin = (id: number) => {
pointerId = id
}
const register = (...newStops: Array<Fn | null | undefined>) => {
for (const stop of newStops) {
if (typeof stop === 'function') {
stops.push(stop)
}
}
}
const matches = (event: PointerEvent) =>
pointerId !== null && event.pointerId === pointerId
const isActive = () => pointerId !== null
const clear = () => {
for (const stop of stops) {
stop()
}
stops = []
pointerId = null
}
return { begin, register, matches, isActive, clear }
}
export function useSlotLinkInteraction({
nodeId,
index,
type,
readonly
}: SlotInteractionOptions): SlotInteractionHandlers {
if (readonly) {
return {
onPointerDown: () => {}
}
}
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
const candidate: SlotDropCandidate = { layout, compatible: false }
if (state.source) {
candidate.compatible = evaluateCompatibility(
state.source,
candidate
).allowable
}
return candidate
}
const conversion = useSharedCanvasPositionConversion()
const pointerSession = createPointerSession()
const cleanupInteraction = () => {
pointerSession.clear()
endDrag()
}
const updatePointerState = (event: PointerEvent) => {
const clientX = event.clientX
const clientY = event.clientY
const [canvasX, canvasY] = conversion.clientPosToCanvasPos([
clientX,
clientY
])
updatePointerPosition(clientX, clientY, canvasX, canvasY)
}
const handlePointerMove = (event: PointerEvent) => {
if (!pointerSession.matches(event)) 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
if (source.type === 'output' && slotLayout.type === 'input') {
const outputSlot = sourceNode.outputs?.[source.slotIndex]
const inputSlot = targetNode.inputs?.[slotLayout.index]
if (!outputSlot || !inputSlot) return
graph.beforeChange()
sourceNode.connectSlots(outputSlot, targetNode, inputSlot, undefined)
return
}
if (source.type === 'input' && slotLayout.type === 'output') {
const inputSlot = sourceNode.inputs?.[source.slotIndex]
const outputSlot = targetNode.outputs?.[slotLayout.index]
if (!inputSlot || !outputSlot) return
graph.beforeChange()
sourceNode.disconnectInput(source.slotIndex, true)
targetNode.connectSlots(outputSlot, sourceNode, inputSlot, undefined)
}
}
const finishInteraction = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
event.preventDefault()
if (state.source) {
const candidate = candidateFromTarget(event.target)
if (candidate?.compatible) {
connectSlots(candidate.layout)
}
}
cleanupInteraction()
app.canvas?.setDirty(true)
}
const handlePointerUp = (event: PointerEvent) => {
finishInteraction(event)
}
const handlePointerCancel = (event: PointerEvent) => {
if (!pointerSession.matches(event)) return
cleanupInteraction()
app.canvas?.setDirty(true)
}
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
if (!nodeId) return
if (pointerSession.isActive()) 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
)
pointerSession.begin(event.pointerId)
updatePointerState(event)
pointerSession.register(
useEventListener(window, 'pointermove', handlePointerMove, {
capture: true
}),
useEventListener(window, 'pointerup', handlePointerUp, {
capture: true
}),
useEventListener(window, 'pointercancel', handlePointerCancel, {
capture: true
})
)
app.canvas?.setDirty(true)
event.preventDefault()
event.stopPropagation()
}
onBeforeUnmount(() => {
if (pointerSession.isActive()) {
cleanupInteraction()
}
})
return {
onPointerDown
}
}