mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-01 20:17:31 +00:00
Compare commits
3 Commits
version-bu
...
DynamicGro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d2030de0 | ||
|
|
20c238fdc5 | ||
|
|
8d397c61fa |
@@ -344,6 +344,16 @@ export const zDynamicComboInputSpec = z.tuple([
|
||||
})
|
||||
])
|
||||
|
||||
export const zDynamicGroupInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICGROUP_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
template: zComfyInputsSpec,
|
||||
min: z.number().int().nonnegative().optional().default(0),
|
||||
max: z.number().int().positive().max(100).optional().default(50),
|
||||
group_name: z.string().optional()
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
removable?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
@@ -206,7 +207,8 @@ function extractWidgetDisplayOptions(
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
read_only: widget.options.read_only,
|
||||
removable: widget.options.removable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
@@ -8,6 +12,7 @@ const dynamicTypeResolvers: Record<
|
||||
(inputSpec: InputSpecV2) => string[]
|
||||
> = {
|
||||
COMFY_AUTOGROW_V3: resolveAutogrowType,
|
||||
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
|
||||
COMFY_MATCHTYPE_V3: (input) =>
|
||||
zMatchTypeOptions
|
||||
.safeParse(input)
|
||||
@@ -20,6 +25,21 @@ export function resolveInputType(input: InputSpecV2): string[] {
|
||||
: input.type.split(',')
|
||||
}
|
||||
|
||||
function resolveDynamicGroupType(rawSpec: InputSpecV2): string[] {
|
||||
const parsed = zDynamicGroupInputSpec.safeParse([rawSpec.type, rawSpec])
|
||||
const template = parsed.data?.[1]?.template
|
||||
if (!template) return []
|
||||
const inputTypes: (Record<string, InputSpec> | undefined)[] = [
|
||||
template.required,
|
||||
template.optional
|
||||
]
|
||||
return inputTypes.flatMap((inputType) =>
|
||||
Object.entries(inputType ?? {}).flatMap(([name, v]) =>
|
||||
resolveInputType(transformInputSpecV1ToV2(v, { name }))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveAutogrowType(rawSpec: InputSpecV2): string[] {
|
||||
const { input } = zAutogrowOptions.safeParse(rawSpec).data?.template ?? {}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -47,6 +47,22 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function addDynamicGroup(
|
||||
node: LGraphNode,
|
||||
template: object,
|
||||
{ min, max, name = 'g' }: { min?: number; max?: number; name?: string } = {}
|
||||
) {
|
||||
const options: Record<string, unknown> = { template }
|
||||
if (min !== undefined) options.min = min
|
||||
if (max !== undefined) options.max = max
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
|
||||
name,
|
||||
isOptional: false
|
||||
})
|
||||
)
|
||||
}
|
||||
function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
addNodeInput(
|
||||
node,
|
||||
@@ -287,3 +303,85 @@ describe('Autogrow', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
describe('Dynamic Groups', () => {
|
||||
const stringTemplate = { required: { a: ['STRING', {}] } }
|
||||
const widgetNames = (node: LGraphNode) => node.widgets!.map((w) => w.name)
|
||||
const inputNames = (node: LGraphNode) => node.inputs.map((i) => i.name)
|
||||
const widgetNamed = (node: LGraphNode, name: string) =>
|
||||
node.widgets!.find((w) => w.name === name)!
|
||||
|
||||
test('renders min rows on creation', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 2, max: 5 })
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
expect(inputNames(node)).toStrictEqual([])
|
||||
})
|
||||
|
||||
test('add row appends a new row up to max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 2 })
|
||||
expect(widgetNames(node)).toStrictEqual(['g'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
|
||||
// At max, further adds are ignored.
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('controller disabled option set at max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 1 })
|
||||
expect(widgetNamed(node, 'g').options?.disabled).toBe(false)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(widgetNamed(node, 'g').options?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
test('remove row renumbers later rows', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
const state = (
|
||||
node as Parameters<typeof widgetNamed>[0] & {
|
||||
comfyDynamic: {
|
||||
dynamicGroup: Record<
|
||||
string,
|
||||
{ addRow: () => void; removeRow: (r: number) => void }
|
||||
>
|
||||
}
|
||||
}
|
||||
).comfyDynamic.dynamicGroup['g']
|
||||
state.addRow()
|
||||
state.addRow()
|
||||
state.addRow()
|
||||
|
||||
const row0Field = widgetNamed(node, 'g.0.a')
|
||||
const row2Field = widgetNamed(node, 'g.2.a')
|
||||
|
||||
state.removeRow(1)
|
||||
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
// Row 0 is untouched; the former row 2 shifts down into row 1.
|
||||
expect(widgetNamed(node, 'g.0.a')).toBe(row0Field)
|
||||
expect(widgetNamed(node, 'g.1.a')).toBe(row2Field)
|
||||
})
|
||||
|
||||
test('rows below min cannot be removed', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
|
||||
const state = (
|
||||
node as Parameters<typeof widgetNamed>[0] & {
|
||||
comfyDynamic: {
|
||||
dynamicGroup: Record<string, { removeRow: (r: number) => void }>
|
||||
}
|
||||
}
|
||||
).comfyDynamic.dynamicGroup['g']
|
||||
|
||||
// Row 0 is at the min boundary — removing it is a no-op.
|
||||
state.removeRow(0)
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,11 +13,13 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicComboInputSpec,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -28,6 +30,18 @@ import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type DynamicGroupState = {
|
||||
min: number
|
||||
max: number
|
||||
groupName?: string
|
||||
inputSpecs: InputSpecV2[]
|
||||
addRow: () => void
|
||||
removeRow: (row: number) => void
|
||||
}
|
||||
export type DynamicGroupNode = LGraphNode & {
|
||||
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
|
||||
}
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
|
||||
comfyDynamic: { matchType: Record<string, Record<string, string>> }
|
||||
@@ -214,7 +228,217 @@ function dynamicComboWidget(
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
function withComfyDynamicGroup(
|
||||
node: LGraphNode
|
||||
): asserts node is DynamicGroupNode {
|
||||
if (node.comfyDynamic?.dynamicGroup) return
|
||||
node.comfyDynamic ??= {}
|
||||
node.comfyDynamic.dynamicGroup = {}
|
||||
}
|
||||
|
||||
const fieldName = (group: string, row: number, field: string) =>
|
||||
`${group}.${row}.${field}`
|
||||
|
||||
/** Rename a field that sits above the removed row, shifting its index down. */
|
||||
function shiftedFieldName(
|
||||
group: string,
|
||||
name: string,
|
||||
removedRow: number
|
||||
): string | undefined {
|
||||
const prefix = `${group}.`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const rest = name.slice(prefix.length)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot === -1) return undefined
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (!Number.isInteger(row) || row <= removedRow) return undefined
|
||||
return fieldName(group, row - 1, rest.slice(dot + 1))
|
||||
}
|
||||
|
||||
const isGroupField = (group: string, name: string) =>
|
||||
name.startsWith(`${group}.`)
|
||||
|
||||
const belongsToRow = (group: string, name: string, row: number): boolean =>
|
||||
name.startsWith(`${group}.${row}.`)
|
||||
|
||||
function countGroupRows(group: string, node: LGraphNode): number {
|
||||
const rows = new Set<number>()
|
||||
for (const w of node.widgets ?? []) {
|
||||
if (!isGroupField(group, w.name)) continue
|
||||
const rest = w.name.slice(group.length + 1)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot !== -1) {
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (Number.isInteger(row)) rows.add(row)
|
||||
}
|
||||
}
|
||||
return rows.size
|
||||
}
|
||||
|
||||
/** Build field widgets for a single row, returning them detached from the node. */
|
||||
function createRow(
|
||||
group: string,
|
||||
row: number,
|
||||
state: DynamicGroupState,
|
||||
node: DynamicGroupNode
|
||||
): IBaseWidget[] {
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
const startLen = node.widgets!.length
|
||||
|
||||
for (const spec of state.inputSpecs)
|
||||
addNodeInput(node, {
|
||||
...spec,
|
||||
name: fieldName(group, row, spec.name),
|
||||
display_name: spec.display_name ?? spec.name,
|
||||
hidden: true,
|
||||
socketless: true
|
||||
})
|
||||
|
||||
return node.widgets!.splice(startLen)
|
||||
}
|
||||
|
||||
function insertRowAfterGroup(
|
||||
group: string,
|
||||
node: LGraphNode,
|
||||
rowWidgets: IBaseWidget[]
|
||||
): void {
|
||||
const lastIdx = node.widgets!.findLastIndex(
|
||||
(w) => w.name === group || isGroupField(group, w.name)
|
||||
)
|
||||
node.widgets!.splice(lastIdx + 1, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function syncController(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
const controller = node.widgets?.find((w) => w.name === group)
|
||||
if (!state || !controller) return
|
||||
controller.options ??= {}
|
||||
controller.options.disabled = countGroupRows(group, node) >= state.max
|
||||
// Route through setSize (not `size[1] = …`) so the layout store and the Vue
|
||||
// node's min-height floor are updated; a direct buffer write bypasses the
|
||||
// size setter and leaves the node unable to shrink after rows are removed.
|
||||
node.setSize([node.size[0], node.computeSize()[1]])
|
||||
}
|
||||
|
||||
function addRow(group: string, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
const row = countGroupRows(group, node)
|
||||
if (row >= state.max) return
|
||||
insertRowAfterGroup(group, node, createRow(group, row, state, node))
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
function removeRow(group: string, row: number, node: DynamicGroupNode): void {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state || row < state.min) return
|
||||
|
||||
for (const w of remove(node.widgets!, (w) =>
|
||||
belongsToRow(group, w.name, row)
|
||||
))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => belongsToRow(group, inp.name, row))
|
||||
|
||||
for (const w of node.widgets ?? []) {
|
||||
const shifted = shiftedFieldName(group, w.name, row)
|
||||
if (shifted !== undefined) w.name = shifted
|
||||
}
|
||||
for (const inp of node.inputs) {
|
||||
const shifted = shiftedFieldName(group, inp.name, row)
|
||||
if (shifted === undefined) continue
|
||||
inp.name = shifted
|
||||
if (inp.widget) inp.widget.name = shifted
|
||||
}
|
||||
|
||||
syncController(group, node)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
/** Rebuild the group from scratch to hold exactly `count` rows. */
|
||||
function rebuildRows(group: string, count: number, node: DynamicGroupNode) {
|
||||
const state = node.comfyDynamic.dynamicGroup[group]
|
||||
if (!state) return
|
||||
node.widgets ??= []
|
||||
|
||||
const isRowMember = (name: string) => isGroupField(group, name)
|
||||
for (const w of remove(node.widgets, (w) => isRowMember(w.name)))
|
||||
w.onRemove?.()
|
||||
remove(node.inputs, (inp) => isRowMember(inp.name))
|
||||
|
||||
const insertAt = node.widgets.findIndex((w) => w.name === group) + 1
|
||||
const rowWidgets: IBaseWidget[] = []
|
||||
for (let row = 0; row < count; row++)
|
||||
rowWidgets.push(...createRow(group, row, state, node))
|
||||
node.widgets.splice(insertAt, 0, ...rowWidgets)
|
||||
}
|
||||
|
||||
function dynamicGroupWidget(
|
||||
node: LGraphNode,
|
||||
inputName: string,
|
||||
untypedInputData: InputSpec,
|
||||
_appArg: ComfyApp
|
||||
) {
|
||||
const parseResult = zDynamicGroupInputSpec.safeParse(untypedInputData)
|
||||
if (!parseResult.success) throw new Error('invalid DynamicGroup spec')
|
||||
const [, { template, min, max, group_name: groupName }] = parseResult.data
|
||||
|
||||
const toSpecs = (
|
||||
inputs: Record<string, InputSpec> | undefined,
|
||||
isOptional: boolean
|
||||
) =>
|
||||
Object.entries(inputs ?? {}).map(([name, spec]) =>
|
||||
transformInputSpecV1ToV2(spec, { name, isOptional })
|
||||
)
|
||||
const inputSpecs = [
|
||||
...toSpecs(template.required, false),
|
||||
...toSpecs(template.optional, true)
|
||||
]
|
||||
|
||||
withComfyDynamicGroup(node)
|
||||
const typedNode = node as DynamicGroupNode
|
||||
typedNode.comfyDynamic.dynamicGroup[inputName] = {
|
||||
min,
|
||||
max,
|
||||
groupName,
|
||||
inputSpecs,
|
||||
addRow: () => addRow(inputName, typedNode),
|
||||
removeRow: (row: number) => removeRow(inputName, row, typedNode)
|
||||
}
|
||||
|
||||
node.widgets ??= []
|
||||
const controller = node.addCustomWidget({
|
||||
name: inputName,
|
||||
type: 'dynamic_group',
|
||||
value: min,
|
||||
y: 0,
|
||||
serialize: true,
|
||||
callback: () => addRow(inputName, typedNode),
|
||||
options: { socketless: true, disabled: false, min, max }
|
||||
})
|
||||
|
||||
Object.defineProperty(controller, 'value', {
|
||||
get() {
|
||||
return countGroupRows(inputName, typedNode)
|
||||
},
|
||||
set(count: unknown) {
|
||||
if (typeof count !== 'number') return
|
||||
rebuildRows(inputName, count, typedNode)
|
||||
syncController(inputName, typedNode)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
controller.value = min
|
||||
|
||||
return { widget: controller }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = {
|
||||
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
|
||||
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
|
||||
}
|
||||
const dynamicInputs: Record<
|
||||
string,
|
||||
(node: LGraphNode, inputSpec: InputSpecV2) => void
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
|
||||
// Vue widget options
|
||||
disabled?: boolean
|
||||
removable?: boolean
|
||||
useGrouping?: boolean
|
||||
placeholder?: string
|
||||
showThumbnails?: boolean
|
||||
|
||||
@@ -2248,6 +2248,12 @@
|
||||
"slots": "Node Slots Error",
|
||||
"widgets": "Node Widgets Error"
|
||||
},
|
||||
"dynamicGroup": {
|
||||
"addGroup": "Add {group_name}",
|
||||
"removeGroup": "Remove {group_name}",
|
||||
"group": "{group_name} #{index}",
|
||||
"defaultGroupName": "Group"
|
||||
},
|
||||
"oauth": {
|
||||
"consent": {
|
||||
"allow": "Continue",
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div
|
||||
class="col-span-2 grid grid-cols-[minmax(80px,min-content)_minmax(125px,1fr)] gap-x-2 gap-y-1"
|
||||
>
|
||||
<template v-for="row in rowIndices" :key="row">
|
||||
<div
|
||||
class="col-span-2 mt-1 flex items-center justify-between border-t border-node-component-surface pt-1"
|
||||
>
|
||||
<span
|
||||
class="truncate text-xs font-medium text-node-component-slot-text"
|
||||
>
|
||||
{{
|
||||
t('dynamicGroup.group', { group_name: groupName, index: row + 1 })
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
v-if="row >= minRows"
|
||||
v-tooltip.top="
|
||||
t('dynamicGroup.removeGroup', { group_name: groupName })
|
||||
"
|
||||
type="button"
|
||||
class="mr-1.75 flex cursor-pointer appearance-none border-0 bg-transparent p-0 text-node-component-slot-text/40 transition-colors duration-150 hover:text-danger-100 focus-visible:outline-none"
|
||||
:aria-label="t('dynamicGroup.removeGroup', { group_name: groupName })"
|
||||
@click="onRemoveRow(row)"
|
||||
>
|
||||
<span
|
||||
class="icon-[material-symbols--close] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<component
|
||||
:is="fw.component"
|
||||
v-for="fw in rowWidgets(row)"
|
||||
:key="fw.name"
|
||||
:model-value="fw.value"
|
||||
:widget="fw.simplified"
|
||||
:node-id="nodeId"
|
||||
:node-type="nodeType"
|
||||
class="col-span-2"
|
||||
@update:model-value="fw.onUpdate"
|
||||
/>
|
||||
</template>
|
||||
<Button
|
||||
:disabled="addDisabled"
|
||||
class="col-span-2 mt-1 border-0 bg-component-node-widget-background text-node-component-slot-text"
|
||||
size="sm"
|
||||
variant="textonly"
|
||||
@click="onAddRow"
|
||||
>
|
||||
<span
|
||||
class="mr-1 icon-[material-symbols--add] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('dynamicGroup.addGroup', { group_name: groupName }) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { DynamicGroupNode } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type { WidgetState } from '@/types/widgetState'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const { widget, nodeId, nodeType } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
nodeId: string
|
||||
nodeType?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const group = widget.name
|
||||
|
||||
const node = computed(
|
||||
() => app.graph?.getNodeById(toNodeId(nodeId)) as DynamicGroupNode | undefined
|
||||
)
|
||||
|
||||
const groupState = computed(
|
||||
() => node.value?.comfyDynamic?.dynamicGroup?.[group]
|
||||
)
|
||||
|
||||
const minRows = computed(() => groupState.value?.min ?? 0)
|
||||
const groupName = computed(
|
||||
() => groupState.value?.groupName ?? t('dynamicGroup.defaultGroupName')
|
||||
)
|
||||
|
||||
interface FieldWidgetView {
|
||||
name: string
|
||||
row: number
|
||||
component: Component
|
||||
simplified: SimplifiedWidget
|
||||
value: WidgetValue
|
||||
onUpdate: (value: WidgetValue) => void
|
||||
}
|
||||
|
||||
function resolveWidgetState(w: IBaseWidget): WidgetState | undefined {
|
||||
if (w.widgetId) return widgetValueStore.getWidget(w.widgetId)
|
||||
const graphId = node.value?.graph?.rootGraph?.id
|
||||
if (!graphId) return undefined
|
||||
const localId = stripGraphPrefix(String(nodeId))
|
||||
if (!localId) return undefined
|
||||
return widgetValueStore.getWidget(widgetId(graphId, localId, w.name))
|
||||
}
|
||||
|
||||
function toFieldView(
|
||||
n: DynamicGroupNode,
|
||||
w: IBaseWidget,
|
||||
row: number,
|
||||
fieldName: string
|
||||
): FieldWidgetView {
|
||||
const state = resolveWidgetState(w)
|
||||
const value = state?.value ?? w.value
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: w.name,
|
||||
type: state?.type ?? w.type,
|
||||
value,
|
||||
label: state?.label ?? w.label ?? fieldName,
|
||||
options: state?.options ?? w.options,
|
||||
spec: nodeDefStore.getInputSpecForWidget(n, w.name)
|
||||
}
|
||||
return {
|
||||
name: w.name,
|
||||
row,
|
||||
component: getComponent(w.type) ?? WidgetLegacy,
|
||||
simplified,
|
||||
value,
|
||||
onUpdate: (next: WidgetValue) => {
|
||||
if (state) state.value = next
|
||||
w.value = next ?? undefined
|
||||
w.callback?.(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fieldWidgets = computed<FieldWidgetView[]>(() => {
|
||||
const n = node.value
|
||||
if (!n?.widgets) return []
|
||||
const prefix = `${group}.`
|
||||
const views: FieldWidgetView[] = []
|
||||
for (const w of n.widgets) {
|
||||
if (!w.name.startsWith(prefix)) continue
|
||||
const rest = w.name.slice(prefix.length)
|
||||
const dot = rest.indexOf('.')
|
||||
if (dot === -1) continue
|
||||
const row = Number(rest.slice(0, dot))
|
||||
if (!Number.isInteger(row)) continue
|
||||
views.push(toFieldView(n, w, row, rest.slice(dot + 1)))
|
||||
}
|
||||
return views
|
||||
})
|
||||
|
||||
const rowIndices = computed(() =>
|
||||
[...new Set(fieldWidgets.value.map((fw) => fw.row))].sort((a, b) => a - b)
|
||||
)
|
||||
|
||||
const addDisabled = computed(
|
||||
() => rowIndices.value.length >= (groupState.value?.max ?? Infinity)
|
||||
)
|
||||
|
||||
function rowWidgets(row: number): FieldWidgetView[] {
|
||||
return fieldWidgets.value.filter((fw) => fw.row === row)
|
||||
}
|
||||
|
||||
function onAddRow() {
|
||||
groupState.value?.addRow()
|
||||
}
|
||||
|
||||
function onRemoveRow(row: number) {
|
||||
groupState.value?.removeRow(row)
|
||||
}
|
||||
</script>
|
||||
@@ -75,6 +75,10 @@ const WidgetBoundingBoxes = defineAsyncComponent(
|
||||
const WidgetColors = defineAsyncComponent(
|
||||
() => import('@/components/palette/WidgetColors.vue')
|
||||
)
|
||||
const WidgetDynamicGroup = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/renderer/extensions/vueNodes/widgets/components/WidgetDynamicGroup.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetButton,
|
||||
@@ -241,6 +245,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['COLORS'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'dynamic_group',
|
||||
{
|
||||
component: WidgetDynamicGroup,
|
||||
aliases: [],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -304,6 +304,7 @@ export const useLitegraphService = () => {
|
||||
hidden: inputSpec.hidden
|
||||
})
|
||||
if (inputSpec.hidden !== undefined) widget.hidden = inputSpec.hidden
|
||||
if (inputSpec.socketless) widget.options.socketless = true
|
||||
if (dynamic) widget.tooltip = inputSpec.tooltip
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user