Compare commits

..

13 Commits

Author SHA1 Message Date
GitHub Action
574a3c21c9 [automated] Apply ESLint and Prettier fixes 2025-12-13 18:01:13 +00:00
Austin Mroz
87c3ba636f Fix unused export 2025-12-13 09:51:53 -08:00
Austin Mroz
48962a4970 Remove unused code 2025-12-13 09:51:50 -08:00
Austin Mroz
c4efedfa1d Fix ColorPicker, further test cleanup 2025-12-13 09:50:15 -08:00
Austin Mroz
1c75a49978 Add control button to vue combo widget 2025-12-13 09:50:15 -08:00
Austin Mroz
090ee7a981 Fix node previews 2025-12-13 09:50:15 -08:00
Austin Mroz
a07097e25d Fix unit tests 2025-12-13 09:50:14 -08:00
Austin Mroz
948320137e Make vue inputs reactive 2025-12-13 09:48:06 -08:00
Austin Mroz
a50eb8dd57 End duck violence
I would prefer that valueRef used the TValue generic, but that causes
many, many awful errors. This is far preferable
2025-12-13 09:48:06 -08:00
Austin Mroz
47650bebf8 Migrate vue widget data to use refs 2025-12-13 09:48:04 -08:00
Austin Mroz
3eece91eb6 Wip control widget state migration 2025-12-13 09:37:44 -08:00
Comfy Org PR Bot
5187a77234 1.35.6 (#7452)
Patch version increment to 1.35.6

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7452-1-35-6-2c86d73d36508158952bd1308e819410)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-12-13 05:51:04 -07:00
AustinMroz
b22ba97a13 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>
2025-12-13 05:23:56 -07:00
76 changed files with 828 additions and 796 deletions

View File

@@ -51,8 +51,6 @@ defineProps<{
canProceed: boolean
/** Whether the location step should be disabled */
disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */
disableSettingsStep: boolean
}>()

View File

@@ -160,7 +160,7 @@ export class VueNodeHelpers {
return {
input: widget.locator('input'),
incrementButton: widget.locator('button').first(),
decrementButton: widget.locator('button').last()
decrementButton: widget.locator('button').nth(1)
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -15,7 +15,9 @@ test.describe('Vue Integer Widget', () => {
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
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 initialValue = Number(await controls.input.inputValue())

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.35.5",
"version": "1.35.6",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -264,7 +264,7 @@ if (!releaseInfo) {
}
// Output as JSON for GitHub Actions
// eslint-disable-next-line no-console
console.log(JSON.stringify(releaseInfo, null, 2))
export { resolveRelease }

View File

@@ -5,13 +5,13 @@
<header
class="flex items-center justify-between border-b border-interface-stroke px-4 py-4"
>
<h2 class="m-0 text-sm font-normal text-text-primary">
<p class="m-0 text-[14px] font-normal leading-none">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogTitle') }}
</h2>
</p>
<IconButton
type="transparent"
size="sm"
class="size-4 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
class="size-6 bg-transparent text-text-secondary hover:bg-secondary-background hover:opacity-100"
:aria-label="t('g.close')"
@click="onCancel"
>
@@ -19,28 +19,28 @@
</IconButton>
</header>
<div class="flex flex-col gap-6 px-4 py-4">
<div class="text-sm text-text-secondary">
<div class="flex flex-col gap-4 px-4 py-4 text-[14px] text-text-secondary">
<p class="m-0">
{{
t('sideToolbar.queueProgressOverlay.clearHistoryDialogDescription')
}}
<br /><br />
</p>
<p class="m-0">
{{ t('sideToolbar.queueProgressOverlay.clearHistoryDialogAssetsNote') }}
</div>
</p>
</div>
<footer class="flex items-center justify-end px-4 py-4">
<div class="flex items-center gap-4">
<Button
class="h-6 px-1 py-1 text-sm"
severity="secondary"
text
<div class="flex items-center gap-4 text-[14px] leading-none">
<TextButton
class="min-h-[24px] px-1 py-1 text-[14px] leading-[1] text-text-secondary hover:text-text-primary"
type="transparent"
:label="t('g.cancel')"
@click="onCancel"
/>
<Button
class="h-10 px-4 py-2 text-sm font-normal"
severity="danger"
<TextButton
class="min-h-[32px] px-4 py-2 text-[12px] font-normal leading-[1]"
type="secondary"
:label="t('g.clear')"
:disabled="isClearing"
@click="onConfirm"
@@ -51,11 +51,11 @@
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import TextButton from '@/components/button/TextButton.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useDialogStore } from '@/stores/dialogStore'
import { useQueueStore } from '@/stores/queueStore'

View File

@@ -3,7 +3,8 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { reactive, ref, shallowReactive, watch } from 'vue'
import type { Ref } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type {
@@ -20,7 +21,8 @@ import type { NodeId } from '@/renderer/core/layout/types'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { WidgetValue, ControlOptions } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import type {
LGraph,
@@ -40,14 +42,15 @@ export interface WidgetSlotMetadata {
export interface SafeWidgetData {
name: string
type: string
value: WidgetValue
value: () => Ref<WidgetValue>
borderStyle?: string
callback?: ((value: unknown) => void) | undefined
controlWidget?: () => Ref<ControlOptions>
isDOMWidget?: boolean
label?: string
options?: IWidgetOptions<unknown>
callback?: ((value: unknown) => void) | undefined
spec?: InputSpec
slotMetadata?: WidgetSlotMetadata
isDOMWidget?: boolean
borderStyle?: string
spec?: InputSpec
}
export interface VueNodeData {
@@ -84,6 +87,49 @@ export interface GraphNodeManager {
cleanup(): void
}
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function getControlWidget(
widget: IBaseWidget
): (() => Ref<ControlOptions>) | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
)
if (!cagWidget) return
const cagRef = ref<ControlOptions>(normalizeControlOption(cagWidget.value))
watch(cagRef, (value) => {
cagWidget.value = normalizeControlOption(value)
cagWidget.callback?.(cagWidget.value)
})
return () => cagRef
}
export function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
@@ -91,18 +137,27 @@ export function safeWidgetMapper(
const nodeDefStore = useNodeDefStore()
return function (widget) {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
widget.value = widget.options.values[0]
}
if (!widget.valueRef) {
const valueRef = ref(widget.value)
watch(valueRef, (newValue) => {
widget.value = newValue
widget.callback?.(newValue)
})
widget.callback = useChainCallback(widget.callback, () => {
if (valueRef.value !== widget.value)
valueRef.value = normalizeWidgetValue(widget.value) ?? undefined
})
widget.valueRef = () => valueRef
}
const spec = nodeDefStore.getInputSpecForWidget(node, widget.name)
const slotInfo = slotMetadata.get(widget.name)
@@ -115,20 +170,20 @@ export function safeWidgetMapper(
return {
name: widget.name,
type: widget.type,
value: value,
value: widget.valueRef,
borderStyle,
callback: widget.callback,
isDOMWidget: isDOMWidget(widget),
label: widget.label,
options: widget.options,
spec,
slotMetadata: slotInfo
slotMetadata: slotInfo,
controlWidget: getControlWidget(widget)
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
value: () => ref()
}
}
}
@@ -204,6 +259,15 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
reactiveWidgets.splice(0, reactiveWidgets.length, ...v)
}
})
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
return reactiveInputs
},
set(v) {
reactiveInputs.splice(0, reactiveInputs.length, ...v)
}
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
node.inputs?.forEach((input, index) => {
@@ -238,7 +302,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: node.inputs ? [...node.inputs] : undefined,
inputs: reactiveInputs,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
@@ -252,128 +316,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
return nodeRefs.get(id)
}
/**
* Validates that a value is a valid WidgetValue type
*/
const validateWidgetValue = (value: unknown): WidgetValue => {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
/**
* Updates Vue state when widget values change
*/
const updateVueWidgetState = (
nodeId: string,
widgetName: string,
value: unknown
): void => {
try {
const currentData = vueNodeData.get(nodeId)
if (!currentData?.widgets) return
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
)
// Create a completely new object to ensure Vue reactivity triggers
const updatedData = {
...currentData,
widgets: updatedWidgets
}
vueNodeData.set(nodeId, updatedData)
} catch (error) {
// Ignore widget update errors to prevent cascade failures
}
}
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedWidgetCallback = (
widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
// Validate that the value is of an acceptable type
if (
value !== null &&
value !== undefined &&
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
typeof value !== 'object'
) {
console.warn(`Invalid widget value type: ${typeof value}`)
updateInProgress = false
return
}
// Always update widget.value to ensure sync
widget.value = value
// 2. Call the original callback if it exists
if (originalCallback) {
originalCallback.call(widget, value)
}
// 3. Update Vue state to maintain synchronization
updateVueWidgetState(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedWidgetCallback(
widget,
originalCallback,
nodeId
)
})
}
const syncWithGraph = () => {
if (!graph?._nodes) return
@@ -394,9 +336,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Store non-reactive reference
nodeRefs.set(id, node)
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
@@ -415,9 +354,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))

View File

@@ -257,6 +257,7 @@ export class PrimitiveNode extends LGraphNode {
undefined,
inputData
)
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
let filter = this.widgets_values?.[2]
if (filter && this.widgets && this.widgets.length === 3) {
this.widgets[2].value = filter

View File

@@ -1,3 +1,5 @@
import type { Ref } from 'vue'
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph'
import type { CanvasPointerEvent } from './events'
@@ -284,6 +286,7 @@ export interface IBaseWidget<
/** Widget type (see {@link TWidgetType}) */
type: TType
value?: TValue
valueRef?: () => Ref<boolean | number | string | object | undefined>
/**
* Whether the widget value should be serialized on node serialization.

View File

@@ -734,9 +734,9 @@
"jobsFailed": "{count} job failed | {count} jobs failed",
"cancelJobTooltip": "Cancel job",
"clearQueueTooltip": "Clear queue",
"clearHistoryDialogTitle": "Clear your history?",
"clearHistoryDialogDescription": "All jobs below will be removed.",
"clearHistoryDialogAssetsNote": "None of your outputs will be deleted."
"clearHistoryDialogTitle": "Clear your job queue history?",
"clearHistoryDialogDescription": "All the finished or failed jobs below will be removed from this Job queue panel.",
"clearHistoryDialogAssetsNote": "Assets generated by these jobs wont be deleted and can always be viewed from the assets panel."
},
"workflowTab": {
"confirmDeleteTitle": "Delete workflow?",
@@ -2052,6 +2052,24 @@
"placeholderVideo": "Select video...",
"placeholderModel": "Select model...",
"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": {

View File

@@ -24,7 +24,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type {
@@ -51,14 +51,16 @@ const nodeData = computed<VueNodeData>(() => {
.map(([name, input]) => ({
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: input.type === 'COMBO' &&
Array.isArray(input.options) &&
input.options.length > 0
? input.options[0]
: '',
value: () =>
ref(
input.default !== undefined
? input.default
: input.type === 'COMBO' &&
Array.isArray(input.options) &&
input.options.length > 0
? input.options[0]
: ''
),
options: {
hidden: input.hidden,
advanced: input.advanced,

View File

@@ -59,7 +59,6 @@
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
class="col-span-2"
@update:model-value="widget.updateHandler"
/>
</div>
</template>
@@ -69,7 +68,7 @@
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref, toValue } from 'vue'
import type { Component } from 'vue'
import type { Component, Ref } from 'vue'
import type {
VueNodeData,
@@ -136,8 +135,7 @@ interface ProcessedWidget {
type: string
vueComponent: Component
simplified: SimplifiedWidget
value: WidgetValue
updateHandler: (value: WidgetValue) => void
value: () => Ref<WidgetValue>
tooltipConfig: TooltipOptions
slotMetadata?: WidgetSlotMetadata
}
@@ -170,20 +168,9 @@ const processedWidgets = computed((): ProcessedWidget[] => {
value: widget.value,
label: widget.label,
options: widgetOptions,
callback: widget.callback,
spec: widget.spec,
borderStyle: widget.borderStyle
}
function updateHandler(value: WidgetValue) {
// Update the widget value directly
widget.value = value
// Skip callback for asset widgets - their callback opens the modal,
// but Vue asset mode handles selection through the dropdown
if (widget.type !== 'asset') {
widget.callback?.(value)
}
borderStyle: widget.borderStyle,
controlWidget: widget.controlWidget
}
const tooltipText = getWidgetTooltip(widget)
@@ -195,7 +182,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
vueComponent,
simplified,
value: widget.value,
updateHandler,
tooltipConfig,
slotMetadata
})

View File

@@ -0,0 +1,153 @@
<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 type { Ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import type { ControlOptions } from '@/types/simplifiedWidget'
type ControlOption = {
description: string
mode: ControlOptions
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 controlOptions: ControlOption[] = [
{
mode: 'randomize',
icon: 'icon-[lucide--shuffle]',
title: 'randomize',
description: 'randomizeDesc'
},
{
mode: 'increment',
text: '+1',
title: 'increment',
description: 'incrementDesc'
},
{
mode: 'decrement',
text: '-1',
title: 'decrement',
description: 'decrementDesc'
}
]
const widgetControlMode = computed(() =>
settingStore.get('Comfy.WidgetControlMode')
)
const props = defineProps<{
controlWidget: () => Ref<ControlOptions>
}>()
const handleToggle = (mode: ControlOptions) => {
if (props.controlWidget().value === mode) return
props.controlWidget().value = mode
}
const isActive = (mode: ControlOptions) => {
return props.controlWidget().value === 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>
{{ $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="
(v) => (v ? handleToggle(option.mode) : handleToggle('fixed'))
"
/>
</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>

View File

@@ -31,11 +31,7 @@ const props = defineProps<{
nodeId: string
}>()
const modelValue = defineModel<string>('modelValue')
defineEmits<{
'update:modelValue': [value: string]
}>()
const modelValue = props.widget.value()
// Get litegraph node
const litegraphNode = computed(() => {
@@ -50,7 +46,7 @@ const isOutputNodeRef = computed(() => {
return isOutputNode(node)
})
const audioFilePath = computed(() => props.widget.value as string)
const audioFilePath = props.widget.value()
// Computed audio URL from widget value (for input files)
const audioUrlFromWidget = computed(() => {

View File

@@ -3,6 +3,7 @@ import Button from 'primevue/button'
import type { ButtonProps } from 'primevue/button'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { ref, watch } from 'vue'
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -12,13 +13,16 @@ describe('WidgetButton Interactions', () => {
options: Partial<ButtonProps> = {},
callback?: () => void,
name: string = 'test_button'
): SimplifiedWidget<void> => ({
name,
type: 'button',
value: undefined,
options,
callback
})
): SimplifiedWidget<void> => {
const valueRef = ref()
if (callback) watch(valueRef, callback)
return {
name,
type: 'button',
value: () => valueRef,
options
}
}
const mountComponent = (widget: SimplifiedWidget<void>, readonly = false) => {
return mount(WidgetButton, {
@@ -195,11 +199,7 @@ describe('WidgetButton Interactions', () => {
const widget = createMockWidget({}, mockCallback)
const wrapper = mountComponent(widget)
// Simulate rapid clicks
const clickPromises = Array.from({ length: 16 }, () =>
clickButton(wrapper)
)
await Promise.all(clickPromises)
for (let i = 0; i < 16; i++) await clickButton(wrapper)
expect(mockCallback).toHaveBeenCalledTimes(16)
})

View File

@@ -37,8 +37,8 @@ const filteredProps = computed(() =>
)
const handleClick = () => {
if (props.widget.callback) {
props.widget.callback()
}
const ref = props.widget.value()
//@ts-expect-error - need to actually assign value, can't use triggerRef :(
ref.value = !ref.value
}
</script>

View File

@@ -21,12 +21,12 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
const value = defineModel<ChartData>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
}>()
const value = props.widget.value()
const chartType = computed(() => props.widget.options?.type ?? 'line')
const chartData = computed(() => value.value || { labels: [], datasets: [] })

View File

@@ -3,6 +3,7 @@ import ColorPicker from 'primevue/colorpicker'
import type { ColorPickerProps } from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -14,13 +15,16 @@ describe('WidgetColorPicker Value Binding', () => {
value: string = '#000000',
options: Partial<ColorPickerProps> = {},
callback?: (value: string) => void
): SimplifiedWidget<string> => ({
name: 'test_color_picker',
type: 'color',
value,
options,
callback
})
): SimplifiedWidget<string> => {
const valueRef = ref(value)
if (callback) watch(valueRef, (v) => callback(v))
return {
name: 'test_color_picker',
type: 'color',
value: () => valueRef,
options
}
}
const mountComponent = (
widget: SimplifiedWidget<string>,
@@ -49,80 +53,61 @@ describe('WidgetColorPicker Value Binding', () => {
) => {
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
await colorPicker.setValue(value)
return wrapper.emitted('update:modelValue')
}
describe('Vue Event Emission', () => {
it('emits Vue event when color changes', async () => {
const widget = createMockWidget('#ff0000')
describe('Value Binding', () => {
it('triggers callback when color changes', async () => {
const callback = vi.fn()
const widget = createMockWidget('#ff0000', {}, callback)
const wrapper = mountComponent(widget, '#ff0000')
const emitted = await setColorPickerValue(wrapper, '#00ff00')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
await setColorPickerValue(wrapper, '#00ff00')
expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00')
})
it('handles different color formats', async () => {
const widget = createMockWidget('#ffffff')
const callback = vi.fn()
const widget = createMockWidget('#ffffff', {}, callback)
const wrapper = mountComponent(widget, '#ffffff')
const emitted = await setColorPickerValue(wrapper, '#123abc')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#123abc')
await setColorPickerValue(wrapper, '#123abc')
expect(callback).toHaveBeenCalledExactlyOnceWith('#123abc')
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('#000000', {}, undefined)
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
// Should still emit Vue event
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#ff00ff')
})
it('normalizes bare hex without # to #hex on emit', async () => {
const widget = createMockWidget('ff0000')
it('normalizes bare hex without # to #hex', async () => {
const callback = vi.fn()
const widget = createMockWidget('ff0000', {}, callback)
const wrapper = mountComponent(widget, 'ff0000')
const emitted = await setColorPickerValue(wrapper, '00ff00')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
await setColorPickerValue(wrapper, '00ff00')
expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00')
})
it('normalizes rgb() strings to #hex on emit', async (context) => {
context.skip('needs diagnosis')
const widget = createMockWidget('#000000')
it('normalizes rgb() strings to #hex', async () => {
const callback = vi.fn()
const widget = createMockWidget('#000000', { format: 'rgb' }, callback)
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#ff0000')
await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
expect(callback).toHaveBeenCalledExactlyOnceWith('#ff0000')
})
it('normalizes hsb() strings to #hex on emit', async () => {
const widget = createMockWidget('#000000', { format: 'hsb' })
it('normalizes hsb() strings to #hex', async () => {
const callback = vi.fn()
const widget = createMockWidget('#000000', { format: 'hsb' }, callback)
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#00ff00')
await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
expect(callback).toHaveBeenCalledExactlyOnceWith('#00ff00')
})
it('normalizes HSB object values to #hex on emit', async () => {
const widget = createMockWidget('#000000', { format: 'hsb' })
it('normalizes HSB object values to #hex', async () => {
const callback = vi.fn()
const widget = createMockWidget('#000000', { format: 'hsb' }, callback)
const wrapper = mountComponent(widget, '#000000')
const emitted = await setColorPickerValue(wrapper, {
h: 240,
s: 100,
b: 100
})
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#0000ff')
await setColorPickerValue(wrapper, { h: 240, s: 100, b: 100 })
expect(callback).toHaveBeenCalledExactlyOnceWith('#0000ff')
})
})
@@ -265,15 +250,15 @@ describe('WidgetColorPicker Value Binding', () => {
})
it('handles invalid color formats gracefully', async () => {
const widget = createMockWidget('invalid-color')
const callback = vi.fn()
const widget = createMockWidget('invalid-color', {}, callback)
const wrapper = mountComponent(widget, 'invalid-color')
const colorText = wrapper.find('[data-testid="widget-color-text"]')
expect(colorText.text()).toBe('#000000')
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('#000000')
await setColorPickerValue(wrapper, 'invalid-color')
expect(callback).toHaveBeenCalledExactlyOnceWith('#000000')
})
it('handles widget with no options', () => {

View File

@@ -14,7 +14,6 @@
:pt="{
preview: '!w-full !h-full !border-none'
}"
@update:model-value="onPickerUpdate"
/>
<span
class="text-xs truncate min-w-[4ch]"
@@ -27,11 +26,11 @@
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed, ref, watch } from 'vue'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { isColorFormat, toHexFromFormat } from '@/utils/colorUtil'
import type { ColorFormat, HSB } from '@/utils/colorUtil'
import type { ColorFormat } from '@/utils/colorUtil'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
@@ -45,39 +44,23 @@ type WidgetOptions = { format?: ColorFormat } & Record<string, unknown>
const props = defineProps<{
widget: SimplifiedWidget<string, WidgetOptions>
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const modelValue = props.widget.value()
const format = computed<ColorFormat>(() => {
const optionFormat = props.widget.options?.format
return isColorFormat(optionFormat) ? optionFormat : 'hex'
})
type PickerValue = string | HSB
const localValue = ref<PickerValue>(
toHexFromFormat(
props.modelValue || '#000000',
isColorFormat(props.widget.options?.format)
? props.widget.options.format
: 'hex'
)
)
watch(
() => props.modelValue,
(newVal) => {
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
const localValue = computed({
get() {
return toHexFromFormat(modelValue.value || '#000000', format.value)
},
set(v) {
modelValue.value = toHexFromFormat(v, format.value)
}
)
function onPickerUpdate(val: unknown) {
localValue.value = val as PickerValue
emit('update:modelValue', toHexFromFormat(val, format.value))
}
})
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const

View File

@@ -4,6 +4,7 @@ import Galleria from 'primevue/galleria'
import type { GalleriaProps } from 'primevue/galleria'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import { ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -52,7 +53,7 @@ function createMockWidget(
return {
name: 'test_galleria',
type: 'array',
value,
value: () => ref(value),
options
}
}

View File

@@ -68,12 +68,12 @@ export interface GalleryImage {
export type GalleryValue = string[] | GalleryImage[]
const value = defineModel<GalleryValue>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<GalleryValue>
}>()
const value = props.widget.value()
const activeIndex = ref(0)
const { t } = useI18n()

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import ImageCompare from 'primevue/imagecompare'
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -15,7 +16,7 @@ describe('WidgetImageCompare Display', () => {
): SimplifiedWidget<ImageCompareValue | string> => ({
name: 'test_imagecompare',
type: 'object',
value,
value: () => ref(value),
options
})

View File

@@ -44,24 +44,24 @@ const props = defineProps<{
}>()
const beforeImage = computed(() => {
const value = props.widget.value
const value = props.widget.value().value
return typeof value === 'string' ? value : value?.before || ''
})
const afterImage = computed(() => {
const value = props.widget.value
const value = props.widget.value().value
return typeof value === 'string' ? '' : value?.after || ''
})
const beforeAlt = computed(() => {
const value = props.widget.value
const value = props.widget.value().value
return typeof value === 'object' && value?.beforeAlt
? value.beforeAlt
: 'Before image'
})
const afterAlt = computed(() => {
const value = props.widget.value
const value = props.widget.value().value
return typeof value === 'object' && value?.afterAlt
? value.afterAlt
: 'After image'

View File

@@ -1,23 +1,43 @@
<script setup lang="ts">
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { computed } from 'vue'
import type {
SimplifiedControlWidget,
SimplifiedWidget
} from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
import WidgetInputNumberSlider from './WidgetInputNumberSlider.vue'
import WidgetWithControl from './WidgetWithControl.vue'
defineProps<{
const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const modelValue = defineModel<number>({ default: 0 })
const modelValue = props.widget.value()
const hasControlAfterGenerate = computed(() => {
return !!props.widget.controlWidget
})
</script>
<template>
<WidgetWithControl
v-if="hasControlAfterGenerate"
:widget="widget as SimplifiedControlWidget<number>"
:comp="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
/>
<component
:is="
widget.type === 'slider'
? WidgetInputNumberSlider
: WidgetInputNumberInput
"
v-else
v-model="modelValue"
:widget="widget"
v-bind="$attrs"

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputNumber from 'primevue/inputnumber'
import { describe, expect, it } from 'vitest'
import { ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -13,12 +14,13 @@ function createMockWidget(
options: SimplifiedWidget['options'] = {},
callback?: (value: number) => void
): SimplifiedWidget<number> {
const valueRef = ref(value)
if (callback) watch(valueRef, (v) => callback(v))
return {
name: 'test_input_number',
type,
value,
options,
callback
value: () => valueRef,
options
}
}
@@ -49,16 +51,15 @@ describe('WidgetInputNumberInput Value Binding', () => {
expect(input.value).toBe('42')
})
it('emits update:modelValue when value changes', async () => {
const widget = createMockWidget(10, 'int')
it('triggers callback when value changes', async () => {
const callback = vi.fn()
const widget = createMockWidget(10, 'int', {}, callback)
const wrapper = mountComponent(widget, 10)
const inputNumber = wrapper.findComponent(InputNumber)
await inputNumber.vm.$emit('update:modelValue', 20)
await inputNumber.setValue(20)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(20)
expect(callback).toHaveBeenCalledExactlyOnceWith(20)
})
it('handles negative values', () => {

View File

@@ -16,7 +16,7 @@ const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const modelValue = defineModel<number>({ default: 0 })
const modelValue = props.widget.value()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
@@ -89,8 +89,11 @@ const buttonTooltip = computed(() => {
:show-buttons="!buttonsDisabled"
:pt="{
root: {
class:
'[&>input]:bg-transparent [&>input]:border-0 [&>input]:truncate [&>input]:min-w-[4ch]'
class: cn(
'[&>input]:bg-transparent [&>input]:border-0',
'[&>input]:truncate [&>input]:min-w-[4ch]',
$slots.default && '[&>input]:pr-7'
)
},
decrementButton: {
class: 'w-8 border-0'
@@ -107,6 +110,9 @@ const buttonTooltip = computed(() => {
<span class="pi pi-minus text-sm" />
</template>
</InputNumber>
<div class="absolute top-5 right-8 h-4 w-7 -translate-y-4/5">
<slot />
</div>
</WidgetLayoutField>
</template>

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputNumber from 'primevue/inputnumber'
import { describe, expect, it } from 'vitest'
import { ref, watch } from 'vue'
import Slider from '@/components/ui/slider/Slider.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -13,12 +14,13 @@ function createMockWidget(
options: SimplifiedWidget['options'] = {},
callback?: (value: number) => void
): SimplifiedWidget<number> {
const valueRef = ref(value)
if (callback) watch(valueRef, callback)
return {
name: 'test_slider',
type: 'float',
value,
options: { min: 0, max: 100, step: 1, precision: 0, ...options },
callback
value: () => valueRef,
options: { min: 0, max: 100, step: 1, precision: 0, ...options }
}
}

View File

@@ -39,6 +39,7 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { useNumberStepCalculation } from '../composables/useNumberStepCalculation'
import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
@@ -47,7 +48,7 @@ const { widget } = defineProps<{
widget: SimplifiedWidget<number>
}>()
const modelValue = defineModel<number>({ default: 0 })
const modelValue = widget.value()
const timesEmptied = ref(0)
@@ -56,7 +57,7 @@ const updateLocalValue = (newValue: number[] | undefined): void => {
}
const handleNumberInputUpdate = (newValue: number | undefined) => {
if (newValue) {
if (newValue !== undefined) {
updateLocalValue([newValue])
return
}
@@ -67,33 +68,11 @@ const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
const p = widget.options?.precision
const precision = typeof p === 'number' && p >= 0 ? p : undefined
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// Use step2 (correct input spec value) instead of step (legacy 10x value)
if (widget.options?.step2 !== undefined) {
return widget.options.step2
}
// Otherwise, derive from precision
if (precision.value === undefined) {
return undefined
}
if (precision.value === 0) {
return 1
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return 1 / Math.pow(10, precision.value)
})
const stepValue = useNumberStepCalculation(widget.options, precision, true)
const sliderNumberPt = useNumberWidgetButtonPt({
roundedLeft: true,

View File

@@ -4,6 +4,7 @@ import InputText from 'primevue/inputtext'
import type { InputTextProps } from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import { describe, expect, it } from 'vitest'
import { ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -14,13 +15,16 @@ describe('WidgetInputText Value Binding', () => {
value: string = 'default',
options: Partial<InputTextProps> = {},
callback?: (value: string) => void
): SimplifiedWidget<string> => ({
name: 'test_input',
type: 'string',
value,
options,
callback
})
): SimplifiedWidget<string> => {
const valueRef = ref(value)
if (callback) watch(valueRef, (v) => callback(v))
return {
name: 'test_input',
type: 'string',
value: () => valueRef,
options
}
}
const mountComponent = (
widget: SimplifiedWidget<string>,
@@ -54,86 +58,26 @@ describe('WidgetInputText Value Binding', () => {
return input
}
describe('Vue Event Emission', () => {
it('emits Vue event when input value changes on blur', async () => {
const widget = createMockWidget('hello')
const wrapper = mountComponent(widget, 'hello')
await setInputValueAndTrigger(wrapper, 'world', 'blur')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('world')
})
it('emits Vue event when enter key is pressed', async () => {
const widget = createMockWidget('initial')
const wrapper = mountComponent(widget, 'initial')
await setInputValueAndTrigger(wrapper, 'new value', 'keydown.enter')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('new value')
})
describe('Widget Value Callbacks', () => {
it('handles empty string values', async () => {
const widget = createMockWidget('something')
const callback = vi.fn()
const widget = createMockWidget('something', {}, callback)
const wrapper = mountComponent(widget, 'something')
await setInputValueAndTrigger(wrapper, '')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('')
expect(callback).toHaveBeenCalledExactlyOnceWith('')
})
it('handles special characters correctly', async () => {
const widget = createMockWidget('normal')
const callback = vi.fn()
const widget = createMockWidget('normal', {}, callback)
const wrapper = mountComponent(widget, 'normal')
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
await setInputValueAndTrigger(wrapper, specialText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(specialText)
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('test', {}, undefined)
const wrapper = mountComponent(widget, 'test')
await setInputValueAndTrigger(wrapper, 'new value')
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('new value')
})
})
describe('User Interactions', () => {
it('emits update:modelValue on blur', async () => {
const widget = createMockWidget('original')
const wrapper = mountComponent(widget, 'original')
await setInputValueAndTrigger(wrapper, 'updated')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('updated')
})
it('emits update:modelValue on enter key', async () => {
const widget = createMockWidget('start')
const wrapper = mountComponent(widget, 'start')
await setInputValueAndTrigger(wrapper, 'finish', 'keydown.enter')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('finish')
expect(callback).toHaveBeenCalledExactlyOnceWith(specialText)
})
})
@@ -154,27 +98,25 @@ describe('WidgetInputText Value Binding', () => {
describe('Edge Cases', () => {
it('handles very long strings', async () => {
const widget = createMockWidget('short')
const callback = vi.fn()
const widget = createMockWidget('short', {}, callback)
const wrapper = mountComponent(widget, 'short')
const longString = 'a'.repeat(10000)
await setInputValueAndTrigger(wrapper, longString)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(longString)
expect(callback).toHaveBeenCalledExactlyOnceWith(longString)
})
it('handles unicode characters', async () => {
const widget = createMockWidget('ascii')
const callback = vi.fn()
const widget = createMockWidget('ascii', {}, callback)
const wrapper = mountComponent(widget, 'ascii')
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
await setInputValueAndTrigger(wrapper, unicodeText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(unicodeText)
expect(callback).toHaveBeenCalledExactlyOnceWith(unicodeText)
})
})
})

View File

@@ -29,7 +29,7 @@ const props = defineProps<{
widget: SimplifiedWidget<string>
}>()
const modelValue = defineModel<string>({ default: '' })
const modelValue = props.widget.value()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)

View File

@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Textarea from 'primevue/textarea'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { nextTick, ref, watch } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
@@ -28,13 +28,16 @@ describe('WidgetMarkdown Dual Mode Display', () => {
value: string = '# Default Heading\nSome **bold** text.',
options: Record<string, unknown> = {},
callback?: (value: string) => void
): SimplifiedWidget<string> => ({
name: 'test_markdown',
type: 'string',
value,
options,
callback
})
): SimplifiedWidget<string> => {
const valueRef = ref(value)
if (callback) watch(valueRef, (v) => callback(v))
return {
name: 'test_markdown',
type: 'string',
value: () => valueRef,
options
}
}
const mountComponent = (
widget: SimplifiedWidget<string>,
@@ -209,8 +212,9 @@ describe('WidgetMarkdown Dual Mode Display', () => {
})
describe('Value Updates', () => {
it('emits update:modelValue when textarea content changes', async () => {
const widget = createMockWidget('# Original')
it('triggers callback when textarea content changes', async () => {
const callback = vi.fn()
const widget = createMockWidget('# Original', {}, callback)
const wrapper = mountComponent(widget, '# Original')
await clickToEdit(wrapper)
@@ -219,9 +223,7 @@ describe('WidgetMarkdown Dual Mode Display', () => {
await textarea.setValue('# Updated Content')
await textarea.trigger('input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual(['# Updated Content'])
expect(callback).toHaveBeenLastCalledWith('# Updated Content')
})
it('renders updated HTML after value change and blur', async () => {
@@ -239,38 +241,6 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(displayDiv.html()).toContain('<h2>New Heading</h2>')
expect(displayDiv.html()).toContain('<strong>bold</strong>')
})
it('emits update:modelValue for callback handling at parent level', async () => {
const widget = createMockWidget('# Test', {})
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('# Changed')
await textarea.trigger('input')
// The widget should emit the change for parent (NodeWidgets) to handle callbacks
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual(['# Changed'])
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('# Test', {}, undefined)
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
await textarea.setValue('# Changed')
// Should not throw error and should still emit Vue event
await expect(textarea.trigger('input')).resolves.not.toThrow()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
})
})
describe('Complex Markdown Rendering', () => {
@@ -337,8 +307,9 @@ Another line with more content.`
})
it('handles unicode characters', async () => {
const callback = vi.fn()
const unicode = '# Unicode: 🎨 αβγ 中文 العربية 🚀'
const widget = createMockWidget(unicode)
const widget = createMockWidget(unicode, {}, callback)
const wrapper = mountComponent(widget, unicode)
await clickToEdit(wrapper)
@@ -348,9 +319,7 @@ Another line with more content.`
await textarea.setValue(unicode + ' more unicode')
await textarea.trigger('input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![emitted!.length - 1]).toEqual([unicode + ' more unicode'])
expect(callback).toHaveBeenLastCalledWith(unicode + ' more unicode')
})
it('handles rapid edit mode toggling', async () => {

View File

@@ -38,7 +38,7 @@ const { widget } = defineProps<{
widget: SimplifiedWidget<string>
}>()
const modelValue = defineModel<string>({ default: '' })
const modelValue = widget.value()
// State
const isEditing = ref(false)

View File

@@ -92,6 +92,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useAudioService } from '@/services/audioService'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
@@ -99,8 +100,9 @@ import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
import { formatTime } from '../utils/audioUtils'
const props = defineProps<{
readonly?: boolean
nodeId: string
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
// Audio element ref
@@ -152,7 +154,7 @@ const { isPlaying, audioElementKey } = playback
// Computed for waveform animation
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
const modelValue = defineModel<string>({ default: '' })
const modelValue = props.widget.value()
const litegraphNode = computed(() => {
if (!props.nodeId || !app.canvas.graph) return null

View File

@@ -9,6 +9,11 @@
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
/>
<WidgetWithControl
v-else-if="widget.controlWidget"
:comp="WidgetSelectDefault"
:widget="widget as SimplifiedControlWidget<string>"
/>
<WidgetSelectDefault v-else v-model="modelValue" :widget />
</template>
@@ -20,11 +25,15 @@ 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'
const props = defineProps<{
@@ -32,7 +41,7 @@ const props = defineProps<{
nodeType?: string
}>()
const modelValue = defineModel<string | undefined>()
const modelValue = props.widget.value()
const comboSpec = computed<ComboInputSpec | undefined>(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {

View File

@@ -1,29 +1,34 @@
<template>
<WidgetLayoutField :widget>
<Select
v-model="modelValue"
:invalid
:filter="selectOptions.length > 4"
:auto-filter-focus="selectOptions.length > 4"
:options="selectOptions"
v-bind="combinedProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:aria-label="widget.name"
size="small"
:pt="{
option: 'text-xs',
dropdown: 'w-8',
label: 'truncate min-w-[4ch]',
overlay: 'w-fit min-w-full'
}"
data-capture-wheel="true"
/>
<div class="relative">
<Select
v-model="modelValue"
:invalid
:filter="selectOptions.length > 4"
:auto-filter-focus="selectOptions.length > 4"
:options="selectOptions"
v-bind="combinedProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:aria-label="widget.name"
size="small"
:pt="{
option: 'text-xs',
dropdown: 'w-8',
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">
<slot />
</div>
</div>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -36,17 +41,15 @@ import {
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const slots = useSlots()
interface Props {
widget: SimplifiedWidget<string | undefined>
}
const props = defineProps<Props>()
const modelValue = defineModel<string | undefined>({
default(props: Props) {
return props.widget.options?.values?.[0] || ''
}
})
const modelValue = props.widget.value()
// Transform compatibility props for overlay positioning
const transformCompatProps = useTransformCompatOverlayProps()

View File

@@ -43,11 +43,7 @@ provide(
computed(() => props.assetKind)
)
const modelValue = defineModel<string | undefined>({
default(props: Props) {
return props.widget.options?.values?.[0] || ''
}
})
const modelValue = props.widget.value()
const toastStore = useToastStore()
const queueStore = useQueueStore()
@@ -313,11 +309,6 @@ async function handleFilesUpdate(files: File[]) {
// 3. Update widget value to the first uploaded file
modelValue.value = uploadedPaths[0]
// 4. Trigger callback to notify underlying LiteGraph widget
if (props.widget.callback) {
props.widget.callback(uploadedPaths[0])
}
} catch (error) {
console.error('Upload error:', error)
toastStore.addAlert(`Upload failed: ${error}`)

View File

@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Textarea from 'primevue/textarea'
import { describe, expect, it } from 'vitest'
import { ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -12,12 +13,13 @@ function createMockWidget(
options: SimplifiedWidget['options'] = {},
callback?: (value: string) => void
): SimplifiedWidget<string> {
const valueRef = ref(value)
if (callback) watch(valueRef, (v) => callback(v))
return {
name: 'test_textarea',
type: 'string',
value,
options,
callback
value: () => valueRef,
options
}
}
@@ -58,98 +60,47 @@ async function setTextareaValueAndTrigger(
}
describe('WidgetTextarea Value Binding', () => {
describe('Vue Event Emission', () => {
it('emits Vue event when textarea value changes on blur', async () => {
const widget = createMockWidget('hello')
const wrapper = mountComponent(widget, 'hello')
await setTextareaValueAndTrigger(wrapper, 'world', 'blur')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('world')
})
it('emits Vue event when textarea value changes on input', async () => {
const widget = createMockWidget('initial')
describe('Widget Value Callbacks', () => {
it('triggers callback when textarea value changes on input', async () => {
const callback = vi.fn()
const widget = createMockWidget('initial', {}, callback)
const wrapper = mountComponent(widget, 'initial')
await setTextareaValueAndTrigger(wrapper, 'new content', 'input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('new content')
expect(callback).toHaveBeenCalledExactlyOnceWith('new content')
})
it('handles empty string values', async () => {
const widget = createMockWidget('something')
const callback = vi.fn()
const widget = createMockWidget('something', {}, callback)
const wrapper = mountComponent(widget, 'something')
await setTextareaValueAndTrigger(wrapper, '')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('')
expect(callback).toHaveBeenCalledExactlyOnceWith('')
})
it('handles multiline text correctly', async () => {
const widget = createMockWidget('single line')
const callback = vi.fn()
const widget = createMockWidget('single line', {}, callback)
const wrapper = mountComponent(widget, 'single line')
const multilineText = 'Line 1\nLine 2\nLine 3'
await setTextareaValueAndTrigger(wrapper, multilineText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(multilineText)
expect(callback).toHaveBeenCalledExactlyOnceWith(multilineText)
})
it('handles special characters correctly', async () => {
const widget = createMockWidget('normal')
const callback = vi.fn()
const widget = createMockWidget('normal', {}, callback)
const wrapper = mountComponent(widget, 'normal')
const specialText = 'special @#$%^&*()[]{}|\\:";\'<>?,./'
await setTextareaValueAndTrigger(wrapper, specialText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(specialText)
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('test', {}, undefined)
const wrapper = mountComponent(widget, 'test')
await setTextareaValueAndTrigger(wrapper, 'new value')
// Should still emit Vue event
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('new value')
})
})
describe('User Interactions', () => {
it('emits update:modelValue on blur', async () => {
const widget = createMockWidget('original')
const wrapper = mountComponent(widget, 'original')
await setTextareaValueAndTrigger(wrapper, 'updated')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('updated')
})
it('emits update:modelValue on input', async () => {
const widget = createMockWidget('start')
const wrapper = mountComponent(widget, 'start')
await setTextareaValueAndTrigger(wrapper, 'finish', 'input')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain('finish')
expect(callback).toHaveBeenCalledExactlyOnceWith(specialText)
})
})
@@ -199,39 +150,36 @@ describe('WidgetTextarea Value Binding', () => {
describe('Edge Cases', () => {
it('handles very long text', async () => {
const widget = createMockWidget('short')
const callback = vi.fn()
const widget = createMockWidget('short', {}, callback)
const wrapper = mountComponent(widget, 'short')
const longText = 'a'.repeat(10000)
await setTextareaValueAndTrigger(wrapper, longText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(longText)
expect(callback).toHaveBeenCalledExactlyOnceWith(longText)
})
it('handles unicode characters', async () => {
const widget = createMockWidget('ascii')
const callback = vi.fn()
const widget = createMockWidget('ascii', {}, callback)
const wrapper = mountComponent(widget, 'ascii')
const unicodeText = '🎨 Unicode: αβγ 中文 العربية 🚀'
await setTextareaValueAndTrigger(wrapper, unicodeText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(unicodeText)
expect(callback).toHaveBeenCalledExactlyOnceWith(unicodeText)
})
it('handles text with tabs and spaces', async () => {
const widget = createMockWidget('normal')
const callback = vi.fn()
const widget = createMockWidget('normal', {}, callback)
const wrapper = mountComponent(widget, 'normal')
const formattedText = '\tIndented line\n Spaced line\n\t\tDouble indent'
await setTextareaValueAndTrigger(wrapper, formattedText)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted?.[0]).toContain(formattedText)
expect(callback).toHaveBeenCalledExactlyOnceWith(formattedText)
})
})
})

View File

@@ -46,7 +46,7 @@ const { widget, placeholder = '' } = defineProps<{
placeholder?: string
}>()
const modelValue = defineModel<string>({ default: '' })
const modelValue = widget.value()
const filteredProps = computed(() =>
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)

View File

@@ -3,6 +3,7 @@ import PrimeVue from 'primevue/config'
import ToggleSwitch from 'primevue/toggleswitch'
import type { ToggleSwitchProps } from 'primevue/toggleswitch'
import { describe, expect, it } from 'vitest'
import { ref, watch } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -13,13 +14,16 @@ describe('WidgetToggleSwitch Value Binding', () => {
value: boolean = false,
options: Partial<ToggleSwitchProps> = {},
callback?: (value: boolean) => void
): SimplifiedWidget<boolean> => ({
name: 'test_toggle',
type: 'boolean',
value,
options,
callback
})
): SimplifiedWidget<boolean> => {
const valueRef = ref(value)
if (callback) watch(valueRef, (v) => callback(v))
return {
name: 'test_toggle',
type: 'boolean',
value: () => valueRef,
options
}
}
const mountComponent = (
widget: SimplifiedWidget<boolean>,
@@ -39,48 +43,6 @@ describe('WidgetToggleSwitch Value Binding', () => {
})
}
describe('Vue Event Emission', () => {
it('emits Vue event when toggled from false to true', async () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
await toggle.setValue(true)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(true)
})
it('emits Vue event when toggled from true to false', async () => {
const widget = createMockWidget(true)
const wrapper = mountComponent(widget, true)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
await toggle.setValue(false)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(false)
})
it('handles value changes gracefully', async () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
// Should not throw when changing values
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
await toggle.setValue(true)
await toggle.setValue(false)
// Should emit events for all changes
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(2)
expect(emitted![0]).toContain(true)
expect(emitted![1]).toContain(false)
})
})
describe('Component Rendering', () => {
it('renders toggle switch component', () => {
const widget = createMockWidget(false)
@@ -109,27 +71,9 @@ describe('WidgetToggleSwitch Value Binding', () => {
})
describe('Multiple Value Changes', () => {
it('handles rapid toggling correctly', async () => {
const widget = createMockWidget(false)
const wrapper = mountComponent(widget, false)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
// Rapid toggle sequence
await toggle.setValue(true)
await toggle.setValue(false)
await toggle.setValue(true)
// Should have emitted 3 Vue events with correct values
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(3)
expect(emitted![0]).toContain(true)
expect(emitted![1]).toContain(false)
expect(emitted![2]).toContain(true)
})
it('maintains state consistency during multiple changes', async () => {
const widget = createMockWidget(false)
const callback = vi.fn()
const widget = createMockWidget(false, {}, callback)
const wrapper = mountComponent(widget, false)
const toggle = wrapper.findComponent({ name: 'ToggleSwitch' })
@@ -140,13 +84,12 @@ describe('WidgetToggleSwitch Value Binding', () => {
await toggle.setValue(true)
await toggle.setValue(false)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toHaveLength(4)
expect(callback).toHaveBeenCalledTimes(4)
// Verify alternating pattern
expect(emitted![0]).toContain(true)
expect(emitted![1]).toContain(false)
expect(emitted![2]).toContain(true)
expect(emitted![3]).toContain(false)
expect(callback).toHaveBeenNthCalledWith(1, true)
expect(callback).toHaveBeenNthCalledWith(2, false)
expect(callback).toHaveBeenNthCalledWith(3, true)
expect(callback).toHaveBeenNthCalledWith(4, false)
})
})
})

View File

@@ -25,7 +25,7 @@ const { widget } = defineProps<{
widget: SimplifiedWidget<boolean>
}>()
const modelValue = defineModel<boolean>()
const modelValue = widget.value()
const filteredProps = computed(() =>
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)

View File

@@ -0,0 +1,57 @@
<script setup lang="ts" generic="T extends WidgetValue">
import Button from 'primevue/button'
import { computed, defineAsyncComponent, ref } from 'vue'
import type { Ref } from 'vue'
import type {
ControlOptions,
SimplifiedControlWidget,
WidgetValue
} from '@/types/simplifiedWidget'
const NumberControlPopover = defineAsyncComponent(
() => import('./NumberControlPopover.vue')
)
function useControlButtonIcon(controlMode: Ref<ControlOptions>) {
return computed(() => {
switch (controlMode.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]'
}
})
}
const props = defineProps<{
widget: SimplifiedControlWidget<T>
comp: unknown
}>()
const popover = ref()
const controlButtonIcon = useControlButtonIcon(props.widget.controlWidget())
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>
<template>
<component :is="comp" v-bind="$attrs" :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>
<NumberControlPopover ref="popover" :control-widget="widget.controlWidget" />
</template>

View File

@@ -28,7 +28,7 @@ const hideLayoutField = inject<boolean>('hideLayoutField', false)
<div
:class="
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
)
"

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ export function clone<T>(obj: T): T {
}
/**
* @knipIgnoreUnusedButUsedByCustomNodes
* @deprecated Use `applyTextReplacements` from `@/utils/searchAndReplace` instead
* There are external callers to this function, so we need to keep it for now
*/
@@ -25,7 +24,6 @@ export function applyTextReplacements(app: ComfyApp, value: string): string {
return _applyTextReplacements(app.rootGraph, value)
}
/** @knipIgnoreUnusedButUsedByCustomNodes */
export async function addStylesheet(
urlOrFile: string,
relativeTo?: string

View File

@@ -2,6 +2,8 @@
* Simplified widget interface for Vue-based node rendering
* Removes all DOM manipulation and positioning concerns
*/
import type { Ref } from 'vue'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
/** Valid types for widget values */
@@ -15,6 +17,23 @@ export type WidgetValue =
| void
| 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 interface SimplifiedWidget<
T extends WidgetValue = WidgetValue,
O = Record<string, any>
@@ -26,7 +45,7 @@ export interface SimplifiedWidget<
type: string
/** Current value of the widget */
value: T
value: () => Ref<T>
borderStyle?: string
@@ -47,4 +66,8 @@ export interface SimplifiedWidget<
/** Optional input specification backing this widget */
spec?: InputSpecV2
controlWidget?: () => Ref<ControlOptions>
}
export type SimplifiedControlWidget<T extends WidgetValue = WidgetValue> =
SimplifiedWidget<T> & Required<Pick<SimplifiedWidget<T>, 'controlWidget'>>

View File

@@ -1,7 +1,7 @@
import { memoize } from 'es-toolkit/compat'
type RGB = { r: number; g: number; b: number }
export interface HSB {
interface HSB {
h: number
s: number
b: number

View File

@@ -3,7 +3,7 @@ import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
@@ -64,7 +64,7 @@ const isDesktop = isElectron()
const batchCountWidget = {
options: { step2: 1, precision: 1, min: 1, max: 100 },
value: 1,
value: () => ref(1),
name: t('Number of generations'),
type: 'number'
}

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type {
SafeWidgetData,
@@ -15,11 +16,10 @@ describe('NodeWidgets', () => {
): SafeWidgetData => ({
name: 'test_widget',
type: 'combo',
value: 'test_value',
value: () => ref('test_value'),
options: {
values: ['option1', 'option2']
},
callback: undefined,
spec: undefined,
label: undefined,
isDOMWidget: false,

View File

@@ -4,6 +4,7 @@ import PrimeVue from 'primevue/config'
import Select from 'primevue/select'
import type { SelectProps } from 'primevue/select'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref, watch } from 'vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -51,17 +52,20 @@ describe('WidgetSelect Value Binding', () => {
> = {},
callback?: (value: string | undefined) => void,
spec?: ComboInputSpec
): SimplifiedWidget<string | undefined> => ({
name: 'test_select',
type: 'combo',
value,
options: {
values: ['option1', 'option2', 'option3'],
...options
},
callback,
spec
})
): SimplifiedWidget<string | undefined> => {
const valueRef = ref(value)
if (callback) watch(valueRef, (v) => callback(v))
return {
name: 'test_select',
type: 'combo',
value: () => valueRef,
options: {
values: ['option1', 'option2', 'option3'],
...options
},
spec
}
}
const mountComponent = (
widget: SimplifiedWidget<string | undefined>,
@@ -81,67 +85,57 @@ describe('WidgetSelect Value Binding', () => {
})
}
const setSelectValueAndEmit = async (
const setSelectValue = async (
wrapper: ReturnType<typeof mount>,
value: string
) => {
const select = wrapper.findComponent({ name: 'Select' })
await select.setValue(value)
return wrapper.emitted('update:modelValue')
}
describe('Vue Event Emission', () => {
it('emits Vue event when selection changes', async () => {
const widget = createMockWidget('option1')
describe('Widget Value Callbacks', () => {
it('triggers callback when selection changes', async () => {
const callback = vi.fn()
const widget = createMockWidget('option1', {}, callback)
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
await setSelectValue(wrapper, 'option2')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('option2')
expect(callback).toHaveBeenCalledExactlyOnceWith('option2')
})
it('emits string value for different options', async () => {
const widget = createMockWidget('option1')
it('handles string value for different options', async () => {
const callback = vi.fn()
const widget = createMockWidget('option1', {}, callback)
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option3')
expect(emitted).toBeDefined()
// Should emit the string value
expect(emitted![0]).toContain('option3')
await setSelectValue(wrapper, 'option3')
expect(callback).toHaveBeenCalledExactlyOnceWith('option3')
})
it('handles custom option values', async () => {
const customOptions = ['custom_a', 'custom_b', 'custom_c']
const widget = createMockWidget('custom_a', { values: customOptions })
const callback = vi.fn()
const widget = createMockWidget(
'custom_a',
{ values: customOptions },
callback
)
const wrapper = mountComponent(widget, 'custom_a')
const emitted = await setSelectValueAndEmit(wrapper, 'custom_b')
await setSelectValue(wrapper, 'custom_b')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('custom_b')
})
it('handles missing callback gracefully', async () => {
const widget = createMockWidget('option1', {}, undefined)
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
// Should emit Vue event
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('option2')
expect(callback).toHaveBeenCalledExactlyOnceWith('custom_b')
})
it('handles value changes gracefully', async () => {
const widget = createMockWidget('option1')
const callback = vi.fn()
const widget = createMockWidget('option1', {}, callback)
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(wrapper, 'option2')
await setSelectValue(wrapper, 'option2')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('option2')
expect(callback).toHaveBeenCalledExactlyOnceWith('option2')
})
})
@@ -172,43 +166,43 @@ describe('WidgetSelect Value Binding', () => {
'option@#$%',
'option/with\\slashes'
]
const widget = createMockWidget(specialOptions[0], {
values: specialOptions
})
const callback = vi.fn()
const widget = createMockWidget(
specialOptions[0],
{
values: specialOptions
},
callback
)
const wrapper = mountComponent(widget, specialOptions[0])
const emitted = await setSelectValueAndEmit(wrapper, specialOptions[1])
await setSelectValue(wrapper, specialOptions[1])
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(specialOptions[1])
expect(callback).toHaveBeenCalledExactlyOnceWith(specialOptions[1])
})
})
describe('Edge Cases', () => {
it('handles selection of non-existent option gracefully', async () => {
const widget = createMockWidget('option1')
const callback = vi.fn()
const widget = createMockWidget('option1', {}, callback)
const wrapper = mountComponent(widget, 'option1')
const emitted = await setSelectValueAndEmit(
wrapper,
'non_existent_option'
)
await setSelectValue(wrapper, 'non_existent_option')
// Should still emit Vue event with the value
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('non_existent_option')
// Should still trigger callback with the value
expect(callback).toHaveBeenCalledExactlyOnceWith('non_existent_option')
})
it('handles numeric string options correctly', async () => {
const callback = vi.fn()
const numericOptions = ['1', '2', '10', '100']
const widget = createMockWidget('1', { values: numericOptions })
const widget = createMockWidget('1', { values: numericOptions }, callback)
const wrapper = mountComponent(widget, '1')
const emitted = await setSelectValueAndEmit(wrapper, '100')
await setSelectValue(wrapper, '100')
// Should maintain string type in emitted event
expect(emitted).toBeDefined()
expect(emitted![0]).toContain('100')
expect(callback).toHaveBeenCalledExactlyOnceWith('100')
})
})

View File

@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
@@ -34,7 +35,7 @@ describe('WidgetSelect asset mode', () => {
const createWidget = (): SimplifiedWidget<string | undefined> => ({
name: 'ckpt_name',
type: 'combo',
value: undefined,
value: () => ref(),
options: {
values: []
}

View File

@@ -3,6 +3,7 @@ import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import type { ComponentPublicInstance } from 'vue'
import { nextTick, ref, watch } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -24,17 +25,22 @@ describe('WidgetSelectDropdown custom label mapping', () => {
values?: string[]
getOptionLabel?: (value: string | null) => string
} = {},
spec?: ComboInputSpec
): SimplifiedWidget<string | undefined> => ({
name: 'test_image_select',
type: 'combo',
value,
options: {
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
...options
},
spec
})
spec?: ComboInputSpec,
callback?: (value: string | undefined) => void
): SimplifiedWidget<string | undefined> => {
const valueRef = ref(value)
if (callback) watch(valueRef, (v) => callback(v))
return {
name: 'test_image_select',
type: 'combo',
value: () => valueRef,
options: {
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
...options
},
spec
}
}
const mountComponent = (
widget: SimplifiedWidget<string | undefined>,
@@ -102,26 +108,29 @@ describe('WidgetSelectDropdown custom label mapping', () => {
expect(getOptionLabel).toHaveBeenCalledWith('hash789.png')
})
it('emits original values when items with custom labels are selected', async () => {
it('triggers callback with original values when items with custom labels are selected', async () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (!value) return 'No file'
return `Custom: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const callback = vi.fn()
const widget = createMockWidget(
'img_001.png',
{
getOptionLabel
},
undefined,
callback
)
const wrapper = mountComponent(widget, 'img_001.png')
// Simulate selecting an item
const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg
wrapper.vm.updateSelectedItems(selectedSet)
// Should emit the original value, not the custom label
expect(wrapper.emitted('update:modelValue')).toBeDefined()
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
'photo_abc.jpg'
])
await nextTick()
expect(callback).toHaveBeenCalledWith('photo_abc.jpg')
})
it('falls back to original value when label mapping fails', () => {

View File

@@ -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 {
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', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
})
describe('getComponent', () => {
// Test number type mappings
describe('number types', () => {