Compare commits

..

10 Commits

Author SHA1 Message Date
Christian Byrne
5b35ee6eed Merge branch 'main' into test/cov-app 2026-06-25 18:57:12 -07:00
Connor Byrne
f91b2ba771 docs: drop @deprecated tag from canonical isApiJson
This is now the canonical location; the wrapper in app.ts points here as
the replacement, so the tag has no valid target.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#discussion_r3263065147
2026-06-25 18:55:38 -07:00
Connor Byrne
39bfb900f6 refactor: use const for entityMap in sanitizeNodeName
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#discussion_r3263065145
2026-06-25 18:55:19 -07:00
Connor Byrne
5efbc325f7 refactor: use named isObject import from es-toolkit/compat
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#discussion_r3263065149
2026-06-25 18:54:31 -07:00
Alexander Brown
7e4f58ca26 Merge branch 'main' into test/cov-app 2026-05-18 17:33:08 -07:00
Christian Byrne
027ddeb427 Merge branch 'main' into test/cov-app 2026-05-04 13:26:57 -07:00
bymyself
eb4c397808 fix: guard positionBatchLayout against empty input
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#discussion_r3114705436
2026-04-21 18:45:53 -07:00
Christian Byrne
484f6dc341 Merge branch 'main' into test/cov-app 2026-04-20 19:46:44 -07:00
bymyself
c2e06edf87 refactor: extract pure functions from ComfyApp into appUtil
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11400#pullrequestreview-2907693605

- Extract sanitizeNodeName, isApiJson, stackNodesVertically,
  positionBatchLayout into src/scripts/appUtil.ts as pure functions
- ComfyApp methods delegate to the extracted functions
- Replace mock-heavy tests (deprecated getter assertions, private method
  bracket-notation access, store delegation checks) with zero-mock pure
  function tests in appUtil.test.ts
- Restore app.test.ts to its existing integration tests that use minimal
  mocking of owned modules (usePaste, litegraphUtil)
2026-04-20 19:26:46 -07:00
bymyself
90799092d5 test: extend unit tests for ComfyApp
Cover sanitizeNodeName, getPreviewFormatParam, getRandParam,
onClipspaceEditorClosed, nodeOutputs setter, deprecated getters
(lastNodeErrors, lastExecutionError, runningNodeId, storageLocation,
isNewUserSession, shiftDown, widgets, extensions, progress),
showMissingNodesError, isApiJson, isGraphReady, configuringGraph,
positionNodes, and clientPosToCanvasPos/canvasPosToClientPos.
2026-04-18 21:56:53 -07:00
13 changed files with 248 additions and 647 deletions

View File

@@ -344,15 +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)
})
])
export const zMatchTypeOptions = z.object({
...zBaseInputOptions.shape,
type: z.literal('COMFY_MATCHTYPE_V3'),

View File

@@ -84,7 +84,6 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
removable?: boolean
values?: unknown
}
/** Input specification from node definition */
@@ -214,8 +213,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,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,9 +1,7 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test, vi } from 'vitest'
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 { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
@@ -49,22 +47,6 @@ 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,
@@ -305,101 +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.__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'])
})
})

View File

@@ -2,12 +2,10 @@ import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { t } from '@/i18n'
import type {
ISlotType,
INodeInputSlot,
INodeOutputSlot,
Point
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -15,14 +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 { 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'
@@ -33,15 +28,6 @@ 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>> }
@@ -228,321 +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 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
}
export const dynamicWidgets = { COMFY_DYNAMICCOMBO_V3: dynamicComboWidget }
const dynamicInputs: Record<
string,
(node: LGraphNode, inputSpec: InputSpecV2) => void

View File

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

View File

@@ -2237,11 +2237,6 @@
"slots": "Node Slots Error",
"widgets": "Node Widgets Error"
},
"dynamicGroup": {
"addRow": "Add row",
"removeRow": "Remove row",
"row": "Row {index}"
},
"oauth": {
"consent": {
"allow": "Continue",

View File

@@ -1,34 +0,0 @@
<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>

View File

@@ -1,43 +0,0 @@
<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>

View File

@@ -75,14 +75,6 @@ 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,
@@ -249,22 +241,6 @@ 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
}
]
]

View File

@@ -165,19 +165,18 @@ import {
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
export function sanitizeNodeName(string: string) {
let entityMap = {
'&': '',
'<': '',
'>': '',
'"': '',
"'": '',
'`': '',
'=': ''
}
return String(string).replace(/[&<>"'`=]/g, function fromEntityMap(s) {
return entityMap[s as keyof typeof entityMap]
})
import {
isApiJson,
positionBatchLayout,
sanitizeNodeName,
stackNodesVertically
} from './appUtil'
export {
isApiJson,
positionBatchLayout,
sanitizeNodeName,
stackNodesVertically
}
function syncPromotedComboHostOptions(rootGraph: LGraph): void {
@@ -1988,61 +1987,20 @@ export class ComfyApp {
this.canvas.selectItems(videoNodes)
}
/**
* Positions batched nodes in drag and drop
* @param nodes
* @param batchNode
*/
positionNodes(nodes: LGraphNode[]): void {
if (nodes.length <= 1) return
const [x, y] = nodes[0].getBounding()
const nodeHeight = 150
nodes.forEach((node, index) => {
if (index > 0) {
node.pos = [x, y + nodeHeight * index + 25 * (index + 1)]
}
})
this.canvas.graph?.change()
if (stackNodesVertically(nodes)) {
this.canvas.graph?.change()
}
}
positionBatchNodes(nodes: LGraphNode[], batchNode: LGraphNode): void {
const [x, y, width] = nodes[0].getBounding()
batchNode.pos = [x + width + 100, y + 30]
// Retrieving Node Height is inconsistent
let height = 0
if (nodes[0].type === 'LoadImage') {
height = 344
}
nodes.forEach((node, index) => {
if (index > 0) {
node.pos = [x, y + height * index + 25 * (index + 1)]
}
})
positionBatchLayout(nodes, batchNode)
this.canvas.graph?.change()
}
// @deprecated
/** @deprecated Use {@link isApiJson} from @/scripts/appUtil instead */
isApiJson(data: unknown): data is ComfyApiWorkflow {
if (!_.isObject(data) || Array.isArray(data)) {
return false
}
if (Object.keys(data).length === 0) return false
return Object.values(data).every((node) => {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return false
}
const { class_type: classType, inputs } = node as Record<string, unknown>
const inputsIsRecord = _.isObject(inputs) && !Array.isArray(inputs)
return typeof classType === 'string' && inputsIsRecord
})
return isApiJson(data)
}
loadApiJson(apiData: ComfyApiWorkflow, fileName: string) {

130
src/scripts/appUtil.test.ts Normal file
View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest'
import {
isApiJson,
positionBatchLayout,
sanitizeNodeName,
stackNodesVertically
} from './appUtil'
function createNodeLike(
pos: [number, number],
bounding: number[],
type = 'LoadImage'
) {
return {
pos,
type,
getBounding: () => new Float64Array(bounding)
}
}
describe('sanitizeNodeName', () => {
it('strips dangerous HTML entity characters', () => {
expect(sanitizeNodeName('a&b<c>d"e\'f`g=h')).toBe('abcdefgh')
})
it('returns the string unchanged when no entities are present', () => {
expect(sanitizeNodeName('KSampler')).toBe('KSampler')
})
it('handles empty string', () => {
expect(sanitizeNodeName('')).toBe('')
})
})
describe('isApiJson', () => {
it('accepts valid API workflow data', () => {
const data = {
'1': { class_type: 'KSampler', inputs: { seed: 42 } },
'2': { class_type: 'CLIPTextEncode', inputs: { text: 'hello' } }
}
expect(isApiJson(data)).toBe(true)
})
it('rejects empty object', () => {
expect(isApiJson({})).toBe(false)
})
it('rejects arrays', () => {
expect(isApiJson([1, 2, 3])).toBe(false)
})
it('rejects non-objects', () => {
expect(isApiJson('string')).toBe(false)
expect(isApiJson(42)).toBe(false)
expect(isApiJson(null)).toBe(false)
})
it('rejects when a node lacks class_type', () => {
expect(isApiJson({ '1': { inputs: { seed: 42 } } })).toBe(false)
})
it('rejects when inputs is an array instead of object', () => {
expect(isApiJson({ '1': { class_type: 'KSampler', inputs: [1, 2] } })).toBe(
false
)
})
})
describe('stackNodesVertically', () => {
it('returns false for a single node', () => {
const node = createNodeLike([100, 200], [100, 200, 300, 400])
expect(stackNodesVertically([node])).toBe(false)
expect(node.pos).toEqual([100, 200])
})
it('stacks multiple nodes below the first', () => {
const node1 = createNodeLike([100, 200], [100, 200, 300, 400])
const node2 = createNodeLike([0, 0], [0, 0, 200, 100])
const node3 = createNodeLike([0, 0], [0, 0, 200, 100])
expect(stackNodesVertically([node1, node2, node3])).toBe(true)
expect(node1.pos).toEqual([100, 200])
expect(node2.pos).toEqual([100, 400])
expect(node3.pos).toEqual([100, 575])
})
it('returns false for empty array', () => {
expect(stackNodesVertically([])).toBe(false)
})
})
describe('positionBatchLayout', () => {
it('places batch node to the right of the first node', () => {
const node1 = createNodeLike([100, 200], [100, 200, 300, 400])
const batchNode = createNodeLike([0, 0], [0, 0, 0, 0])
positionBatchLayout([node1], batchNode)
expect(batchNode.pos).toEqual([500, 230])
})
it('stacks LoadImage nodes at 344px height intervals', () => {
const node1 = createNodeLike([100, 200], [100, 200, 300, 400], 'LoadImage')
const node2 = createNodeLike([0, 0], [0, 0, 200, 100], 'LoadImage')
const batchNode = createNodeLike([0, 0], [0, 0, 0, 0])
positionBatchLayout([node1, node2], batchNode)
expect(node1.pos).toEqual([100, 200])
expect(node2.pos).toEqual([100, 544 + 50])
})
it('handles empty nodes array without throwing', () => {
const batchNode = createNodeLike([50, 50], [0, 0, 0, 0])
positionBatchLayout([], batchNode)
expect(batchNode.pos).toEqual([50, 50])
})
it('uses zero height for non-LoadImage nodes', () => {
const node1 = createNodeLike([100, 200], [100, 200, 300, 400], 'Other')
const node2 = createNodeLike([0, 0], [0, 0, 200, 100], 'Other')
const batchNode = createNodeLike([0, 0], [0, 0, 0, 0])
positionBatchLayout([node1, node2], batchNode)
expect(node2.pos).toEqual([100, 200 + 50])
})
})

95
src/scripts/appUtil.ts Normal file
View File

@@ -0,0 +1,95 @@
import { isObject } from 'es-toolkit/compat'
import type { Rect } from '@/lib/litegraph/src/interfaces'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
/**
* Strips dangerous HTML entity characters from node names.
*/
export function sanitizeNodeName(string: string) {
const entityMap = {
'&': '',
'<': '',
'>': '',
'"': '',
"'": '',
'`': '',
'=': ''
}
return String(string).replace(/[&<>"'`=]/g, function fromEntityMap(s) {
return entityMap[s as keyof typeof entityMap]
})
}
/**
* Checks whether the given data conforms to the ComfyUI API workflow format.
* Each top-level value must have a string `class_type` and an object `inputs`.
*/
export function isApiJson(data: unknown): data is ComfyApiWorkflow {
if (!isObject(data) || Array.isArray(data)) {
return false
}
if (Object.keys(data).length === 0) return false
return Object.values(data).every((node) => {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return false
}
const { class_type: classType, inputs } = node as Record<string, unknown>
const inputsIsRecord = isObject(inputs) && !Array.isArray(inputs)
return typeof classType === 'string' && inputsIsRecord
})
}
/**
* Vertically stacks nodes below the first node's position.
* Returns `true` if any positions were changed.
*/
export function stackNodesVertically(
nodes: { pos: [number, number]; getBounding: () => Rect }[]
): boolean {
if (nodes.length <= 1) return false
const [x, y] = nodes[0].getBounding()
const nodeHeight = 150
nodes.forEach((node, index) => {
if (index > 0) {
node.pos = [x, y + nodeHeight * index + 25 * (index + 1)]
}
})
return true
}
/**
* Positions image nodes vertically and places a batch node to their right.
*/
export function positionBatchLayout(
nodes: {
pos: [number, number]
type: string
getBounding: () => Rect
}[],
batchNode: { pos: [number, number] }
): void {
if (nodes.length === 0) {
return
}
const [x, y, width] = nodes[0].getBounding()
batchNode.pos = [x + width + 100, y + 30]
// Retrieving Node Height is inconsistent
let height = 0
if (nodes[0].type === 'LoadImage') {
height = 344
}
nodes.forEach((node, index) => {
if (index > 0) {
node.pos = [x, y + height * index + 25 * (index + 1)]
}
})
}