mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 16:17:58 +00:00
Compare commits
3 Commits
vue-custom
...
fix/captur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81087c111a | ||
|
|
ac93a6ba3f | ||
|
|
961af8731e |
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 |
@@ -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",
|
||||
|
||||
31
packages/tailwind-utils/README.md
Normal file
31
packages/tailwind-utils/README.md
Normal 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:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
30
packages/tailwind-utils/package.json
Normal file
30
packages/tailwind-utils/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
packages/tailwind-utils/src/index.ts
Normal file
8
packages/tailwind-utils/src/index.ts
Normal 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))
|
||||
}
|
||||
8
packages/tailwind-utils/tsconfig.json
Normal file
8
packages/tailwind-utils/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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(',')
|
||||
})
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
|
||||
117
tests-ui/tests/composables/usePreserveWidgetScroll.test.ts
Normal file
117
tests-ui/tests/composables/usePreserveWidgetScroll.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user