mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +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>
865 lines
27 KiB
TypeScript
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
|
|
}
|
|
}
|