mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-23 15:59:47 +00:00
## Summary Promoted primitive subgraph inputs (String, Int) render their link anchor at the header position instead of the widget row. Renaming subgraph input labels breaks the match entirely, causing connections to detach from their widgets visually. ## Changes - **What**: Fix widget-input slot positioning for promoted subgraph inputs in both LiteGraph and Vue (Nodes 2.0) rendering modes - `_arrangeWidgetInputSlots`: Removed Vue mode branch that skipped setting `input.pos`. Promoted widget inputs aren't rendered as `<InputSlot>` Vue components (NodeSlots filters them out), so `input.pos` is the only position fallback - `drawConnections`: Added pre-pass to arrange nodes with unpositioned widget-input slots before link rendering. The background canvas renders before the foreground canvas calls `arrange()`, so positions weren't set on the first frame - `SubgraphNode`: Sync `input.widget.name` with the display name on label rename and initial setup. The `IWidgetLocator` name diverged from `PromotedWidgetView.name` after rename, breaking all name-based slot↔widget matching (`_arrangeWidgetInputSlots`, `getWidgetFromSlot`, `getSlotFromWidget`) ## Review Focus - The `_arrangeWidgetInputSlots` rewrite iterates `_concreteInputs` directly instead of building a spread-copy map — simpler and avoids the stale index issue - `input.widget.name` is now kept in sync with the display name (`input.label ?? subgraphInput.name`). This is a semantic shift from using the raw internal name, but it's required for all name-based matching to work after renames. The value is overwritten on deserialize by `_setWidget` anyway - The `_widget` fallback in `_arrangeWidgetInputSlots` is a safety net for edge cases where the name still doesn't match (e.g., stale cache) Fixes #9998 ## Screenshots <img width="847" height="476" alt="Screenshot 2026-03-17 at 3 05 32 PM" src="https://github.com/user-attachments/assets/38f10563-f0bc-44dd-a1a5-f4a7832575d0" /> <img width="804" height="471" alt="Screenshot 2026-03-17 at 3 05 23 PM" src="https://github.com/user-attachments/assets/3237a7ee-f3e5-4084-b330-371def3415bd" /> <img width="974" height="571" alt="Screenshot 2026-03-17 at 3 05 16 PM" src="https://github.com/user-attachments/assets/cafdca46-8d9b-40e1-8561-02cbb25ee8f2" /> <img width="967" height="558" alt="Screenshot 2026-03-17 at 3 05 06 PM" src="https://github.com/user-attachments/assets/fc03ce43-906c-474d-b3bc-ddf08eb37c75" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10195-fix-subgraph-promoted-widget-input-slot-positions-after-label-rename-3266d73d365081dfa623dd94dd87c718) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: jaeone94 <jaeone.prt@gmail.com>
475 lines
15 KiB
Vue
475 lines
15 KiB
Vue
<template>
|
|
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
|
|
{{ st('nodeErrors.widgets', 'Node Widgets Error') }}
|
|
</div>
|
|
<div
|
|
v-else
|
|
:class="
|
|
cn(
|
|
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
|
|
shouldHandleNodePointerEvents
|
|
? 'pointer-events-auto'
|
|
: 'pointer-events-none'
|
|
)
|
|
"
|
|
:style="{
|
|
'grid-template-rows': gridTemplateRows,
|
|
flex: gridTemplateRows.includes('auto') ? 1 : undefined
|
|
}"
|
|
@pointerdown.capture="handleBringToFront"
|
|
@pointerdown="handleWidgetPointerEvent"
|
|
@pointermove="handleWidgetPointerEvent"
|
|
@pointerup="handleWidgetPointerEvent"
|
|
>
|
|
<template v-for="widget in processedWidgets" :key="widget.renderKey">
|
|
<div
|
|
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
|
|
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
|
|
>
|
|
<!-- Widget Input Slot Dot -->
|
|
<div
|
|
:class="
|
|
cn(
|
|
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
|
|
widget.slotMetadata?.linked && 'opacity-100'
|
|
)
|
|
"
|
|
>
|
|
<InputSlot
|
|
v-if="widget.slotMetadata"
|
|
:slot-data="{
|
|
name: widget.name,
|
|
type: widget.type,
|
|
boundingRect: [0, 0, 0, 0]
|
|
}"
|
|
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
|
:has-error="widget.hasError"
|
|
:index="widget.slotMetadata.index"
|
|
:socketless="widget.simplified.spec?.socketless"
|
|
dot-only
|
|
/>
|
|
</div>
|
|
<!-- Widget Component -->
|
|
<AppInput
|
|
:id="widget.id"
|
|
:name="widget.name"
|
|
:enable="canSelectInputs && !widget.simplified.options?.disabled"
|
|
>
|
|
<component
|
|
:is="widget.vueComponent"
|
|
v-model="widget.value"
|
|
v-tooltip.left="widget.tooltipConfig"
|
|
:widget="widget.simplified"
|
|
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
|
|
:node-type="nodeType"
|
|
:class="
|
|
cn(
|
|
'col-span-2',
|
|
widget.hasError && 'font-bold text-node-stroke-error'
|
|
)
|
|
"
|
|
@update:model-value="widget.updateHandler"
|
|
@contextmenu="widget.handleContextMenu"
|
|
/>
|
|
</AppInput>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { TooltipOptions } from 'primevue'
|
|
import { computed, onErrorCaptured, ref, toValue } from 'vue'
|
|
import type { Component } from 'vue'
|
|
|
|
import type {
|
|
SafeWidgetData,
|
|
VueNodeData,
|
|
WidgetSlotMetadata
|
|
} from '@/composables/graph/useGraphNodeManager'
|
|
import { useAppMode } from '@/composables/useAppMode'
|
|
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
|
|
import { useErrorHandling } from '@/composables/useErrorHandling'
|
|
import { st } from '@/i18n'
|
|
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
|
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
|
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
|
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
|
|
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
|
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
|
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
|
|
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
|
|
// Import widget components directly
|
|
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
|
import {
|
|
getComponent,
|
|
shouldExpand,
|
|
shouldRenderAsVue
|
|
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
|
import { nodeTypeValidForApp } from '@/stores/appModeStore'
|
|
import type { WidgetState } from '@/stores/widgetValueStore'
|
|
import {
|
|
stripGraphPrefix,
|
|
useWidgetValueStore
|
|
} from '@/stores/widgetValueStore'
|
|
import { usePromotionStore } from '@/stores/promotionStore'
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
|
import type {
|
|
LinkedUpstreamInfo,
|
|
SimplifiedWidget,
|
|
WidgetValue
|
|
} from '@/types/simplifiedWidget'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
|
|
import { app } from '@/scripts/app'
|
|
|
|
import InputSlot from './InputSlot.vue'
|
|
|
|
interface NodeWidgetsProps {
|
|
nodeData?: VueNodeData
|
|
}
|
|
|
|
const { nodeData } = defineProps<NodeWidgetsProps>()
|
|
|
|
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
|
useCanvasInteractions()
|
|
const { isSelectInputsMode } = useAppMode()
|
|
const canvasStore = useCanvasStore()
|
|
const { bringNodeToFront } = useNodeZIndex()
|
|
const promotionStore = usePromotionStore()
|
|
const executionErrorStore = useExecutionErrorStore()
|
|
const missingModelStore = useMissingModelStore()
|
|
|
|
function handleWidgetPointerEvent(event: PointerEvent) {
|
|
if (shouldHandleNodePointerEvents.value) return
|
|
event.stopPropagation()
|
|
forwardEventToCanvas(event)
|
|
}
|
|
|
|
function handleBringToFront() {
|
|
if (nodeData?.id != null) {
|
|
bringNodeToFront(String(nodeData.id))
|
|
}
|
|
}
|
|
|
|
const { handleNodeRightClick } = useNodeEventHandlers()
|
|
|
|
// Error boundary implementation
|
|
const renderError = ref<string | null>(null)
|
|
|
|
const { toastErrorHandler } = useErrorHandling()
|
|
|
|
onErrorCaptured((error) => {
|
|
renderError.value = error.message
|
|
toastErrorHandler(error)
|
|
return false
|
|
})
|
|
|
|
const canSelectInputs = computed(
|
|
() =>
|
|
isSelectInputsMode.value &&
|
|
nodeData?.mode === LGraphEventMode.ALWAYS &&
|
|
nodeTypeValidForApp(nodeData.type) &&
|
|
!nodeData.hasErrors
|
|
)
|
|
const nodeType = computed(() => nodeData?.type || '')
|
|
const settingStore = useSettingStore()
|
|
const showAdvanced = computed(
|
|
() =>
|
|
nodeData?.showAdvanced ||
|
|
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
|
|
)
|
|
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
|
nodeType.value
|
|
)
|
|
const widgetValueStore = useWidgetValueStore()
|
|
|
|
function createWidgetUpdateHandler(
|
|
widgetState: WidgetState | undefined,
|
|
widget: SafeWidgetData,
|
|
nodeExecId: string,
|
|
widgetOptions: IWidgetOptions | Record<string, never>
|
|
): (newValue: WidgetValue) => void {
|
|
return (newValue: WidgetValue) => {
|
|
if (widgetState) widgetState.value = newValue
|
|
widget.callback?.(newValue)
|
|
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
|
|
executionErrorStore.clearWidgetRelatedErrors(
|
|
effectiveExecId,
|
|
widget.slotName ?? widget.name,
|
|
widget.name,
|
|
newValue,
|
|
{ min: widgetOptions?.min, max: widgetOptions?.max }
|
|
)
|
|
}
|
|
}
|
|
|
|
interface ProcessedWidget {
|
|
advanced: boolean
|
|
handleContextMenu: (e: PointerEvent) => void
|
|
hasLayoutSize: boolean
|
|
hasError: boolean
|
|
hidden: boolean
|
|
id: string
|
|
name: string
|
|
renderKey: string
|
|
simplified: SimplifiedWidget
|
|
tooltipConfig: TooltipOptions
|
|
type: string
|
|
updateHandler: (value: WidgetValue) => void
|
|
value: WidgetValue
|
|
vueComponent: Component
|
|
slotMetadata?: WidgetSlotMetadata
|
|
}
|
|
|
|
function hasWidgetError(
|
|
widget: SafeWidgetData,
|
|
nodeExecId: string,
|
|
nodeErrors: { errors: { extra_info?: { input_name?: string } }[] } | undefined
|
|
): boolean {
|
|
const errors = widget.sourceExecutionId
|
|
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
|
|
: nodeErrors?.errors
|
|
const inputName = widget.slotName ?? widget.name
|
|
return (
|
|
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
|
|
missingModelStore.isWidgetMissingModel(
|
|
widget.sourceExecutionId ?? nodeExecId,
|
|
widget.name
|
|
)
|
|
)
|
|
}
|
|
|
|
function getWidgetIdentity(
|
|
widget: SafeWidgetData,
|
|
nodeId: string | number | undefined,
|
|
index: number
|
|
): {
|
|
dedupeIdentity?: string
|
|
renderKey: string
|
|
} {
|
|
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
|
const storeWidgetName = widget.storeName ?? widget.name
|
|
const slotNameForIdentity = widget.slotName ?? widget.name
|
|
const stableIdentityRoot = rawWidgetId
|
|
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
|
: widget.sourceExecutionId
|
|
? `exec:${widget.sourceExecutionId}`
|
|
: undefined
|
|
|
|
const dedupeIdentity = stableIdentityRoot
|
|
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
|
|
: undefined
|
|
const renderKey =
|
|
dedupeIdentity ??
|
|
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
|
|
|
|
return {
|
|
dedupeIdentity,
|
|
renderKey
|
|
}
|
|
}
|
|
|
|
function isWidgetVisible(options: IWidgetOptions): boolean {
|
|
const hidden = options.hidden ?? false
|
|
const advanced = options.advanced ?? false
|
|
return !hidden && (!advanced || showAdvanced.value)
|
|
}
|
|
|
|
const processedWidgets = computed((): ProcessedWidget[] => {
|
|
if (!nodeData?.widgets) return []
|
|
|
|
// nodeData.id is the local node ID; subgraph nodes need the full execution
|
|
// path (e.g. "65:63") to match keys in lastNodeErrors.
|
|
const nodeExecId = app.isGraphReady
|
|
? getExecutionIdFromNodeData(app.rootGraph, nodeData)
|
|
: String(nodeData.id ?? '')
|
|
|
|
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
|
|
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
|
|
|
const nodeId = nodeData.id
|
|
const { widgets } = nodeData
|
|
const result: ProcessedWidget[] = []
|
|
const uniqueWidgets: Array<{
|
|
widget: SafeWidgetData
|
|
identity: ReturnType<typeof getWidgetIdentity>
|
|
mergedOptions: IWidgetOptions
|
|
widgetState: WidgetState | undefined
|
|
isVisible: boolean
|
|
}> = []
|
|
const dedupeIndexByIdentity = new Map<string, number>()
|
|
|
|
for (const [index, widget] of widgets.entries()) {
|
|
if (!shouldRenderAsVue(widget)) continue
|
|
|
|
const identity = getWidgetIdentity(widget, nodeId, index)
|
|
const storeWidgetName = widget.storeName ?? widget.name
|
|
const bareWidgetId = String(
|
|
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
|
)
|
|
const widgetState = graphId
|
|
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
|
|
: undefined
|
|
const mergedOptions: IWidgetOptions = {
|
|
...(widget.options ?? {}),
|
|
...(widgetState?.options ?? {})
|
|
}
|
|
const visible = isWidgetVisible(mergedOptions)
|
|
if (!identity.dedupeIdentity) {
|
|
uniqueWidgets.push({
|
|
widget,
|
|
identity,
|
|
mergedOptions,
|
|
widgetState,
|
|
isVisible: visible
|
|
})
|
|
continue
|
|
}
|
|
|
|
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
|
|
if (existingIndex === undefined) {
|
|
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
|
|
uniqueWidgets.push({
|
|
widget,
|
|
identity,
|
|
mergedOptions,
|
|
widgetState,
|
|
isVisible: visible
|
|
})
|
|
continue
|
|
}
|
|
|
|
const existingWidget = uniqueWidgets[existingIndex]
|
|
if (existingWidget && !existingWidget.isVisible && visible) {
|
|
uniqueWidgets[existingIndex] = {
|
|
widget,
|
|
identity,
|
|
mergedOptions,
|
|
widgetState,
|
|
isVisible: true
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const {
|
|
widget,
|
|
mergedOptions,
|
|
widgetState,
|
|
identity: { renderKey }
|
|
} of uniqueWidgets) {
|
|
const hostNodeId = String(nodeId ?? '')
|
|
const bareWidgetId = String(
|
|
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
|
|
)
|
|
const promotionSourceNodeId = widget.storeName
|
|
? String(bareWidgetId)
|
|
: undefined
|
|
|
|
const vueComponent =
|
|
getComponent(widget.type) ||
|
|
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
|
|
|
|
const { slotMetadata } = widget
|
|
|
|
// Get value from store (falls back to undefined if not registered)
|
|
const value = widgetState?.value as WidgetValue
|
|
|
|
// Build options from store state, with disabled override for
|
|
// slot-linked widgets or widgets with disabled state (e.g. display-only)
|
|
const isDisabled = slotMetadata?.linked || widgetState?.disabled
|
|
const widgetOptions = isDisabled
|
|
? { ...mergedOptions, disabled: true }
|
|
: mergedOptions
|
|
|
|
const borderStyle =
|
|
graphId &&
|
|
promotionStore.isPromotedByAny(graphId, {
|
|
sourceNodeId: hostNodeId,
|
|
sourceWidgetName: widget.storeName ?? widget.name,
|
|
disambiguatingSourceNodeId: promotionSourceNodeId
|
|
})
|
|
? 'ring ring-component-node-widget-promoted'
|
|
: mergedOptions.advanced
|
|
? 'ring ring-component-node-widget-advanced'
|
|
: undefined
|
|
|
|
const linkedUpstream: LinkedUpstreamInfo | undefined =
|
|
slotMetadata?.linked && slotMetadata.originNodeId
|
|
? {
|
|
nodeId: slotMetadata.originNodeId,
|
|
outputName: slotMetadata.originOutputName
|
|
}
|
|
: undefined
|
|
|
|
const simplified: SimplifiedWidget = {
|
|
name: widget.name,
|
|
type: widget.type,
|
|
value,
|
|
borderStyle,
|
|
callback: widget.callback,
|
|
controlWidget: widget.controlWidget,
|
|
label: widget.promotedLabel ?? widgetState?.label,
|
|
linkedUpstream,
|
|
options: widgetOptions,
|
|
spec: widget.spec
|
|
}
|
|
|
|
const updateHandler = createWidgetUpdateHandler(
|
|
widgetState,
|
|
widget,
|
|
nodeExecId,
|
|
widgetOptions
|
|
)
|
|
|
|
const tooltipText = getWidgetTooltip(widget)
|
|
const tooltipConfig = createTooltipConfig(tooltipText)
|
|
const handleContextMenu = (e: PointerEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
handleNodeRightClick(e, nodeId)
|
|
showNodeOptions(
|
|
e,
|
|
widget.name,
|
|
widget.nodeId !== undefined
|
|
? String(stripGraphPrefix(widget.nodeId))
|
|
: undefined
|
|
)
|
|
}
|
|
|
|
result.push({
|
|
advanced: mergedOptions.advanced ?? false,
|
|
handleContextMenu,
|
|
hasLayoutSize: widget.hasLayoutSize ?? false,
|
|
hasError: hasWidgetError(widget, nodeExecId, nodeErrors),
|
|
hidden: mergedOptions.hidden ?? false,
|
|
id: String(bareWidgetId),
|
|
name: widget.name,
|
|
renderKey,
|
|
type: widget.type,
|
|
vueComponent,
|
|
simplified,
|
|
value,
|
|
updateHandler,
|
|
tooltipConfig,
|
|
slotMetadata
|
|
})
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
const gridTemplateRows = computed((): string => {
|
|
// Use processedWidgets directly since it already has store-based hidden/advanced
|
|
return toValue(processedWidgets)
|
|
.filter((w) => !w.hidden && (!w.advanced || showAdvanced.value))
|
|
.map((w) =>
|
|
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
|
|
)
|
|
.join(' ')
|
|
})
|
|
</script>
|