Support "control after generate" in vue (#6985)
Continuation of #6034 with - Updated synchronization for seed - Properly truncates the displayed widget value for the button - Synchronizes control after generate state with litegraph and allows for serialization Several issues from original PR have not (yet) been addressed, but are likely better moved to future PR - fix step value being 10 (legacy system) - ensure it works with COMBO (Fixed in #7095) - ensure it works with FLOAT (Fixed in #7095) - either implement or remove the config button functionality - think it should open settings? <img width="280" height="694" alt="image" src="https://github.com/user-attachments/assets/f36f1cb0-237d-4bfc-bff1-e4976775cf98" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6985-Support-control-after-generate-in-vue-2b86d73d365081d8b01ce489d887ff00) by [Unito](https://www.unito.io) --------- Co-authored-by: bymyself <cbyrne@comfy.org> Co-authored-by: github-actions <github-actions@github.com>
@@ -51,8 +51,6 @@ defineProps<{
|
|||||||
canProceed: boolean
|
canProceed: boolean
|
||||||
/** Whether the location step should be disabled */
|
/** Whether the location step should be disabled */
|
||||||
disableLocationStep: boolean
|
disableLocationStep: boolean
|
||||||
/** Whether the migration step should be disabled */
|
|
||||||
disableMigrationStep: boolean
|
|
||||||
/** Whether the settings step should be disabled */
|
/** Whether the settings step should be disabled */
|
||||||
disableSettingsStep: boolean
|
disableSettingsStep: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export class VueNodeHelpers {
|
|||||||
return {
|
return {
|
||||||
input: widget.locator('input'),
|
input: widget.locator('input'),
|
||||||
incrementButton: widget.locator('button').first(),
|
incrementButton: widget.locator('button').first(),
|
||||||
decrementButton: widget.locator('button').last()
|
decrementButton: widget.locator('button').nth(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
@@ -15,7 +15,9 @@ test.describe('Vue Integer Widget', () => {
|
|||||||
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
|
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
|
||||||
await comfyPage.vueNodes.waitForNodes()
|
await comfyPage.vueNodes.waitForNodes()
|
||||||
|
|
||||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
|
const seedWidget = comfyPage.vueNodes
|
||||||
|
.getWidgetByName('KSampler', 'seed')
|
||||||
|
.first()
|
||||||
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||||
const initialValue = Number(await controls.input.inputValue())
|
const initialValue = Number(await controls.input.inputValue())
|
||||||
|
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ if (!releaseInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Output as JSON for GitHub Actions
|
// Output as JSON for GitHub Actions
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(JSON.stringify(releaseInfo, null, 2))
|
console.log(JSON.stringify(releaseInfo, null, 2))
|
||||||
|
|
||||||
export { resolveRelease }
|
export { resolveRelease }
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import type { NodeId } from '@/renderer/core/layout/types'
|
|||||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import { isDOMWidget } from '@/scripts/domWidget'
|
import { isDOMWidget } from '@/scripts/domWidget'
|
||||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||||
|
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
LGraph,
|
LGraph,
|
||||||
@@ -47,6 +48,7 @@ export interface SafeWidgetData {
|
|||||||
spec?: InputSpec
|
spec?: InputSpec
|
||||||
slotMetadata?: WidgetSlotMetadata
|
slotMetadata?: WidgetSlotMetadata
|
||||||
isDOMWidget?: boolean
|
isDOMWidget?: boolean
|
||||||
|
controlWidget?: SafeControlWidget
|
||||||
borderStyle?: string
|
borderStyle?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +86,17 @@ export interface GraphNodeManager {
|
|||||||
cleanup(): void
|
cleanup(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
|
||||||
|
const cagWidget = widget.linkedWidgets?.find(
|
||||||
|
(w) => w.name == 'control_after_generate'
|
||||||
|
)
|
||||||
|
if (!cagWidget) return
|
||||||
|
return {
|
||||||
|
value: normalizeControlOption(cagWidget.value),
|
||||||
|
update: (value) => (cagWidget.value = normalizeControlOption(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function safeWidgetMapper(
|
export function safeWidgetMapper(
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
slotMetadata: Map<string, WidgetSlotMetadata>
|
slotMetadata: Map<string, WidgetSlotMetadata>
|
||||||
@@ -122,7 +135,8 @@ export function safeWidgetMapper(
|
|||||||
label: widget.label,
|
label: widget.label,
|
||||||
options: widget.options,
|
options: widget.options,
|
||||||
spec,
|
spec,
|
||||||
slotMetadata: slotInfo
|
slotMetadata: slotInfo,
|
||||||
|
controlWidget: getControlWidget(widget)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2052,6 +2052,24 @@
|
|||||||
"placeholderVideo": "Select video...",
|
"placeholderVideo": "Select video...",
|
||||||
"placeholderModel": "Select model...",
|
"placeholderModel": "Select model...",
|
||||||
"placeholderUnknown": "Select media..."
|
"placeholderUnknown": "Select media..."
|
||||||
|
},
|
||||||
|
"numberControl": {
|
||||||
|
"header" : {
|
||||||
|
"prefix": "Automatically update the value",
|
||||||
|
"after": "AFTER",
|
||||||
|
"before": "BEFORE",
|
||||||
|
"postfix": "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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widgetFileUpload": {
|
"widgetFileUpload": {
|
||||||
|
|||||||
@@ -172,7 +172,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
|||||||
options: widgetOptions,
|
options: widgetOptions,
|
||||||
callback: widget.callback,
|
callback: widget.callback,
|
||||||
spec: widget.spec,
|
spec: widget.spec,
|
||||||
borderStyle: widget.borderStyle
|
borderStyle: widget.borderStyle,
|
||||||
|
controlWidget: widget.controlWidget
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHandler(value: WidgetValue) {
|
function updateHandler(value: WidgetValue) {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<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 = {
|
||||||
|
description: string
|
||||||
|
mode: NumberControlMode
|
||||||
|
icon?: string
|
||||||
|
text?: string
|
||||||
|
title: 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"
|
||||||
|
>
|
||||||
|
<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') }}
|
||||||
|
<span class="text-base-foreground font-medium">
|
||||||
|
{{
|
||||||
|
widgetControlMode === 'before'
|
||||||
|
? $t('widgets.numberControl.header.before')
|
||||||
|
: $t('widgets.numberControl.header.after')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
{{ $t('widgets.numberControl.header.postfix') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<ToggleSwitch
|
||||||
|
:model-value="isActive(option.mode)"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@update:model-value="handleToggle(option.mode)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-border-subtle"></div>
|
||||||
|
<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>
|
||||||
@@ -1,22 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
import type { 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'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
widget: SimplifiedWidget<number>
|
widget: SimplifiedWidget<number>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const modelValue = defineModel<number>({ default: 0 })
|
const modelValue = defineModel<number>({ default: 0 })
|
||||||
|
|
||||||
|
const hasControlAfterGenerate = computed(() => {
|
||||||
|
return !!props.widget.controlWidget
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="
|
:is="
|
||||||
widget.type === 'slider'
|
hasControlAfterGenerate
|
||||||
? WidgetInputNumberSlider
|
? WidgetInputNumberWithControl
|
||||||
: WidgetInputNumberInput
|
: widget.type === 'slider'
|
||||||
|
? WidgetInputNumberSlider
|
||||||
|
: WidgetInputNumberInput
|
||||||
"
|
"
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:widget="widget"
|
:widget="widget"
|
||||||
|
|||||||
@@ -89,8 +89,11 @@ const buttonTooltip = computed(() => {
|
|||||||
:show-buttons="!buttonsDisabled"
|
:show-buttons="!buttonsDisabled"
|
||||||
:pt="{
|
:pt="{
|
||||||
root: {
|
root: {
|
||||||
class:
|
class: cn(
|
||||||
'[&>input]:bg-transparent [&>input]:border-0 [&>input]:truncate [&>input]:min-w-[4ch]'
|
'[&>input]:bg-transparent [&>input]:border-0',
|
||||||
|
'[&>input]:truncate [&>input]:min-w-[4ch]',
|
||||||
|
$slots.default && '[&>input]:pr-7'
|
||||||
|
)
|
||||||
},
|
},
|
||||||
decrementButton: {
|
decrementButton: {
|
||||||
class: 'w-8 border-0'
|
class: 'w-8 border-0'
|
||||||
@@ -107,6 +110,9 @@ const buttonTooltip = computed(() => {
|
|||||||
<span class="pi pi-minus text-sm" />
|
<span class="pi pi-minus text-sm" />
|
||||||
</template>
|
</template>
|
||||||
</InputNumber>
|
</InputNumber>
|
||||||
|
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</WidgetLayoutField>
|
</WidgetLayoutField>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
filterWidgetProps
|
filterWidgetProps
|
||||||
} from '@/utils/widgetPropFilter'
|
} from '@/utils/widgetPropFilter'
|
||||||
|
|
||||||
|
import { useNumberStepCalculation } from '../composables/useNumberStepCalculation'
|
||||||
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
|
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
|
||||||
import { WidgetInputBaseClass } from './layout'
|
import { WidgetInputBaseClass } from './layout'
|
||||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||||
@@ -56,7 +57,7 @@ const updateLocalValue = (newValue: number[] | undefined): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNumberInputUpdate = (newValue: number | undefined) => {
|
const handleNumberInputUpdate = (newValue: number | undefined) => {
|
||||||
if (newValue) {
|
if (newValue !== undefined) {
|
||||||
updateLocalValue([newValue])
|
updateLocalValue([newValue])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,33 +68,11 @@ const filteredProps = computed(() =>
|
|||||||
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get the precision value for proper number formatting
|
const p = widget.options?.precision
|
||||||
const precision = computed(() => {
|
const precision = typeof p === 'number' && p >= 0 ? p : undefined
|
||||||
const p = widget.options?.precision
|
|
||||||
// Treat negative or non-numeric precision as undefined
|
|
||||||
return typeof p === 'number' && p >= 0 ? p : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
// Calculate the step value based on precision or widget options
|
// Calculate the step value based on precision or widget options
|
||||||
const stepValue = computed(() => {
|
const stepValue = useNumberStepCalculation(widget.options, precision, true)
|
||||||
// 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 sliderNumberPt = useNumberWidgetButtonPt({
|
const sliderNumberPt = useNumberWidgetButtonPt({
|
||||||
roundedLeft: true,
|
roundedLeft: true,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { defineAsyncComponent, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
|
import type { NumberControlMode } from '../composables/useStepperControl'
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
const setControlMode = (mode: NumberControlMode) => {
|
||||||
|
controlMode.value = mode
|
||||||
|
props.widget.controlWidget!.update(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
:control-mode
|
||||||
|
@update:control-mode="setControlMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -146,11 +146,9 @@ const outputItems = computed<DropdownItem[]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const allItems = computed<DropdownItem[]>(() => {
|
const allItems = computed<DropdownItem[]>(() => {
|
||||||
if (props.isAssetMode && assetData) {
|
|
||||||
return assetData.dropdownItems.value
|
|
||||||
}
|
|
||||||
return [...inputItems.value, ...outputItems.value]
|
return [...inputItems.value, ...outputItems.value]
|
||||||
})
|
})
|
||||||
|
|
||||||
const dropdownItems = computed<DropdownItem[]>(() => {
|
const dropdownItems = computed<DropdownItem[]>(() => {
|
||||||
if (props.isAssetMode) {
|
if (props.isAssetMode) {
|
||||||
return allItems.value
|
return allItems.value
|
||||||
@@ -163,7 +161,7 @@ const dropdownItems = computed<DropdownItem[]>(() => {
|
|||||||
return outputItems.value
|
return outputItems.value
|
||||||
case 'all':
|
case 'all':
|
||||||
default:
|
default:
|
||||||
return allItems.value
|
return [...inputItems.value, ...outputItems.value]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const hideLayoutField = inject<boolean>('hideLayoutField', false)
|
|||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'cursor-default min-w-0 rounded-lg space-y-1 focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
'cursor-default min-w-0 rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||||
widget.borderStyle
|
widget.borderStyle
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ import {
|
|||||||
import { assetService } from '@/platform/assets/services/assetService'
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
|
||||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
|
||||||
import type {
|
import type {
|
||||||
ComboInputSpec,
|
ComboInputSpec,
|
||||||
InputSpec
|
InputSpec
|
||||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||||
import { addValueControlWidgets } from '@/scripts/widgets'
|
|
||||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||||
|
import { addValueControlWidgets } from '@/scripts/widgets'
|
||||||
import { useAssetsStore } from '@/stores/assetsStore'
|
import { useAssetsStore } from '@/stores/assetsStore'
|
||||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||||
|
|
||||||
@@ -69,6 +69,16 @@ const addMultiSelectWidget = (
|
|||||||
addWidget(node, widget as BaseDOMWidget<object | string>)
|
addWidget(node, widget as BaseDOMWidget<object | string>)
|
||||||
// TODO: Add remote support to multi-select widget
|
// TODO: Add remote support to multi-select widget
|
||||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
|
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
|
||||||
|
if (inputSpec.control_after_generate) {
|
||||||
|
widget.linkedWidgets = addValueControlWidgets(
|
||||||
|
node,
|
||||||
|
widget,
|
||||||
|
'fixed',
|
||||||
|
undefined,
|
||||||
|
transformInputSpecV2ToV1(inputSpec)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return widget
|
return widget
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
|||||||
import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import { isFloatInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||||
|
import { addValueControlWidget } from '@/scripts/widgets'
|
||||||
|
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||||
|
|
||||||
function onFloatValueChange(this: INumericWidget, v: number) {
|
function onFloatValueChange(this: INumericWidget, v: number) {
|
||||||
const round = this.options.round
|
const round = this.options.round
|
||||||
@@ -55,7 +57,7 @@ export const useFloatWidget = () => {
|
|||||||
|
|
||||||
/** Assertion {@link inputSpec.default} */
|
/** Assertion {@link inputSpec.default} */
|
||||||
const defaultValue = (inputSpec.default as number | undefined) ?? 0
|
const defaultValue = (inputSpec.default as number | undefined) ?? 0
|
||||||
return node.addWidget(
|
const widget = node.addWidget(
|
||||||
widgetType,
|
widgetType,
|
||||||
inputSpec.name,
|
inputSpec.name,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
@@ -73,6 +75,20 @@ export const useFloatWidget = () => {
|
|||||||
precision
|
precision
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (inputSpec.control_after_generate) {
|
||||||
|
const controlWidget = addValueControlWidget(
|
||||||
|
node,
|
||||||
|
widget,
|
||||||
|
'fixed',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
transformInputSpecV2ToV1(inputSpec)
|
||||||
|
)
|
||||||
|
widget.linkedWidgets = [controlWidget]
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget
|
||||||
}
|
}
|
||||||
|
|
||||||
return widgetConstructor
|
return widgetConstructor
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
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 type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import { addValueControlWidget } from '@/scripts/widgets'
|
import { isIntInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||||
|
import { addValueControlWidget } from '@/scripts/widgets'
|
||||||
|
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||||
|
|
||||||
function onValueChange(this: INumericWidget, v: number) {
|
function onValueChange(this: INumericWidget, v: number) {
|
||||||
// For integers, always round to the nearest step
|
// For integers, always round to the nearest step
|
||||||
@@ -69,14 +69,10 @@ export const useIntWidget = () => {
|
|||||||
|
|
||||||
const controlAfterGenerate =
|
const controlAfterGenerate =
|
||||||
inputSpec.control_after_generate ??
|
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)
|
['seed', 'noise_seed'].includes(inputSpec.name)
|
||||||
|
|
||||||
if (controlAfterGenerate) {
|
if (controlAfterGenerate) {
|
||||||
const seedControl = addValueControlWidget(
|
const controlWidget = addValueControlWidget(
|
||||||
node,
|
node,
|
||||||
widget,
|
widget,
|
||||||
'randomize',
|
'randomize',
|
||||||
@@ -84,7 +80,7 @@ export const useIntWidget = () => {
|
|||||||
undefined,
|
undefined,
|
||||||
transformInputSpecV2ToV1(inputSpec)
|
transformInputSpecV2ToV1(inputSpec)
|
||||||
)
|
)
|
||||||
widget.linkedWidgets = [seedControl]
|
widget.linkedWidgets = [controlWidget]
|
||||||
}
|
}
|
||||||
|
|
||||||
return widget
|
return widget
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { computed, toValue } from 'vue'
|
||||||
|
import type { MaybeRefOrGetter } 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,
|
||||||
|
precisionArg: MaybeRefOrGetter<number | undefined>,
|
||||||
|
returnUndefinedForDefault = false
|
||||||
|
) {
|
||||||
|
return computed(() => {
|
||||||
|
const precision = toValue(precisionArg)
|
||||||
|
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||||
|
if (options?.step2 !== undefined) {
|
||||||
|
return Number(options.step2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (precision === undefined) {
|
||||||
|
return returnUndefinedForDefault ? undefined : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (precision === 0) return 1
|
||||||
|
|
||||||
|
// For precision > 0, step = 1 / (10^precision)
|
||||||
|
const step = 1 / Math.pow(10, precision)
|
||||||
|
return returnUndefinedForDefault ? step : Number(step.toFixed(precision))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ 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,
|
||||||
@@ -1358,6 +1359,7 @@ 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)
|
||||||
@@ -1402,6 +1404,7 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export function clone<T>(obj: T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @knipIgnoreUnusedButUsedByCustomNodes
|
|
||||||
* @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead
|
* @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead
|
||||||
* There are external callers to this function, so we need to keep it for now
|
* 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.rootGraph, value)
|
return _applyTextReplacements(app.rootGraph, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @knipIgnoreUnusedButUsedByCustomNodes */
|
|
||||||
export async function addStylesheet(
|
export async function addStylesheet(
|
||||||
urlOrFile: string,
|
urlOrFile: string,
|
||||||
relativeTo?: string
|
relativeTo?: string
|
||||||
|
|||||||
@@ -15,6 +15,28 @@ export type WidgetValue =
|
|||||||
| void
|
| void
|
||||||
| File[]
|
| File[]
|
||||||
|
|
||||||
|
const CONTROL_OPTIONS = [
|
||||||
|
'fixed',
|
||||||
|
'increment',
|
||||||
|
'decrement',
|
||||||
|
'randomize'
|
||||||
|
] as const
|
||||||
|
export type ControlOptions = (typeof CONTROL_OPTIONS)[number]
|
||||||
|
|
||||||
|
function isControlOption(val: WidgetValue): val is ControlOptions {
|
||||||
|
return CONTROL_OPTIONS.includes(val as ControlOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeControlOption(val: WidgetValue): ControlOptions {
|
||||||
|
if (isControlOption(val)) return val
|
||||||
|
return 'randomize'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SafeControlWidget = {
|
||||||
|
value: ControlOptions
|
||||||
|
update: (value: WidgetValue) => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface SimplifiedWidget<
|
export interface SimplifiedWidget<
|
||||||
T extends WidgetValue = WidgetValue,
|
T extends WidgetValue = WidgetValue,
|
||||||
O = Record<string, any>
|
O = Record<string, any>
|
||||||
@@ -47,4 +69,6 @@ export interface SimplifiedWidget<
|
|||||||
|
|
||||||
/** Optional input specification backing this widget */
|
/** Optional input specification backing this widget */
|
||||||
spec?: InputSpecV2
|
spec?: InputSpecV2
|
||||||
|
|
||||||
|
controlWidget?: SafeControlWidget
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
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,4 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { setActivePinia } from 'pinia'
|
||||||
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getComponent,
|
getComponent,
|
||||||
@@ -26,7 +28,18 @@ 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', () => {
|
describe('widgetRegistry', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
describe('getComponent', () => {
|
describe('getComponent', () => {
|
||||||
// Test number type mappings
|
// Test number type mappings
|
||||||
describe('number types', () => {
|
describe('number types', () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||