mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 09:27:41 +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:
@@ -4,12 +4,11 @@ import RadioButton from 'primevue/radiobutton'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import { NumberControlMode } from '../composables/useStepperControl'
|
||||
import type { ControlOptions } from '@/types/simplifiedWidget'
|
||||
|
||||
type ControlOption = {
|
||||
description: string
|
||||
mode: NumberControlMode
|
||||
mode: ControlOptions
|
||||
icon?: string
|
||||
text?: string
|
||||
title: string
|
||||
@@ -23,39 +22,27 @@ const toggle = (event: 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.FIXED,
|
||||
mode: 'fixed',
|
||||
icon: 'icon-[lucide--pencil-off]',
|
||||
title: 'fixed',
|
||||
description: 'fixedDesc'
|
||||
},
|
||||
{
|
||||
mode: NumberControlMode.INCREMENT,
|
||||
mode: 'increment',
|
||||
text: '+1',
|
||||
title: 'increment',
|
||||
description: 'incrementDesc'
|
||||
},
|
||||
{
|
||||
mode: NumberControlMode.DECREMENT,
|
||||
mode: 'decrement',
|
||||
text: '-1',
|
||||
title: 'decrement',
|
||||
description: 'decrementDesc'
|
||||
},
|
||||
{
|
||||
mode: NumberControlMode.RANDOMIZE,
|
||||
mode: 'randomize',
|
||||
icon: 'icon-[lucide--shuffle]',
|
||||
title: 'randomize',
|
||||
description: 'randomizeDesc'
|
||||
@@ -66,7 +53,7 @@ const widgetControlMode = computed(() =>
|
||||
settingStore.get('Comfy.WidgetControlMode')
|
||||
)
|
||||
|
||||
const controlMode = defineModel<NumberControlMode>()
|
||||
const controlMode = defineModel<ControlOptions>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -76,15 +63,15 @@ const controlMode = defineModel<NumberControlMode>()
|
||||
>
|
||||
<div class="w-113 max-w-md p-4 space-y-4">
|
||||
<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">
|
||||
{{
|
||||
widgetControlMode === 'before'
|
||||
? $t('widgets.numberControl.header.before')
|
||||
: $t('widgets.numberControl.header.after')
|
||||
? $t('widgets.valueControl.header.before')
|
||||
: $t('widgets.valueControl.header.after')
|
||||
}}
|
||||
</span>
|
||||
{{ $t('widgets.numberControl.header.postfix') }}
|
||||
{{ $t('widgets.valueControl.header.postfix') }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@@ -114,18 +101,14 @@ const controlMode = defineModel<NumberControlMode>()
|
||||
<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>
|
||||
{{ $t(`widgets.valueControl.${option.title}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-normal text-muted-foreground leading-tight"
|
||||
>
|
||||
{{ $t(`widgets.numberControl.${option.description}`) }}
|
||||
{{ $t(`widgets.valueControl.${option.description}`) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type {
|
||||
SimplifiedControlWidget,
|
||||
SimplifiedWidget
|
||||
} from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
|
||||
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
|
||||
import WidgetInputNumberWithControl from './WidgetInputNumberWithControl.vue'
|
||||
import WidgetWithControl from './WidgetWithControl.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
@@ -19,14 +22,23 @@ const hasControlAfterGenerate = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WidgetWithControl
|
||||
v-if="hasControlAfterGenerate"
|
||||
v-model="modelValue"
|
||||
:widget="widget as SimplifiedControlWidget<number>"
|
||||
:component="
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
/>
|
||||
<component
|
||||
:is="
|
||||
hasControlAfterGenerate
|
||||
? WidgetInputNumberWithControl
|
||||
: widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
widget.type === 'slider'
|
||||
? WidgetInputNumberSlider
|
||||
: WidgetInputNumberInput
|
||||
"
|
||||
v-else
|
||||
v-model="modelValue"
|
||||
:widget="widget"
|
||||
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"
|
||||
:default-layout-mode="defaultLayoutMode"
|
||||
/>
|
||||
<WidgetWithControl
|
||||
v-else-if="widget.controlWidget"
|
||||
:component="WidgetSelectDefault"
|
||||
:widget="widget as StringControlWidget"
|
||||
/>
|
||||
<WidgetSelectDefault v-else v-model="modelValue" :widget />
|
||||
</template>
|
||||
|
||||
@@ -20,13 +25,19 @@ 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 WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.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 {
|
||||
SimplifiedControlWidget,
|
||||
SimplifiedWidget
|
||||
} from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
type StringControlWidget = SimplifiedControlWidget<string | undefined>
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
nodeType?: string
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8',
|
||||
label: 'truncate min-w-[4ch]',
|
||||
label: cn('truncate min-w-[4ch]', $slots.default && 'mr-5'),
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
/>
|
||||
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5 flex">
|
||||
<slot />
|
||||
</div>
|
||||
</WidgetLayoutField>
|
||||
</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)
|
||||
}
|
||||
Reference in New Issue
Block a user