Files
ComfyUI_frontend/src/composables/graph/useGraphNodeManager.ts
Arthur R Longbottom 657ae6a6c3 fix: subgraph promoted widget input label rename (#10195)
## 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>
2026-03-23 14:33:03 -07:00

865 lines
27 KiB
TypeScript

/**
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import type {
LGraph,
LGraphBadge,
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: string
originOutputName?: string
}
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
nodeId?: NodeId
storeNodeId?: NodeId
name: string
storeName?: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Original LiteGraph widget name used for slot metadata matching.
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
executing: boolean
id: NodeId
mode: number
selected: boolean
title: string
type: string
apiNode?: boolean
badges?: (LGraphBadge | (() => LGraphBadge))[]
bgcolor?: string
color?: string
flags?: {
collapsed?: boolean
ghost?: boolean
pinned?: boolean
}
hasErrors?: boolean
inputs?: INodeInputSlot[]
outputs?: INodeOutputSlot[]
resizable?: boolean
shape?: number
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<string, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
}
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
if (!isPromotedWidgetView(widget)) return false
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
if (!sourceWidget) return false
const innerWidget = sourceWidget.widget
return (
('element' in innerWidget && !!innerWidget.element) ||
('component' in innerWidget && !!innerWidget.component)
)
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
)
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
interface SharedWidgetEnhancements {
controlWidget?: SafeControlWidget
spec?: InputSpec
}
function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
} | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName,
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
displayName: widget.name,
promotedSource: null
}
}
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
const promotedSource =
matchedInput?._widget === widget
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
: directSource
return {
displayName,
promotedSource
}
}
return function (widget) {
try {
const { displayName, promotedSource } =
resolvePromotedWidgetIdentity(widget)
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo =
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const isPromotedPseudoWidget =
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const resolvedSourceResult =
isPromotedWidgetView(widget) && promotedSource
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
)
: null
const resolvedSource =
resolvedSourceResult?.status === 'resolved'
? resolvedSourceResult.resolved
: undefined
const sourceWidget = resolvedSource?.widget
const sourceNode = resolvedSource?.node
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(
sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId
)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const storeName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = storeName ?? displayName
return {
nodeId,
storeNodeId: nodeId,
name,
storeName,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
options: isPromotedPseudoWidget
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
canvasOnly: true
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
// For promoted widgets, name is sourceWidgetName while widget.name
// is the subgraph input slot name — store the slot name for lookups.
slotName: name !== widget.name ? widget.name : undefined,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
}
}
}
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: string | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
if (link) {
originNodeId = String(link.origin_id)
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
'widgets'
)
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
if (existingWidgetsDescriptor?.get) {
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
// Preserve it but sync results into a reactive array for Vue.
const originalGetter = existingWidgetsDescriptor.get
Object.defineProperty(node, 'widgets', {
get() {
const current: IBaseWidget[] = originalGetter.call(node) ?? []
if (
current.length !== reactiveWidgets.length ||
current.some((w, i) => w !== reactiveWidgets[i])
) {
reactiveWidgets.splice(0, reactiveWidgets.length, ...current)
}
return reactiveWidgets
},
set: existingWidgetsDescriptor.set ?? (() => {}),
configurable: true,
enumerable: true
})
} else {
Object.defineProperty(node, 'widgets', {
get() {
return reactiveWidgets
},
set(v) {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
},
configurable: true,
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
return reactiveInputs
},
set(v) {
reactiveInputs.splice(0, reactiveInputs.length, ...v)
},
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
return reactiveOutputs
},
set(v) {
reactiveOutputs.splice(0, reactiveOutputs.length, ...v)
},
configurable: true,
enumerable: true
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
const nodeType =
node.type ||
node.constructor?.comfyClass ||
node.constructor?.title ||
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: String(node.id),
title: typeof node.title === 'string' ? node.title : '',
type: nodeType,
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
subgraphId,
apiNode,
badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: reactiveOutputs,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
resizable: node.resizable,
shape: node.shape,
showAdvanced: node.showAdvanced
}
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<string, LGraphNode>()
const refreshNodeSlots = (nodeId: string) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
}
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
vueNodeData.delete(id)
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = String(node.id)
// Store non-reactive reference
nodeRefs.set(id, node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
}
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: nodePosition,
size: nodeSize,
zIndex: node.order || 0,
visible: true
})
}
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
}
}
/**
* Handles node removal from the graph - cleans up all references
*/
const handleNodeRemoved = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
// Call original callback if provided
if (originalCallback) {
originalCallback(node)
}
}
/**
* Creates cleanup function for event listeners and state
*/
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
originalOnTrigger: ((event: LGraphTriggerEvent) => void) | undefined
) => {
return () => {
// Restore original callbacks
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
/**
* Sets up event listeners - now simplified with extracted handlers
*/
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
const originalOnNodeRemoved = graph.onNodeRemoved
const originalOnTrigger = graph.onTrigger
// Set up graph event handlers
graph.onNodeAdded = (node: LGraphNode) => {
handleNodeAdded(node, originalOnNodeAdded)
}
graph.onNodeRemoved = (node: LGraphNode) => {
handleNodeRemoved(node, originalOnNodeRemoved)
}
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
'node:property:changed': (propertyEvent) => {
const nodeId = String(propertyEvent.nodeId)
const currentData = vueNodeData.get(nodeId)
if (currentData) {
switch (propertyEvent.property) {
case 'title':
vueNodeData.set(nodeId, {
...currentData,
title: String(propertyEvent.newValue)
})
break
case 'has_errors':
vueNodeData.set(nodeId, {
...currentData,
hasErrors: Boolean(propertyEvent.newValue)
})
break
case 'flags.collapsed':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(propertyEvent.newValue)
}
})
break
case 'flags.ghost':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
ghost: Boolean(propertyEvent.newValue)
}
})
break
case 'flags.pinned':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
pinned: Boolean(propertyEvent.newValue)
}
})
break
case 'mode':
vueNodeData.set(nodeId, {
...currentData,
mode:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: 0
})
break
case 'color':
vueNodeData.set(nodeId, {
...currentData,
color:
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
break
case 'bgcolor':
vueNodeData.set(nodeId, {
...currentData,
bgcolor:
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
break
case 'shape':
vueNodeData.set(nodeId, {
...currentData,
shape:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: undefined
})
break
case 'showAdvanced':
vueNodeData.set(nodeId, {
...currentData,
showAdvanced: Boolean(propertyEvent.newValue)
})
break
}
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(String(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
const nodeId = String(slotLabelEvent.nodeId)
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}
graph.onTrigger = (event: LGraphTriggerEvent) => {
switch (event.type) {
case 'node:property:changed':
triggerHandlers['node:property:changed'](event)
break
case 'node:slot-errors:changed':
triggerHandlers['node:slot-errors:changed'](event)
break
case 'node:slot-links:changed':
triggerHandlers['node:slot-links:changed'](event)
break
case 'node:slot-label:changed':
triggerHandlers['node:slot-label:changed'](event)
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state
syncWithGraph()
// Return cleanup function
return createCleanupFunction(
originalOnNodeAdded || undefined,
originalOnNodeRemoved || undefined,
originalOnTrigger || undefined
)
}
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {
graph.onNodeAdded(node)
}
})
}
return {
vueNodeData,
getNode,
cleanup
}
}