Compare commits
2 Commits
DynamicGro
...
fix/websit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
870700d7ee | ||
|
|
61919b2702 |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
@@ -248,7 +248,7 @@
|
||||
@utility ppformula-text-center {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.19em;
|
||||
top: 0.1em;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
validateComfyNodeDef,
|
||||
zDynamicGroupInputSpec
|
||||
} from '../schemas/nodeDefSchema'
|
||||
import { validateComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
|
||||
const EXAMPLE_NODE_DEF: ComfyNodeDef = {
|
||||
@@ -68,42 +65,3 @@ describe('validateNodeDef', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('zDynamicGroupInputSpec', () => {
|
||||
const template = { required: { a: ['STRING', {}] } }
|
||||
|
||||
it('rejects min greater than max', () => {
|
||||
expect(
|
||||
zDynamicGroupInputSpec.safeParse([
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{ template, min: 60, max: 50 }
|
||||
]).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts min equal to max', () => {
|
||||
expect(
|
||||
zDynamicGroupInputSpec.safeParse([
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{ template, min: 3, max: 3 }
|
||||
]).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('applies default min and max', () => {
|
||||
const parsed = zDynamicGroupInputSpec.parse([
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{ template }
|
||||
])
|
||||
expect(parsed[1].min).toBe(0)
|
||||
expect(parsed[1].max).toBe(50)
|
||||
})
|
||||
|
||||
it('accepts an optional group_name', () => {
|
||||
const parsed = zDynamicGroupInputSpec.parse([
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{ template, group_name: 'Lora' }
|
||||
])
|
||||
expect(parsed[1].group_name).toBe('Lora')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -344,26 +344,6 @@ 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()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.min > data.max) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'min must be less than or equal to max',
|
||||
path: ['min']
|
||||
})
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
@@ -79,7 +79,6 @@ export interface SafeWidgetData {
|
||||
advanced?: boolean
|
||||
hidden?: boolean
|
||||
read_only?: boolean
|
||||
removable?: boolean
|
||||
values?: unknown
|
||||
}
|
||||
/** Input specification from node definition */
|
||||
@@ -207,8 +206,7 @@ function extractWidgetDisplayOptions(
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only,
|
||||
removable: widget.options.removable
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { resolveInputType } from './dynamicTypes'
|
||||
|
||||
describe('resolveInputType', () => {
|
||||
it('resolves field types from a dynamic group template', () => {
|
||||
const spec = transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{
|
||||
template: {
|
||||
required: { image: ['IMAGE', {}] },
|
||||
optional: { text: ['STRING', {}] }
|
||||
}
|
||||
}
|
||||
] as InputSpec,
|
||||
{ name: 'loras', isOptional: false }
|
||||
)
|
||||
|
||||
expect(resolveInputType(spec)).toEqual(['IMAGE', 'STRING'])
|
||||
})
|
||||
|
||||
it('resolves nested combo types inside a dynamic group template', () => {
|
||||
const spec = transformInputSpecV1ToV2(
|
||||
[
|
||||
'COMFY_DYNAMICGROUP_V3',
|
||||
{
|
||||
template: {
|
||||
required: {
|
||||
mode: [['a', 'b'], {}]
|
||||
}
|
||||
}
|
||||
}
|
||||
] as InputSpec,
|
||||
{ name: 'loras', isOptional: false }
|
||||
)
|
||||
|
||||
expect(resolveInputType(spec)).toEqual(['COMBO'])
|
||||
})
|
||||
|
||||
it('returns an empty list for an invalid dynamic group spec', () => {
|
||||
const spec = transformInputSpecV1ToV2(
|
||||
['COMFY_DYNAMICGROUP_V3', { template: { required: {} } }] as InputSpec,
|
||||
{ name: 'loras', isOptional: false }
|
||||
)
|
||||
spec.type = 'COMFY_DYNAMICGROUP_V3'
|
||||
spec.template = undefined as never
|
||||
|
||||
expect(resolveInputType(spec)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,5 @@
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import {
|
||||
zAutogrowOptions,
|
||||
zDynamicGroupInputSpec,
|
||||
zMatchTypeOptions
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { zAutogrowOptions, zMatchTypeOptions } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
@@ -12,7 +8,6 @@ const dynamicTypeResolvers: Record<
|
||||
(inputSpec: InputSpecV2) => string[]
|
||||
> = {
|
||||
COMFY_AUTOGROW_V3: resolveAutogrowType,
|
||||
COMFY_DYNAMICGROUP_V3: resolveDynamicGroupType,
|
||||
COMFY_MATCHTYPE_V3: (input) =>
|
||||
zMatchTypeOptions
|
||||
.safeParse(input)
|
||||
@@ -25,21 +20,6 @@ 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,8 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { DynamicGroupNode } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -49,33 +47,6 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
|
||||
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
|
||||
)
|
||||
}
|
||||
function addDynamicGroup(
|
||||
node: LGraphNode,
|
||||
template: object,
|
||||
{
|
||||
min,
|
||||
max,
|
||||
name = 'g',
|
||||
group_name
|
||||
}: {
|
||||
min?: number
|
||||
max?: number
|
||||
name?: string
|
||||
group_name?: string
|
||||
} = {}
|
||||
) {
|
||||
const options: Record<string, unknown> = { template }
|
||||
if (min !== undefined) options.min = min
|
||||
if (max !== undefined) options.max = max
|
||||
if (group_name !== undefined) options.group_name = group_name
|
||||
addNodeInput(
|
||||
node,
|
||||
transformInputSpecV1ToV2(['COMFY_DYNAMICGROUP_V3', options] as InputSpec, {
|
||||
name,
|
||||
isOptional: false
|
||||
})
|
||||
)
|
||||
}
|
||||
function addAutogrow(node: LGraphNode, template: unknown) {
|
||||
addNodeInput(
|
||||
node,
|
||||
@@ -316,173 +287,3 @@ 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('remove row disconnects linked sockets and renumbers inputs', () => {
|
||||
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 graph = new LGraph()
|
||||
graph.add(node)
|
||||
node.addInput('g.1.a', 'STRING')
|
||||
const row1Index = node.inputs.findIndex((i) => i.name === 'g.1.a')
|
||||
connectInput(node, row1Index, graph)
|
||||
const linkId = node.inputs[row1Index].link!
|
||||
node.addInput('g.2.a', 'STRING')
|
||||
|
||||
state.removeRow(1)
|
||||
|
||||
expect(graph.links[linkId]).toBeUndefined()
|
||||
expect(inputNames(node)).toStrictEqual(['g.1.a'])
|
||||
expect(node.inputs[0].link).toBeNull()
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a'])
|
||||
})
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
test('controller value setter rebuilds rows within min and max', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 1, max: 4 })
|
||||
const controller = widgetNamed(node, 'g')
|
||||
|
||||
controller.value = 3
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a', 'g.1.a', 'g.2.a'])
|
||||
expect(controller.value).toBe(3)
|
||||
|
||||
controller.value = 99
|
||||
expect(widgetNames(node)).toStrictEqual([
|
||||
'g',
|
||||
'g.0.a',
|
||||
'g.1.a',
|
||||
'g.2.a',
|
||||
'g.3.a'
|
||||
])
|
||||
expect(controller.value).toBe(4)
|
||||
|
||||
controller.value = 0
|
||||
expect(widgetNames(node)).toStrictEqual(['g', 'g.0.a'])
|
||||
expect(controller.value).toBe(1)
|
||||
})
|
||||
|
||||
test('stores group_name on dynamic group state', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, {
|
||||
min: 1,
|
||||
max: 3,
|
||||
name: 'loras',
|
||||
group_name: 'Lora'
|
||||
})
|
||||
const state = (node as unknown as DynamicGroupNode).comfyDynamic
|
||||
.dynamicGroup.loras
|
||||
|
||||
expect(state.groupName).toBe('Lora')
|
||||
})
|
||||
|
||||
test('remove row renames linked input widget metadata', () => {
|
||||
const node = testNode()
|
||||
addDynamicGroup(node, stringTemplate, { min: 0, max: 5 })
|
||||
const state = (node as unknown as DynamicGroupNode).comfyDynamic
|
||||
.dynamicGroup['g']
|
||||
state.addRow()
|
||||
state.addRow()
|
||||
|
||||
const row2Input = node.addInput('g.2.a', 'STRING')
|
||||
row2Input.widget = { name: 'g.2.a' }
|
||||
|
||||
state.removeRow(1)
|
||||
|
||||
expect(row2Input.name).toBe('g.1.a')
|
||||
expect(row2Input.widget?.name).toBe('g.1.a')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,13 +13,11 @@ 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'
|
||||
@@ -30,18 +28,6 @@ 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>> }
|
||||
@@ -228,229 +214,7 @@ function dynamicComboWidget(
|
||||
return { widget, minWidth, minHeight }
|
||||
}
|
||||
|
||||
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 removeGroupInputs(
|
||||
node: DynamicGroupNode,
|
||||
predicate: (name: string) => boolean
|
||||
): void {
|
||||
for (let i = node.inputs.length - 1; i >= 0; i--) {
|
||||
if (predicate(node.inputs[i].name)) node.removeInput(i)
|
||||
}
|
||||
}
|
||||
|
||||
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?.()
|
||||
removeGroupInputs(node, (name) => belongsToRow(group, 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?.()
|
||||
removeGroupInputs(node, isRowMember)
|
||||
|
||||
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
|
||||
const state = typedNode.comfyDynamic.dynamicGroup[inputName]
|
||||
if (!state) return
|
||||
const clamped = Math.min(Math.max(count, state.min), state.max)
|
||||
rebuildRows(inputName, clamped, typedNode)
|
||||
syncController(inputName, typedNode)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
controller.value = min
|
||||
|
||||
return { widget: controller }
|
||||
}
|
||||
|
||||
export const dynamicWidgets = {
|
||||
COMFY_DYNAMICCOMBO_V3: dynamicComboWidget,
|
||||
COMFY_DYNAMICGROUP_V3: dynamicGroupWidget
|
||||
}
|
||||
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
|
||||
const dynamicInputs: Record<
|
||||
string,
|
||||
(node: LGraphNode, inputSpec: InputSpecV2) => void
|
||||
|
||||
@@ -75,7 +75,6 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
|
||||
// Vue widget options
|
||||
disabled?: boolean
|
||||
removable?: boolean
|
||||
useGrouping?: boolean
|
||||
placeholder?: string
|
||||
showThumbnails?: boolean
|
||||
|
||||
@@ -911,8 +911,8 @@
|
||||
"nodes": "Nodes",
|
||||
"models": "Models",
|
||||
"assets": "Assets",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates",
|
||||
"console": "Console",
|
||||
"menu": "Menu",
|
||||
"imported": "Imported",
|
||||
@@ -2253,12 +2253,6 @@
|
||||
"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",
|
||||
@@ -3093,7 +3087,7 @@
|
||||
"share": "Share"
|
||||
},
|
||||
"shortcuts": {
|
||||
"shortcuts": "Shortcuts",
|
||||
"shortcuts": "Shortcuts",
|
||||
"essentials": "Essential",
|
||||
"viewControls": "View Controls",
|
||||
"manageShortcuts": "Manage Shortcuts",
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { DynamicGroupNode } from '@/core/graph/widgets/dynamicWidgets'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetDynamicGroup from './WidgetDynamicGroup.vue'
|
||||
|
||||
const appMocks = vi.hoisted(() => ({
|
||||
graph: null as LGraph | null
|
||||
}))
|
||||
|
||||
const FieldStub = vi.hoisted(() => ({
|
||||
name: 'FieldStub',
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
widget: { type: Object, required: true }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<input data-testid="field" :aria-label="widget.name" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
get graph() {
|
||||
return appMocks.graph
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
|
||||
() => ({
|
||||
getComponent: () => FieldStub
|
||||
})
|
||||
)
|
||||
|
||||
const ButtonStub = {
|
||||
name: 'Button',
|
||||
props: { disabled: Boolean },
|
||||
template: '<button type="button" :disabled="disabled"><slot /></button>'
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
dynamicGroup: {
|
||||
addGroup: 'Add {group_name}',
|
||||
removeGroup: 'Remove {group_name}',
|
||||
group: '{group_name} #{index}',
|
||||
defaultGroupName: 'Group'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function fieldWidget(name: string, value = ''): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
type: 'string',
|
||||
value,
|
||||
options: {},
|
||||
y: 0
|
||||
}
|
||||
}
|
||||
|
||||
function createDynamicGroupNode({
|
||||
min = 1,
|
||||
max = 3,
|
||||
groupName = 'Lora',
|
||||
fieldsPerRow = ['text'],
|
||||
rows = [0]
|
||||
}: {
|
||||
min?: number
|
||||
max?: number
|
||||
groupName?: string
|
||||
fieldsPerRow?: string[]
|
||||
rows?: number[]
|
||||
} = {}): DynamicGroupNode {
|
||||
const node = new LGraphNode('test') as DynamicGroupNode &
|
||||
Partial<HasInitialMinSize>
|
||||
node._initialMinSize = { width: 1, height: 1 }
|
||||
node.widgets = [
|
||||
{
|
||||
name: 'loras',
|
||||
type: 'dynamic_group',
|
||||
value: rows.length,
|
||||
options: { min, max },
|
||||
y: 0
|
||||
},
|
||||
...rows.flatMap((row) =>
|
||||
fieldsPerRow.map((field) => fieldWidget(`loras.${row}.${field}`))
|
||||
)
|
||||
]
|
||||
|
||||
const state = {
|
||||
min,
|
||||
max,
|
||||
groupName,
|
||||
inputSpecs: [],
|
||||
addRow: vi.fn(),
|
||||
removeRow: vi.fn()
|
||||
}
|
||||
node.comfyDynamic = { dynamicGroup: { loras: state } }
|
||||
|
||||
const graph = new LGraph()
|
||||
graph.add(node)
|
||||
appMocks.graph = graph
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
function mountWidgetDynamicGroup(node: DynamicGroupNode) {
|
||||
const state = node.comfyDynamic.dynamicGroup.loras
|
||||
const widget: SimplifiedWidget<number> = {
|
||||
name: 'loras',
|
||||
type: 'dynamic_group',
|
||||
value: node.widgets!.filter((w) => w.name.startsWith('loras.')).length,
|
||||
options: { min: state.min, max: state.max }
|
||||
}
|
||||
|
||||
return render(WidgetDynamicGroup, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { Button: ButtonStub }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
nodeId: toNodeId(String(node.id)),
|
||||
nodeType: 'testnode'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WidgetDynamicGroup', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia())
|
||||
appMocks.graph = null
|
||||
})
|
||||
|
||||
it('renders one row per field widget with the configured group name', () => {
|
||||
mountWidgetDynamicGroup(createDynamicGroupNode({ min: 2, rows: [0, 1] }))
|
||||
|
||||
expect(screen.getByText('Lora #1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lora #2')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('field')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders multiple fields per row', () => {
|
||||
mountWidgetDynamicGroup(
|
||||
createDynamicGroupNode({
|
||||
rows: [0, 1],
|
||||
fieldsPerRow: ['text', 'strength']
|
||||
})
|
||||
)
|
||||
|
||||
expect(screen.getAllByTestId('field')).toHaveLength(4)
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'loras.0.text' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('textbox', { name: 'loras.1.strength' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls addRow when the add button is clicked', async () => {
|
||||
const node = createDynamicGroupNode({ min: 1, max: 3 })
|
||||
const user = userEvent.setup()
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
await user.click(screen.getByRole('button', { name: 'Add Lora' }))
|
||||
|
||||
expect(node.comfyDynamic.dynamicGroup.loras.addRow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls removeRow with the correct row index', async () => {
|
||||
const node = createDynamicGroupNode({ min: 0, max: 3, rows: [0, 1, 2] })
|
||||
const user = userEvent.setup()
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
const removeButtons = screen.getAllByRole('button', { name: 'Remove Lora' })
|
||||
await user.click(removeButtons[2]!)
|
||||
|
||||
expect(node.comfyDynamic.dynamicGroup.loras.removeRow).toHaveBeenCalledWith(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
it('only shows remove buttons for rows above the minimum', () => {
|
||||
mountWidgetDynamicGroup(createDynamicGroupNode({ min: 2, rows: [0, 1, 2] }))
|
||||
|
||||
const removeButtons = screen.getAllByRole('button', { name: 'Remove Lora' })
|
||||
expect(removeButtons).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides all remove buttons when row count equals min', () => {
|
||||
mountWidgetDynamicGroup(createDynamicGroupNode({ min: 1, rows: [0] }))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove Lora' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables the add button when the group is at max capacity', () => {
|
||||
mountWidgetDynamicGroup(
|
||||
createDynamicGroupNode({ min: 0, max: 2, rows: [0, 1] })
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add Lora' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables the add button when below max capacity', () => {
|
||||
mountWidgetDynamicGroup(
|
||||
createDynamicGroupNode({ min: 0, max: 3, rows: [0, 1] })
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add Lora' })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('updates a field widget value when edited', async () => {
|
||||
const node = createDynamicGroupNode({ rows: [0] })
|
||||
const rowWidget = node.widgets!.find((w) => w.name === 'loras.0.text')!
|
||||
const user = userEvent.setup()
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
|
||||
const field = screen.getByTestId('field')
|
||||
await user.clear(field)
|
||||
await user.type(field, 'my-lora')
|
||||
|
||||
expect(rowWidget.value).toBe('my-lora')
|
||||
})
|
||||
|
||||
it('uses the default group name when groupName is not configured', () => {
|
||||
const node = createDynamicGroupNode({ rows: [0] })
|
||||
node.comfyDynamic.dynamicGroup.loras.groupName = undefined
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
|
||||
expect(screen.getByText('Group #1')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add Group' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nothing when the node is not on the graph', () => {
|
||||
const node = createDynamicGroupNode({ rows: [0, 1] })
|
||||
appMocks.graph = null
|
||||
|
||||
mountWidgetDynamicGroup(node)
|
||||
|
||||
expect(screen.queryAllByTestId('field')).toHaveLength(0)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Add Group' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
<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,10 +75,6 @@ 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,
|
||||
@@ -245,14 +241,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['COLORS'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'dynamic_group',
|
||||
{
|
||||
component: WidgetDynamicGroup,
|
||||
aliases: [],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -304,7 +304,6 @@ 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
|
||||
}
|
||||
|
||||
|
||||