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>
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 98 KiB |
@@ -2672,7 +2672,7 @@ export class LGraphCanvas
|
||||
): boolean {
|
||||
const outputLinks = [
|
||||
...(output.links ?? []),
|
||||
...[...(output._floatingLinks ?? new Set())]
|
||||
...(output._floatingLinks ?? new Set())
|
||||
]
|
||||
return outputLinks.some(
|
||||
(linkId) =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
36
src/renderer/extensions/vueNodes/utils/nodeDataUtils.ts
Normal 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)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||