mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
Fix doubled control application (#7550)
With reactivity fixed, control widgets would apply twice. This is fixed by using the litegraph implementation. Also adds control widget support for combos Followup to #7539. Known Issue: - Primitive node do not have litegraph callbacks properly setup. As a result, they will display an updated value when modified by control widgets. Fixing this will requires a larger, separate PR ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7550-Fix-doubled-control-application-2cb6d73d365081739a2fc40fdfb3630e) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -257,6 +257,8 @@ export class PrimitiveNode extends LGraphNode {
|
|||||||
undefined,
|
undefined,
|
||||||
inputData
|
inputData
|
||||||
)
|
)
|
||||||
|
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
|
||||||
|
|
||||||
let filter = this.widgets_values?.[2]
|
let filter = this.widgets_values?.[2]
|
||||||
if (filter && this.widgets && this.widgets.length === 3) {
|
if (filter && this.widgets && this.widgets.length === 3) {
|
||||||
this.widgets[2].value = filter
|
this.widgets[2].value = filter
|
||||||
|
|||||||
@@ -2077,7 +2077,7 @@
|
|||||||
"placeholderModel": "Select model...",
|
"placeholderModel": "Select model...",
|
||||||
"placeholderUnknown": "Select media..."
|
"placeholderUnknown": "Select media..."
|
||||||
},
|
},
|
||||||
"numberControl": {
|
"valueControl": {
|
||||||
"header": {
|
"header": {
|
||||||
"prefix": "Automatically update the value",
|
"prefix": "Automatically update the value",
|
||||||
"after": "AFTER",
|
"after": "AFTER",
|
||||||
@@ -2090,9 +2090,9 @@
|
|||||||
"randomize": "Randomize Value",
|
"randomize": "Randomize Value",
|
||||||
"randomizeDesc": "Shuffles the value randomly after each generation",
|
"randomizeDesc": "Shuffles the value randomly after each generation",
|
||||||
"increment": "Increment Value",
|
"increment": "Increment Value",
|
||||||
"incrementDesc": "Adds 1 to the value number",
|
"incrementDesc": "Adds 1 to value or selects the next option",
|
||||||
"decrement": "Decrement Value",
|
"decrement": "Decrement Value",
|
||||||
"decrementDesc": "Subtracts 1 from the value number",
|
"decrementDesc": "Subtracts 1 from value or selects the previous option",
|
||||||
"fixed": "Fixed Value",
|
"fixed": "Fixed Value",
|
||||||
"fixedDesc": "Leaves value unchanged",
|
"fixedDesc": "Leaves value unchanged",
|
||||||
"editSettings": "Edit control settings"
|
"editSettings": "Edit control settings"
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import RadioButton from 'primevue/radiobutton'
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import type { ControlOptions } from '@/types/simplifiedWidget'
|
||||||
import { NumberControlMode } from '../composables/useStepperControl'
|
|
||||||
|
|
||||||
type ControlOption = {
|
type ControlOption = {
|
||||||
description: string
|
description: string
|
||||||
mode: NumberControlMode
|
mode: ControlOptions
|
||||||
icon?: string
|
icon?: string
|
||||||
text?: string
|
text?: string
|
||||||
title: string
|
title: string
|
||||||
@@ -23,39 +22,27 @@ const toggle = (event: Event) => {
|
|||||||
}
|
}
|
||||||
defineExpose({ toggle })
|
defineExpose({ toggle })
|
||||||
|
|
||||||
const ENABLE_LINK_TO_GLOBAL = false
|
|
||||||
|
|
||||||
const controlOptions: ControlOption[] = [
|
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.FIXED,
|
mode: 'fixed',
|
||||||
icon: 'icon-[lucide--pencil-off]',
|
icon: 'icon-[lucide--pencil-off]',
|
||||||
title: 'fixed',
|
title: 'fixed',
|
||||||
description: 'fixedDesc'
|
description: 'fixedDesc'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
mode: NumberControlMode.INCREMENT,
|
mode: 'increment',
|
||||||
text: '+1',
|
text: '+1',
|
||||||
title: 'increment',
|
title: 'increment',
|
||||||
description: 'incrementDesc'
|
description: 'incrementDesc'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
mode: NumberControlMode.DECREMENT,
|
mode: 'decrement',
|
||||||
text: '-1',
|
text: '-1',
|
||||||
title: 'decrement',
|
title: 'decrement',
|
||||||
description: 'decrementDesc'
|
description: 'decrementDesc'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
mode: NumberControlMode.RANDOMIZE,
|
mode: 'randomize',
|
||||||
icon: 'icon-[lucide--shuffle]',
|
icon: 'icon-[lucide--shuffle]',
|
||||||
title: 'randomize',
|
title: 'randomize',
|
||||||
description: 'randomizeDesc'
|
description: 'randomizeDesc'
|
||||||
@@ -66,7 +53,7 @@ const widgetControlMode = computed(() =>
|
|||||||
settingStore.get('Comfy.WidgetControlMode')
|
settingStore.get('Comfy.WidgetControlMode')
|
||||||
)
|
)
|
||||||
|
|
||||||
const controlMode = defineModel<NumberControlMode>()
|
const controlMode = defineModel<ControlOptions>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -76,15 +63,15 @@ const controlMode = defineModel<NumberControlMode>()
|
|||||||
>
|
>
|
||||||
<div class="w-113 max-w-md p-4 space-y-4">
|
<div class="w-113 max-w-md p-4 space-y-4">
|
||||||
<div class="text-sm text-muted-foreground leading-tight">
|
<div class="text-sm text-muted-foreground leading-tight">
|
||||||
{{ $t('widgets.numberControl.header.prefix') }}
|
{{ $t('widgets.valueControl.header.prefix') }}
|
||||||
<span class="text-base-foreground font-medium">
|
<span class="text-base-foreground font-medium">
|
||||||
{{
|
{{
|
||||||
widgetControlMode === 'before'
|
widgetControlMode === 'before'
|
||||||
? $t('widgets.numberControl.header.before')
|
? $t('widgets.valueControl.header.before')
|
||||||
: $t('widgets.numberControl.header.after')
|
: $t('widgets.valueControl.header.after')
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
{{ $t('widgets.numberControl.header.postfix') }}
|
{{ $t('widgets.valueControl.header.postfix') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -114,18 +101,14 @@ const controlMode = defineModel<NumberControlMode>()
|
|||||||
<div
|
<div
|
||||||
class="text-sm font-normal text-base-foreground leading-tight"
|
class="text-sm font-normal text-base-foreground leading-tight"
|
||||||
>
|
>
|
||||||
<span v-if="option.mode === NumberControlMode.LINK_TO_GLOBAL">
|
<span>
|
||||||
{{ $t('widgets.numberControl.linkToGlobal') }}
|
{{ $t(`widgets.valueControl.${option.title}`) }}
|
||||||
<em>{{ $t('widgets.numberControl.linkToGlobalSeed') }}</em>
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ $t(`widgets.numberControl.${option.title}`) }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-sm font-normal text-muted-foreground leading-tight"
|
class="text-sm font-normal text-muted-foreground leading-tight"
|
||||||
>
|
>
|
||||||
{{ $t(`widgets.numberControl.${option.description}`) }}
|
{{ $t(`widgets.valueControl.${option.description}`) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
import type {
|
||||||
|
SimplifiedControlWidget,
|
||||||
|
SimplifiedWidget
|
||||||
|
} from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||||
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
||||||
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
|
import WidgetWithControl from './WidgetWithControl.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
widget: SimplifiedWidget<number>
|
widget: SimplifiedWidget<number>
|
||||||
@@ -19,14 +22,23 @@ const hasControlAfterGenerate = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<WidgetWithControl
|
||||||
|
v-if="hasControlAfterGenerate"
|
||||||
|
v-model="modelValue"
|
||||||
|
:widget="widget as SimplifiedControlWidget<number>"
|
||||||
|
:component="
|
||||||
|
widget.type === 'slider'
|
||||||
|
? WidgetInputNumberSlider
|
||||||
|
: WidgetInputNumberInput
|
||||||
|
"
|
||||||
|
/>
|
||||||
<component
|
<component
|
||||||
:is="
|
:is="
|
||||||
hasControlAfterGenerate
|
widget.type === 'slider'
|
||||||
? WidgetInputNumberWithControl
|
? WidgetInputNumberSlider
|
||||||
: widget.type === 'slider'
|
: WidgetInputNumberInput
|
||||||
? WidgetInputNumberSlider
|
|
||||||
: WidgetInputNumberInput
|
|
||||||
"
|
"
|
||||||
|
v-else
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:widget="widget"
|
:widget="widget"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import Button from 'primevue/button'
|
|
||||||
import { defineAsyncComponent, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
|
||||||
|
|
||||||
import { useStepperControl } from '../composables/useStepperControl'
|
|
||||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
|
||||||
|
|
||||||
const NumberControlPopover = defineAsyncComponent(
|
|
||||||
() => import('./NumberControlPopover.vue')
|
|
||||||
)
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
widget: SimplifiedWidget<number>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const modelValue = defineModel<number>({ default: 0 })
|
|
||||||
const popover = ref()
|
|
||||||
|
|
||||||
const handleControlChange = (newValue: number) => {
|
|
||||||
modelValue.value = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
const { controlMode, controlButtonIcon } = useStepperControl(
|
|
||||||
modelValue,
|
|
||||||
{
|
|
||||||
...props.widget.options,
|
|
||||||
onChange: handleControlChange
|
|
||||||
},
|
|
||||||
props.widget.controlWidget!.value
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(controlMode, props.widget.controlWidget!.update)
|
|
||||||
|
|
||||||
const togglePopover = (event: Event) => {
|
|
||||||
popover.value.toggle(event)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative grid grid-cols-subgrid">
|
|
||||||
<WidgetInputNumberInput
|
|
||||||
v-model="modelValue"
|
|
||||||
:widget
|
|
||||||
class="grid grid-cols-subgrid col-span-2"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
size="small"
|
|
||||||
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
|
|
||||||
@click="togglePopover"
|
|
||||||
>
|
|
||||||
<i :class="`${controlButtonIcon} text-blue-100 text-xs`" />
|
|
||||||
</Button>
|
|
||||||
</WidgetInputNumberInput>
|
|
||||||
<NumberControlPopover ref="popover" v-model="controlMode" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
:is-asset-mode="isAssetMode"
|
:is-asset-mode="isAssetMode"
|
||||||
:default-layout-mode="defaultLayoutMode"
|
:default-layout-mode="defaultLayoutMode"
|
||||||
/>
|
/>
|
||||||
|
<WidgetWithControl
|
||||||
|
v-else-if="widget.controlWidget"
|
||||||
|
:component="WidgetSelectDefault"
|
||||||
|
:widget="widget as StringControlWidget"
|
||||||
|
/>
|
||||||
<WidgetSelectDefault v-else v-model="modelValue" :widget />
|
<WidgetSelectDefault v-else v-model="modelValue" :widget />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -20,13 +25,19 @@ import { isCloud } from '@/platform/distribution/types'
|
|||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||||
|
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||||
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
import type {
|
||||||
|
SimplifiedControlWidget,
|
||||||
|
SimplifiedWidget
|
||||||
|
} from '@/types/simplifiedWidget'
|
||||||
import type { AssetKind } from '@/types/widgetTypes'
|
import type { AssetKind } from '@/types/widgetTypes'
|
||||||
|
|
||||||
|
type StringControlWidget = SimplifiedControlWidget<string | undefined>
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
widget: SimplifiedWidget<string | undefined>
|
widget: SimplifiedWidget<string | undefined>
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
|
|||||||
@@ -13,11 +13,14 @@
|
|||||||
:pt="{
|
:pt="{
|
||||||
option: 'text-xs',
|
option: 'text-xs',
|
||||||
dropdown: 'w-8',
|
dropdown: 'w-8',
|
||||||
label: 'truncate min-w-[4ch]',
|
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
|
||||||
overlay: 'w-fit min-w-full'
|
overlay: 'w-fit min-w-full'
|
||||||
}"
|
}"
|
||||||
data-capture-wheel="true"
|
data-capture-wheel="true"
|
||||||
/>
|
/>
|
||||||
|
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</WidgetLayoutField>
|
</WidgetLayoutField>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts" generic="T extends WidgetValue">
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SimplifiedControlWidget,
|
||||||
|
WidgetValue
|
||||||
|
} from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
|
const ValueControlPopover = defineAsyncComponent(
|
||||||
|
() => import('./ValueControlPopover.vue')
|
||||||
|
)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
widget: SimplifiedControlWidget<T>
|
||||||
|
component: Component
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modelValue = defineModel<T>()
|
||||||
|
|
||||||
|
const popover = ref()
|
||||||
|
|
||||||
|
const controlModel = ref(props.widget.controlWidget.value)
|
||||||
|
|
||||||
|
const controlButtonIcon = computed(() => {
|
||||||
|
switch (controlModel.value) {
|
||||||
|
case 'increment':
|
||||||
|
return 'pi pi-plus'
|
||||||
|
case 'decrement':
|
||||||
|
return 'pi pi-minus'
|
||||||
|
case 'fixed':
|
||||||
|
return 'icon-[lucide--pencil-off]'
|
||||||
|
default:
|
||||||
|
return 'icon-[lucide--shuffle]'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(controlModel, props.widget.controlWidget.update)
|
||||||
|
|
||||||
|
const togglePopover = (event: Event) => {
|
||||||
|
popover.value.toggle(event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative grid grid-cols-subgrid">
|
||||||
|
<component :is="component" v-bind="$attrs" v-model="modelValue" :widget>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="small"
|
||||||
|
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
|
||||||
|
@pointerdown.stop.prevent="togglePopover"
|
||||||
|
>
|
||||||
|
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
|
||||||
|
</Button>
|
||||||
|
</component>
|
||||||
|
<ValueControlPopover ref="popover" v-model="controlModel" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
||||||
import type { Ref } from 'vue'
|
|
||||||
|
|
||||||
import type { ControlOptions } from '@/types/simplifiedWidget'
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertToEnum(str?: ControlOptions): NumberControlMode {
|
|
||||||
switch (str) {
|
|
||||||
case 'fixed':
|
|
||||||
return NumberControlMode.FIXED
|
|
||||||
case 'increment':
|
|
||||||
return NumberControlMode.INCREMENT
|
|
||||||
case 'decrement':
|
|
||||||
return NumberControlMode.DECREMENT
|
|
||||||
case 'randomize':
|
|
||||||
return NumberControlMode.RANDOMIZE
|
|
||||||
}
|
|
||||||
return NumberControlMode.RANDOMIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
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.FIXED:
|
|
||||||
return 'icon-[lucide--pencil-off]'
|
|
||||||
case NumberControlMode.LINK_TO_GLOBAL:
|
|
||||||
return 'pi pi-link'
|
|
||||||
default:
|
|
||||||
return 'icon-[lucide--shuffle]'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStepperControl(
|
|
||||||
modelValue: Ref<number>,
|
|
||||||
options: StepperControlOptions,
|
|
||||||
defaultValue?: ControlOptions
|
|
||||||
) {
|
|
||||||
const controlMode = ref<NumberControlMode>(convertToEnum(defaultValue))
|
|
||||||
const controlId = Symbol('numberControl')
|
|
||||||
|
|
||||||
const applyControl = () => {
|
|
||||||
const { min = 0, max = 1000000, step2, step = 1, onChange } = options
|
|
||||||
const safeMax = Math.min(2 ** 50, max)
|
|
||||||
const safeMin = Math.max(-(2 ** 50), min)
|
|
||||||
// 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(safeMax, modelValue.value + actualStep)
|
|
||||||
break
|
|
||||||
case NumberControlMode.DECREMENT:
|
|
||||||
newValue = Math.max(safeMin, modelValue.value - actualStep)
|
|
||||||
break
|
|
||||||
case NumberControlMode.RANDOMIZE:
|
|
||||||
newValue = Math.floor(Math.random() * (safeMax - safeMin + 1)) + safeMin
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
const controlButtonIcon = useControlButtonIcon(controlMode)
|
|
||||||
|
|
||||||
return {
|
|
||||||
applyControl,
|
|
||||||
controlButtonIcon,
|
|
||||||
controlMode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
type NodeId,
|
type NodeId,
|
||||||
isSubgraphDefinition
|
isSubgraphDefinition
|
||||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
import { executeNumberControls } from '@/renderer/extensions/vueNodes/widgets/services/NumberControlRegistry'
|
|
||||||
import type {
|
import type {
|
||||||
ExecutionErrorWsMessage,
|
ExecutionErrorWsMessage,
|
||||||
NodeError,
|
NodeError,
|
||||||
@@ -1359,7 +1358,6 @@ export class ComfyApp {
|
|||||||
forEachNode(this.rootGraph, (node) => {
|
forEachNode(this.rootGraph, (node) => {
|
||||||
for (const widget of node.widgets ?? []) widget.beforeQueued?.()
|
for (const widget of node.widgets ?? []) widget.beforeQueued?.()
|
||||||
})
|
})
|
||||||
executeNumberControls('before')
|
|
||||||
|
|
||||||
const p = await this.graphToPrompt(this.rootGraph)
|
const p = await this.graphToPrompt(this.rootGraph)
|
||||||
const queuedNodes = collectAllNodes(this.rootGraph)
|
const queuedNodes = collectAllNodes(this.rootGraph)
|
||||||
@@ -1404,7 +1402,6 @@ export class ComfyApp {
|
|||||||
// Allow widgets to run callbacks after a prompt has been queued
|
// Allow widgets to run callbacks after a prompt has been queued
|
||||||
// e.g. random seed after every gen
|
// e.g. random seed after every gen
|
||||||
executeWidgetsCallback(queuedNodes, 'afterQueued')
|
executeWidgetsCallback(queuedNodes, 'afterQueued')
|
||||||
executeNumberControls('after')
|
|
||||||
this.canvas.draw(true, true)
|
this.canvas.draw(true, true)
|
||||||
await this.ui.queue.update()
|
await this.ui.queue.update()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,3 +72,10 @@ export interface SimplifiedWidget<
|
|||||||
|
|
||||||
controlWidget?: SafeControlWidget
|
controlWidget?: SafeControlWidget
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SimplifiedControlWidget<
|
||||||
|
T extends WidgetValue = WidgetValue,
|
||||||
|
O = Record<string, any>
|
||||||
|
> extends SimplifiedWidget<T, O> {
|
||||||
|
controlWidget: SafeControlWidget
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { setActivePinia } from 'pinia'
|
|
||||||
import { createTestingPinia } from '@pinia/testing'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
import {
|
|
||||||
NumberControlMode,
|
|
||||||
useStepperControl
|
|
||||||
} from '@/renderer/extensions/vueNodes/widgets/composables/useStepperControl'
|
|
||||||
|
|
||||||
// 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(createTestingPinia())
|
|
||||||
vi.clearAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('initialization', () => {
|
|
||||||
it('should initialize with RANDOMIZED mode', () => {
|
|
||||||
const modelValue = ref(100)
|
|
||||||
const options = { min: 0, max: 1000, step: 1 }
|
|
||||||
|
|
||||||
const { controlMode } = useStepperControl(modelValue, options)
|
|
||||||
|
|
||||||
expect(controlMode.value).toBe(NumberControlMode.RANDOMIZE)
|
|
||||||
})
|
|
||||||
|
|
||||||
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.RANDOMIZE)
|
|
||||||
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 { controlMode, applyControl } = useStepperControl(
|
|
||||||
modelValue,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
controlMode.value = NumberControlMode.FIXED
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
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 { controlMode, applyControl } = useStepperControl(
|
|
||||||
modelValue,
|
|
||||||
options
|
|
||||||
)
|
|
||||||
controlMode.value = NumberControlMode.FIXED
|
|
||||||
|
|
||||||
applyControl()
|
|
||||||
|
|
||||||
expect(onChange).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user