Compare commits

..

3 Commits

Author SHA1 Message Date
Arjan Singh
81087c111a [fix] Stop widget scroll events from being intercepted by LiteGraph 2025-09-25 21:33:32 -07:00
Christian Byrne
ac93a6ba3f Disable number grouping (thousands comma separators) by default in Vue node number widgets (#5776)
## Summary

Makes the
[useGrouping](https://primevue.org/inputnumber/#api.inputnumber.props.useGrouping)
prop for number widgets disabled by default, aligning with the old UI
(also requested via design). Node authors can still enable if they want
by setting prop explicitly.

## Changes

- **What**: Modified
[WidgetInputNumberInput](https://primevue.org/inputnumber/) to disable
`useGrouping` by default, requiring explicit opt-in via widget options
- **Testing**: Added component tests covering value binding, component
rendering, step calculations, and grouping behavior

## Review Focus

UX impact on existing nodes that may have relied on default grouping
behavior and test coverage for edge cases with precision calculations.

## Screenshots (if applicable)

*Before*:

<img width="1685" height="879" alt="Screenshot from 2025-09-25 11-34-34"
src="https://github.com/user-attachments/assets/432097ab-203d-4f86-8ca0-721b27ee33de"
/>

*After*:

<img width="1951" height="1175" alt="Screenshot from 2025-09-25
11-35-27"
src="https://github.com/user-attachments/assets/74d35b62-612e-4dbf-b6e2-0ac17af03ea1"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5776-Disable-number-grouping-thousands-comma-separators-by-default-in-Vue-node-number-widget-2796d73d365081369ca6c155335d0d57)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <448862+DrJKL@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-09-25 17:46:46 -07:00
filtered
961af8731e Split Tailwind utility functions out to a shared package (#5777)
## Summary

Split tailwind utils out to a shared package within the monorepo.

## Changes

- Creates `@comfyorg/tailwind-utils` package
- Does not require export, publishing, etc
- Uses `export` to ensure this change does not impact other PRs (many
imports to update)
- If we _want_ to update all imports, there are two commits ready to be
re-applied
  - e.g. `git revert 80960c2a82c0d1ac06eee1bb83ac333216b2b376`

## Review Focus

- Is this pattern desirable?
- Should we just include this in a broader design-system split? I kind
of vote yes, but also it's a good small, first step.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5777-Split-Tailwind-utility-functions-out-to-a-shared-package-2796d73d3650815f976fc73b4fb86ef3)
by [Unito](https://www.unito.io)
2025-09-25 16:55:21 -07:00
19 changed files with 906 additions and 740 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -107,6 +107,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/tailwind-utils": "workspace:*",
"@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
"@primeuix/styled": "0.3.2",
@@ -130,7 +131,6 @@
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"chart.js": "^4.5.0",
"clsx": "^2.1.1",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"es-toolkit": "^1.39.9",
@@ -148,7 +148,6 @@
"primevue": "^4.2.5",
"reka-ui": "^2.5.0",
"semver": "^7.7.2",
"tailwind-merge": "^3.3.1",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",

View File

@@ -0,0 +1,31 @@
# @comfyorg/tailwind-utils
Shared Tailwind CSS utility functions for the ComfyUI Frontend monorepo.
## Usage
The `cn` function combines `clsx` and `tailwind-merge` to handle conditional classes and resolve Tailwind conflicts.
```typescript
import { cn } from '@comfyorg/tailwind-utils'
// Use with conditional classes (object)
<div :class="cn('transition-opacity', { 'opacity-75': !isHovered })" />
// Use with conditional classes (ternary)
<button
:class="cn('px-4 py-2', isActive ? 'bg-blue-500' : 'bg-gray-500')"
/>
```
## Installation
This package is part of the ComfyUI Frontend monorepo and is automatically available to all workspace packages.
```json
{
"dependencies": {
"@comfyorg/tailwind-utils": "workspace:*"
}
}
```

View File

@@ -0,0 +1,30 @@
{
"name": "@comfyorg/tailwind-utils",
"version": "1.0.0",
"type": "module",
"description": "Shared Tailwind CSS utilities for ComfyUI Frontend",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
},
"dependencies": {
"clsx": "^2.1.1",
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"typescript": "^5.4.5"
}
}

View File

@@ -0,0 +1,8 @@
import clsx, { type ClassArray } from 'clsx'
import { twMerge } from 'tailwind-merge'
export type { ClassValue } from 'clsx'
export function cn(...inputs: ClassArray) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}

35
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@comfyorg/comfyui-electron-types':
specifier: 0.4.73-0
version: 0.4.73-0
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
@@ -86,9 +89,6 @@ importers:
chart.js:
specifier: ^4.5.0
version: 4.5.0
clsx:
specifier: ^2.1.1
version: 2.1.1
dompurify:
specifier: ^3.2.5
version: 3.2.5
@@ -140,9 +140,6 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.2
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
three:
specifier: ^0.170.0
version: 0.170.0
@@ -355,6 +352,19 @@ importers:
specifier: ^3.24.1
version: 3.24.1(zod@3.24.1)
packages/tailwind-utils:
dependencies:
clsx:
specifier: ^2.1.1
version: 2.1.1
tailwind-merge:
specifier: ^2.2.0
version: 2.6.0
devDependencies:
typescript:
specifier: ^5.4.5
version: 5.9.2
packages:
'@adobe/css-tools@4.4.3':
@@ -5888,8 +5898,8 @@ packages:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
tailwindcss-primeui@0.6.1:
resolution: {integrity: sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A==}
@@ -6323,6 +6333,9 @@ packages:
vue-component-type-helpers@3.0.7:
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
vue-component-type-helpers@3.0.8:
resolution: {integrity: sha512-WyR30Eq15Y/+odrUUMax6FmPbZwAp/HnC7qgR1r3lVFAcqwQ4wUoV79Mbh4SxDy3NiqDa+G4TOKD5xXSgBHo5A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -8879,7 +8892,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.0.7
vue-component-type-helpers: 3.0.8
'@swc/helpers@0.5.17':
dependencies:
@@ -13075,7 +13088,7 @@ snapshots:
dependencies:
'@pkgr/core': 0.2.9
tailwind-merge@3.3.1: {}
tailwind-merge@2.6.0: {}
tailwindcss-primeui@0.6.1(tailwindcss@4.1.12):
dependencies:
@@ -13545,6 +13558,8 @@ snapshots:
vue-component-type-helpers@3.0.7: {}
vue-component-type-helpers@3.0.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:
vue: 3.5.13(typescript@5.9.2)

View File

@@ -16,6 +16,7 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { usePreserveWidgetScroll } from '@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -24,6 +25,9 @@ import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
// Preserve native scrolling in Vue widgets
usePreserveWidgetScroll()
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
}

View File

@@ -1,44 +0,0 @@
/**
* Factory interface for creating audio widgets
* Provides consistent API for both Vue and LiteGraph implementations
*/
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export interface WidgetCreationResult {
widget: IBaseWidget
minWidth?: number
minHeight?: number
}
export interface AudioWidgetFactory {
/**
* Creates an AUDIO_UI widget for audio playback/preview
*/
createAudioUI(node: LGraphNode, inputName: string): WidgetCreationResult
/**
* Creates an AUDIOUPLOAD widget for file uploads
*/
createAudioUpload(node: LGraphNode, inputName: string): WidgetCreationResult
/**
* Creates an AUDIO_RECORD widget for audio recording
*/
createAudioRecord(node: LGraphNode, inputName: string): WidgetCreationResult
/**
* Hook called before registering node definitions
*/
beforeRegisterNodeDef?(nodeType: any, nodeData: any): void
/**
* Hook called when node outputs are updated
*/
onNodeOutputsUpdated?(nodeOutputs: Record<string, any>): void
/**
* Hook called when a node is created (for RecordAudio setup)
*/
onNodeCreated?(node: LGraphNode): Promise<void>
}

View File

@@ -1,446 +0,0 @@
/**
* LiteGraph Audio Widget Factory
* Creates full DOM-based audio widgets with rich functionality
*/
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { t } from '@/i18n'
import { type LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { vueWidgetSerializationStore } from '@/stores/vueWidgetSerializationStore'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../../scripts/api'
import { app } from '../../../scripts/app'
import type {
AudioWidgetFactory,
WidgetCreationResult
} from './AudioWidgetFactory'
export class LiteGraphAudioWidgetFactory implements AudioWidgetFactory {
beforeRegisterNodeDef(nodeType: any, nodeData: any): void {
// Add audioUI widget requirement for audio nodes
if (
[
'LoadAudio',
'SaveAudio',
'PreviewAudio',
'SaveAudioMP3',
'SaveAudioOpus',
'RecordAudio'
].includes(nodeType.prototype?.comfyClass)
) {
nodeData.input.required.audioUI = ['AUDIO_UI', {}]
}
// Add upload widget requirement for nodes with audio_upload flag
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
nodeData.input.required.upload = ['AUDIOUPLOAD', {}]
}
}
createAudioUI(node: LGraphNode, inputName: string): WidgetCreationResult {
// Create DOM audio element
const audio = document.createElement('audio')
audio.controls = true
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, 'audioUI', audio)
audioUIWidget.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')
const isOutputNode = nodeData.output_node
if (isOutputNode) {
// Hide the audio widget when there is no audio initially
audioUIWidget.element.classList.add('empty-audio-widget')
// Populate the audio widget UI on node execution
const onExecuted = node.onExecuted
const factory = this // Capture factory reference for callback
node.onExecuted = function (message: any) {
// @ts-expect-error fixme ts strict error
onExecuted?.apply(this, arguments)
const audios = message.audio
if (!audios) return
const audio = audios[0]
audioUIWidget.element.src = api.apiURL(
factory.getResourceURL(audio.subfolder, audio.filename, audio.type)
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
audioUIWidget.onRemove = useChainCallback(audioUIWidget.onRemove, () => {
if (!audioUIWidget.element) return
audioUIWidget.element.pause()
audioUIWidget.element.src = ''
audioUIWidget.element.remove()
})
// Add serialization support for RecordAudio nodes
if ((node.constructor as any).comfyClass === 'RecordAudio') {
const nodeId = node.id
audioUIWidget.serializeValue = async () => {
let serializationFn = vueWidgetSerializationStore.get(
`${nodeId}-audioUI`
)
// Fallback: try with current node.id in case it changed
if (!serializationFn && node.id !== nodeId) {
serializationFn = vueWidgetSerializationStore.get(
`${node.id}-audioUI`
)
}
if (serializationFn) {
const result = await serializationFn()
// Update both LiteGraph widgets for consistency
const audioWidget = node.widgets?.find((w) => w.name === 'audio')
const audioUIWidget = node.widgets?.find((w) => w.name === 'audioUI')
if (audioWidget && result) {
audioWidget.value = result
}
if (audioUIWidget && result) {
audioUIWidget.value = result
}
return result
}
return ''
}
}
return { widget: audioUIWidget }
}
createAudioUpload(node: LGraphNode, inputName: string): WidgetCreationResult {
// Find the related audio widgets
const audioWidget = node.widgets?.find(
(w) => w.name === 'audio'
) as IStringWidget
const audioUIWidget = node.widgets?.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
const factory = this // Capture factory reference
const onAudioWidgetUpdate = () => {
audioUIWidget.element.src = api.apiURL(
factory.getResourceURL(
...factory.splitFilePath(audioWidget.value as string)
)
)
}
// Initially load default audio file to audioUIWidget
if (audioWidget.value) {
onAudioWidgetUpdate()
}
audioWidget.callback = onAudioWidgetUpdate
// Load saved audio file widget values if restoring from workflow
const onGraphConfigured = node.onGraphConfigured
node.onGraphConfigured = function () {
// @ts-expect-error fixme ts strict error
onGraphConfigured?.apply(this, arguments)
if (audioWidget.value) {
onAudioWidgetUpdate()
}
}
const handleUpload = async (files: File[]) => {
if (files?.length) {
await this.uploadFile(audioWidget, audioUIWidget, files[0], true)
}
return files
}
const isAudioFile = (file: File) => file.type.startsWith('audio/')
const { openFileSelection } = useNodeFileInput(node, {
accept: 'audio/*',
onSelect: handleUpload
})
// The widget to pop up the upload dialog
const uploadWidget = node.addWidget(
'button',
inputName,
'',
openFileSelection,
{
serialize: false
}
)
uploadWidget.label = t('g.choose_file_to_upload')
useNodeDragAndDrop(node, {
fileFilter: isAudioFile,
onDrop: handleUpload
})
useNodePaste(node, {
fileFilter: isAudioFile,
onPaste: handleUpload
})
node.previewMediaType = 'audio'
return { widget: uploadWidget }
}
createAudioRecord(node: LGraphNode, inputName: string): WidgetCreationResult {
const audio = document.createElement('audio')
audio.controls = true
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, 'audioUI', audio)
let mediaRecorder: MediaRecorder | null = null
let isRecording = false
let audioChunks: Blob[] = []
let currentStream: MediaStream | null = null
let recordWidget: IBaseWidget | null = null
let stopPromise: Promise<void> | null = null
let stopResolve: (() => void) | null = null
audioUIWidget.serializeValue = async () => {
if (isRecording && mediaRecorder) {
stopPromise = new Promise((resolve) => {
stopResolve = resolve
})
mediaRecorder.stop()
await stopPromise
}
const audioSrc = audioUIWidget.element.src
if (!audioSrc) {
useToastStore().addAlert(t('g.noAudioRecorded'))
return ''
}
const blob = await fetch(audioSrc).then((r) => r.blob())
return await useAudioService().convertBlobToFileAndSubmit(blob)
}
recordWidget = node.addWidget(
'button',
inputName,
'',
async () => {
if (!isRecording) {
try {
currentStream = await navigator.mediaDevices.getUserMedia({
audio: true
})
mediaRecorder = new ExtendableMediaRecorder(currentStream, {
mimeType: 'audio/wav'
}) as unknown as MediaRecorder
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data)
}
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
useAudioService().stopAllTracks(currentStream)
if (
audioUIWidget.element.src &&
audioUIWidget.element.src.startsWith('blob:')
) {
URL.revokeObjectURL(audioUIWidget.element.src)
}
audioUIWidget.element.src = URL.createObjectURL(audioBlob)
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
if (stopResolve) {
stopResolve()
stopResolve = null
stopPromise = null
}
}
mediaRecorder.onerror = (event) => {
console.error('MediaRecorder error:', event)
useAudioService().stopAllTracks(currentStream)
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
if (stopResolve) {
stopResolve()
stopResolve = null
stopPromise = null
}
}
mediaRecorder.start()
isRecording = true
if (recordWidget) {
recordWidget.label = t('g.stopRecording')
}
} catch (err) {
console.error('Error accessing microphone:', err)
useToastStore().addAlert(t('g.micPermissionDenied'))
if (mediaRecorder) {
try {
mediaRecorder.stop()
} catch {}
}
useAudioService().stopAllTracks(currentStream)
currentStream = null
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
}
} else if (mediaRecorder && isRecording) {
mediaRecorder.stop()
}
},
{ serialize: false }
)
recordWidget.label = t('g.startRecording')
const originalOnRemoved = node.onRemoved
node.onRemoved = function () {
if (isRecording && mediaRecorder) {
mediaRecorder.stop()
}
useAudioService().stopAllTracks(currentStream)
if (audioUIWidget.element.src?.startsWith('blob:')) {
URL.revokeObjectURL(audioUIWidget.element.src)
}
originalOnRemoved?.call(this)
}
return { widget: recordWidget }
}
onNodeOutputsUpdated(nodeOutputs: Record<string, any>): void {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
if ('audio' in output) {
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
if (!node) continue
const audioUIWidget = node.widgets?.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
const audio = output.audio[0]
audioUIWidget.element.src = api.apiURL(
this.getResourceURL(audio.subfolder, audio.filename, audio.type)
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
}
async onNodeCreated(node: LGraphNode): Promise<void> {
if ((node.constructor as any).comfyClass === 'RecordAudio') {
await useAudioService().registerWavEncoder()
}
}
// Helper methods
private splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
return ['', path]
}
return [
path.substring(0, folder_separator),
path.substring(folder_separator + 1)
]
}
private getResourceURL(
subfolder: string,
filename: string,
type = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`
}
private async uploadFile(
audioWidget: IStringWidget,
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
file: File,
updateNode: boolean,
pasted: boolean = false
): Promise<void> {
try {
const body = new FormData()
body.append('image', file)
if (pasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json()
let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path
if (!audioWidget.options?.values?.includes(path)) {
audioWidget.options = audioWidget.options || {}
audioWidget.options.values = audioWidget.options.values || []
audioWidget.options.values.push(path)
}
if (updateNode) {
audioUIWidget.element.src = api.apiURL(
this.getResourceURL(...this.splitFilePath(path))
)
audioWidget.value = path
}
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
useToastStore().addAlert(String(error))
}
}
}

View File

@@ -1,145 +0,0 @@
/**
* Vue Audio Widget Factory
* Creates minimal placeholder widgets that Vue components can recognize and render
*/
import { t } from '@/i18n'
import { type LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAudioService } from '@/services/audioService'
import { vueWidgetSerializationStore } from '@/stores/vueWidgetSerializationStore'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../../scripts/api'
import { app } from '../../../scripts/app'
import type {
AudioWidgetFactory,
WidgetCreationResult
} from './AudioWidgetFactory'
export class VueAudioWidgetFactory implements AudioWidgetFactory {
beforeRegisterNodeDef(nodeType: any, nodeData: any): void {
// Add audioUI widget requirement for audio nodes
if (
[
'LoadAudio',
'SaveAudio',
'PreviewAudio',
'SaveAudioMP3',
'SaveAudioOpus',
'RecordAudio'
].includes(nodeType.prototype?.comfyClass)
) {
nodeData.input.required.audioUI = ['AUDIO_UI', {}]
}
// Add upload widget requirement for nodes with audio_upload flag
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
nodeData.input.required.upload = ['AUDIOUPLOAD', {}]
}
}
createAudioUI(node: LGraphNode, inputName: string): WidgetCreationResult {
// Create simple placeholder widget for Vue
const audioUIWidget = node.addWidget('custom', inputName, '', () => {}, {
serialize: false
}) as IBaseWidget
// Set the type that Vue components expect
audioUIWidget.type = 'AUDIO_UI'
// Add serialization support for RecordAudio nodes
if ((node.constructor as any).comfyClass === 'RecordAudio') {
audioUIWidget.serializeValue = async () => {
const serializationFn = vueWidgetSerializationStore.get(
`${node.id}-audioUI`
)
if (serializationFn) {
const result = await serializationFn()
audioUIWidget.value = result
return result
}
return audioUIWidget.value || ''
}
}
return { widget: audioUIWidget }
}
createAudioUpload(node: LGraphNode, inputName: string): WidgetCreationResult {
// Create simple placeholder widget for Vue
const uploadWidget = node.addWidget('button', inputName, '', () => {}, {
serialize: false
}) as IBaseWidget
// Set the type that Vue components expect
uploadWidget.type = 'AUDIOUPLOAD'
uploadWidget.label = t('g.choose_file_to_upload')
return { widget: uploadWidget }
}
createAudioRecord(node: LGraphNode, inputName: string): WidgetCreationResult {
// Create simple placeholder widget for Vue
const recordWidget = node.addWidget('custom', inputName, '', () => {}, {
serialize: true
}) as IBaseWidget
// Set the type that Vue components expect
recordWidget.type = 'AUDIO_RECORD'
// Set up serialization bridge for RecordAudio
recordWidget.serializeValue = async () => {
const serializationFn = vueWidgetSerializationStore.get(
`${node.id}-audioUI`
)
if (serializationFn) {
const result = await serializationFn()
recordWidget.value = result
return result
}
return recordWidget.value || ''
}
return { widget: recordWidget }
}
onNodeOutputsUpdated(nodeOutputs: Record<string, any>): void {
// Helper function to get resource URL
function getResourceURL(
subfolder: string,
filename: string,
type = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`
}
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
if ('audio' in output) {
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
if (!node) continue
const audioUIWidget = node.widgets?.find(
(w) => w.name === 'audioUI'
) as IBaseWidget
if (audioUIWidget) {
const audio = output.audio[0]
audioUIWidget.value = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
)
}
}
}
}
async onNodeCreated(node: LGraphNode): Promise<void> {
if ((node.constructor as any).comfyClass === 'RecordAudio') {
await useAudioService().registerWavEncoder()
}
}
}

View File

@@ -1,55 +1,428 @@
/**
* Audio Widget Extension - Factory Pattern Implementation
* Dynamically selects between Vue and LiteGraph implementations
*/
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { type NodeLocatorId } from '@/types'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
import type { AudioWidgetFactory } from './factories/AudioWidgetFactory'
import { LiteGraphAudioWidgetFactory } from './factories/LiteGraphAudioWidgetFactory'
import { VueAudioWidgetFactory } from './factories/VueAudioWidgetFactory'
// Create the appropriate factory based on the current mode
const audioFactory: AudioWidgetFactory = LiteGraph.vueNodesMode
? new VueAudioWidgetFactory()
: new LiteGraphAudioWidgetFactory()
function splitFilePath(path: string): [string, string] {
const folder_separator = path.lastIndexOf('/')
if (folder_separator === -1) {
return ['', path]
}
return [
path.substring(0, folder_separator),
path.substring(folder_separator + 1)
]
}
// AudioWidget MUST be registered first, as AUDIOUPLOAD depends on AUDIO_UI to be present
function getResourceURL(
subfolder: string,
filename: string,
type: ResultItemType = 'input'
): string {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`
}
async function uploadFile(
audioWidget: IStringWidget,
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
file: File,
updateNode: boolean,
pasted: boolean = false
) {
try {
// Wrap file in formdata so it includes filename
const body = new FormData()
body.append('image', file)
if (pasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status === 200) {
const data = await resp.json()
// Add the file to the dropdown list and update the widget value
let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path
// @ts-expect-error fixme ts strict error
if (!audioWidget.options.values.includes(path)) {
// @ts-expect-error fixme ts strict error
audioWidget.options.values.push(path)
}
if (updateNode) {
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(path))
)
audioWidget.value = path
}
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
// @ts-expect-error fixme ts strict error
useToastStore().addAlert(error)
}
}
// AudioWidget MUST be registered first, as AUDIOUPLOAD depends on AUDIO_UI to be
// present.
app.registerExtension({
name: 'Comfy.AudioWidget',
async beforeRegisterNodeDef(nodeType, nodeData) {
audioFactory.beforeRegisterNodeDef?.(nodeType, nodeData)
if (
[
'LoadAudio',
'SaveAudio',
'PreviewAudio',
'SaveAudioMP3',
'SaveAudioOpus'
].includes(
// @ts-expect-error fixme ts strict error
nodeType.prototype.comfyClass
)
) {
// @ts-expect-error fixme ts strict error
nodeData.input.required.audioUI = ['AUDIO_UI', {}]
}
},
getCustomWidgets() {
return {
AUDIO_UI: audioFactory.createAudioUI.bind(audioFactory)
AUDIO_UI(node: LGraphNode, inputName: string) {
const audio = document.createElement('audio')
audio.controls = true
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')
const isOutputNode = nodeData.output_node
if (isOutputNode) {
// Hide the audio widget when there is no audio initially.
audioUIWidget.element.classList.add('empty-audio-widget')
// Populate the audio widget UI on node execution.
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
// @ts-expect-error fixme ts strict error
onExecuted?.apply(this, arguments)
const audios = message.audio
if (!audios) return
const audio = audios[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
audioUIWidget.onRemove = useChainCallback(
audioUIWidget.onRemove,
() => {
if (!audioUIWidget.element) return
audioUIWidget.element.pause()
audioUIWidget.element.src = ''
audioUIWidget.element.remove()
}
)
return { widget: audioUIWidget }
}
}
},
onNodeOutputsUpdated(nodeOutputs: Record<string, any>) {
audioFactory.onNodeOutputsUpdated?.(nodeOutputs)
onNodeOutputsUpdated(nodeOutputs: Record<NodeLocatorId, any>) {
for (const [nodeLocatorId, output] of Object.entries(nodeOutputs)) {
if ('audio' in output) {
const node = getNodeByLocatorId(app.graph, nodeLocatorId)
if (!node) continue
// @ts-expect-error fixme ts strict error
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
const audio = output.audio[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder, audio.filename, audio.type)
)
audioUIWidget.element.classList.remove('empty-audio-widget')
}
}
}
})
app.registerExtension({
name: 'Comfy.UploadAudio',
async beforeRegisterNodeDef(nodeType, nodeData) {
audioFactory.beforeRegisterNodeDef?.(nodeType, nodeData)
async beforeRegisterNodeDef(_nodeType, nodeData: ComfyNodeDef) {
if (nodeData?.input?.required?.audio?.[1]?.audio_upload === true) {
nodeData.input.required.upload = ['AUDIOUPLOAD', {}]
}
},
getCustomWidgets() {
return {
AUDIOUPLOAD: audioFactory.createAudioUpload.bind(audioFactory)
AUDIOUPLOAD(node, inputName: string) {
// The widget that allows user to select file.
// @ts-expect-error fixme ts strict error
const audioWidget = node.widgets.find(
(w) => w.name === 'audio'
) as IStringWidget
// @ts-expect-error fixme ts strict error
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
const onAudioWidgetUpdate = () => {
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(audioWidget.value as string))
)
}
// Initially load default audio file to audioUIWidget.
if (audioWidget.value) {
onAudioWidgetUpdate()
}
audioWidget.callback = onAudioWidgetUpdate
// Load saved audio file widget values if restoring from workflow
const onGraphConfigured = node.onGraphConfigured
node.onGraphConfigured = function () {
// @ts-expect-error fixme ts strict error
onGraphConfigured?.apply(this, arguments)
if (audioWidget.value) {
onAudioWidgetUpdate()
}
}
const handleUpload = async (files: File[]) => {
if (files?.length) {
uploadFile(audioWidget, audioUIWidget, files[0], true)
}
return files
}
const isAudioFile = (file: File) => file.type.startsWith('audio/')
const { openFileSelection } = useNodeFileInput(node, {
accept: 'audio/*',
onSelect: handleUpload
})
// The widget to pop up the upload dialog.
const uploadWidget = node.addWidget(
'button',
inputName,
'',
openFileSelection,
{ serialize: false }
)
uploadWidget.label = t('g.choose_file_to_upload')
useNodeDragAndDrop(node, {
fileFilter: isAudioFile,
onDrop: handleUpload
})
useNodePaste(node, {
fileFilter: isAudioFile,
onPaste: handleUpload
})
node.previewMediaType = 'audio'
return { widget: uploadWidget }
}
}
}
})
app.registerExtension({
name: 'Comfy.RecordAudio',
getCustomWidgets() {
return {
AUDIO_RECORD: audioFactory.createAudioRecord.bind(audioFactory)
AUDIO_RECORD(node, inputName: string) {
const audio = document.createElement('audio')
audio.controls = true
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
let mediaRecorder: MediaRecorder | null = null
let isRecording = false
let audioChunks: Blob[] = []
let currentStream: MediaStream | null = null
let recordWidget: IBaseWidget | null = null
let stopPromise: Promise<void> | null = null
let stopResolve: (() => void) | null = null
audioUIWidget.serializeValue = async () => {
if (isRecording && mediaRecorder) {
stopPromise = new Promise((resolve) => {
stopResolve = resolve
})
mediaRecorder.stop()
await stopPromise
}
const audioSrc = audioUIWidget.element.src
if (!audioSrc) {
useToastStore().addAlert(t('g.noAudioRecorded'))
return ''
}
const blob = await fetch(audioSrc).then((r) => r.blob())
return await useAudioService().convertBlobToFileAndSubmit(blob)
}
recordWidget = node.addWidget(
'button',
inputName,
'',
async () => {
if (!isRecording) {
try {
currentStream = await navigator.mediaDevices.getUserMedia({
audio: true
})
mediaRecorder = new ExtendableMediaRecorder(currentStream, {
mimeType: 'audio/wav'
}) as unknown as MediaRecorder
audioChunks = []
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data)
}
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' })
useAudioService().stopAllTracks(currentStream)
if (
audioUIWidget.element.src &&
audioUIWidget.element.src.startsWith('blob:')
) {
URL.revokeObjectURL(audioUIWidget.element.src)
}
audioUIWidget.element.src = URL.createObjectURL(audioBlob)
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
if (stopResolve) {
stopResolve()
stopResolve = null
stopPromise = null
}
}
mediaRecorder.onerror = (event) => {
console.error('MediaRecorder error:', event)
useAudioService().stopAllTracks(currentStream)
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
if (stopResolve) {
stopResolve()
stopResolve = null
stopPromise = null
}
}
mediaRecorder.start()
isRecording = true
if (recordWidget) {
recordWidget.label = t('g.stopRecording')
}
} catch (err) {
console.error('Error accessing microphone:', err)
useToastStore().addAlert(t('g.micPermissionDenied'))
if (mediaRecorder) {
try {
mediaRecorder.stop()
} catch {}
}
useAudioService().stopAllTracks(currentStream)
currentStream = null
isRecording = false
if (recordWidget) {
recordWidget.label = t('g.startRecording')
}
}
} else if (mediaRecorder && isRecording) {
mediaRecorder.stop()
}
},
{ serialize: false }
)
recordWidget.label = t('g.startRecording')
const originalOnRemoved = node.onRemoved
node.onRemoved = function () {
if (isRecording && mediaRecorder) {
mediaRecorder.stop()
}
useAudioService().stopAllTracks(currentStream)
if (audioUIWidget.element.src?.startsWith('blob:')) {
URL.revokeObjectURL(audioUIWidget.element.src)
}
originalOnRemoved?.call(this)
}
return { widget: recordWidget }
}
}
},
async nodeCreated(node) {
await audioFactory.onNodeCreated?.(node)
if (node.constructor.comfyClass !== 'RecordAudio') return
await useAudioService().registerWavEncoder()
}
})

View File

@@ -0,0 +1,72 @@
import { useEventListener } from '@vueuse/core'
const PRIMEVUE_SCROLLABLE_CLASSES = new Set([
'p-select-option',
'p-dropdown-item'
])
const PRIMEVUE_SCROLLABLE_CONTAINERS = [
'.p-select',
'.p-multiselect',
'.p-treeselect',
'.p-select-dropdown',
'.p-dropdown-panel'
].join(', ')
/**
* Check if an element should handle wheel events natively (scrolling)
* instead of letting them bubble to canvas zoom
*/
function isScrollableElement(target: Element): boolean {
// Check common scrollable elements
const tagName = target.tagName.toLowerCase()
if (tagName === 'textarea' || tagName === 'select' || tagName === 'input') {
return true
}
// Check PrimeVue select options and other dropdown elements
for (const className of target.classList) {
if (PRIMEVUE_SCROLLABLE_CLASSES.has(className)) {
return true
}
}
if (target.closest(PRIMEVUE_SCROLLABLE_CONTAINERS)) {
return true
}
// Check for elements with scrollable overflow
const computedStyle = window.getComputedStyle(target)
const overflowY = computedStyle.overflowY
if (overflowY === 'scroll' || overflowY === 'auto') {
return true
}
// Check if element has scrollable content
if (target.scrollHeight > target.clientHeight && overflowY !== 'hidden') {
return true
}
return false
}
/**
* App-level composable that preserves native scrolling behavior in widgets
* by preventing wheel events from bubbling to the canvas zoom handler.
* Call once at the app level to enable native scrolling in textareas, selects, etc.
*/
export function usePreserveWidgetScroll() {
useEventListener(
window,
'wheel',
(event: WheelEvent) => {
if (
event.target instanceof Element &&
isScrollableElement(event.target)
) {
event.stopPropagation()
}
},
{ capture: true, passive: false }
)
}

View File

@@ -0,0 +1,208 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputNumber from 'primevue/inputnumber'
import { describe, expect, it } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetInputNumberInput from './WidgetInputNumberInput.vue'
function createMockWidget(
value: number = 0,
type: 'int' | 'float' = 'int',
options: SimplifiedWidget['options'] = {},
callback?: (value: number) => void
): SimplifiedWidget<number> {
return {
name: 'test_input_number',
type,
value,
options,
callback
}
}
function mountComponent(
widget: SimplifiedWidget<number>,
modelValue: number,
readonly = false
) {
return mount(WidgetInputNumberInput, {
global: {
plugins: [PrimeVue],
components: { InputNumber }
},
props: {
widget,
modelValue,
readonly
}
})
}
function getNumberInput(wrapper: ReturnType<typeof mount>) {
const input = wrapper.get<HTMLInputElement>('input[inputmode="numeric"]')
return input.element
}
describe('WidgetInputNumberInput Value Binding', () => {
it('displays initial value in input field', () => {
const widget = createMockWidget(42, 'int')
const wrapper = mountComponent(widget, 42)
const input = getNumberInput(wrapper)
expect(input.value).toBe('42')
})
it('emits update:modelValue when value changes', async () => {
const widget = createMockWidget(10, 'int')
const wrapper = mountComponent(widget, 10)
const inputNumber = wrapper.findComponent(InputNumber)
await inputNumber.vm.$emit('update:modelValue', 20)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
expect(emitted![0]).toContain(20)
})
it('handles negative values', () => {
const widget = createMockWidget(-5, 'int')
const wrapper = mountComponent(widget, -5)
const input = getNumberInput(wrapper)
expect(input.value).toBe('-5')
})
it('handles decimal values for float type', () => {
const widget = createMockWidget(3.14, 'float')
const wrapper = mountComponent(widget, 3.14)
const input = getNumberInput(wrapper)
expect(input.value).toBe('3.14')
})
})
describe('WidgetInputNumberInput Component Rendering', () => {
it('renders InputNumber component with show-buttons', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.exists()).toBe(true)
expect(inputNumber.props('showButtons')).toBe(true)
})
it('disables input when readonly', () => {
const widget = createMockWidget(5, 'int', {}, undefined)
const wrapper = mountComponent(widget, 5, true)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('disabled')).toBe(true)
})
it('sets button layout to horizontal', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('buttonLayout')).toBe('horizontal')
})
it('sets size to small', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('size')).toBe('small')
})
})
describe('WidgetInputNumberInput Step Value', () => {
it('defaults to 0 for unrestricted stepping', () => {
const widget = createMockWidget(5, 'int')
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0)
})
it('uses step2 value when provided', () => {
const widget = createMockWidget(5, 'int', { step2: 0.5 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.5)
})
it('calculates step from precision for precision 0', () => {
const widget = createMockWidget(5, 'int', { precision: 0 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(1)
})
it('calculates step from precision for precision 1', () => {
const widget = createMockWidget(5, 'float', { precision: 1 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.1)
})
it('calculates step from precision for precision 2', () => {
const widget = createMockWidget(5, 'float', { precision: 2 })
const wrapper = mountComponent(widget, 5)
const inputNumber = wrapper.findComponent(InputNumber)
expect(inputNumber.props('step')).toBe(0.01)
})
})
describe('WidgetInputNumberInput Grouping Behavior', () => {
it('displays numbers without commas by default for int widgets', () => {
const widget = createMockWidget(1000, 'int')
const wrapper = mountComponent(widget, 1000)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1000')
expect(input.value).not.toContain(',')
})
it('displays numbers without commas by default for float widgets', () => {
const widget = createMockWidget(1000.5, 'float')
const wrapper = mountComponent(widget, 1000.5)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1000.5')
expect(input.value).not.toContain(',')
})
it('displays numbers with commas when grouping enabled', () => {
const widget = createMockWidget(1000, 'int', { useGrouping: true })
const wrapper = mountComponent(widget, 1000)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1,000')
expect(input.value).toContain(',')
})
it('displays numbers without commas when grouping explicitly disabled', () => {
const widget = createMockWidget(1000, 'int', { useGrouping: false })
const wrapper = mountComponent(widget, 1000)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1000')
expect(input.value).not.toContain(',')
})
it('displays numbers without commas when useGrouping option is undefined', () => {
const widget = createMockWidget(1000, 'int', { useGrouping: undefined })
const wrapper = mountComponent(widget, 1000)
const input = getNumberInput(wrapper)
expect(input.value).toBe('1000')
expect(input.value).not.toContain(',')
})
})

View File

@@ -48,6 +48,11 @@ const stepValue = computed(() => {
// Default to 'any' for unrestricted stepping
return 0
})
// Disable grouping separators by default unless explicitly enabled by the node author
const useGrouping = computed(() => {
return props.widget.options?.useGrouping === true
})
</script>
<template>
@@ -60,6 +65,7 @@ const stepValue = computed(() => {
size="small"
:disabled="readonly"
:step="stepValue"
:use-grouping="useGrouping"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
:pt="{
incrementButton:

View File

@@ -1,63 +0,0 @@
/**
* Store for managing Vue widget serialization functions.
* This allows Vue components to register serialization handlers that can be
* accessed by LiteGraph widgets for workflow saving/loading.
*/
type SerializationFunction = () => Promise<string>
class VueWidgetSerializationStore {
private registry = new Map<string, SerializationFunction>()
/**
* Register a serialization function for a widget
* @param key Unique identifier for the widget (e.g., "{nodeId}-{widgetName}")
* @param serializeFn Function that serializes the widget value
*/
register(key: string, serializeFn: SerializationFunction): void {
this.registry.set(key, serializeFn)
}
/**
* Unregister a serialization function
* @param key The key used during registration
*/
unregister(key: string): void {
this.registry.delete(key)
}
/**
* Get a serialization function for a widget
* @param key The key to look up
* @returns The serialization function if found, undefined otherwise
*/
get(key: string): SerializationFunction | undefined {
return this.registry.get(key)
}
/**
* Check if a serialization function is registered
* @param key The key to check
* @returns True if registered, false otherwise
*/
has(key: string): boolean {
return this.registry.has(key)
}
/**
* Clear all registered serialization functions
*/
clear(): void {
this.registry.clear()
}
/**
* Get all registered keys (useful for debugging)
*/
getKeys(): string[] {
return Array.from(this.registry.keys())
}
}
// Create singleton instance
export const vueWidgetSerializationStore = new VueWidgetSerializationStore()

View File

@@ -1,8 +1 @@
import clsx, { type ClassArray } from 'clsx'
import { twMerge } from 'tailwind-merge'
export type { ClassValue } from 'clsx'
export function cn(...inputs: ClassArray) {
return twMerge(clsx(inputs))
}
export { cn, type ClassValue } from '@comfyorg/tailwind-utils'

View File

@@ -0,0 +1,117 @@
/**
* @vitest-environment happy-dom
*/
import { useEventListener } from '@vueuse/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mock VueUse
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn()
}))
describe('usePreserveWidgetScroll', () => {
let mockUseEventListener: ReturnType<typeof vi.fn>
beforeEach(() => {
mockUseEventListener = vi.mocked(useEventListener)
vi.resetModules()
})
afterEach(() => {
vi.clearAllMocks()
})
it('should set up window wheel event listener with capture and passive options', async () => {
const { usePreserveWidgetScroll } = await import(
'@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
)
usePreserveWidgetScroll()
expect(mockUseEventListener).toHaveBeenCalledWith(
window,
'wheel',
expect.any(Function),
{ capture: true, passive: false }
)
})
it('should call stopPropagation on textarea wheel events', async () => {
let wheelHandler: (event: WheelEvent) => void
mockUseEventListener.mockImplementation((_target, _event, handler) => {
wheelHandler = handler
})
const { usePreserveWidgetScroll } = await import(
'@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
)
usePreserveWidgetScroll()
// Create real DOM textarea element and dispatch wheel event
const textarea = document.createElement('textarea')
document.body.appendChild(textarea)
const wheelEvent = new WheelEvent('wheel', { bubbles: true })
const stopPropagation = vi.fn()
wheelEvent.stopPropagation = stopPropagation
Object.defineProperty(wheelEvent, 'target', { value: textarea })
wheelHandler!(wheelEvent)
expect(stopPropagation).toHaveBeenCalled()
})
it('should not call stopPropagation on non-scrollable elements', async () => {
let wheelHandler: (event: WheelEvent) => void
mockUseEventListener.mockImplementation((_target, _event, handler) => {
wheelHandler = handler
})
const { usePreserveWidgetScroll } = await import(
'@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
)
usePreserveWidgetScroll()
// Create regular div element
const div = document.createElement('div')
document.body.appendChild(div)
const wheelEvent = new WheelEvent('wheel', { bubbles: true })
const stopPropagation = vi.fn()
wheelEvent.stopPropagation = stopPropagation
Object.defineProperty(wheelEvent, 'target', { value: div })
wheelHandler!(wheelEvent)
expect(stopPropagation).not.toHaveBeenCalled()
})
it('should handle PrimeVue select dropdown elements', async () => {
let wheelHandler: (event: WheelEvent) => void
mockUseEventListener.mockImplementation((_target, _event, handler) => {
wheelHandler = handler
})
const { usePreserveWidgetScroll } = await import(
'@/renderer/extensions/vueNodes/composables/usePreserveWidgetScroll'
)
usePreserveWidgetScroll()
// Create element with PrimeVue select option class
const div = document.createElement('div')
div.classList.add('p-select-option')
document.body.appendChild(div)
const wheelEvent = new WheelEvent('wheel', { bubbles: true })
const stopPropagation = vi.fn()
wheelEvent.stopPropagation = stopPropagation
Object.defineProperty(wheelEvent, 'target', { value: div })
wheelHandler!(wheelEvent)
expect(stopPropagation).toHaveBeenCalled()
})
})