mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-26 01:27:23 +00:00
Compare commits
1 Commits
feat/ephem
...
DynamicGro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
842e3d7541 |
@@ -344,6 +344,15 @@ 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)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
removable?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
@@ -213,7 +214,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,7 +1,9 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -47,6 +49,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 +305,101 @@ 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.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
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(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// At max, further adds are ignored.
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('remove row renumbers later rows', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const row0Field = widgetNamed(node, 'g.0.a')
|
||||
const row2Field = widgetNamed(node, 'g.2.a')
|
||||
|
||||
widgetNamed(node, 'g.__row__1').callback?.(undefined)
|
||||
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.__row__0',
|
||||
'g.0.a',
|
||||
'g.__row__1',
|
||||
'g.1.a'
|
||||
])
|
||||
expect(inputNames(node)).toStrictEqual(['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 are not removable', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
expect(widgetNamed(node, 'g.__row__0').options?.removable).toBe(false)
|
||||
expect(widgetNamed(node, 'g.__row__1').options?.removable).toBe(true)
|
||||
|
||||
// Attempting to remove a protected row is a no-op.
|
||||
widgetNamed(node, 'g.__row__0').callback?.(undefined)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
test('canvas click removes a row only on the remove hit target', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
widgetNamed(node, 'g').callback?.(undefined)
|
||||
|
||||
const header = widgetNamed(node, 'g.__row__1')
|
||||
const up = { type: 'pointerup' } as CanvasPointerEvent
|
||||
const down = { type: 'pointerdown' } as CanvasPointerEvent
|
||||
const xCenter = node.size[0] - 15 - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
// Releasing away from the remove target does nothing.
|
||||
header.mouse?.(up, [0, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// A pointerdown on the target does nothing (only release acts).
|
||||
header.mouse?.(down, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a', 'g.1.a'])
|
||||
|
||||
// Releasing on the target removes the row.
|
||||
header.mouse?.(up, [xCenter, 0] as Point, node)
|
||||
expect(inputNames(node)).toStrictEqual(['g.0.a'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,10 +2,12 @@ import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { t } from '@/i18n'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
INodeOutputSlot,
|
||||
Point
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -13,11 +15,14 @@ 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 { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
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 +33,15 @@ import { widgetId } from '@/types/widgetId'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
type DynamicGroupState = {
|
||||
min: number
|
||||
max: number
|
||||
inputSpecs: InputSpecV2[]
|
||||
}
|
||||
type DynamicGroupNode = LGraphNode & {
|
||||
comfyDynamic: { dynamicGroup: Record<string, DynamicGroupState> }
|
||||
}
|
||||
|
||||
type MatchTypeNode = LGraphNode &
|
||||
Pick<Required<LGraphNode>, 'onConnectionsChange'> & {
|
||||
comfyDynamic: { matchType: Record<string, Record<string, string>> }
|
||||
@@ -210,7 +224,321 @@ 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 ROW_MARKER = '__row__'
|
||||
const rowHeaderName = (group: string, row: number) =>
|
||||
`${group}.${ROW_MARKER}${row}`
|
||||
const fieldName = (group: string, row: number, field: string) =>
|
||||
`${group}.${row}.${field}`
|
||||
|
||||
/** Extract the row index from a header widget name, or `undefined`. */
|
||||
function headerRowIndex(group: string, name: string): number | undefined {
|
||||
const prefix = `${group}.${ROW_MARKER}`
|
||||
if (!name.startsWith(prefix)) return undefined
|
||||
const row = Number(name.slice(prefix.length))
|
||||
return Number.isInteger(row) ? row : undefined
|
||||
}
|
||||
|
||||
/** 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 belongsToRow = (group: string, name: string, row: number): boolean =>
|
||||
name === rowHeaderName(group, row) || name.startsWith(`${group}.${row}.`)
|
||||
|
||||
const CANVAS_MARGIN = 15
|
||||
|
||||
/** Draw the "Add row" capsule button on the LiteGraph canvas. */
|
||||
function drawGroupButton(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
disabled: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
if (disabled) ctx.globalAlpha *= 0.5
|
||||
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(CANVAS_MARGIN, y, width - CANVAS_MARGIN * 2, height, [
|
||||
height * 0.5
|
||||
])
|
||||
ctx.fill()
|
||||
if (!disabled) ctx.stroke()
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label, width * 0.5, y + height * 0.7)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Horizontal centre of a row header's remove (✕) hit target. */
|
||||
const removeButtonCenterX = (width: number) =>
|
||||
width - CANVAS_MARGIN - LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
|
||||
/** Draw a row header (label on the left, ✕ on the right) on the canvas. */
|
||||
function drawGroupRowHeader(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
y: number,
|
||||
label: string,
|
||||
removable: boolean
|
||||
): void {
|
||||
const height = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
ctx.save()
|
||||
ctx.font = `${LiteGraph.NODE_TEXT_SIZE}px ${LiteGraph.NODE_FONT}`
|
||||
ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(label, CANVAS_MARGIN, y + height * 0.7)
|
||||
if (removable) {
|
||||
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('\u2715', removeButtonCenterX(width), y + height * 0.7)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
const countGroupRows = (group: string, node: LGraphNode): number =>
|
||||
(node.widgets ?? []).reduce(
|
||||
(count, w) =>
|
||||
headerRowIndex(group, w.name) !== undefined ? count + 1 : count,
|
||||
0
|
||||
)
|
||||
|
||||
/** Build a row's header + field widgets, 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
|
||||
|
||||
const header = node.addCustomWidget({
|
||||
name: rowHeaderName(group, row),
|
||||
type: 'dynamic_group_row',
|
||||
value: row,
|
||||
y: 0,
|
||||
serialize: false,
|
||||
callback: undefined as IBaseWidget['callback'],
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
const idx = headerRowIndex(group, this.name) ?? 0
|
||||
const label = t('dynamicGroup.row', { index: idx + 1 })
|
||||
drawGroupRowHeader(ctx, width, y, label, !!this.options?.removable)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent, pos: Point) {
|
||||
if (event.type !== 'pointerup' || !this.options?.removable) return false
|
||||
const half = LiteGraph.NODE_WIDGET_HEIGHT * 0.5
|
||||
if (Math.abs(pos[0] - removeButtonCenterX(node.size[0])) > half)
|
||||
return false
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, removable: row >= state.min }
|
||||
})
|
||||
header.callback = function (this: IBaseWidget) {
|
||||
const idx = headerRowIndex(group, this.name)
|
||||
if (idx !== undefined) removeRow(group, idx, node)
|
||||
}
|
||||
|
||||
for (const spec of state.inputSpecs)
|
||||
addNodeInput(node, {
|
||||
...spec,
|
||||
name: fieldName(group, row, spec.name),
|
||||
display_name: spec.display_name ?? spec.name
|
||||
})
|
||||
|
||||
return node.widgets!.splice(startLen)
|
||||
}
|
||||
|
||||
function insertRowAfterGroup(
|
||||
group: string,
|
||||
node: LGraphNode,
|
||||
rowWidgets: IBaseWidget[]
|
||||
): void {
|
||||
const lastIdx = node.widgets!.findLastIndex(
|
||||
(w) => w.name === group || w.name.startsWith(`${group}.`)
|
||||
)
|
||||
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
|
||||
node.size[1] = node.computeSize([...node.size])[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 headerRow = headerRowIndex(group, w.name)
|
||||
if (headerRow !== undefined && headerRow > row) {
|
||||
w.name = rowHeaderName(group, headerRow - 1)
|
||||
w.options ??= {}
|
||||
w.options.removable = headerRow - 1 >= state.min
|
||||
continue
|
||||
}
|
||||
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) => name.startsWith(`${group}.`)
|
||||
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 }] = 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, inputSpecs }
|
||||
|
||||
node.widgets ??= []
|
||||
const controller = node.addCustomWidget({
|
||||
name: inputName,
|
||||
type: 'dynamic_group_add',
|
||||
value: min,
|
||||
y: 0,
|
||||
serialize: true,
|
||||
callback: () => addRow(inputName, typedNode),
|
||||
draw(
|
||||
this: IBaseWidget,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
_node: LGraphNode,
|
||||
width: number,
|
||||
y: number
|
||||
) {
|
||||
drawGroupButton(
|
||||
ctx,
|
||||
width,
|
||||
y,
|
||||
t('dynamicGroup.addRow'),
|
||||
!!this.options?.disabled
|
||||
)
|
||||
},
|
||||
mouse(this: IBaseWidget, event: CanvasPointerEvent) {
|
||||
if (event.type !== 'pointerup' || this.options?.disabled) return false
|
||||
addRow(inputName, typedNode)
|
||||
return true
|
||||
},
|
||||
options: { serialize: false, socketless: true, disabled: false }
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
|
||||
// Vue widget options
|
||||
disabled?: boolean
|
||||
removable?: boolean
|
||||
useGrouping?: boolean
|
||||
placeholder?: string
|
||||
showThumbnails?: boolean
|
||||
|
||||
@@ -2233,6 +2233,11 @@
|
||||
"slots": "Node Slots Error",
|
||||
"widgets": "Node Widgets Error"
|
||||
},
|
||||
"dynamicGroup": {
|
||||
"addRow": "Add row",
|
||||
"removeRow": "Remove row",
|
||||
"row": "Row {index}"
|
||||
},
|
||||
"oauth": {
|
||||
"consent": {
|
||||
"allow": "Continue",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="col-span-2 flex justify-start">
|
||||
<Button
|
||||
class="border-0 bg-component-node-widget-background px-2 py-1 text-base-foreground"
|
||||
:disabled="widget.options?.disabled"
|
||||
size="sm"
|
||||
variant="textonly"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span
|
||||
class="mr-1 icon-[material-symbols--add] size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('dynamicGroup.addRow') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleClick() {
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-node-slot-background col-span-2 flex items-center justify-between border-t pt-1"
|
||||
>
|
||||
<span class="text-xs font-medium text-base-foreground/70">
|
||||
{{ rowLabel }}
|
||||
</span>
|
||||
<button
|
||||
v-if="widget.options?.removable"
|
||||
class="hover:text-danger rounded-sm p-0.5 text-base-foreground/50 transition-colors"
|
||||
:aria-label="t('dynamicGroup.removeRow')"
|
||||
@click="handleRemove"
|
||||
>
|
||||
<span
|
||||
class="icon-[material-symbols--close] size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const rowLabel = computed(() => {
|
||||
const match = /__row__(\d+)$/.exec(widget.name)
|
||||
const index = match ? Number(match[1]) : 0
|
||||
return t('dynamicGroup.row', { index: index + 1 })
|
||||
})
|
||||
|
||||
function handleRemove() {
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
</script>
|
||||
@@ -75,6 +75,14 @@ const WidgetBoundingBoxes = defineAsyncComponent(
|
||||
const WidgetColors = defineAsyncComponent(
|
||||
() => import('@/components/palette/WidgetColors.vue')
|
||||
)
|
||||
const WidgetDynamicGroupAdd = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/renderer/extensions/vueNodes/widgets/components/WidgetDynamicGroupAdd.vue')
|
||||
)
|
||||
const WidgetDynamicGroupRow = defineAsyncComponent(
|
||||
() =>
|
||||
import('@/renderer/extensions/vueNodes/widgets/components/WidgetDynamicGroupRow.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetButton,
|
||||
@@ -241,6 +249,22 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['COLORS'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'dynamic_group_add',
|
||||
{
|
||||
component: WidgetDynamicGroupAdd,
|
||||
aliases: ['COMFY_DYNAMICGROUP_V3'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'dynamic_group_row',
|
||||
{
|
||||
component: WidgetDynamicGroupRow,
|
||||
aliases: [],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user