Feat: Vue Node Slot Improvements (#6359)

## Summary

Several fixes and improvements to the slot behavior on Vue nodes.

## Changes

- **What**: Restore the pseudo-slots, if there are slots being hidden by
collapse
- **What**: Connections while collapsed
- **What**: Display the links in a more reasonable location
- **What**: Fixes styling of linked widgets
- **What**: [~Fix reconnecting logic to prioritize newly disconnected
and now empty
slots~](https://github.com/Comfy-Org/ComfyUI_frontend/pull/6370)

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)


https://github.com/user-attachments/assets/913cfb8f-acdd-4f3d-b619-c280cc11cce5


<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6359-WIP-Collapsed-nodes-multislots-29b6d73d3650817289d5f0a8efdade84)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Alexander Brown
2025-10-29 20:32:05 -07:00
committed by GitHub
parent 5e9a9923e4
commit e606ff34ec
17 changed files with 263 additions and 72 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -2672,7 +2672,7 @@ export class LGraphCanvas
): boolean {
const outputLinks = [
...(output.links ?? []),
...[...(output._floatingLinks ?? new Set())]
...(output._floatingLinks ?? new Set())
]
return outputLinks.some(
(linkId) =>

View File

@@ -154,10 +154,13 @@ export function createLinkConnectorAdapter(): LinkConnectorAdapter | null {
const graph = app.canvas?.graph
const connector = app.canvas?.linkConnector
if (!graph || !connector) return null
let adapter = adapterByGraph.get(graph)
if (!adapter || adapter.linkConnector !== connector) {
adapter = new LinkConnectorAdapter(graph, connector)
adapterByGraph.set(graph, adapter)
const adapter = adapterByGraph.get(graph)
if (adapter && adapter.linkConnector === connector) {
return adapter
}
return adapter
const newAdapter = new LinkConnectorAdapter(graph, connector)
adapterByGraph.set(graph, newAdapter)
return newAdapter
}

View File

@@ -15,7 +15,7 @@
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
'outline-transparent -outline-offset-2 outline-2',
'outline-transparent outline-2',
borderClass,
outlineClass,
{
@@ -44,7 +44,20 @@
@wheel="handleWheel"
@contextmenu="handleContextMenu"
>
<div class="flex items-center">
<div class="flex flex-col justify-center items-center relative">
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
@@ -132,12 +145,14 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { nonWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
@@ -233,6 +248,9 @@ const nodeOpacity = computed(() => {
return globalOpacity
})
const hasInputs = computed(() => nonWidgetedInputs(nodeData).length > 0)
const hasOutputs = computed((): boolean => !!nodeData.outputs?.length)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()

View File

@@ -2,8 +2,11 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ $t('Node Slots Error') }}
</div>
<div v-else class="lg-node-slots flex justify-between">
<div v-if="filteredInputs.length" class="flex flex-col gap-1">
<div v-else :class="cn('flex justify-between', unifiedWrapperClass)">
<div
v-if="filteredInputs.length"
:class="cn('flex flex-col gap-1', unifiedDotsClass)"
>
<InputSlot
v-for="(input, index) in filteredInputs"
:key="`input-${index}`"
@@ -14,7 +17,10 @@
/>
</div>
<div v-if="nodeData?.outputs?.length" class="ml-auto flex flex-col gap-1">
<div
v-if="nodeData?.outputs?.length"
:class="cn('ml-auto flex flex-col gap-1', unifiedDotsClass)"
>
<OutputSlot
v-for="(output, index) in nodeData.outputs"
:key="`output-${index}`"
@@ -33,40 +39,43 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { isSlotObject } from '@/utils/typeGuardUtil'
import {
linkedWidgetedInputs,
nonWidgetedInputs
} from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { cn } from '@/utils/tailwindUtil'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
nodeData?: VueNodeData
nodeData: VueNodeData
unified?: boolean
}
const { nodeData = null } = defineProps<NodeSlotsProps>()
const { nodeData, unified = false } = defineProps<NodeSlotsProps>()
// Filter out input slots that have corresponding widgets
const filteredInputs = computed(() => {
if (!nodeData?.inputs) return []
const linkedWidgetInputs = computed(() =>
unified ? linkedWidgetedInputs(nodeData) : []
)
return nodeData.inputs
.filter((input) => {
// Check if this slot has a widget property (indicating it has a corresponding widget)
if (isSlotObject(input) && 'widget' in input && input.widget) {
// This slot has a widget, so we should not display it separately
return false
}
return true
})
.map((input) =>
isSlotObject(input)
? input
: ({
name: typeof input === 'string' ? input : '',
type: 'any',
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
} as INodeSlot)
)
})
const filteredInputs = computed(() => [
...nonWidgetedInputs(nodeData),
...linkedWidgetInputs.value
])
const unifiedWrapperClass = computed((): string =>
cn(
unified &&
'absolute inset-0 items-center pointer-events-none opacity-0 z-30'
)
)
const unifiedDotsClass = computed((): string =>
cn(
unified &&
'grid grid-cols-1 grid-rows-1 gap-0 [&>*]:row-span-full [&>*]:col-span-full place-items-center'
)
)
// Get the actual index of an input slot in the node's inputs array
// (accounting for filtered widget slots)

View File

@@ -24,7 +24,12 @@
<!-- Widget Input Slot Dot -->
<div
class="z-10 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
:class="
cn(
'z-10 w-3 opacity-0 transition-opacity duration-150 group-hover:opacity-100 flex items-center',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
@@ -35,7 +40,7 @@
}"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:index="widget.slotMetadata.index"
:dot-only="true"
dot-only
/>
</div>
<!-- Widget Component -->
@@ -140,9 +145,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
// This prevents conflicting input sources - when a slot is linked to another
// node's output, the widget should be read-only to avoid data conflicts
if (slotMetadata?.linked) {
widgetOptions = widget.options
? { ...widget.options, disabled: true }
: { disabled: true }
widgetOptions = { ...widget.options, disabled: true }
}
const simplified: SimplifiedWidget = {

View File

@@ -1,6 +1,5 @@
import { useEventListener } from '@vueuse/core'
import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
import type { Fn } from '@vueuse/core'
import { onBeforeUnmount } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
@@ -555,6 +554,8 @@ export function useSlotLinkInteraction({
if (event.button !== 0) return
if (!nodeId) return
if (pointerSession.isActive()) return
event.preventDefault()
event.stopPropagation()
const canvas = app.canvas
const graph = canvas?.graph
@@ -613,7 +614,7 @@ export function useSlotLinkInteraction({
if (shouldBatchDisconnectOutputLinks && resolvedNode) {
resolvedNode.disconnectOutput(index)
app.canvas?.setDirty(true, true)
canvas.setDirty(true, true)
event.preventDefault()
event.stopPropagation()
return
@@ -634,20 +635,18 @@ export function useSlotLinkInteraction({
const shouldMoveExistingInput =
isInputSlot && !shouldBreakExistingInputLink && hasExistingInputLink
if (activeAdapter) {
if (isOutputSlot) {
activeAdapter.beginFromOutput(localNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
activeAdapter.beginFromInput(localNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
if (isOutputSlot) {
activeAdapter.beginFromOutput(localNodeId, index, {
moveExisting: shouldMoveExistingOutput
})
} else {
activeAdapter.beginFromInput(localNodeId, index, {
moveExisting: shouldMoveExistingInput
})
}
if (shouldMoveExistingInput && existingInputLink) {
existingInputLink._dragging = true
}
if (shouldMoveExistingInput && existingInputLink) {
existingInputLink._dragging = true
}
syncRenderLinkOrigins()
@@ -678,21 +677,19 @@ export function useSlotLinkInteraction({
toCanvasPointerEvent(event)
updatePointerState(event)
if (activeAdapter) {
activeAdapter.linkConnector.state.snapLinksPos = [
state.pointer.canvas.x,
state.pointer.canvas.y
]
}
activeAdapter.linkConnector.state.snapLinksPos = [
state.pointer.canvas.x,
state.pointer.canvas.y
]
pointerSession.register(
useEventListener(window, 'pointermove', handlePointerMove, {
useEventListener('pointermove', handlePointerMove, {
capture: true
}),
useEventListener(window, 'pointerup', handlePointerUp, {
useEventListener('pointerup', handlePointerUp, {
capture: true
}),
useEventListener(window, 'pointercancel', handlePointerCancel, {
useEventListener('pointercancel', handlePointerCancel, {
capture: true
})
)
@@ -710,12 +707,10 @@ export function useSlotLinkInteraction({
: activeAdapter.isOutputValidDrop(slotLayout.nodeId, idx)
setCompatibleForKey(key, ok)
}
app.canvas?.setDirty(true, true)
event.preventDefault()
event.stopPropagation()
canvas.setDirty(true, true)
}
onBeforeUnmount(() => {
tryOnScopeDispose(() => {
if (pointerSession.isActive()) {
cleanupInteraction()
}

View File

@@ -0,0 +1,127 @@
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type {
INodeInputSlot,
IWidgetLocator
} from '@/lib/litegraph/src/interfaces'
import type { LinkId } from '@/renderer/core/layout/types'
import {
linkedWidgetedInputs,
nonWidgetedInputs
} from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { describe, it } from 'vitest'
function makeFakeInputSlot(
name: string,
withWidget = false,
link: LinkId | null = null
): INodeInputSlot {
const widget: IWidgetLocator | undefined = withWidget ? { name } : undefined
return {
name,
widget,
link,
boundingRect: [0, 0, 0, 0],
type: 'FAKE'
}
}
function makeFakeNodeData(inputs: INodeInputSlot[]): VueNodeData {
const nodeData: Partial<VueNodeData> = { inputs }
return nodeData as VueNodeData
}
describe('nodeDataUtils', () => {
describe('nonWidgetedInputs', () => {
it('should handle an empty inputs list', () => {
const inputs: INodeInputSlot[] = []
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(0)
})
it('should handle a list of only widgeted inputs', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first', true),
makeFakeInputSlot('second', true)
]
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(0)
})
it('should handle a list of only slot inputs', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second')
]
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(2)
})
it('should handle a list of mixed inputs', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true)
]
const nodeData = makeFakeNodeData(inputs)
const actual = nonWidgetedInputs(nodeData)
expect(actual.length).toBe(2)
})
})
describe('linkedWidgetedInputs', () => {
it('should return input slots that are bound to widgets and are linked: none present', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true)
]
const nodeData = makeFakeNodeData(inputs)
const actual = linkedWidgetedInputs(nodeData)
expect(actual.length).toBe(0)
})
it('should return input slots that are bound to widgets and are linked: one present', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true, 1)
]
const nodeData = makeFakeNodeData(inputs)
const actual = linkedWidgetedInputs(nodeData)
expect(actual.length).toBe(1)
})
it('should return input slots that are bound to widgets and are linked: multiple present', () => {
const inputs: INodeInputSlot[] = [
makeFakeInputSlot('first'),
makeFakeInputSlot('second'),
makeFakeInputSlot('third', true),
makeFakeInputSlot('fourth', true, 1),
makeFakeInputSlot('fifth', true, 2)
]
const nodeData = makeFakeNodeData(inputs)
const actual = linkedWidgetedInputs(nodeData)
expect(actual.length).toBe(2)
})
})
})

View File

@@ -0,0 +1,36 @@
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { INodeInputSlot, INodeSlot } from '@/lib/litegraph/src/interfaces'
import { isSlotObject } from '@/utils/typeGuardUtil'
function coerceINodeSlot(input: INodeInputSlot): INodeSlot {
return isSlotObject(input)
? input
: {
name: typeof input === 'string' ? input : '',
type: 'any',
boundingRect: [0, 0, 0, 0]
}
}
function inputHasWidget(input: INodeInputSlot) {
return isSlotObject(input) && 'widget' in input && input.widget
}
export function nonWidgetedInputs(
nodeData: VueNodeData | undefined
): INodeSlot[] {
if (!nodeData?.inputs) return []
return nodeData.inputs
.filter((input) => !inputHasWidget(input))
.map(coerceINodeSlot)
}
export function linkedWidgetedInputs(
nodeData: VueNodeData | undefined
): INodeSlot[] {
if (!nodeData?.inputs) return []
return nodeData.inputs
.filter((input) => inputHasWidget(input) && !!input.link)
.map(coerceINodeSlot)
}

View File

@@ -2,8 +2,8 @@ import { cn } from '@/utils/tailwindUtil'
export const WidgetInputBaseClass = cn([
// Background
'bg-node-component-widget-input-surface',
'text-node-component-widget-input',
'not-disabled:bg-node-component-widget-input-surface',
'not-disabled:text-node-component-widget-input',
// Outline
'border-none',
'outline outline-offset-[-1px] outline-node-stroke',