Compare commits

...

7 Commits

Author SHA1 Message Date
bymyself
29af7857e0 fix test 2025-11-13 17:10:17 -08:00
bymyself
6acc7b07ca feat: update NumberControlPopover with semantic design tokens 2025-11-13 12:28:26 -08:00
bymyself
04bd165acb handle legacy step value 2025-11-11 11:49:47 -08:00
bymyself
cfaeed183e seed widget2 2025-11-11 11:49:47 -08:00
bymyself
57025df8df seed widget 2025-11-11 11:49:47 -08:00
bymyself
f853cb9fbe don't use forEach 2025-11-11 11:49:47 -08:00
bymyself
23fdbe4c88 add control_after_generate ui 2025-11-11 11:49:47 -08:00
26 changed files with 1418 additions and 213 deletions

View File

@@ -17,6 +17,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue } from '@/types/simplifiedWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import type {
LGraph,
@@ -33,6 +34,11 @@ export interface WidgetSlotMetadata {
linked: boolean
}
type NumericWidgetOptions = Record<string, unknown> & {
step?: number
step2?: number
}
export interface SafeWidgetData {
name: string
type: string
@@ -127,6 +133,25 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
? String(node.graph.id)
: null
// Extract safe widget data
const cloneWidgetOptions = (widget: IBaseWidget) => {
const options = widget.options
? ({ ...widget.options } as NumericWidgetOptions)
: undefined
if (
options &&
(widget.type === 'number' || widget.type === 'slider') &&
options.step2 === undefined &&
typeof options.step === 'number'
) {
const baseStep = Number.isFinite(options.step)
? (options.step as number)
: 10
const legacyStep = baseStep === 0 ? 10 : baseStep
options.step2 = legacyStep * 0.1
}
return options
}
const slotMetadata = new Map<string, WidgetSlotMetadata>()
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
@@ -140,6 +165,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgets: SafeWidgetData[] = []
node.inputs?.forEach((input, index) => {
if (!input?.widget?.name) return
slotMetadata.set(input.widget.name, {
@@ -147,45 +173,54 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
linked: input.link != null
})
})
return (
node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
node.widgets?.forEach((widget) => {
if (
(widget as IBaseWidget & { [IS_CONTROL_WIDGET]?: boolean })[
IS_CONTROL_WIDGET
]
) {
return
}
return {
name: widget.name,
type: widget.type,
value: value,
label: widget.label,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
}
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
}) ?? []
)
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
widgets.push({
name: widget.name,
type: widget.type,
value,
label: widget.label,
options: cloneWidgetOptions(widget),
callback: widget.callback,
spec,
slotMetadata: slotInfo,
isDOMWidget: isDOMWidget(widget)
})
} catch (error) {
widgets.push({
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
})
}
})
return widgets
})
const nodeType =

View File

@@ -2069,6 +2069,22 @@
"placeholderVideo": "Select video...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media..."
},
"numberControl": {
"controlHeaderBefore": "Automatically update the value",
"controlHeaderAfter": "AFTER",
"controlHeaderBefore2": "BEFORE",
"controlHeaderEnd": "running the workflow:",
"linkToGlobal": "Link to",
"linkToGlobalSeed": "Global Value",
"linkToGlobalDesc": "Unique value linked to the Global Value's control setting",
"randomize": "Randomize Value",
"randomizeDesc": "Shuffles the value randomly after each generation",
"increment": "Increment Value",
"incrementDesc": "Adds 1 to the value number",
"decrement": "Decrement Value",
"decrementDesc": "Subtracts 1 from the value number",
"editSettings": "Edit control settings"
}
},
"nodeHelpPage": {

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { NumberControlMode } from '../composables/useStepperControl'
type ControlOption = {
mode: NumberControlMode
icon?: string
title: string
description: string
text?: string
}
const popover = ref()
const settingStore = useSettingStore()
const dialogService = useDialogService()
const toggle = (event: Event) => {
popover.value.toggle(event)
}
defineExpose({ toggle })
const ENABLE_LINK_TO_GLOBAL = false
const controlOptions: ControlOption[] = [
...(ENABLE_LINK_TO_GLOBAL
? ([
{
mode: NumberControlMode.LINK_TO_GLOBAL,
icon: 'pi pi-link',
title: 'linkToGlobal',
description: 'linkToGlobalDesc'
} satisfies ControlOption
] as ControlOption[])
: []),
{
mode: NumberControlMode.RANDOMIZE,
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
},
{
mode: NumberControlMode.INCREMENT,
text: '+1',
title: 'increment',
description: 'incrementDesc'
},
{
mode: NumberControlMode.DECREMENT,
text: '-1',
title: 'decrement',
description: 'decrementDesc'
}
]
const widgetControlMode = computed(() =>
settingStore.get('Comfy.WidgetControlMode')
)
const props = defineProps<{
controlMode: NumberControlMode
}>()
const emit = defineEmits<{
'update:controlMode': [mode: NumberControlMode]
}>()
const handleToggle = (mode: NumberControlMode) => {
if (props.controlMode === mode) return
emit('update:controlMode', mode)
}
const isActive = (mode: NumberControlMode) => {
return props.controlMode === mode
}
const handleEditSettings = () => {
popover.value.hide()
dialogService.showSettingsDialog()
}
</script>
<template>
<Popover
ref="popover"
class="bg-interface-panel-surface border border-interface-stroke rounded-lg"
>
<!-- Responsive width with proper constraints -->
<div class="w-113 max-w-md p-4 space-y-4">
<!-- Header text with semantic tokens -->
<div class="text-sm text-muted-foreground leading-tight">
{{ $t('widgets.numberControl.controlHeaderBefore') }}
<span class="text-base-foreground font-medium">
{{
widgetControlMode === 'before'
? $t('widgets.numberControl.controlHeaderBefore2')
: $t('widgets.numberControl.controlHeaderAfter')
}}
</span>
{{ $t('widgets.numberControl.controlHeaderEnd') }}
</div>
<!-- Control options with proper spacing -->
<div class="space-y-2">
<div
v-for="option in controlOptions"
:key="option.mode"
class="flex items-center justify-between py-2 gap-7"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<!-- Icon container with semantic background -->
<div
class="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0 bg-secondary-background border border-border-subtle"
>
<i
v-if="option.icon"
:class="option.icon"
class="text-base text-base-foreground"
/>
<span
v-if="option.text"
class="text-xs font-normal text-base-foreground"
>
{{ option.text }}
</span>
</div>
<!-- Text content with proper semantic colors -->
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<div
class="text-sm font-normal text-base-foreground leading-tight"
>
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
{{ $t('widgets.numberControl.linkToGlobal') }}
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
</span>
<span v-else>
{{ $t(`widgets.numberControl.${option.title}`) }}
</span>
</div>
<div
class="text-sm font-normal text-muted-foreground leading-tight"
>
{{ $t(`widgets.numberControl.${option.description}`) }}
</div>
</div>
</div>
<!-- Toggle switch with proper sizing -->
<ToggleSwitch
:model-value="isActive(option.mode)"
class="flex-shrink-0"
@update:model-value="handleToggle(option.mode)"
/>
</div>
</div>
<!-- Divider using semantic border -->
<div class="border-t border-border-subtle"></div>
<!-- Settings button with semantic styling -->
<Button
class="w-full bg-secondary-background hover:bg-secondary-background-hover border-0 rounded-lg p-2 text-sm"
@click="handleEditSettings"
>
<div class="flex items-center justify-center gap-1">
<i class="pi pi-cog text-xs text-muted-foreground" />
<span class="font-normal text-base-foreground">{{
$t('widgets.numberControl.editSettings')
}}</span>
</div>
</Button>
</div>
</Popover>
</template>

View File

@@ -1,22 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
defineProps<{
const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const modelValue = defineModel<number>({ default: 0 })
const hasControlAfterGenerate = computed(() => {
return props.widget.spec?.control_after_generate === true
})
</script>
<template>
<component
:is="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
hasControlAfterGenerate
? WidgetInputNumberWithControl
: widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-model="modelValue"
:widget="widget"

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -10,6 +10,7 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { useNumberStepCalculation } from '../composables/useNumberStepCalculation'
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -29,6 +30,15 @@ const { localValue, onChange } = useNumberWidgetValue(
emit
)
watch(
() => props.modelValue,
(newValue) => {
if (newValue !== localValue.value) {
localValue.value = newValue
}
}
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
@@ -41,28 +51,10 @@ const precision = computed(() => {
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (props.widget.options?.step2 !== undefined) {
return Number(props.widget.options.step2)
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {
return 1
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return Number((1 / Math.pow(10, precision.value)).toFixed(precision.value))
}
// Default to 'any' for unrestricted stepping
return 0
})
const stepValue = useNumberStepCalculation(props.widget.options, precision)
// Disable grouping separators by default unless explicitly enabled by the node author
const useGrouping = computed(() => {
return props.widget.options?.useGrouping === true
})
const useGrouping = computed(() => props.widget.options?.useGrouping === true)
// Check if increment/decrement buttons should be disabled due to precision limits
const buttonsDisabled = computed(() => {
@@ -73,6 +65,7 @@ const buttonsDisabled = computed(() => {
)
})
// Tooltip message for disabled buttons
const buttonTooltip = computed(() => {
if (buttonsDisabled.value) {
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'

View File

@@ -45,6 +45,7 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { useNumberStepCalculation } from '../composables/useNumberStepCalculation'
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -68,7 +69,7 @@ const updateLocalValue = (newValue: number[] | undefined): void => {
}
const handleNumberInputUpdate = (newValue: number | undefined) => {
if (newValue) {
if (newValue !== undefined) {
updateLocalValue([newValue])
return
}
@@ -87,25 +88,7 @@ const precision = computed(() => {
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (widget.options?.step2 !== undefined) {
return widget.options.step2
}
// Otherwise, derive from precision
if (precision.value === undefined) {
return undefined
}
if (precision.value === 0) {
return 1
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return 1 / Math.pow(10, precision.value)
})
const stepValue = useNumberStepCalculation(widget.options, precision, true)
const buttonsDisabled = computed(() => {
const currentValue = localValue.value ?? 0

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { defineAsyncComponent, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useControlButtonIcon } from '../composables/useControlButtonIcon'
import {
NumberControlMode,
useStepperControl
} from '../composables/useStepperControl'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
const NumberControlPopover = defineAsyncComponent(
() => import('./NumberControlPopover.vue')
)
const props = defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const modelValue = defineModel<number>({ default: 0 })
const popover = ref()
const handleControlChange = (newValue: number) => {
modelValue.value = newValue
emit('update:modelValue', newValue)
}
const { controlMode } = useStepperControl(modelValue, {
...props.widget.options,
onChange: handleControlChange
})
if (controlMode.value === NumberControlMode.FIXED) {
controlMode.value = NumberControlMode.RANDOMIZE
}
const controlButtonIcon = useControlButtonIcon(controlMode)
const setControlMode = (mode: NumberControlMode) => {
controlMode.value = mode
}
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>
<template>
<div class="relative">
<WidgetInputNumberInput
v-model="modelValue"
:widget="widget"
:readonly="readonly"
/>
<Button
variant="link"
size="small"
class="absolute top-1/2 right-12 h-4 w-7 -translate-y-1/2 rounded-xl bg-blue-100/30 p-0"
@click="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
</Button>
<NumberControlPopover
ref="popover"
:control-mode="controlMode"
@update:control-mode="setControlMode"
/>
</div>
</template>

View File

@@ -1,126 +1,111 @@
<template>
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-bind="props"
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
@update:model-value="handleUpdateModelValue"
/>
<WidgetSelectDefault
v-else
:widget="widget"
:model-value="modelValue"
@update:model-value="handleUpdateModelValue"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from 'primevue/button'
import { computed, defineAsyncComponent, ref } from 'vue'
import type { Ref } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ResultItemType } from '@/schemas/apiSchema'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import { useComboControl } from '../composables/useComboControl'
import { useControlButtonIcon } from '../composables/useControlButtonIcon'
import { NumberControlMode } from '../composables/useStepperControl'
import WidgetSelectBase from './WidgetSelectBase.vue'
const NumberControlPopover = defineAsyncComponent(
() => import('./NumberControlPopover.vue')
)
type ComboValue = string | number | undefined
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
nodeType?: string
widget: SimplifiedWidget<ComboValue>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
'update:modelValue': [value: ComboValue]
}>()
function handleUpdateModelValue(value: string | number | undefined) {
const modelValue = defineModel<ComboValue>({ default: undefined })
const modelRef = modelValue as Ref<ComboValue>
const hasControlAfterGenerate = computed(
() => props.widget.spec?.control_after_generate === true
)
const popover = ref()
const availableValues = computed<ComboValue[]>(() => {
const values = props.widget.options?.values
if (Array.isArray(values)) return values
if (typeof values === 'function') {
try {
const result = values()
return Array.isArray(result) ? result : []
} catch (error) {
return []
}
}
if (values && typeof values === 'object') {
return Object.values(values) as ComboValue[]
}
return []
})
const updateModelValue = (value: ComboValue) => {
modelRef.value = value
emit('update:modelValue', value)
}
const comboSpec = computed<ComboInputSpec | undefined>(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
return props.widget.spec
}
return undefined
})
const controlModeRef: Ref<NumberControlMode> = hasControlAfterGenerate.value
? (() => {
const comboControl = useComboControl(modelRef, {
values: availableValues,
onChange: updateModelValue
})
if (comboControl.controlMode.value === NumberControlMode.FIXED) {
comboControl.controlMode.value = NumberControlMode.RANDOMIZE
}
return comboControl.controlMode
})()
: ref(NumberControlMode.FIXED)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean
folder: ResultItemType | undefined
}>(() => {
const spec = comboSpec.value
if (!spec) {
return {
kind: 'unknown',
allowUpload: false,
folder: undefined
}
}
const controlButtonIcon = useControlButtonIcon(controlModeRef)
const {
image_upload,
animated_image_upload,
video_upload,
image_folder,
audio_upload
} = spec
const togglePopover = (event: Event) => {
popover.value?.toggle(event)
}
let kind: AssetKind = 'unknown'
if (video_upload) {
kind = 'video'
} else if (image_upload || animated_image_upload) {
kind = 'image'
} else if (audio_upload) {
kind = 'audio'
}
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
const allowUpload =
image_upload === true ||
animated_image_upload === true ||
video_upload === true ||
audio_upload === true
return {
kind,
allowUpload,
folder: image_folder
}
})
const isAssetMode = computed(() => {
if (isCloud) {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
props.nodeType,
props.widget.name
)
return isUsingAssetAPI && isEligible
}
return false
})
const assetKind = computed(() => specDescriptor.value.kind)
const isDropdownUIWidget = computed(
() => isAssetMode.value || assetKind.value !== 'unknown'
)
const allowUpload = computed(() => specDescriptor.value.allowUpload)
const uploadFolder = computed<ResultItemType>(() => {
return specDescriptor.value.folder ?? 'input'
})
const defaultLayoutMode = computed<LayoutMode>(() => {
return isAssetMode.value ? 'list' : 'grid'
})
const setControlMode = (mode: NumberControlMode) => {
controlModeRef.value = mode
}
</script>
<template>
<div v-if="hasControlAfterGenerate" class="relative">
<WidgetSelectBase
:widget="widget"
:model-value="modelValue"
@update:model-value="updateModelValue"
/>
<Button
variant="link"
size="small"
class="absolute top-1/2 right-12 h-4 w-7 -translate-y-1/2 rounded-xl bg-blue-100/30 p-0"
@click="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
</Button>
<NumberControlPopover
ref="popover"
:control-mode="controlModeRef"
@update:control-mode="setControlMode"
/>
</div>
<WidgetSelectBase
v-else
:widget="widget"
:model-value="modelValue"
@update:model-value="updateModelValue"
/>
</template>

View File

@@ -0,0 +1,99 @@
<template>
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-bind="props"
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
@update:model-value="handleUpdateModelValue"
/>
<WidgetSelectDefault
v-else
v-bind="props"
@update:model-value="handleUpdateModelValue"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import WidgetSelectDefault from './WidgetSelectDefault.vue'
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
function handleUpdateModelValue(value: string | number | undefined) {
emit('update:modelValue', value)
}
const comboSpec = computed<ComboInputSpec | undefined>(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
return props.widget.spec
}
return undefined
})
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean
folder: ResultItemType | undefined
}>(() => {
const spec = comboSpec.value
if (!spec) {
return {
kind: 'unknown',
allowUpload: false,
folder: undefined
}
}
const {
image_upload,
animated_image_upload,
video_upload,
image_folder,
audio_upload
} = spec
let kind: AssetKind = 'unknown'
if (video_upload) {
kind = 'video'
} else if (image_upload || animated_image_upload) {
kind = 'image'
} else if (audio_upload) {
kind = 'audio'
}
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
const allowUpload =
image_upload === true ||
animated_image_upload === true ||
video_upload === true ||
audio_upload === true
return {
kind,
allowUpload,
folder: image_folder
}
})
const assetKind = computed(() => specDescriptor.value.kind)
const isDropdownUIWidget = computed(() => assetKind.value !== 'unknown')
const allowUpload = computed(() => specDescriptor.value.allowUpload)
const uploadFolder = computed<ResultItemType>(() => {
return specDescriptor.value.folder ?? 'input'
})
</script>

View File

@@ -151,11 +151,9 @@ const outputItems = computed<DropdownItem[]>(() => {
})
const allItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
return assetData.dropdownItems.value
}
return [...inputItems.value, ...outputItems.value]
})
const dropdownItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode) {
return allItems.value
@@ -168,7 +166,7 @@ const dropdownItems = computed<DropdownItem[]>(() => {
return outputItems.value
case 'all':
default:
return allItems.value
return [...inputItems.value, ...outputItems.value]
}
})

View File

@@ -0,0 +1,69 @@
import { onMounted, onUnmounted, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import { numberControlRegistry } from '../services/NumberControlRegistry'
import { NumberControlMode } from './useStepperControl'
type ComboValue = string | number | undefined
interface ComboControlOptions {
values: ComputedRef<ComboValue[]>
onChange?: (value: ComboValue) => void
}
export function useComboControl(
modelValue: Ref<ComboValue>,
options: ComboControlOptions
) {
const controlMode = ref<NumberControlMode>(NumberControlMode.FIXED)
const controlId = Symbol('comboControl')
const applyControl = () => {
const choices = options.values.value.filter(
(value): value is string | number => value !== undefined
)
if (!choices.length) return
const currentIndex = Math.max(
0,
choices.findIndex((value) => value === modelValue.value)
)
let nextValue: ComboValue = modelValue.value
switch (controlMode.value) {
case NumberControlMode.FIXED:
return
case NumberControlMode.INCREMENT:
nextValue = choices[Math.min(currentIndex + 1, choices.length - 1)]
break
case NumberControlMode.DECREMENT:
nextValue = choices[Math.max(currentIndex - 1, 0)]
break
case NumberControlMode.RANDOMIZE:
nextValue = choices[Math.floor(Math.random() * choices.length)]
break
default:
return
}
if (options.onChange) {
options.onChange(nextValue)
} else {
modelValue.value = nextValue
}
}
onMounted(() => {
numberControlRegistry.register(controlId, applyControl)
})
onUnmounted(() => {
numberControlRegistry.unregister(controlId)
})
return {
controlMode,
applyControl
}
}

View File

@@ -13,16 +13,16 @@ import {
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
ComboInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { addValueControlWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidgets } from '@/scripts/widgets'
import { useAssetsStore } from '@/stores/assetsStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -69,6 +69,16 @@ const addMultiSelectWidget = (
addWidget(node, widget as BaseDOMWidget<object | string>)
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}

View File

@@ -0,0 +1,21 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import { NumberControlMode } from './useStepperControl'
export function useControlButtonIcon(controlMode: Ref<NumberControlMode>) {
return computed(() => {
switch (controlMode.value) {
case NumberControlMode.INCREMENT:
return 'pi pi-plus'
case NumberControlMode.DECREMENT:
return 'pi pi-minus'
case NumberControlMode.RANDOMIZE:
return 'icon-[lucide--shuffle]'
case NumberControlMode.LINK_TO_GLOBAL:
return 'pi pi-link'
default:
return 'icon-[lucide--shuffle]'
}
})
}

View File

@@ -6,6 +6,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidget } from '@/scripts/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
function onFloatValueChange(this: INumericWidget, v: number) {
const round = this.options.round
@@ -55,7 +57,7 @@ export const useFloatWidget = () => {
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
return node.addWidget(
const widget = node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
@@ -73,6 +75,20 @@ export const useFloatWidget = () => {
precision
}
)
if (inputSpec.control_after_generate) {
const controlWidget = addValueControlWidget(
node,
widget,
'randomize',
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [controlWidget]
}
return widget
}
return widgetConstructor

View File

@@ -1,11 +1,11 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { addValueControlWidget } from '@/scripts/widgets'
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { addValueControlWidget } from '@/scripts/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
function onValueChange(this: INumericWidget, v: number) {
// For integers, always round to the nearest step
@@ -69,14 +69,10 @@ export const useIntWidget = () => {
const controlAfterGenerate =
inputSpec.control_after_generate ??
/**
* Compatibility with legacy node convention. Int input with name
* 'seed' or 'noise_seed' get automatically added a control widget.
*/
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const seedControl = addValueControlWidget(
const controlWidget = addValueControlWidget(
node,
widget,
'randomize',
@@ -84,7 +80,7 @@ export const useIntWidget = () => {
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [seedControl]
widget.linkedWidgets = [controlWidget]
}
return widget

View File

@@ -0,0 +1,36 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
interface NumberWidgetOptions {
step2?: number
precision?: number
}
/**
* Shared composable for calculating step values in number input widgets
* Handles both explicit step2 values and precision-derived steps
*/
export function useNumberStepCalculation(
options: NumberWidgetOptions | undefined,
precision: Ref<number | undefined>,
returnUndefinedForDefault = false
) {
return computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (options?.step2 !== undefined) {
return Number(options.step2)
}
if (precision.value === undefined) {
return returnUndefinedForDefault ? undefined : 0
}
if (precision.value === 0) return 1
// For precision > 0, step = 1 / (10^precision)
const step = 1 / Math.pow(10, precision.value)
return returnUndefinedForDefault
? step
: Number(step.toFixed(precision.value))
})
}

View File

@@ -0,0 +1,80 @@
import { onMounted, onUnmounted, ref } from 'vue'
import type { Ref } from 'vue'
import { useGlobalSeedStore } from '@/stores/globalSeedStore'
import { numberControlRegistry } from '../services/NumberControlRegistry'
export enum NumberControlMode {
FIXED = 'fixed',
INCREMENT = 'increment',
DECREMENT = 'decrement',
RANDOMIZE = 'randomize',
LINK_TO_GLOBAL = 'linkToGlobal'
}
interface StepperControlOptions {
min?: number
max?: number
step?: number
step2?: number
onChange?: (value: number) => void
}
export function useStepperControl(
modelValue: Ref<number>,
options: StepperControlOptions
) {
const controlMode = ref<NumberControlMode>(NumberControlMode.FIXED)
const controlId = Symbol('numberControl')
const globalSeedStore = useGlobalSeedStore()
const applyControl = () => {
const { min = 0, max = 1000000, step2, step = 1, onChange } = options
// Use step2 if available (widget context), otherwise use step as-is (direct API usage)
const actualStep = step2 !== undefined ? step2 : step
let newValue: number
switch (controlMode.value) {
case NumberControlMode.FIXED:
// Do nothing - keep current value
return
case NumberControlMode.INCREMENT:
newValue = Math.min(max, modelValue.value + actualStep)
break
case NumberControlMode.DECREMENT:
newValue = Math.max(min, modelValue.value - actualStep)
break
case NumberControlMode.RANDOMIZE:
newValue = Math.floor(Math.random() * (max - min + 1)) + min
break
case NumberControlMode.LINK_TO_GLOBAL:
// Use global seed value, constrained by min/max
newValue = Math.max(min, Math.min(max, globalSeedStore.globalSeed))
break
default:
return
}
if (onChange) {
onChange(newValue)
} else {
modelValue.value = newValue
}
}
// Register with singleton registry
onMounted(() => {
numberControlRegistry.register(controlId, applyControl)
})
// Cleanup on unmount
onUnmounted(() => {
numberControlRegistry.unregister(controlId)
})
return {
controlMode,
applyControl
}
}

View File

@@ -72,7 +72,11 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
],
[
'multiselect',
{ component: WidgetMultiSelect, aliases: ['MULTISELECT'], essential: false }
{
component: WidgetMultiSelect,
aliases: ['MULTISELECT'],
essential: false
}
],
[
'selectbutton',
@@ -113,7 +117,11 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
],
[
'treeselect',
{ component: WidgetTreeSelect, aliases: ['TREESELECT'], essential: false }
{
component: WidgetTreeSelect,
aliases: ['TREESELECT'],
essential: false
}
],
[
'markdown',

View File

@@ -0,0 +1,59 @@
import { useSettingStore } from '@/platform/settings/settingStore'
/**
* Registry for managing Vue number controls with deterministic execution timing.
* Uses a simple singleton pattern with no reactivity for optimal performance.
*/
export class NumberControlRegistry {
private controls = new Map<symbol, () => void>()
/**
* Register a number control callback
*/
register(id: symbol, applyFn: () => void): void {
this.controls.set(id, applyFn)
}
/**
* Unregister a number control callback
*/
unregister(id: symbol): void {
this.controls.delete(id)
}
/**
* Execute all registered controls for the given phase
*/
executeControls(phase: 'before' | 'after'): void {
const settingStore = useSettingStore()
if (settingStore.get('Comfy.WidgetControlMode') === phase) {
for (const applyFn of this.controls.values()) {
applyFn()
}
}
}
/**
* Get the number of registered controls (for testing)
*/
getControlCount(): number {
return this.controls.size
}
/**
* Clear all registered controls (for testing)
*/
clear(): void {
this.controls.clear()
}
}
// Global singleton instance
export const numberControlRegistry = new NumberControlRegistry()
/**
* Public API function to execute number controls
*/
export function executeNumberControls(phase: 'before' | 'after'): void {
numberControlRegistry.executeControls(phase)
}

View File

@@ -31,6 +31,7 @@ import {
type NodeId,
isSubgraphDefinition
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
import type {
ExecutionErrorWsMessage,
NodeError,
@@ -1316,6 +1317,7 @@ export class ComfyApp {
for (const subgraph of this.graph.subgraphs.values()) {
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
}
executeNumberControls('before')
const p = await this.graphToPrompt(this.graph)
try {
@@ -1366,6 +1368,7 @@ export class ComfyApp {
for (const subgraph of this.graph.subgraphs.values()) {
executeWidgetsCallback(subgraph.nodes, 'afterQueued')
}
executeNumberControls('after')
this.canvas.draw(true, true)
await this.ui.queue.update()

View File

@@ -17,7 +17,6 @@ export function clone<T>(obj: T): T {
}
/**
* @knipIgnoreUnusedButUsedByCustomNodes
* @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead
* There are external callers to this function, so we need to keep it for now
*/
@@ -25,7 +24,6 @@ export function applyTextReplacements(app: ComfyApp, value: string): string {
return _applyTextReplacements(app.graph, value)
}
/** @knipIgnoreUnusedButUsedByCustomNodes */
export async function addStylesheet(
urlOrFile: string,
relativeTo?: string
@@ -51,9 +49,23 @@ export async function addStylesheet(
})
}
/** @knipIgnoreUnusedButUsedByCustomNodes */
export { downloadBlob } from '@/base/common/downloadUtil'
if (typeof window !== 'undefined') {
import('@/base/common/downloadUtil')
.then((module) => {
const fn = (
module as {
downloadBlob?: typeof import('@/base/common/downloadUtil').downloadBlob
}
).downloadBlob
if (typeof fn === 'function') {
;(window as any).downloadBlob = fn
}
})
.catch(() => {})
}
export function uploadFile(accept: string) {
return new Promise<File>((resolve, reject) => {
const input = document.createElement('input')

View File

@@ -0,0 +1,16 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useGlobalSeedStore = defineStore('globalSeed', () => {
// Global seed value that linked controls will use
const globalSeed = ref(Math.floor(Math.random() * 1000000))
const setGlobalSeed = (value: number) => {
globalSeed.value = value
}
return {
globalSeed,
setGlobalSeed
}
})

View File

@@ -0,0 +1,265 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
NumberControlMode,
useStepperControl
} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl'
// Mock the global seed store
vi.mock('@/stores/globalSeedStore', () => ({
useGlobalSeedStore: () => ({
globalSeed: 12345
})
}))
// Mock the registry to spy on calls
vi.mock(
'@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry',
() => ({
numberControlRegistry: {
register: vi.fn(),
unregister: vi.fn(),
executeControls: vi.fn(),
getControlCount: vi.fn(() => 0),
clear: vi.fn()
},
executeNumberControls: vi.fn()
})
)
describe('useStepperControl', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('initialization', () => {
it('should initialize with FIXED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode } = useStepperControl(modelValue, options)
expect(controlMode.value).toBe(NumberControlMode.FIXED)
})
it('should return control mode and apply function', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
expect(controlMode.value).toBe(NumberControlMode.FIXED)
expect(typeof applyControl).toBe('function')
})
})
describe('control modes', () => {
it('should not change value in FIXED mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 }
const { applyControl } = useStepperControl(modelValue, options)
applyControl()
expect(modelValue.value).toBe(100)
})
it('should increment value in INCREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(105)
})
it('should decrement value in DECREMENT mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 5 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT
applyControl()
expect(modelValue.value).toBe(95)
})
it('should respect min/max bounds for INCREMENT', () => {
const modelValue = ref(995)
const options = { min: 0, max: 1000, step: 10 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(1000) // Clamped to max
})
it('should respect min/max bounds for DECREMENT', () => {
const modelValue = ref(5)
const options = { min: 0, max: 1000, step: 10 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.DECREMENT
applyControl()
expect(modelValue.value).toBe(0) // Clamped to min
})
it('should randomize value in RANDOMIZE mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 10, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE
applyControl()
// Value should be within bounds
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(10)
// Run multiple times to check randomness (value should change at least once)
for (let i = 0; i < 10; i++) {
const beforeValue = modelValue.value
applyControl()
if (modelValue.value !== beforeValue) {
// Randomness working - test passes
return
}
}
// If we get here, randomness might not be working (very unlikely)
expect(true).toBe(true) // Still pass the test
})
it('should use global seed in LINK_TO_GLOBAL mode', () => {
const modelValue = ref(100)
const options = { min: 0, max: 100000, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.LINK_TO_GLOBAL
applyControl()
expect(modelValue.value).toBe(12345) // From mocked global seed store
})
it('should clamp global seed to min/max bounds', () => {
const modelValue = ref(100)
const options = { min: 20000, max: 50000, step: 1 }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.LINK_TO_GLOBAL
applyControl()
expect(modelValue.value).toBe(20000) // Global seed (12345) clamped to min (20000)
})
})
describe('default options', () => {
it('should use default options when not provided', () => {
const modelValue = ref(100)
const options = {} // Empty options
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(101) // Default step is 1
})
it('should use default min/max for randomize', () => {
const modelValue = ref(100)
const options = {} // Empty options - should use defaults
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.RANDOMIZE
applyControl()
// Should be within default bounds (0 to 1000000)
expect(modelValue.value).toBeGreaterThanOrEqual(0)
expect(modelValue.value).toBeLessThanOrEqual(1000000)
})
})
describe('onChange callback', () => {
it('should call onChange callback when provided', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(onChange).toHaveBeenCalledWith(101)
})
it('should fallback to direct assignment when onChange not provided', () => {
const modelValue = ref(100)
const options = { min: 0, max: 1000, step: 1 } // No onChange
const { controlMode, applyControl } = useStepperControl(
modelValue,
options
)
controlMode.value = NumberControlMode.INCREMENT
applyControl()
expect(modelValue.value).toBe(101)
})
it('should not call onChange in FIXED mode', () => {
const modelValue = ref(100)
const onChange = vi.fn()
const options = { min: 0, max: 1000, step: 1, onChange }
const { applyControl } = useStepperControl(modelValue, options)
// controlMode remains FIXED by default
applyControl()
expect(onChange).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import WidgetAudioUI from '@/renderer/extensions/vueNodes/widgets/components/WidgetAudioUI.vue'
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
@@ -22,7 +23,19 @@ vi.mock('@/stores/queueStore', () => ({
}))
}))
// Mock the settings store for components that might use it
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => 'before')
})
}))
describe('widgetRegistry', () => {
beforeEach(() => {
// Create a fresh pinia and activate it for each test
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('getComponent', () => {
// Test number type mappings
describe('number types', () => {

View File

@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NumberControlRegistry } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
// Mock the settings store
const mockGetSetting = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting
})
}))
describe('NumberControlRegistry', () => {
let registry: NumberControlRegistry
beforeEach(() => {
registry = new NumberControlRegistry()
vi.clearAllMocks()
})
describe('register and unregister', () => {
it('should register a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)
})
it('should unregister a control callback', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
expect(registry.getControlCount()).toBe(1)
registry.unregister(controlId)
expect(registry.getControlCount()).toBe(0)
})
it('should handle multiple registrations', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()
registry.register(control1, callback1)
registry.register(control2, callback2)
expect(registry.getControlCount()).toBe(2)
registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})
it('should handle unregistering non-existent controls gracefully', () => {
const nonExistentId = Symbol('non-existent')
expect(() => registry.unregister(nonExistentId)).not.toThrow()
expect(registry.getControlCount()).toBe(0)
})
})
describe('executeControls', () => {
it('should execute controls when mode matches phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
// Mock setting store to return 'before'
mockGetSetting.mockReturnValue('before')
registry.register(controlId, mockCallback)
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})
it('should not execute controls when mode does not match phase', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
// Mock setting store to return 'after'
mockGetSetting.mockReturnValue('after')
registry.register(controlId, mockCallback)
registry.executeControls('before')
expect(mockCallback).not.toHaveBeenCalled()
})
it('should execute all registered controls when mode matches', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
const callback1 = vi.fn()
const callback2 = vi.fn()
mockGetSetting.mockReturnValue('before')
registry.register(control1, callback1)
registry.register(control2, callback2)
registry.executeControls('before')
expect(callback1).toHaveBeenCalledTimes(1)
expect(callback2).toHaveBeenCalledTimes(1)
})
it('should handle empty registry gracefully', () => {
mockGetSetting.mockReturnValue('before')
expect(() => registry.executeControls('before')).not.toThrow()
expect(mockGetSetting).toHaveBeenCalledWith('Comfy.WidgetControlMode')
})
it('should work with both before and after phases', () => {
const controlId = Symbol('test-control')
const mockCallback = vi.fn()
registry.register(controlId, mockCallback)
// Test 'before' phase
mockGetSetting.mockReturnValue('before')
registry.executeControls('before')
expect(mockCallback).toHaveBeenCalledTimes(1)
// Test 'after' phase
mockGetSetting.mockReturnValue('after')
registry.executeControls('after')
expect(mockCallback).toHaveBeenCalledTimes(2)
})
})
describe('utility methods', () => {
it('should return correct control count', () => {
expect(registry.getControlCount()).toBe(0)
const control1 = Symbol('control1')
const control2 = Symbol('control2')
registry.register(control1, vi.fn())
expect(registry.getControlCount()).toBe(1)
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)
registry.unregister(control1)
expect(registry.getControlCount()).toBe(1)
})
it('should clear all controls', () => {
const control1 = Symbol('control1')
const control2 = Symbol('control2')
registry.register(control1, vi.fn())
registry.register(control2, vi.fn())
expect(registry.getControlCount()).toBe(2)
registry.clear()
expect(registry.getControlCount()).toBe(0)
})
})
})

View File

@@ -0,0 +1,61 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useGlobalSeedStore } from '@/stores/globalSeedStore'
describe('useGlobalSeedStore', () => {
let store: ReturnType<typeof useGlobalSeedStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useGlobalSeedStore()
})
describe('initialization', () => {
it('should initialize with a random global seed', () => {
expect(typeof store.globalSeed).toBe('number')
expect(store.globalSeed).toBeGreaterThanOrEqual(0)
expect(store.globalSeed).toBeLessThan(1000000)
})
it('should create different seeds for different store instances', () => {
const store1 = useGlobalSeedStore()
setActivePinia(createPinia()) // Reset pinia
const store2 = useGlobalSeedStore()
// Very unlikely to be the same (1 in 1,000,000 chance)
expect(store1.globalSeed).not.toBe(store2.globalSeed)
})
})
describe('setGlobalSeed', () => {
it('should update the global seed value', () => {
const newSeed = 42
store.setGlobalSeed(newSeed)
expect(store.globalSeed).toBe(newSeed)
})
it('should accept any number value', () => {
const testValues = [0, 1, 999999, 1000000, -1, 123.456]
for (const value of testValues) {
store.setGlobalSeed(value)
expect(store.globalSeed).toBe(value)
}
})
})
describe('reactivity', () => {
it('should be reactive when global seed changes', () => {
const initialSeed = store.globalSeed
const newSeed = initialSeed + 100
store.setGlobalSeed(newSeed)
expect(store.globalSeed).toBe(newSeed)
expect(store.globalSeed).not.toBe(initialSeed)
})
})
})