Compare commits

..

2 Commits

Author SHA1 Message Date
github-actions
870700d7ee [automated] Update website screenshot expectations 2026-07-04 07:07:08 +00:00
Mobeen Abdullah
61919b2702 fix(website): center button labels by tuning ppformula-text-center
The PP Formula optical-centering shift was 0.19em, which over-corrected
and left every button/badge/nav label about 1.4px low. Reduce it to
0.1em, measured via canvas glyph-ink metrics (~0.09-0.10em centers
uppercase). It is a position:relative nudge only, so no box sizes or
layout change. Verified no regressions across Button, BrandButton,
ButtonPill, Badge, nav, and DownloadLocalButton on 12 pages.
2026-07-04 09:57:55 +05:00
24 changed files with 9 additions and 1060 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -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

View File

@@ -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')
})
})

View File

@@ -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'),

View File

@@ -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
}
}

View File

@@ -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([])
})
})

View File

@@ -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 ?? {}

View File

@@ -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')
})
})

View File

@@ -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

View File

@@ -75,7 +75,6 @@ export interface IWidgetOptions<TValues = unknown> {
// Vue widget options
disabled?: boolean
removable?: boolean
useGrouping?: boolean
placeholder?: string
showThumbnails?: boolean

View File

@@ -911,8 +911,8 @@
"nodes": "Nodes",
"models": "Models",
"assets": "Assets",
"workflows": "Workflows",
"templates": "Templates",
"workflows": "Work­flows",
"templates": "Tem­plates",
"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": "Short­cuts",
"essentials": "Essential",
"viewControls": "View Controls",
"manageShortcuts": "Manage Shortcuts",

View File

@@ -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()
})
})

View File

@@ -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>

View File

@@ -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
}
]
]

View File

@@ -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
}