Files
ComfyUI_frontend/src/extensions/core/groupNode.ts
Christian Byrne 4a3bd39650 [feat] Restore group node conversion menu with deprecated label (#4967)
## Summary
- Partially reverts commit c84218d6 to restore group node functionality
in context menus
- Adds "(Deprecated)" label to indicate the feature is deprecated
- Fixes TypeError when right-clicking on group nodes
- Re-enables tests that were disabled when the feature was removed

## Changes
1. **Restored context menu options** - Added back "Convert to Group Node
(Deprecated)" and "Manage Group Nodes" menu items
2. **Fixed null reference error** - Added null-safe operator to prevent
errors when right-clicking group nodes
3. **Re-enabled tests** - Restored 7 tests that were disabled in commit
586f8824

## Test plan
- [x] Right-click on canvas → verify "Convert to Group Node
(Deprecated)" appears
- [x] Right-click on nodes → verify the same menu option appears
- [x] Select multiple nodes and use the menu option → verify conversion
works
- [x] Right-click on group nodes → verify no errors occur
- [x] Run browser tests → verify all re-enabled tests pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-4967-feat-Restore-group-node-conversion-menu-with-deprecated-label-24e6d73d36508149a6f2dbef47223e94)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-13 15:13:18 -07:00

1826 lines
58 KiB
TypeScript

import { t } from '@/i18n'
import { type NodeId } from '@/lib/litegraph/src/LGraphNode'
import {
type ExecutableLGraphNode,
type ExecutionId,
LGraphCanvas,
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import {
ComfyLink,
ComfyNode,
ComfyWorkflowJSON
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { ComfyExtension } from '@/types/comfy'
import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO'
import { GROUP } from '@/utils/executableGroupNodeDto'
import { deserialiseAndCreate, serialise } from '@/utils/vintageClipboard'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
import { ManageGroupDialog } from './groupNodeManage'
import { mergeIfValid } from './widgetInputs'
type GroupNodeWorkflowData = {
external: ComfyLink[]
links: ComfyLink[]
nodes: ComfyNode[]
}
// v1 Prefix + Separator: workflow/
// v2 Prefix + Separator: workflow> (ComfyUI_frontend v1.2.63)
const PREFIX = 'workflow'
const SEPARATOR = '>'
const Workflow = {
InUse: {
Free: 0,
Registered: 1,
InWorkflow: 2
},
// @ts-expect-error fixme ts strict error
isInUseGroupNode(name) {
const id = `${PREFIX}${SEPARATOR}${name}`
// Check if lready registered/in use in this workflow
// @ts-expect-error fixme ts strict error
if (app.graph.extra?.groupNodes?.[name]) {
if (app.graph.nodes.find((n) => n.type === id)) {
return Workflow.InUse.InWorkflow
} else {
return Workflow.InUse.Registered
}
}
return Workflow.InUse.Free
},
storeGroupNode(name: string, data: GroupNodeWorkflowData) {
let extra = app.graph.extra
if (!extra) app.graph.extra = extra = {}
let groupNodes = extra.groupNodes
if (!groupNodes) extra.groupNodes = groupNodes = {}
// @ts-expect-error fixme ts strict error
groupNodes[name] = data
}
}
class GroupNodeBuilder {
nodes: LGraphNode[]
// @ts-expect-error fixme ts strict error
nodeData: GroupNodeWorkflowData
constructor(nodes: LGraphNode[]) {
this.nodes = nodes
}
async build() {
const name = await this.getName()
if (!name) return
// Sort the nodes so they are in execution order
// this allows for widgets to be in the correct order when reconstructing
this.sortNodes()
this.nodeData = this.getNodeData()
Workflow.storeGroupNode(name, this.nodeData)
return { name, nodeData: this.nodeData }
}
async getName() {
const name = await useDialogService().prompt({
title: t('groupNode.create'),
message: t('groupNode.enterName'),
defaultValue: ''
})
if (!name) return
const used = Workflow.isInUseGroupNode(name)
switch (used) {
case Workflow.InUse.InWorkflow:
useToastStore().addAlert(
'An in use group node with this name already exists embedded in this workflow, please remove any instances or use a new name.'
)
return
case Workflow.InUse.Registered:
if (
!confirm(
'A group node with this name already exists embedded in this workflow, are you sure you want to overwrite it?'
)
) {
return
}
break
}
return name
}
sortNodes() {
// Gets the builders nodes in graph execution order
const nodesInOrder = app.graph.computeExecutionOrder(false)
this.nodes = this.nodes
.map((node) => ({ index: nodesInOrder.indexOf(node), node }))
// @ts-expect-error id might be string
.sort((a, b) => a.index - b.index || a.node.id - b.node.id)
.map(({ node }) => node)
}
getNodeData() {
// @ts-expect-error fixme ts strict error
const storeLinkTypes = (config) => {
// Store link types for dynamically typed nodes e.g. reroutes
for (const link of config.links) {
const origin = app.graph.getNodeById(link[4])
// @ts-expect-error fixme ts strict error
const type = origin.outputs[link[1]].type
link.push(type)
}
}
// @ts-expect-error fixme ts strict error
const storeExternalLinks = (config) => {
// Store any external links to the group in the config so when rebuilding we add extra slots
config.external = []
for (let i = 0; i < this.nodes.length; i++) {
const node = this.nodes[i]
if (!node.outputs?.length) continue
for (let slot = 0; slot < node.outputs.length; slot++) {
let hasExternal = false
const output = node.outputs[slot]
let type = output.type
if (!output.links?.length) continue
for (const l of output.links) {
const link = app.graph.links[l]
if (!link) continue
if (type === '*') type = link.type
if (!app.canvas.selected_nodes[link.target_id]) {
hasExternal = true
break
}
}
if (hasExternal) {
config.external.push([i, slot, type])
}
}
}
}
// Use the built in copyToClipboard function to generate the node data we need
try {
// @ts-expect-error fixme ts strict error
const serialised = serialise(this.nodes, app.canvas.graph)
const config = JSON.parse(serialised)
storeLinkTypes(config)
storeExternalLinks(config)
return config
} finally {
}
}
}
export class GroupNodeConfig {
name: string
nodeData: any
inputCount: number
oldToNewOutputMap: {}
newToOldOutputMap: {}
oldToNewInputMap: {}
oldToNewWidgetMap: {}
newToOldWidgetMap: {}
primitiveDefs: {}
widgetToPrimitive: {}
primitiveToWidget: {}
nodeInputs: {}
outputVisibility: any[]
// @ts-expect-error fixme ts strict error
nodeDef: ComfyNodeDef
// @ts-expect-error fixme ts strict error
inputs: any[]
// @ts-expect-error fixme ts strict error
linksFrom: {}
// @ts-expect-error fixme ts strict error
linksTo: {}
// @ts-expect-error fixme ts strict error
externalFrom: {}
// @ts-expect-error fixme ts strict error
constructor(name, nodeData) {
this.name = name
this.nodeData = nodeData
this.getLinks()
this.inputCount = 0
this.oldToNewOutputMap = {}
this.newToOldOutputMap = {}
this.oldToNewInputMap = {}
this.oldToNewWidgetMap = {}
this.newToOldWidgetMap = {}
this.primitiveDefs = {}
this.widgetToPrimitive = {}
this.primitiveToWidget = {}
this.nodeInputs = {}
this.outputVisibility = []
}
async registerType(source = PREFIX) {
this.nodeDef = {
output: [],
output_name: [],
output_is_list: [],
// @ts-expect-error Unused, doesn't exist
output_is_hidden: [],
name: source + SEPARATOR + this.name,
display_name: this.name,
category: 'group nodes' + (SEPARATOR + source),
input: { required: {} },
description: `Group node combining ${this.nodeData.nodes
// @ts-expect-error fixme ts strict error
.map((n) => n.type)
.join(', ')}`,
python_module: 'custom_nodes.' + this.name,
[GROUP]: this
}
this.inputs = []
const seenInputs = {}
const seenOutputs = {}
for (let i = 0; i < this.nodeData.nodes.length; i++) {
const node = this.nodeData.nodes[i]
node.index = i
this.processNode(node, seenInputs, seenOutputs)
}
for (const p of this.#convertedToProcess) {
// @ts-expect-error fixme ts strict error
p()
}
// @ts-expect-error fixme ts strict error
this.#convertedToProcess = null
await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef)
useNodeDefStore().addNodeDef(this.nodeDef)
}
getLinks() {
this.linksFrom = {}
this.linksTo = {}
this.externalFrom = {}
// Extract links for easy lookup
for (const l of this.nodeData.links) {
const [sourceNodeId, sourceNodeSlot, targetNodeId, targetNodeSlot] = l
// Skip links outside the copy config
if (sourceNodeId == null) continue
// @ts-expect-error fixme ts strict error
if (!this.linksFrom[sourceNodeId]) {
// @ts-expect-error fixme ts strict error
this.linksFrom[sourceNodeId] = {}
}
// @ts-expect-error fixme ts strict error
if (!this.linksFrom[sourceNodeId][sourceNodeSlot]) {
// @ts-expect-error fixme ts strict error
this.linksFrom[sourceNodeId][sourceNodeSlot] = []
}
// @ts-expect-error fixme ts strict error
this.linksFrom[sourceNodeId][sourceNodeSlot].push(l)
// @ts-expect-error fixme ts strict error
if (!this.linksTo[targetNodeId]) {
// @ts-expect-error fixme ts strict error
this.linksTo[targetNodeId] = {}
}
// @ts-expect-error fixme ts strict error
this.linksTo[targetNodeId][targetNodeSlot] = l
}
if (this.nodeData.external) {
for (const ext of this.nodeData.external) {
// @ts-expect-error fixme ts strict error
if (!this.externalFrom[ext[0]]) {
// @ts-expect-error fixme ts strict error
this.externalFrom[ext[0]] = { [ext[1]]: ext[2] }
} else {
// @ts-expect-error fixme ts strict error
this.externalFrom[ext[0]][ext[1]] = ext[2]
}
}
}
}
// @ts-expect-error fixme ts strict error
processNode(node, seenInputs, seenOutputs) {
const def = this.getNodeDef(node)
if (!def) return
const inputs = { ...def.input?.required, ...def.input?.optional }
this.inputs.push(this.processNodeInputs(node, seenInputs, inputs))
if (def.output?.length) this.processNodeOutputs(node, seenOutputs, def)
}
// @ts-expect-error fixme ts strict error
getNodeDef(node) {
// @ts-expect-error fixme ts strict error
const def = globalDefs[node.type]
if (def) return def
// @ts-expect-error fixme ts strict error
const linksFrom = this.linksFrom[node.index]
if (node.type === 'PrimitiveNode') {
// Skip as its not linked
if (!linksFrom) return
let type = linksFrom['0'][0][5]
if (type === 'COMBO') {
// Use the array items
const source = node.outputs[0].widget.name
const fromTypeName = this.nodeData.nodes[linksFrom['0'][0][2]].type
// @ts-expect-error fixme ts strict error
const fromType = globalDefs[fromTypeName]
const input =
fromType.input.required[source] ?? fromType.input.optional[source]
type = input[0]
}
// @ts-expect-error fixme ts strict error
const def = (this.primitiveDefs[node.index] = {
input: {
required: {
value: [type, {}]
}
},
output: [type],
output_name: [],
output_is_list: []
})
return def
} else if (node.type === 'Reroute') {
// @ts-expect-error fixme ts strict error
const linksTo = this.linksTo[node.index]
// @ts-expect-error fixme ts strict error
if (linksTo && linksFrom && !this.externalFrom[node.index]?.[0]) {
// Being used internally
return null
}
let config = {}
let rerouteType = '*'
if (linksFrom) {
for (const [, , id, slot] of linksFrom['0']) {
const node = this.nodeData.nodes[id]
const input = node.inputs[slot]
if (rerouteType === '*') {
rerouteType = input.type
}
if (input.widget) {
// @ts-expect-error fixme ts strict error
const targetDef = globalDefs[node.type]
const targetWidget =
targetDef.input.required[input.widget.name] ??
targetDef.input.optional[input.widget.name]
const widget = [targetWidget[0], config]
const res = mergeIfValid(
{
// @ts-expect-error fixme ts strict error
widget
},
targetWidget,
false,
null,
widget
)
config = res?.customConfig ?? config
}
}
} else if (linksTo) {
const [id, slot] = linksTo['0']
rerouteType = this.nodeData.nodes[id].outputs[slot].type
} else {
// Reroute used as a pipe
for (const l of this.nodeData.links) {
if (l[2] === node.index) {
rerouteType = l[5]
break
}
}
if (rerouteType === '*') {
// Check for an external link
// @ts-expect-error fixme ts strict error
const t = this.externalFrom[node.index]?.[0]
if (t) {
rerouteType = t
}
}
}
// @ts-expect-error
config.forceInput = true
return {
input: {
required: {
[rerouteType]: [rerouteType, config]
}
},
output: [rerouteType],
output_name: [],
output_is_list: []
}
}
console.warn(
'Skipping virtual node ' +
node.type +
' when building group node ' +
this.name
)
}
// @ts-expect-error fixme ts strict error
getInputConfig(node, inputName, seenInputs, config, extra?) {
const customConfig = this.nodeData.config?.[node.index]?.input?.[inputName]
let name =
customConfig?.name ??
// @ts-expect-error fixme ts strict error
node.inputs?.find((inp) => inp.name === inputName)?.label ??
inputName
let key = name
let prefix = ''
// Special handling for primitive to include the title if it is set rather than just "value"
if ((node.type === 'PrimitiveNode' && node.title) || name in seenInputs) {
prefix = `${node.title ?? node.type} `
key = name = `${prefix}${inputName}`
if (name in seenInputs) {
name = `${prefix}${seenInputs[name]} ${inputName}`
}
}
seenInputs[key] = (seenInputs[key] ?? 1) + 1
if (inputName === 'seed' || inputName === 'noise_seed') {
if (!extra) extra = {}
extra.control_after_generate = `${prefix}control_after_generate`
}
if (config[0] === 'IMAGEUPLOAD') {
if (!extra) extra = {}
extra.widget =
// @ts-expect-error fixme ts strict error
this.oldToNewWidgetMap[node.index]?.[config[1]?.widget ?? 'image'] ??
'image'
}
if (extra) {
config = [config[0], { ...config[1], ...extra }]
}
return { name, config, customConfig }
}
// @ts-expect-error fixme ts strict error
processWidgetInputs(inputs, node, inputNames, seenInputs) {
const slots = []
const converted = new Map()
// @ts-expect-error fixme ts strict error
const widgetMap = (this.oldToNewWidgetMap[node.index] = {})
for (const inputName of inputNames) {
if (useWidgetStore().inputIsWidget(inputs[inputName])) {
const convertedIndex = node.inputs?.findIndex(
// @ts-expect-error fixme ts strict error
(inp) => inp.name === inputName && inp.widget?.name === inputName
)
if (convertedIndex > -1) {
// This widget has been converted to a widget
// We need to store this in the correct position so link ids line up
converted.set(convertedIndex, inputName)
// @ts-expect-error fixme ts strict error
widgetMap[inputName] = null
} else {
// Normal widget
const { name, config } = this.getInputConfig(
node,
inputName,
seenInputs,
inputs[inputName]
)
// @ts-expect-error fixme ts strict error
this.nodeDef.input.required[name] = config
// @ts-expect-error fixme ts strict error
widgetMap[inputName] = name
// @ts-expect-error fixme ts strict error
this.newToOldWidgetMap[name] = { node, inputName }
}
} else {
// Normal input
slots.push(inputName)
}
}
return { converted, slots }
}
// @ts-expect-error fixme ts strict error
checkPrimitiveConnection(link, inputName, inputs) {
const sourceNode = this.nodeData.nodes[link[0]]
if (sourceNode.type === 'PrimitiveNode') {
// Merge link configurations
const [sourceNodeId, _, targetNodeId, __] = link
// @ts-expect-error fixme ts strict error
const primitiveDef = this.primitiveDefs[sourceNodeId]
const targetWidget = inputs[inputName]
const primitiveConfig = primitiveDef.input.required.value
const output = { widget: primitiveConfig }
const config = mergeIfValid(
// @ts-expect-error invalid slot type
output,
targetWidget,
false,
null,
primitiveConfig
)
primitiveConfig[1] =
config?.customConfig ?? inputs[inputName][1]
? { ...inputs[inputName][1] }
: {}
// @ts-expect-error fixme ts strict error
let name = this.oldToNewWidgetMap[sourceNodeId]['value']
name = name.substr(0, name.length - 6)
primitiveConfig[1].control_after_generate = true
primitiveConfig[1].control_prefix = name
// @ts-expect-error fixme ts strict error
let toPrimitive = this.widgetToPrimitive[targetNodeId]
if (!toPrimitive) {
// @ts-expect-error fixme ts strict error
toPrimitive = this.widgetToPrimitive[targetNodeId] = {}
}
if (toPrimitive[inputName]) {
toPrimitive[inputName].push(sourceNodeId)
}
toPrimitive[inputName] = sourceNodeId
// @ts-expect-error fixme ts strict error
let toWidget = this.primitiveToWidget[sourceNodeId]
if (!toWidget) {
// @ts-expect-error fixme ts strict error
toWidget = this.primitiveToWidget[sourceNodeId] = []
}
toWidget.push({ nodeId: targetNodeId, inputName })
}
}
// @ts-expect-error fixme ts strict error
processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs) {
// @ts-expect-error fixme ts strict error
this.nodeInputs[node.index] = {}
for (let i = 0; i < slots.length; i++) {
const inputName = slots[i]
if (linksTo[i]) {
this.checkPrimitiveConnection(linksTo[i], inputName, inputs)
// This input is linked so we can skip it
continue
}
const { name, config, customConfig } = this.getInputConfig(
node,
inputName,
seenInputs,
inputs[inputName]
)
// @ts-expect-error fixme ts strict error
this.nodeInputs[node.index][inputName] = name
if (customConfig?.visible === false) continue
// @ts-expect-error fixme ts strict error
this.nodeDef.input.required[name] = config
inputMap[i] = this.inputCount++
}
}
processConvertedWidgets(
// @ts-expect-error fixme ts strict error
inputs,
// @ts-expect-error fixme ts strict error
node,
// @ts-expect-error fixme ts strict error
slots,
// @ts-expect-error fixme ts strict error
converted,
// @ts-expect-error fixme ts strict error
linksTo,
// @ts-expect-error fixme ts strict error
inputMap,
// @ts-expect-error fixme ts strict error
seenInputs
) {
// Add converted widgets sorted into their index order (ordered as they were converted) so link ids match up
const convertedSlots = [...converted.keys()]
.sort()
.map((k) => converted.get(k))
for (let i = 0; i < convertedSlots.length; i++) {
const inputName = convertedSlots[i]
if (linksTo[slots.length + i]) {
this.checkPrimitiveConnection(
linksTo[slots.length + i],
inputName,
inputs
)
// This input is linked so we can skip it
continue
}
const { name, config } = this.getInputConfig(
node,
inputName,
seenInputs,
inputs[inputName],
{
defaultInput: true
}
)
// @ts-expect-error fixme ts strict error
this.nodeDef.input.required[name] = config
// @ts-expect-error fixme ts strict error
this.newToOldWidgetMap[name] = { node, inputName }
// @ts-expect-error fixme ts strict error
if (!this.oldToNewWidgetMap[node.index]) {
// @ts-expect-error fixme ts strict error
this.oldToNewWidgetMap[node.index] = {}
}
// @ts-expect-error fixme ts strict error
this.oldToNewWidgetMap[node.index][inputName] = name
inputMap[slots.length + i] = this.inputCount++
}
}
#convertedToProcess = []
// @ts-expect-error fixme ts strict error
processNodeInputs(node, seenInputs, inputs) {
// @ts-expect-error fixme ts strict error
const inputMapping = []
const inputNames = Object.keys(inputs)
if (!inputNames.length) return
const { converted, slots } = this.processWidgetInputs(
inputs,
node,
inputNames,
seenInputs
)
// @ts-expect-error fixme ts strict error
const linksTo = this.linksTo[node.index] ?? {}
// @ts-expect-error fixme ts strict error
const inputMap = (this.oldToNewInputMap[node.index] = {})
this.processInputSlots(inputs, node, slots, linksTo, inputMap, seenInputs)
// Converted inputs have to be processed after all other nodes as they'll be at the end of the list
// @ts-expect-error fixme ts strict error
this.#convertedToProcess.push(() =>
this.processConvertedWidgets(
inputs,
node,
slots,
converted,
linksTo,
inputMap,
seenInputs
)
)
// @ts-expect-error fixme ts strict error
return inputMapping
}
// @ts-expect-error fixme ts strict error
processNodeOutputs(node, seenOutputs, def) {
// @ts-expect-error fixme ts strict error
const oldToNew = (this.oldToNewOutputMap[node.index] = {})
// Add outputs
for (let outputId = 0; outputId < def.output.length; outputId++) {
// @ts-expect-error fixme ts strict error
const linksFrom = this.linksFrom[node.index]
// If this output is linked internally we flag it to hide
const hasLink =
// @ts-expect-error fixme ts strict error
linksFrom?.[outputId] && !this.externalFrom[node.index]?.[outputId]
const customConfig =
this.nodeData.config?.[node.index]?.output?.[outputId]
const visible = customConfig?.visible ?? !hasLink
this.outputVisibility.push(visible)
if (!visible) {
continue
}
// @ts-expect-error fixme ts strict error
oldToNew[outputId] = this.nodeDef.output.length
// @ts-expect-error fixme ts strict error
this.newToOldOutputMap[this.nodeDef.output.length] = {
node,
slot: outputId
}
// @ts-expect-error fixme ts strict error
this.nodeDef.output.push(def.output[outputId])
// @ts-expect-error fixme ts strict error
this.nodeDef.output_is_list.push(def.output_is_list[outputId])
let label = customConfig?.name
if (!label) {
label = def.output_name?.[outputId] ?? def.output[outputId]
// @ts-expect-error fixme ts strict error
const output = node.outputs.find((o) => o.name === label)
if (output?.label) {
label = output.label
}
}
let name = label
if (name in seenOutputs) {
const prefix = `${node.title ?? node.type} `
name = `${prefix}${label}`
if (name in seenOutputs) {
name = `${prefix}${node.index} ${label}`
}
}
seenOutputs[name] = 1
// @ts-expect-error fixme ts strict error
this.nodeDef.output_name.push(name)
}
}
// @ts-expect-error fixme ts strict error
static async registerFromWorkflow(groupNodes, missingNodeTypes) {
for (const g in groupNodes) {
const groupData = groupNodes[g]
let hasMissing = false
for (const n of groupData.nodes) {
// Find missing node types
if (!(n.type in LiteGraph.registered_node_types)) {
missingNodeTypes.push({
type: n.type,
hint: ` (In group node '${PREFIX}${SEPARATOR}${g}')`
})
missingNodeTypes.push({
type: `${PREFIX}${SEPARATOR}` + g,
action: {
text: 'Remove from workflow',
// @ts-expect-error fixme ts strict error
callback: (e) => {
delete groupNodes[g]
e.target.textContent = 'Removed'
e.target.style.pointerEvents = 'none'
e.target.style.opacity = 0.7
}
}
})
hasMissing = true
}
}
if (hasMissing) continue
const config = new GroupNodeConfig(g, groupData)
await config.registerType()
}
}
}
export class GroupNodeHandler {
node: LGraphNode
groupData: any
innerNodes: any
constructor(node: LGraphNode) {
this.node = node
this.groupData = node.constructor?.nodeData?.[GROUP]
this.node.setInnerNodes = (innerNodes) => {
this.innerNodes = innerNodes
for (
let innerNodeIndex = 0;
innerNodeIndex < this.innerNodes.length;
innerNodeIndex++
) {
const innerNode = this.innerNodes[innerNodeIndex]
innerNode.graph ??= this.node.graph
for (const w of innerNode.widgets ?? []) {
if (w.type === 'converted-widget') {
w.serializeValue = w.origSerializeValue
}
}
innerNode.index = innerNodeIndex
// @ts-expect-error fixme ts strict error
innerNode.getInputNode = (slot) => {
// Check if this input is internal or external
const externalSlot =
this.groupData.oldToNewInputMap[innerNode.index]?.[slot]
if (externalSlot != null) {
return this.node.getInputNode(externalSlot)
}
// Internal link
const innerLink = this.groupData.linksTo[innerNode.index]?.[slot]
if (!innerLink) return null
const inputNode = innerNodes[innerLink[0]]
// Primitives will already apply their values
if (inputNode.type === 'PrimitiveNode') return null
return inputNode
}
// @ts-expect-error fixme ts strict error
innerNode.getInputLink = (slot) => {
const externalSlot =
this.groupData.oldToNewInputMap[innerNode.index]?.[slot]
if (externalSlot != null) {
// The inner node is connected via the group node inputs
const linkId = this.node.inputs[externalSlot].link
// @ts-expect-error fixme ts strict error
let link = app.graph.links[linkId]
// Use the outer link, but update the target to the inner node
link = {
...link,
target_id: innerNode.id,
target_slot: +slot
}
return link
}
let link = this.groupData.linksTo[innerNode.index]?.[slot]
if (!link) return null
// Use the inner link, but update the origin node to be inner node id
link = {
origin_id: innerNodes[link[0]].id,
origin_slot: link[1],
target_id: innerNode.id,
target_slot: +slot
}
return link
}
}
}
this.node.updateLink = (link) => {
// Replace the group node reference with the internal node
// @ts-expect-error Can this be removed? Or replaced with: LLink.create(link.asSerialisable())
link = { ...link }
const output = this.groupData.newToOldOutputMap[link.origin_slot]
let innerNode = this.innerNodes[output.node.index]
let l
while (innerNode?.type === 'Reroute') {
l = innerNode.getInputLink(0)
innerNode = innerNode.getInputNode(0)
}
if (!innerNode) {
return null
}
if (l && GroupNodeHandler.isGroupNode(innerNode)) {
return innerNode.updateLink(l)
}
link.origin_id = innerNode.id
link.origin_slot = l?.origin_slot ?? output.slot
return link
}
/** @internal Used to flatten the subgraph before execution. Recursive; call with no args. */
this.node.getInnerNodes = (
computedNodeDtos: Map<ExecutionId, ExecutableLGraphNode>,
/** The path of subgraph node IDs. */
subgraphNodePath: readonly NodeId[] = [],
/** The list of nodes to add to. */
nodes: ExecutableLGraphNode[] = [],
/** The set of visited nodes. */
visited = new Set<LGraphNode>()
): ExecutableLGraphNode[] => {
if (visited.has(this.node))
throw new Error('RecursionError: while flattening subgraph')
visited.add(this.node)
if (!this.innerNodes) {
// @ts-expect-error fixme ts strict error
this.node.setInnerNodes(
// @ts-expect-error fixme ts strict error
this.groupData.nodeData.nodes.map((n, i) => {
const innerNode = LiteGraph.createNode(n.type)
// @ts-expect-error fixme ts strict error
innerNode.configure(n)
// @ts-expect-error fixme ts strict error
innerNode.id = `${this.node.id}:${i}`
// @ts-expect-error fixme ts strict error
innerNode.graph = this.node.graph
return innerNode
})
)
}
this.updateInnerWidgets()
const subgraphInstanceIdPath = [...subgraphNodePath, this.node.id]
// Assertion: Deprecated, does not matter.
const subgraphNode = (this.node.graph?.getNodeById(
subgraphNodePath.at(-1)
) ?? undefined) as SubgraphNode | undefined
for (const node of this.innerNodes) {
node.graph ??= this.node.graph
// Create minimal DTOs rather than cloning the node
const currentId = String(node.id)
node.id = currentId.split(':').at(-1)
const aVeryRealNode = new ExecutableGroupNodeChildDTO(
node,
subgraphInstanceIdPath,
computedNodeDtos,
subgraphNode
)
node.id = currentId
aVeryRealNode.groupNodeHandler = this
nodes.push(aVeryRealNode)
}
return nodes
}
// @ts-expect-error fixme ts strict error
this.node.recreate = async () => {
const id = this.node.id
const sz = this.node.size
// @ts-expect-error fixme ts strict error
const nodes = this.node.convertToNodes()
const groupNode = LiteGraph.createNode(this.node.type)
// @ts-expect-error fixme ts strict error
groupNode.id = id
// Reuse the existing nodes for this instance
// @ts-expect-error fixme ts strict error
groupNode.setInnerNodes(nodes)
// @ts-expect-error fixme ts strict error
groupNode[GROUP].populateWidgets()
// @ts-expect-error fixme ts strict error
app.graph.add(groupNode)
// @ts-expect-error fixme ts strict error
groupNode.setSize([
// @ts-expect-error fixme ts strict error
Math.max(groupNode.size[0], sz[0]),
// @ts-expect-error fixme ts strict error
Math.max(groupNode.size[1], sz[1])
])
// Remove all converted nodes and relink them
const builder = new GroupNodeBuilder(nodes)
const nodeData = builder.getNodeData()
// @ts-expect-error fixme ts strict error
groupNode[GROUP].groupData.nodeData.links = nodeData.links
// @ts-expect-error fixme ts strict error
groupNode[GROUP].replaceNodes(nodes)
return groupNode
}
// @ts-expect-error fixme ts strict error
this.node.convertToNodes = () => {
const addInnerNodes = () => {
// Clone the node data so we dont mutate it for other nodes
const c = { ...this.groupData.nodeData }
c.nodes = [...c.nodes]
// @ts-expect-error fixme ts strict error
const innerNodes = this.node.getInnerNodes()
let ids = []
for (let i = 0; i < c.nodes.length; i++) {
let id = innerNodes?.[i]?.id
// Use existing IDs if they are set on the inner nodes
// @ts-expect-error id can be string or number
if (id == null || isNaN(id)) {
// @ts-expect-error fixme ts strict error
id = undefined
} else {
ids.push(id)
}
c.nodes[i] = { ...c.nodes[i], id }
}
deserialiseAndCreate(JSON.stringify(c), app.canvas)
const [x, y] = this.node.pos
let top
let left
// Configure nodes with current widget data
const selectedIds = ids.length
? ids
: Object.keys(app.canvas.selected_nodes)
const newNodes = []
for (let i = 0; i < selectedIds.length; i++) {
const id = selectedIds[i]
const newNode = app.graph.getNodeById(id)
const innerNode = innerNodes[i]
newNodes.push(newNode)
// @ts-expect-error fixme ts strict error
if (left == null || newNode.pos[0] < left) {
// @ts-expect-error fixme ts strict error
left = newNode.pos[0]
}
// @ts-expect-error fixme ts strict error
if (top == null || newNode.pos[1] < top) {
// @ts-expect-error fixme ts strict error
top = newNode.pos[1]
}
// @ts-expect-error fixme ts strict error
if (!newNode.widgets) continue
// @ts-expect-error fixme ts strict error
const map = this.groupData.oldToNewWidgetMap[innerNode.index]
if (map) {
const widgets = Object.keys(map)
for (const oldName of widgets) {
const newName = map[oldName]
if (!newName) continue
// @ts-expect-error fixme ts strict error
const widgetIndex = this.node.widgets.findIndex(
(w) => w.name === newName
)
if (widgetIndex === -1) continue
// Populate the main and any linked widgets
if (innerNode.type === 'PrimitiveNode') {
// @ts-expect-error fixme ts strict error
for (let i = 0; i < newNode.widgets.length; i++) {
// @ts-expect-error fixme ts strict error
newNode.widgets[i].value =
// @ts-expect-error fixme ts strict error
this.node.widgets[widgetIndex + i].value
}
} else {
// @ts-expect-error fixme ts strict error
const outerWidget = this.node.widgets[widgetIndex]
// @ts-expect-error fixme ts strict error
const newWidget = newNode.widgets.find(
(w) => w.name === oldName
)
if (!newWidget) continue
newWidget.value = outerWidget.value
// @ts-expect-error fixme ts strict error
for (let w = 0; w < outerWidget.linkedWidgets?.length; w++) {
// @ts-expect-error fixme ts strict error
newWidget.linkedWidgets[w].value =
// @ts-expect-error fixme ts strict error
outerWidget.linkedWidgets[w].value
}
}
}
}
}
// Shift each node
for (const newNode of newNodes) {
// @ts-expect-error fixme ts strict error
newNode.pos[0] -= left - x
// @ts-expect-error fixme ts strict error
newNode.pos[1] -= top - y
}
return { newNodes, selectedIds }
}
// @ts-expect-error fixme ts strict error
const reconnectInputs = (selectedIds) => {
for (const innerNodeIndex in this.groupData.oldToNewInputMap) {
const id = selectedIds[innerNodeIndex]
const newNode = app.graph.getNodeById(id)
const map = this.groupData.oldToNewInputMap[innerNodeIndex]
for (const innerInputId in map) {
const groupSlotId = map[innerInputId]
if (groupSlotId == null) continue
const slot = node.inputs[groupSlotId]
if (slot.link == null) continue
const link = app.graph.links[slot.link]
if (!link) continue
// connect this node output to the input of another node
const originNode = app.graph.getNodeById(link.origin_id)
// @ts-expect-error fixme ts strict error
originNode.connect(link.origin_slot, newNode, +innerInputId)
}
}
}
// @ts-expect-error fixme ts strict error
const reconnectOutputs = (selectedIds) => {
for (
let groupOutputId = 0;
groupOutputId < node.outputs?.length;
groupOutputId++
) {
const output = node.outputs[groupOutputId]
if (!output.links) continue
const links = [...output.links]
for (const l of links) {
const slot = this.groupData.newToOldOutputMap[groupOutputId]
const link = app.graph.links[l]
const targetNode = app.graph.getNodeById(link.target_id)
const newNode = app.graph.getNodeById(selectedIds[slot.node.index])
// @ts-expect-error fixme ts strict error
newNode.connect(slot.slot, targetNode, link.target_slot)
}
}
}
app.canvas.emitBeforeChange()
try {
const { newNodes, selectedIds } = addInnerNodes()
reconnectInputs(selectedIds)
reconnectOutputs(selectedIds)
app.graph.remove(this.node)
return newNodes
} finally {
app.canvas.emitAfterChange()
}
}
const getExtraMenuOptions = this.node.getExtraMenuOptions
// @ts-expect-error Should pass patched return value getExtraMenuOptions
this.node.getExtraMenuOptions = function (_, options) {
// @ts-expect-error fixme ts strict error
getExtraMenuOptions?.apply(this, arguments)
let optionIndex = options.findIndex((o) => o?.content === 'Outputs')
if (optionIndex === -1) optionIndex = options.length
else optionIndex++
options.splice(
optionIndex,
0,
null,
{
content: 'Convert to nodes',
// @ts-expect-error
callback: () => {
// @ts-expect-error fixme ts strict error
return this.convertToNodes()
}
},
{
content: 'Manage Group Node',
callback: () => manageGroupNodes(this.type)
}
)
}
// Draw custom collapse icon to identity this as a group
const onDrawTitleBox = this.node.onDrawTitleBox
this.node.onDrawTitleBox = function (ctx, height) {
// @ts-expect-error fixme ts strict error
onDrawTitleBox?.apply(this, arguments)
const fill = ctx.fillStyle
ctx.beginPath()
ctx.rect(11, -height + 11, 2, 2)
ctx.rect(14, -height + 11, 2, 2)
ctx.rect(17, -height + 11, 2, 2)
ctx.rect(11, -height + 14, 2, 2)
ctx.rect(14, -height + 14, 2, 2)
ctx.rect(17, -height + 14, 2, 2)
ctx.rect(11, -height + 17, 2, 2)
ctx.rect(14, -height + 17, 2, 2)
ctx.rect(17, -height + 17, 2, 2)
ctx.fillStyle = this.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR
ctx.fill()
ctx.fillStyle = fill
}
// Draw progress label
const onDrawForeground = node.onDrawForeground
const groupData = this.groupData.nodeData
node.onDrawForeground = function (ctx) {
// @ts-expect-error fixme ts strict error
onDrawForeground?.apply?.(this, arguments)
const progressState = useExecutionStore().nodeProgressStates[this.id]
if (
progressState &&
progressState.state === 'running' &&
this.runningInternalNodeId !== null
) {
// @ts-expect-error fixme ts strict error
const n = groupData.nodes[this.runningInternalNodeId]
if (!n) return
const message = `Running ${n.title || n.type} (${this.runningInternalNodeId}/${groupData.nodes.length})`
ctx.save()
ctx.font = '12px sans-serif'
const sz = ctx.measureText(message)
ctx.fillStyle = node.boxcolor || LiteGraph.NODE_DEFAULT_BOXCOLOR
ctx.beginPath()
ctx.roundRect(
0,
-LiteGraph.NODE_TITLE_HEIGHT - 20,
sz.width + 12,
20,
5
)
ctx.fill()
ctx.fillStyle = '#fff'
ctx.fillText(message, 6, -LiteGraph.NODE_TITLE_HEIGHT - 6)
ctx.restore()
}
}
// Flag this node as needing to be reset
const onExecutionStart = this.node.onExecutionStart
this.node.onExecutionStart = function () {
// @ts-expect-error fixme ts strict error
this.resetExecution = true
// @ts-expect-error fixme ts strict error
return onExecutionStart?.apply(this, arguments)
}
const self = this
const onNodeCreated = this.node.onNodeCreated
this.node.onNodeCreated = function () {
if (!this.widgets) {
return
}
const config = self.groupData.nodeData.config
if (config) {
for (const n in config) {
const inputs = config[n]?.input
for (const w in inputs) {
if (inputs[w].visible !== false) continue
const widgetName = self.groupData.oldToNewWidgetMap[n][w]
const widget = this.widgets.find((w) => w.name === widgetName)
if (widget) {
widget.type = 'hidden'
widget.computeSize = () => [0, -4]
}
}
}
}
// @ts-expect-error fixme ts strict error
return onNodeCreated?.apply(this, arguments)
}
// @ts-expect-error fixme ts strict error
function handleEvent(type, getId, getEvent) {
// @ts-expect-error fixme ts strict error
const handler = ({ detail }) => {
const id = getId(detail)
if (!id) return
const node = app.graph.getNodeById(id)
if (node) return
// @ts-expect-error fixme ts strict error
const innerNodeIndex = this.innerNodes?.findIndex((n) => n.id == id)
if (innerNodeIndex > -1) {
// @ts-expect-error fixme ts strict error
this.node.runningInternalNodeId = innerNodeIndex
api.dispatchCustomEvent(
type,
// @ts-expect-error fixme ts strict error
getEvent(detail, `${this.node.id}`, this.node)
)
}
}
api.addEventListener(type, handler)
return handler
}
const executing = handleEvent.call(
this,
'executing',
// @ts-expect-error fixme ts strict error
(d) => d,
// @ts-expect-error fixme ts strict error
(_, id) => id
)
const executed = handleEvent.call(
this,
'executed',
// @ts-expect-error fixme ts strict error
(d) => d?.display_node || d?.node,
// @ts-expect-error fixme ts strict error
(d, id, node) => ({
...d,
node: id,
display_node: id,
merge: !node.resetExecution
})
)
const onRemoved = node.onRemoved
this.node.onRemoved = function () {
// @ts-expect-error fixme ts strict error
onRemoved?.apply(this, arguments)
// api.removeEventListener('progress_state', progress_state)
api.removeEventListener('executing', executing)
api.removeEventListener('executed', executed)
}
this.node.refreshComboInNode = (defs) => {
// Update combo widget options
for (const widgetName in this.groupData.newToOldWidgetMap) {
// @ts-expect-error fixme ts strict error
const widget = this.node.widgets.find((w) => w.name === widgetName)
if (widget?.type === 'combo') {
const old = this.groupData.newToOldWidgetMap[widgetName]
const def = defs[old.node.type]
const input =
def?.input?.required?.[old.inputName] ??
def?.input?.optional?.[old.inputName]
if (!input) continue
widget.options.values = input[0]
if (
old.inputName !== 'image' &&
// @ts-expect-error Widget values
!widget.options.values.includes(widget.value)
) {
// @ts-expect-error fixme ts strict error
widget.value = widget.options.values[0]
// @ts-expect-error fixme ts strict error
widget.callback(widget.value)
}
}
}
}
}
updateInnerWidgets() {
for (const newWidgetName in this.groupData.newToOldWidgetMap) {
// @ts-expect-error fixme ts strict error
const newWidget = this.node.widgets.find((w) => w.name === newWidgetName)
if (!newWidget) continue
const newValue = newWidget.value
const old = this.groupData.newToOldWidgetMap[newWidgetName]
let innerNode = this.innerNodes[old.node.index]
if (innerNode.type === 'PrimitiveNode') {
innerNode.primitiveValue = newValue
const primitiveLinked = this.groupData.primitiveToWidget[old.node.index]
for (const linked of primitiveLinked ?? []) {
const node = this.innerNodes[linked.nodeId]
// @ts-expect-error fixme ts strict error
const widget = node.widgets.find((w) => w.name === linked.inputName)
if (widget) {
widget.value = newValue
}
}
continue
} else if (innerNode.type === 'Reroute') {
const rerouteLinks = this.groupData.linksFrom[old.node.index]
if (rerouteLinks) {
for (const [_, , targetNodeId, targetSlot] of rerouteLinks['0']) {
const node = this.innerNodes[targetNodeId]
const input = node.inputs[targetSlot]
if (input.widget) {
const widget = node.widgets?.find(
// @ts-expect-error fixme ts strict error
(w) => w.name === input.widget.name
)
if (widget) {
widget.value = newValue
}
}
}
}
}
// @ts-expect-error fixme ts strict error
const widget = innerNode.widgets?.find((w) => w.name === old.inputName)
if (widget) {
widget.value = newValue
}
}
}
// @ts-expect-error fixme ts strict error
populatePrimitive(_node, nodeId, oldName) {
// Converted widget, populate primitive if linked
const primitiveId = this.groupData.widgetToPrimitive[nodeId]?.[oldName]
if (primitiveId == null) return
const targetWidgetName =
this.groupData.oldToNewWidgetMap[primitiveId]['value']
// @ts-expect-error fixme ts strict error
const targetWidgetIndex = this.node.widgets.findIndex(
(w) => w.name === targetWidgetName
)
if (targetWidgetIndex > -1) {
const primitiveNode = this.innerNodes[primitiveId]
let len = primitiveNode.widgets.length
if (
len - 1 !==
// @ts-expect-error fixme ts strict error
this.node.widgets[targetWidgetIndex].linkedWidgets?.length
) {
// Fallback handling for if some reason the primitive has a different number of widgets
// we dont want to overwrite random widgets, better to leave blank
len = 1
}
for (let i = 0; i < len; i++) {
// @ts-expect-error fixme ts strict error
this.node.widgets[targetWidgetIndex + i].value =
primitiveNode.widgets[i].value
}
}
return true
}
// @ts-expect-error fixme ts strict error
populateReroute(node, nodeId, map) {
if (node.type !== 'Reroute') return
const link = this.groupData.linksFrom[nodeId]?.[0]?.[0]
if (!link) return
const [, , targetNodeId, targetNodeSlot] = link
const targetNode = this.groupData.nodeData.nodes[targetNodeId]
const inputs = targetNode.inputs
const targetWidget = inputs?.[targetNodeSlot]?.widget
if (!targetWidget) return
const offset = inputs.length - (targetNode.widgets_values?.length ?? 0)
const v = targetNode.widgets_values?.[targetNodeSlot - offset]
if (v == null) return
const widgetName = Object.values(map)[0]
// @ts-expect-error fixme ts strict error
const widget = this.node.widgets.find((w) => w.name === widgetName)
if (widget) {
widget.value = v
}
}
populateWidgets() {
if (!this.node.widgets) return
for (
let nodeId = 0;
nodeId < this.groupData.nodeData.nodes.length;
nodeId++
) {
const node = this.groupData.nodeData.nodes[nodeId]
const map = this.groupData.oldToNewWidgetMap[nodeId] ?? {}
const widgets = Object.keys(map)
if (!node.widgets_values?.length) {
// special handling for populating values into reroutes
// this allows primitives connect to them to pick up the correct value
this.populateReroute(node, nodeId, map)
continue
}
let linkedShift = 0
for (let i = 0; i < widgets.length; i++) {
const oldName = widgets[i]
const newName = map[oldName]
const widgetIndex = this.node.widgets.findIndex(
(w) => w.name === newName
)
const mainWidget = this.node.widgets[widgetIndex]
if (
this.populatePrimitive(node, nodeId, oldName) ||
widgetIndex === -1
) {
// Find the inner widget and shift by the number of linked widgets as they will have been removed too
const innerWidget = this.innerNodes[nodeId].widgets?.find(
// @ts-expect-error fixme ts strict error
(w) => w.name === oldName
)
linkedShift += innerWidget?.linkedWidgets?.length ?? 0
}
if (widgetIndex === -1) {
continue
}
// Populate the main and any linked widget
mainWidget.value = node.widgets_values[i + linkedShift]
// @ts-expect-error fixme ts strict error
for (let w = 0; w < mainWidget.linkedWidgets?.length; w++) {
this.node.widgets[widgetIndex + w + 1].value =
node.widgets_values[i + ++linkedShift]
}
}
}
}
// @ts-expect-error fixme ts strict error
replaceNodes(nodes) {
let top
let left
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (left == null || node.pos[0] < left) {
left = node.pos[0]
}
if (top == null || node.pos[1] < top) {
top = node.pos[1]
}
this.linkOutputs(node, i)
app.graph.remove(node)
// Set internal ID to what is expected after workflow is reloaded
node.id = `${this.node.id}:${i}`
}
this.linkInputs()
this.node.pos = [left, top]
}
// @ts-expect-error fixme ts strict error
linkOutputs(originalNode, nodeId) {
if (!originalNode.outputs) return
for (const output of originalNode.outputs) {
if (!output.links) continue
// Clone the links as they'll be changed if we reconnect
const links = [...output.links]
for (const l of links) {
const link = app.graph.links[l]
if (!link) continue
const targetNode = app.graph.getNodeById(link.target_id)
const newSlot =
this.groupData.oldToNewOutputMap[nodeId]?.[link.origin_slot]
if (newSlot != null) {
// @ts-expect-error fixme ts strict error
this.node.connect(newSlot, targetNode, link.target_slot)
}
}
}
}
linkInputs() {
for (const link of this.groupData.nodeData.links ?? []) {
const [, originSlot, targetId, targetSlot, actualOriginId] = link
const originNode = app.graph.getNodeById(actualOriginId)
if (!originNode) continue // this node is in the group
originNode.connect(
originSlot,
// @ts-expect-error Valid - uses deprecated interface. Required check: if (graph.getNodeById(this.node.id) !== this.node) report()
this.node.id,
this.groupData.oldToNewInputMap[targetId][targetSlot]
)
}
}
// @ts-expect-error fixme ts strict error
static getGroupData(node) {
return (node.nodeData ?? node.constructor?.nodeData)?.[GROUP]
}
static isGroupNode(node: LGraphNode) {
return !!node.constructor?.nodeData?.[GROUP]
}
static async fromNodes(nodes: LGraphNode[]) {
// Process the nodes into the stored workflow group node data
const builder = new GroupNodeBuilder(nodes)
const res = await builder.build()
if (!res) return
const { name, nodeData } = res
// Convert this data into a LG node definition and register it
const config = new GroupNodeConfig(name, nodeData)
await config.registerType()
const groupNode = LiteGraph.createNode(`${PREFIX}${SEPARATOR}${name}`)
// Reuse the existing nodes for this instance
// @ts-expect-error fixme ts strict error
groupNode.setInnerNodes(builder.nodes)
// @ts-expect-error fixme ts strict error
groupNode[GROUP].populateWidgets()
// @ts-expect-error fixme ts strict error
app.graph.add(groupNode)
// Remove all converted nodes and relink them
// @ts-expect-error fixme ts strict error
groupNode[GROUP].replaceNodes(builder.nodes)
return groupNode
}
}
function addConvertToGroupOptions() {
// @ts-expect-error fixme ts strict error
function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {})
const disabled =
selected.length < 2 ||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
options.splice(index, null, {
content: `Convert to Group Node (Deprecated)`,
disabled,
callback: convertSelectedNodesToGroupNode
})
}
// @ts-expect-error fixme ts strict error
function addManageOption(options, index) {
const groups = app.graph.extra?.groupNodes
const disabled = !groups || !Object.keys(groups).length
options.splice(index, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => manageGroupNodes()
})
}
// Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
// @ts-expect-error fixme ts strict error
const options = getCanvasMenuOptions.apply(this, arguments)
const index = options.findIndex((o) => o?.content === 'Add Group')
const insertAt = index === -1 ? options.length - 1 : index + 2
addConvertOption(options, insertAt)
addManageOption(options, insertAt + 1)
return options
}
// Add to nodes
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
// @ts-expect-error fixme ts strict error
const options = getNodeMenuOptions.apply(this, arguments)
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === 'Properties')
const insertAt = index === -1 ? options.length - 1 : index
addConvertOption(options, insertAt)
}
return options
}
}
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
for (const node of nodes) {
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
node.type = node.type.replace(/^workflow\//, `${PREFIX}${SEPARATOR}`)
}
}
}
/**
* Convert selected nodes to a group node
* @throws {Error} if no nodes are selected
* @throws {Error} if a group node is already selected
* @throws {Error} if a group node is selected
*
* The context menu item should not be available if any of the above conditions are met.
* The error is automatically handled by the commandStore when the command is executed.
*/
async function convertSelectedNodesToGroupNode() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
if (nodes.length === 0) {
throw new Error('No nodes selected')
}
if (nodes.length === 1) {
throw new Error('Please select multiple nodes to convert to group node')
}
for (const node of nodes) {
if (node instanceof SubgraphNode) {
throw new Error('Selected nodes contain a subgraph node')
}
if (GroupNodeHandler.isGroupNode(node)) {
throw new Error('Selected nodes contain a group node')
}
}
return await GroupNodeHandler.fromNodes(nodes)
}
function ungroupSelectedGroupNodes() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
for (const node of nodes) {
if (GroupNodeHandler.isGroupNode(node)) {
node.convertToNodes?.()
}
}
}
function manageGroupNodes(type?: string) {
new ManageGroupDialog(app).show(type)
}
const id = 'Comfy.GroupNode'
// @ts-expect-error fixme ts strict error
let globalDefs
const ext: ComfyExtension = {
name: id,
commands: [
{
id: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
label: 'Convert selected nodes to group node',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: convertSelectedNodesToGroupNode
},
{
id: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
label: 'Ungroup selected group nodes',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: ungroupSelectedGroupNodes
},
{
id: 'Comfy.GroupNode.ManageGroupNodes',
label: 'Manage group nodes',
icon: 'pi pi-cog',
versionAdded: '1.3.17',
function: manageGroupNodes
}
],
keybindings: [
{
commandId: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
combo: {
alt: true,
key: 'g'
}
},
{
commandId: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
combo: {
alt: true,
shift: true,
key: 'G'
}
}
],
setup() {
addConvertToGroupOptions()
},
async beforeConfigureGraph(
graphData: ComfyWorkflowJSON,
missingNodeTypes: string[]
) {
const nodes = graphData?.extra?.groupNodes
if (nodes) {
replaceLegacySeparators(graphData.nodes)
await GroupNodeConfig.registerFromWorkflow(nodes, missingNodeTypes)
}
},
addCustomNodeDefs(defs) {
// Store this so we can mutate it later with group nodes
globalDefs = defs
},
nodeCreated(node) {
if (GroupNodeHandler.isGroupNode(node)) {
// @ts-expect-error fixme ts strict error
node[GROUP] = new GroupNodeHandler(node)
// Ensure group nodes pasted from other workflows are stored
// @ts-expect-error fixme ts strict error
if (node.title && node[GROUP]?.groupData?.nodeData) {
// @ts-expect-error fixme ts strict error
Workflow.storeGroupNode(node.title, node[GROUP].groupData.nodeData)
}
}
},
// @ts-expect-error fixme ts strict error
async refreshComboInNodes(defs) {
// Re-register group nodes so new ones are created with the correct options
// @ts-expect-error fixme ts strict error
Object.assign(globalDefs, defs)
const nodes = app.graph.extra?.groupNodes
if (nodes) {
await GroupNodeConfig.registerFromWorkflow(nodes, {})
}
}
}
app.registerExtension(ext)