Compare commits

...

18 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
Alexander Piskun
aab9a30511 feat(api-nodes): add pricing badge for Kling-2.6 nodes (#7381) 2025-12-13 09:52:42 +02:00
Comfy Org PR Bot
b0f5a9ffe2 1.35.5 (#7433)
Patch version increment to 1.35.5

**Base branch:** `main`

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

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-12 23:56:34 -07:00
Christian Byrne
3f777fb54f test: add basic mobile baseline tests (#7415)
## Summary

Allows authors to visualize how their changes affect mobile view. Often
we add some fundamental change to the UI and forget to make it
responsive, this test helps keep track of that.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7415-test-add-basic-mobile-baseline-tests-2c76d73d3650810bb4bad5a1f6c7c53c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-12-12 23:39:17 -07:00
Christian Byrne
6a73288ce3 ci: make nightly release happen automatically every night (#7410)
## Summary

Makes the existing "Release: Version Bump" workflow run at 00:00 UTC
every day.

## Details

- concurrency keeps only one run active while manual dispatch remains
available for ad-hoc bumps.
- inputs are normalized inside the workflow so scheduled runs (which
lack `workflow_dispatch` inputs) safely fall back to `patch`/`main`, and
the version bump + PR formatting steps only use the optional
`pre_release` flag when it is provided
- each nightly invocation closes any lingering bot-authored
`version-bump-*` PRs/branches before creating a new patch PR, preventing
stale locale bumps from conflicting
- checkout now disables credential persistence and `pnpm/action-setup`
is pinned to a commit for supply-chain safety.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7410-ci-make-nightly-release-happen-automatically-every-night-2c76d73d3650813a9e02ee8878370929)
by [Unito](https://www.unito.io)
2025-12-12 22:05:49 -07:00
Christian Byrne
d21ea0f65b fix: loading api-format workflow that contains "parameters" string (#7411)
## Summary

This change extends
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7154 by making sure
the `prompt` metadata tag is parsed before the legacy A1111 fallback
when files are dropped onto the canvas.

ComfyUI embeds two structured payloads into every first-class export
format we support (PNG, WEBP, WEBM, MP4/MOV/M4V, GLB, SVG, MP3,
OGG/FLAC, etc.): `workflow`, which is the full editor JSON with layout
state, and `prompt`, which is the API graph sent to `/prompt`.

During import we try format-specific decoders first and only as a last
resort look for an A1111 file by scanning text chunks for a `parameters`
entry. That compatibility path was always meant to be a best-effort
option, but when we refactored the loader it accidentally enforced the
order `workflow → parameters → prompt`. As soon as a dropped asset
contained a `parameters` chunk—something Image Saver’s “A1111
compatibility” mode always adds—the A1111 converter activated and
blocked the subsequent `prompt` loading logic.

PR #7154 already lifted `workflow` ahead of the fallback, yet any file
lacking the `workflow` chunk but holding both `prompt` and `parameters`
still regressed. Reordering to `workflow → prompt → parameters`
preserves the compatibility shim for genuine A1111 exports while
guaranteeing native Comfy metadata always wins, eliminating the entire
class of failures triggered merely by the presence of the word
`parameters` in an unrelated metadata chunk.

Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/7096, fixes
https://github.com/Comfy-Org/ComfyUI_frontend/issues/6988

## Related 

(fixed by https://github.com/Comfy-Org/ComfyUI_frontend/pull/7154)

- https://github.com/Comfy-Org/ComfyUI_frontend/issues/6633
- https://github.com/Comfy-Org/ComfyUI_frontend/issues/6561

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-12 21:39:30 -07:00
85 changed files with 1022 additions and 795 deletions

View File

@@ -20,6 +20,13 @@ on:
required: true
default: 'main'
type: string
schedule:
# 00:00 UTC ≈ 4:00 PM PST / 5:00 PM PDT on the previous calendar day
- cron: '0 0 * * *'
concurrency:
group: release-version-bump
cancel-in-progress: true
jobs:
bump-version:
@@ -29,15 +36,99 @@ jobs:
pull-requests: write
steps:
- name: Prepare inputs
id: prepared-inputs
shell: bash
env:
RAW_VERSION_TYPE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version_type || '' }}
RAW_PRE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.pre_release || '' }}
RAW_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || '' }}
run: |
set -euo pipefail
VERSION_TYPE="$RAW_VERSION_TYPE"
PRE_RELEASE="$RAW_PRE_RELEASE"
TARGET_BRANCH="$RAW_BRANCH"
if [[ -z "$VERSION_TYPE" ]]; then
VERSION_TYPE='patch'
fi
if [[ -z "$TARGET_BRANCH" ]]; then
TARGET_BRANCH='main'
fi
{
echo "version_type=$VERSION_TYPE"
echo "pre_release=$PRE_RELEASE"
echo "branch=$TARGET_BRANCH"
} >> "$GITHUB_OUTPUT"
- name: Close stale nightly version bump PRs
if: github.event_name == 'schedule'
uses: actions/github-script@v7
with:
github-token: ${{ github.token }}
script: |
const prefix = 'version-bump-'
const closed = []
const prs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100
})
for (const pr of prs) {
if (!pr.head?.ref?.startsWith(prefix)) {
continue
}
if (pr.user?.login !== 'github-actions[bot]') {
continue
}
// Only clean up stale nightly PRs targeting main.
// Adjust here if other target branches should be cleaned.
if (pr.base?.ref !== 'main') {
continue
}
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: 'closed'
})
try {
await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `heads/${pr.head.ref}`
})
} catch (error) {
if (![404, 422].includes(error.status)) {
throw error
}
}
closed.push(pr.number)
}
core.info(`Closed ${closed.length} stale PR(s).`)
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.branch }}
ref: ${{ steps.prepared-inputs.outputs.branch }}
fetch-depth: 0
persist-credentials: false
- name: Validate branch exists
env:
TARGET_BRANCH: ${{ steps.prepared-inputs.outputs.branch }}
run: |
BRANCH="${{ github.event.inputs.branch }}"
BRANCH="$TARGET_BRANCH"
if ! git show-ref --verify --quiet "refs/heads/$BRANCH" && ! git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
echo "❌ Branch '$BRANCH' does not exist"
echo ""
@@ -51,7 +142,7 @@ jobs:
echo "✅ Branch '$BRANCH' exists"
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
with:
version: 10
@@ -62,16 +153,31 @@ jobs:
- name: Bump version
id: bump-version
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
PRE_RELEASE: ${{ steps.prepared-inputs.outputs.pre_release }}
run: |
pnpm version ${{ github.event.inputs.version_type }} --preid ${{ github.event.inputs.pre_release }} --no-git-tag-version
set -euo pipefail
if [[ -n "$PRE_RELEASE" && ! "$VERSION_TYPE" =~ ^pre(major|minor|patch)$ && "$VERSION_TYPE" != "prerelease" ]]; then
echo "❌ pre_release was provided but version_type='$VERSION_TYPE' does not support --preid"
exit 1
fi
if [[ -n "$PRE_RELEASE" ]]; then
pnpm version "$VERSION_TYPE" --preid "$PRE_RELEASE" --no-git-tag-version
else
pnpm version "$VERSION_TYPE" --no-git-tag-version
fi
NEW_VERSION=$(node -p "require('./package.json').version")
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "NEW_VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
- name: Format PR string
id: capitalised
env:
VERSION_TYPE: ${{ steps.prepared-inputs.outputs.version_type }}
run: |
CAPITALISED_TYPE=${{ github.event.inputs.version_type }}
echo "capitalised=${CAPITALISED_TYPE@u}" >> $GITHUB_OUTPUT
CAPITALISED_TYPE="$VERSION_TYPE"
echo "capitalised=${CAPITALISED_TYPE@u}" >> "$GITHUB_OUTPUT"
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
@@ -82,8 +188,8 @@ jobs:
body: |
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
**Base branch:** `${{ github.event.inputs.branch }}`
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
base: ${{ github.event.inputs.branch }}
base: ${{ steps.prepared-inputs.outputs.branch }}
labels: |
Release

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
}>()

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

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)
}
}
}

View File

@@ -12,6 +12,7 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow_prompt_parameters.png',
'workflow.webm',
// Skipped due to 3d widget unstable visual result.
// 3d widget shows grid after fully loaded.

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,29 @@
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { expect } from '@playwright/test'
test.describe('Mobile Baseline Snapshots', () => {
test('@mobile empty canvas', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ConfirmClear', false)
await comfyPage.executeCommand('Comfy.ClearWorkflow')
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 256 })
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
})
test('@mobile default workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
await expect(comfyPage.canvas).toHaveScreenshot(
'mobile-default-workflow.png'
)
})
test('@mobile settings dialog', async ({ comfyPage }) => {
await comfyPage.settingDialog.open()
await comfyPage.nextFrame()
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
'mobile-settings-dialog.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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.4",
"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

@@ -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

@@ -231,6 +231,36 @@ const ltxvPricingCalculator = (node: LGraphNode): string => {
return `$${cost}/Run`
}
const klingVideoWithAudioPricingCalculator: PricingFunction = (
node: LGraphNode
): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
const generateAudioWidget = node.widgets?.find(
(w) => w.name === 'generate_audio'
) as IComboWidget
if (!durationWidget || !generateAudioWidget) {
return '$0.35-1.40/Run (varies with duration & audio)'
}
const duration = String(durationWidget.value)
const generateAudio =
String(generateAudioWidget.value).toLowerCase() === 'true'
if (duration === '5') {
return generateAudio ? '$0.70/Run' : '$0.35/Run'
}
if (duration === '10') {
return generateAudio ? '$1.40/Run' : '$0.70/Run'
}
// Fallback for unexpected duration values
return '$0.35-1.40/Run (varies with duration & audio)'
}
// ---- constants ----
const SORA_SIZES = {
BASIC: new Set(['720x1280', '1280x720']),
@@ -744,6 +774,12 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
KlingOmniProImageNode: {
displayPrice: '$0.028/Run'
},
KlingTextToVideoWithAudio: {
displayPrice: klingVideoWithAudioPricingCalculator
},
KlingImageToVideoWithAudio: {
displayPrice: klingVideoWithAudioPricingCalculator
},
LumaImageToVideoNode: {
displayPrice: (node: LGraphNode): string => {
// Same pricing as LumaVideoNode per CSV
@@ -1931,6 +1967,8 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
KlingTextToVideoWithAudio: ['duration', 'generate_audio'],
KlingImageToVideoWithAudio: ['duration', 'generate_audio'],
KlingOmniProTextToVideoNode: ['duration'],
KlingOmniProFirstLastFrameNode: ['duration'],
KlingOmniProImageToVideoNode: ['duration'],

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

@@ -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

@@ -1468,7 +1468,21 @@ export class ComfyApp {
}
}
// Use parameters as fallback when no workflow exists
if (prompt) {
try {
const promptObj =
typeof prompt === 'string' ? JSON.parse(prompt) : prompt
if (this.isApiJson(promptObj)) {
this.loadApiJson(promptObj, fileName)
return
}
} catch (err) {
console.error('Failed to parse prompt:', err)
}
// Fall through to parameters as a last resort
}
// Use parameters strictly as the final fallback
if (parameters) {
// Note: Not putting this in `importA1111` as it is mostly not used
// by external callers, and `importA1111` has no access to `app`.
@@ -1481,18 +1495,25 @@ export class ComfyApp {
return
}
if (prompt) {
const promptObj = typeof prompt === 'string' ? JSON.parse(prompt) : prompt
this.loadApiJson(promptObj, fileName)
return
}
this.showErrorOnFileLoad(file)
}
// @deprecated
isApiJson(data: unknown) {
return _.isObject(data) && Object.values(data).every((v) => v.class_type)
isApiJson(data: unknown): data is ComfyApiWorkflow {
if (!_.isObject(data) || Array.isArray(data)) {
return false
}
if (Object.keys(data).length === 0) return false
return Object.values(data).every((node) => {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return false
}
const { class_type: classType, inputs } = node as Record<string, unknown>
const inputsIsRecord = _.isObject(inputs) && !Array.isArray(inputs)
return typeof classType === 'string' && inputsIsRecord
})
}
loadApiJson(apiData: ComfyApiWorkflow, fileName: string) {

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', () => {