mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 09:45:13 +00:00
Add Vue File/Media Upload Widget (#4115)
This commit is contained in:
150
src/components/graph/widgets/MediaLoaderWidget.vue
Normal file
150
src/components/graph/widgets/MediaLoaderWidget.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="media-loader-widget w-full px-2">
|
||||||
|
<div
|
||||||
|
class="upload-area border-2 border-dashed border-surface-300 dark-theme:border-surface-600 rounded-lg p-6 text-center bg-surface-50 dark-theme:bg-surface-800 hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors cursor-pointer"
|
||||||
|
:class="{
|
||||||
|
'border-primary-500 bg-primary-50 dark-theme:bg-primary-950': isDragOver
|
||||||
|
}"
|
||||||
|
@click="triggerFileUpload"
|
||||||
|
@dragover.prevent="onDragOver"
|
||||||
|
@dragleave.prevent="onDragLeave"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<i
|
||||||
|
class="pi pi-cloud-upload text-2xl text-surface-500 dark-theme:text-surface-400"
|
||||||
|
></i>
|
||||||
|
<div class="text-sm text-surface-600 dark-theme:text-surface-300">
|
||||||
|
<span>Drop your file here or </span>
|
||||||
|
<span
|
||||||
|
class="text-primary-600 dark-theme:text-primary-400 hover:text-primary-700 dark-theme:hover:text-primary-300 underline cursor-pointer"
|
||||||
|
@click.stop="triggerFileUpload"
|
||||||
|
>
|
||||||
|
browse files
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="accept"
|
||||||
|
class="text-xs text-surface-500 dark-theme:text-surface-400"
|
||||||
|
>
|
||||||
|
Accepted formats: {{ formatAcceptTypes }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
:accept="accept"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="onFileSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||||
|
|
||||||
|
// Props and model
|
||||||
|
const modelValue = defineModel<string[]>({ required: true, default: () => [] })
|
||||||
|
const { widget, accept } = defineProps<{
|
||||||
|
widget: ComponentWidget<string[]>
|
||||||
|
accept?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
const fileInput = ref<HTMLInputElement>()
|
||||||
|
const isDragOver = ref(false)
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const formatAcceptTypes = computed(() => {
|
||||||
|
if (!accept) return ''
|
||||||
|
return accept
|
||||||
|
.split(',')
|
||||||
|
.map((type) =>
|
||||||
|
type
|
||||||
|
.trim()
|
||||||
|
.replace('image/', '')
|
||||||
|
.replace('video/', '')
|
||||||
|
.replace('audio/', '')
|
||||||
|
)
|
||||||
|
.join(', ')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const triggerFileUpload = () => {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFileSelect = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
if (target.files) {
|
||||||
|
handleFiles(Array.from(target.files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
isDragOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragLeave = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
isDragOver.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
isDragOver.value = false
|
||||||
|
|
||||||
|
if (event.dataTransfer?.files) {
|
||||||
|
handleFiles(Array.from(event.dataTransfer.files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFiles = (files: File[]) => {
|
||||||
|
// Filter files based on accept prop if provided
|
||||||
|
let validFiles = files
|
||||||
|
if (accept) {
|
||||||
|
const acceptTypes = accept
|
||||||
|
.split(',')
|
||||||
|
.map((type) => type.trim().toLowerCase())
|
||||||
|
validFiles = files.filter((file) => {
|
||||||
|
return acceptTypes.some((acceptType) => {
|
||||||
|
if (acceptType.includes('*')) {
|
||||||
|
// Handle wildcard types like "image/*"
|
||||||
|
const baseType = acceptType.split('/')[0]
|
||||||
|
return file.type.startsWith(baseType + '/')
|
||||||
|
}
|
||||||
|
return file.type.toLowerCase() === acceptType
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
// Emit files to parent component for handling upload
|
||||||
|
const fileNames = validFiles.map((file) => file.name)
|
||||||
|
modelValue.value = fileNames
|
||||||
|
|
||||||
|
// Trigger the widget's upload handler if available
|
||||||
|
if ((widget.options as any)?.onFilesSelected) {
|
||||||
|
;(widget.options as any).onFilesSelected(validFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-area {
|
||||||
|
min-height: 80px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
border-color: var(--p-primary-500);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
|||||||
|
|
||||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
|
||||||
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
|
||||||
|
import { useNodeMediaUpload } from '@/composables/node/useNodeMediaUpload'
|
||||||
import { useNodePaste } from '@/composables/node/useNodePaste'
|
import { useNodePaste } from '@/composables/node/useNodePaste'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { useToastStore } from '@/stores/toastStore'
|
import { useToastStore } from '@/stores/toastStore'
|
||||||
@@ -36,16 +37,28 @@ interface ImageUploadOptions {
|
|||||||
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
|
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
|
||||||
*/
|
*/
|
||||||
accept?: string
|
accept?: string
|
||||||
|
/**
|
||||||
|
* Whether to use the new Vue MediaLoader widget instead of traditional drag/drop/paste
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
useMediaLoaderWidget?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds image upload to a node via drag & drop, paste, and file input.
|
* Adds image upload to a node via drag & drop, paste, and file input.
|
||||||
|
* Optionally can use the new Vue MediaLoader widget.
|
||||||
*/
|
*/
|
||||||
export const useNodeImageUpload = (
|
export const useNodeImageUpload = (
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
options: ImageUploadOptions
|
options: ImageUploadOptions
|
||||||
) => {
|
) => {
|
||||||
const { fileFilter, onUploadComplete, allow_batch, accept } = options
|
const {
|
||||||
|
fileFilter,
|
||||||
|
onUploadComplete,
|
||||||
|
allow_batch,
|
||||||
|
accept,
|
||||||
|
useMediaLoaderWidget = true
|
||||||
|
} = options
|
||||||
|
|
||||||
const isPastedFile = (file: File): boolean =>
|
const isPastedFile = (file: File): boolean =>
|
||||||
file.name === 'image.png' &&
|
file.name === 'image.png' &&
|
||||||
@@ -68,7 +81,23 @@ export const useNodeImageUpload = (
|
|||||||
return validPaths
|
return validPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle drag & drop
|
// If using the new MediaLoader widget, set it up and return early
|
||||||
|
if (useMediaLoaderWidget) {
|
||||||
|
const { showMediaLoader } = useNodeMediaUpload()
|
||||||
|
const widget = showMediaLoader(node, {
|
||||||
|
fileFilter,
|
||||||
|
onUploadComplete,
|
||||||
|
allow_batch,
|
||||||
|
accept
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
openFileSelection: () => {},
|
||||||
|
handleUpload,
|
||||||
|
mediaLoaderWidget: widget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traditional approach: Handle drag & drop
|
||||||
useNodeDragAndDrop(node, {
|
useNodeDragAndDrop(node, {
|
||||||
fileFilter,
|
fileFilter,
|
||||||
onDrop: handleUploadBatch
|
onDrop: handleUploadBatch
|
||||||
|
|||||||
86
src/composables/node/useNodeMediaUpload.ts
Normal file
86
src/composables/node/useNodeMediaUpload.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
|
||||||
|
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||||
|
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||||
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
|
||||||
|
const MEDIA_LOADER_WIDGET_NAME = '$$node-media-loader'
|
||||||
|
|
||||||
|
interface MediaUploadOptions {
|
||||||
|
fileFilter?: (file: File) => boolean
|
||||||
|
onUploadComplete: (paths: string[]) => void
|
||||||
|
allow_batch?: boolean
|
||||||
|
accept?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for handling media upload with Vue MediaLoader widget
|
||||||
|
*/
|
||||||
|
export function useNodeMediaUpload() {
|
||||||
|
const mediaLoaderWidget = useMediaLoaderWidget()
|
||||||
|
|
||||||
|
const findMediaLoaderWidget = (node: LGraphNode) =>
|
||||||
|
node.widgets?.find((w) => w.name === MEDIA_LOADER_WIDGET_NAME)
|
||||||
|
|
||||||
|
const addMediaLoaderWidget = (
|
||||||
|
node: LGraphNode,
|
||||||
|
options: MediaUploadOptions
|
||||||
|
) => {
|
||||||
|
// Set up the file upload handling using existing logic
|
||||||
|
const { handleUpload } = useNodeImageUpload(node, options)
|
||||||
|
|
||||||
|
// Create the MediaLoader widget
|
||||||
|
const widget = mediaLoaderWidget(node, {
|
||||||
|
name: MEDIA_LOADER_WIDGET_NAME,
|
||||||
|
type: 'MEDIA_LOADER'
|
||||||
|
} as InputSpec)
|
||||||
|
|
||||||
|
// Connect the widget to the upload handler
|
||||||
|
if (widget.options) {
|
||||||
|
;(widget.options as any).onFilesSelected = async (files: File[]) => {
|
||||||
|
const paths = await Promise.all(files.map(handleUpload))
|
||||||
|
const validPaths = paths.filter((p): p is string => !!p)
|
||||||
|
if (validPaths.length) {
|
||||||
|
options.onUploadComplete(validPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows media loader widget for a node
|
||||||
|
* @param node The graph node to show the widget for
|
||||||
|
* @param options Upload configuration options
|
||||||
|
*/
|
||||||
|
function showMediaLoader(node: LGraphNode, options: MediaUploadOptions) {
|
||||||
|
const widget =
|
||||||
|
findMediaLoaderWidget(node) ?? addMediaLoaderWidget(node, options)
|
||||||
|
node.setDirtyCanvas?.(true)
|
||||||
|
return widget
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes media loader widget from a node
|
||||||
|
* @param node The graph node to remove the widget from
|
||||||
|
*/
|
||||||
|
function removeMediaLoader(node: LGraphNode) {
|
||||||
|
if (!node.widgets) return
|
||||||
|
|
||||||
|
const widgetIdx = node.widgets.findIndex(
|
||||||
|
(w) => w.name === MEDIA_LOADER_WIDGET_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
if (widgetIdx > -1) {
|
||||||
|
node.widgets[widgetIdx].onRemove?.()
|
||||||
|
node.widgets.splice(widgetIdx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showMediaLoader,
|
||||||
|
removeMediaLoader,
|
||||||
|
addMediaLoaderWidget
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ export const useDropdownComboWidget = (
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Optional: minimum height for the widget (dropdown needs some height)
|
// Optional: minimum height for the widget (dropdown needs some height)
|
||||||
getMinHeight: () => 42 + PADDING,
|
getMinHeight: () => 48 + PADDING,
|
||||||
|
|
||||||
// Optional: whether to serialize this widget's value
|
// Optional: whether to serialize this widget's value
|
||||||
serialize: true
|
serialize: true
|
||||||
|
|||||||
163
src/composables/widgets/useImageUploadMediaWidget.ts
Normal file
163
src/composables/widgets/useImageUploadMediaWidget.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||||
|
|
||||||
|
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||||
|
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||||
|
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||||
|
import { useValueTransform } from '@/composables/useValueTransform'
|
||||||
|
import type { ResultItem } from '@/schemas/apiSchema'
|
||||||
|
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||||
|
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||||
|
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||||
|
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||||
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
|
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||||
|
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||||
|
|
||||||
|
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||||
|
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||||
|
|
||||||
|
type InternalFile = string | ResultItem
|
||||||
|
type InternalValue = InternalFile | InternalFile[]
|
||||||
|
type ExposedValue = string | string[]
|
||||||
|
|
||||||
|
const isImageFile = (file: File) => file.type.startsWith('image/')
|
||||||
|
const isVideoFile = (file: File) => file.type.startsWith('video/')
|
||||||
|
|
||||||
|
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
|
||||||
|
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
|
||||||
|
value: ExposedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useImageUploadMediaWidget = () => {
|
||||||
|
const widgetConstructor: ComfyWidgetConstructor = (
|
||||||
|
node: LGraphNode,
|
||||||
|
inputName: string,
|
||||||
|
inputData: InputSpec
|
||||||
|
) => {
|
||||||
|
const inputOptions = inputData[1] ?? {}
|
||||||
|
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
|
||||||
|
const nodeOutputStore = useNodeOutputStore()
|
||||||
|
|
||||||
|
const isAnimated = !!inputOptions.animated_image_upload
|
||||||
|
const isVideo = !!inputOptions.video_upload
|
||||||
|
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||||
|
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||||
|
|
||||||
|
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||||
|
// @ts-expect-error InputSpec is not typed correctly
|
||||||
|
const fileComboWidget = findFileComboWidget(node, imageInputName)
|
||||||
|
const initialFile = `${fileComboWidget.value}`
|
||||||
|
const formatPath = (value: InternalFile) =>
|
||||||
|
// @ts-expect-error InputSpec is not typed correctly
|
||||||
|
createAnnotatedPath(value, { rootFolder: image_folder })
|
||||||
|
|
||||||
|
const transform = (internalValue: InternalValue): ExposedValue => {
|
||||||
|
if (!internalValue) return initialFile
|
||||||
|
if (Array.isArray(internalValue))
|
||||||
|
return allow_batch
|
||||||
|
? internalValue.map(formatPath)
|
||||||
|
: formatPath(internalValue[0])
|
||||||
|
return formatPath(internalValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(
|
||||||
|
fileComboWidget,
|
||||||
|
'value',
|
||||||
|
useValueTransform(transform, initialFile)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert the V1 input spec to V2 format for the MediaLoader widget
|
||||||
|
const inputSpecV2 = transformInputSpecV1ToV2(inputData, { name: inputName })
|
||||||
|
|
||||||
|
// Handle widget dimensions based on input options
|
||||||
|
const getMinHeight = () => {
|
||||||
|
let baseHeight = 200
|
||||||
|
|
||||||
|
// Handle multiline attribute for expanded height
|
||||||
|
if (inputOptions.multiline) {
|
||||||
|
baseHeight = Math.max(
|
||||||
|
baseHeight,
|
||||||
|
inputOptions.multiline === true
|
||||||
|
? 120
|
||||||
|
: Number(inputOptions.multiline) || 120
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other height-related attributes
|
||||||
|
if (inputOptions.min_height) {
|
||||||
|
baseHeight = Math.max(baseHeight, Number(inputOptions.min_height))
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseHeight + 8 // Add padding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the MediaLoader widget directly
|
||||||
|
const uploadWidget = new ComponentWidgetImpl<string[], { accept?: string }>(
|
||||||
|
{
|
||||||
|
node,
|
||||||
|
name: inputName,
|
||||||
|
component: MediaLoaderWidget,
|
||||||
|
inputSpec: inputSpecV2,
|
||||||
|
props: {
|
||||||
|
accept
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
getValue: () => [],
|
||||||
|
setValue: () => {},
|
||||||
|
getMinHeight,
|
||||||
|
serialize: false,
|
||||||
|
onFilesSelected: async (files: File[]) => {
|
||||||
|
// Use the existing upload infrastructure
|
||||||
|
const { handleUpload } = useNodeImageUpload(node, {
|
||||||
|
// @ts-expect-error InputSpec is not typed correctly
|
||||||
|
allow_batch,
|
||||||
|
fileFilter,
|
||||||
|
accept,
|
||||||
|
onUploadComplete: (output) => {
|
||||||
|
output.forEach((path) =>
|
||||||
|
addToComboValues(fileComboWidget, path)
|
||||||
|
)
|
||||||
|
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||||
|
fileComboWidget.value = output
|
||||||
|
fileComboWidget.callback?.(output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle each file
|
||||||
|
for (const file of files) {
|
||||||
|
if (fileFilter(file)) {
|
||||||
|
await handleUpload(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as any
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register the widget with the node
|
||||||
|
addWidget(node, uploadWidget as any)
|
||||||
|
|
||||||
|
// Add our own callback to the combo widget to render an image when it changes
|
||||||
|
fileComboWidget.callback = function () {
|
||||||
|
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||||
|
isAnimated
|
||||||
|
})
|
||||||
|
node.graph?.setDirtyCanvas(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On load if we have a value then render the image
|
||||||
|
// The value isnt set immediately so we need to wait a moment
|
||||||
|
// No change callbacks seem to be fired on initial setting of the value
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||||
|
isAnimated
|
||||||
|
})
|
||||||
|
showPreview({ block: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
return { widget: uploadWidget }
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgetConstructor
|
||||||
|
}
|
||||||
70
src/composables/widgets/useMediaLoaderWidget.ts
Normal file
70
src/composables/widgets/useMediaLoaderWidget.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||||
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
import {
|
||||||
|
ComponentWidgetImpl,
|
||||||
|
type DOMWidgetOptions,
|
||||||
|
addWidget
|
||||||
|
} from '@/scripts/domWidget'
|
||||||
|
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||||
|
|
||||||
|
const PADDING = 8
|
||||||
|
|
||||||
|
interface MediaLoaderOptions {
|
||||||
|
defaultValue?: string[]
|
||||||
|
minHeight?: number
|
||||||
|
accept?: string
|
||||||
|
onFilesSelected?: (files: File[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaLoaderWidgetOptions extends DOMWidgetOptions<string[]> {
|
||||||
|
onFilesSelected?: (files: File[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMediaLoaderWidget = (options: MediaLoaderOptions = {}) => {
|
||||||
|
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||||
|
node: LGraphNode,
|
||||||
|
inputSpec: InputSpec
|
||||||
|
) => {
|
||||||
|
// Initialize widget value
|
||||||
|
const widgetValue = ref<string[]>(options.defaultValue ?? [])
|
||||||
|
|
||||||
|
// Create the widget instance
|
||||||
|
const widget = new ComponentWidgetImpl<string[], { accept?: string }>({
|
||||||
|
node,
|
||||||
|
name: inputSpec.name,
|
||||||
|
component: MediaLoaderWidget,
|
||||||
|
inputSpec,
|
||||||
|
props: {
|
||||||
|
accept: options.accept
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
// Required: getter for widget value
|
||||||
|
getValue: () => widgetValue.value,
|
||||||
|
|
||||||
|
// Required: setter for widget value
|
||||||
|
setValue: (value: string[]) => {
|
||||||
|
widgetValue.value = Array.isArray(value) ? value : []
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optional: minimum height for the widget
|
||||||
|
getMinHeight: () => (options.minHeight ?? 500) + PADDING,
|
||||||
|
|
||||||
|
// Optional: whether to serialize this widget's value
|
||||||
|
serialize: true,
|
||||||
|
|
||||||
|
// Custom option for file selection callback
|
||||||
|
onFilesSelected: options.onFilesSelected
|
||||||
|
} as MediaLoaderWidgetOptions
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register the widget with the node
|
||||||
|
addWidget(node, widget as any)
|
||||||
|
|
||||||
|
return widget
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgetConstructor
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@ import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput
|
|||||||
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
|
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
|
||||||
import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget'
|
import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget'
|
||||||
import { useComboWidget } from '@/composables/widgets/useComboWidget'
|
import { useComboWidget } from '@/composables/widgets/useComboWidget'
|
||||||
import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget'
|
import { useImageUploadMediaWidget } from '@/composables/widgets/useImageUploadMediaWidget'
|
||||||
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
|
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
|
||||||
|
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||||
import { useStringWidget } from '@/composables/widgets/useStringWidget'
|
import { useStringWidget } from '@/composables/widgets/useStringWidget'
|
||||||
import { useStringWidgetVue } from '@/composables/widgets/useStringWidgetVue'
|
import { useStringWidgetVue } from '@/composables/widgets/useStringWidgetVue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
@@ -288,7 +289,8 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
|||||||
STRING_DOM: transformWidgetConstructorV2ToV1(useStringWidget()), // Fallback to DOM-based implementation
|
STRING_DOM: transformWidgetConstructorV2ToV1(useStringWidget()), // Fallback to DOM-based implementation
|
||||||
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
|
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
|
||||||
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
|
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
|
||||||
IMAGEUPLOAD: useImageUploadWidget(),
|
COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget()),
|
||||||
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput()),
|
IMAGEUPLOAD: useImageUploadMediaWidget(),
|
||||||
COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget())
|
MEDIA_LOADER: transformWidgetConstructorV2ToV1(useMediaLoaderWidget()),
|
||||||
|
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput())
|
||||||
}
|
}
|
||||||
|
|||||||
108
tests-ui/composables/useMediaLoaderWidget.test.ts
Normal file
108
tests-ui/composables/useMediaLoaderWidget.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||||
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/scripts/domWidget', () => ({
|
||||||
|
ComponentWidgetImpl: class MockComponentWidgetImpl {
|
||||||
|
node: any
|
||||||
|
name: string
|
||||||
|
component: any
|
||||||
|
inputSpec: any
|
||||||
|
props: any
|
||||||
|
options: any
|
||||||
|
|
||||||
|
constructor(config: any) {
|
||||||
|
this.node = config.node
|
||||||
|
this.name = config.name
|
||||||
|
this.component = config.component
|
||||||
|
this.inputSpec = config.inputSpec
|
||||||
|
this.props = config.props
|
||||||
|
this.options = config.options
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addWidget: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/graph/widgets/MediaLoaderWidget.vue', () => ({
|
||||||
|
default: {}
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useMediaLoaderWidget', () => {
|
||||||
|
let mockNode: LGraphNode
|
||||||
|
let mockInputSpec: InputSpec
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockNode = {
|
||||||
|
id: 1,
|
||||||
|
widgets: []
|
||||||
|
} as unknown as LGraphNode
|
||||||
|
|
||||||
|
mockInputSpec = {
|
||||||
|
name: 'test_media_loader',
|
||||||
|
type: 'MEDIA_LOADER'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates widget constructor with default options', () => {
|
||||||
|
const constructor = useMediaLoaderWidget()
|
||||||
|
expect(constructor).toBeInstanceOf(Function)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates widget with custom options', () => {
|
||||||
|
const onFilesSelected = vi.fn()
|
||||||
|
const constructor = useMediaLoaderWidget({
|
||||||
|
defaultValue: ['test.jpg'],
|
||||||
|
minHeight: 120,
|
||||||
|
accept: 'image/*',
|
||||||
|
onFilesSelected
|
||||||
|
})
|
||||||
|
|
||||||
|
const widget = constructor(mockNode, mockInputSpec)
|
||||||
|
|
||||||
|
expect(widget).toBeDefined()
|
||||||
|
expect(widget.name).toBe('test_media_loader')
|
||||||
|
expect((widget.options as any)?.getValue()).toEqual(['test.jpg'])
|
||||||
|
expect((widget.options as any)?.getMinHeight()).toBe(128) // 120 + 8 padding
|
||||||
|
expect((widget.options as any)?.onFilesSelected).toBe(onFilesSelected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles value setting with validation', () => {
|
||||||
|
const constructor = useMediaLoaderWidget()
|
||||||
|
const widget = constructor(mockNode, mockInputSpec)
|
||||||
|
|
||||||
|
// Test valid array
|
||||||
|
;(widget.options as any)?.setValue(['file1.jpg', 'file2.png'])
|
||||||
|
expect((widget.options as any)?.getValue()).toEqual([
|
||||||
|
'file1.jpg',
|
||||||
|
'file2.png'
|
||||||
|
])
|
||||||
|
|
||||||
|
// Test invalid value conversion
|
||||||
|
;(widget.options as any)?.setValue('invalid' as any)
|
||||||
|
expect((widget.options as any)?.getValue()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets correct minimum height with padding', () => {
|
||||||
|
const constructor = useMediaLoaderWidget({ minHeight: 150 })
|
||||||
|
const widget = constructor(mockNode, mockInputSpec)
|
||||||
|
|
||||||
|
expect((widget.options as any)?.getMinHeight()).toBe(158) // 150 + 8 padding
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses default minimum height when not specified', () => {
|
||||||
|
const constructor = useMediaLoaderWidget()
|
||||||
|
const widget = constructor(mockNode, mockInputSpec)
|
||||||
|
|
||||||
|
expect((widget.options as any)?.getMinHeight()).toBe(108) // 100 + 8 padding
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes accept prop to widget', () => {
|
||||||
|
const constructor = useMediaLoaderWidget({ accept: 'video/*' })
|
||||||
|
const widget = constructor(mockNode, mockInputSpec)
|
||||||
|
|
||||||
|
expect((widget as any).props?.accept).toBe('video/*')
|
||||||
|
})
|
||||||
|
})
|
||||||
114
tests-ui/composables/useNodeMediaUpload.test.ts
Normal file
114
tests-ui/composables/useNodeMediaUpload.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useNodeMediaUpload } from '@/composables/node/useNodeMediaUpload'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/composables/widgets/useMediaLoaderWidget', () => ({
|
||||||
|
useMediaLoaderWidget: vi.fn(() =>
|
||||||
|
vi.fn(() => ({
|
||||||
|
name: '$$node-media-loader',
|
||||||
|
options: {
|
||||||
|
onFilesSelected: null
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/node/useNodeImageUpload', () => ({
|
||||||
|
useNodeImageUpload: vi.fn(() => ({
|
||||||
|
handleUpload: vi.fn()
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useNodeMediaUpload', () => {
|
||||||
|
let mockNode: LGraphNode
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockNode = {
|
||||||
|
id: 1,
|
||||||
|
widgets: [],
|
||||||
|
setDirtyCanvas: vi.fn()
|
||||||
|
} as unknown as LGraphNode
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates composable with required methods', () => {
|
||||||
|
const { showMediaLoader, removeMediaLoader, addMediaLoaderWidget } =
|
||||||
|
useNodeMediaUpload()
|
||||||
|
|
||||||
|
expect(showMediaLoader).toBeInstanceOf(Function)
|
||||||
|
expect(removeMediaLoader).toBeInstanceOf(Function)
|
||||||
|
expect(addMediaLoaderWidget).toBeInstanceOf(Function)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows media loader widget with options', () => {
|
||||||
|
const { showMediaLoader } = useNodeMediaUpload()
|
||||||
|
const options = {
|
||||||
|
fileFilter: (file: File) => file.type.startsWith('image/'),
|
||||||
|
onUploadComplete: vi.fn(),
|
||||||
|
allow_batch: true,
|
||||||
|
accept: 'image/*'
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = showMediaLoader(mockNode, options)
|
||||||
|
|
||||||
|
expect(widget).toBeDefined()
|
||||||
|
expect(widget.name).toBe('$$node-media-loader')
|
||||||
|
expect(mockNode.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes media loader widget from node', () => {
|
||||||
|
const { showMediaLoader, removeMediaLoader } = useNodeMediaUpload()
|
||||||
|
const options = {
|
||||||
|
fileFilter: () => true,
|
||||||
|
onUploadComplete: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add widget
|
||||||
|
showMediaLoader(mockNode, options)
|
||||||
|
mockNode.widgets = [
|
||||||
|
{
|
||||||
|
name: '$$node-media-loader',
|
||||||
|
onRemove: vi.fn()
|
||||||
|
}
|
||||||
|
] as any
|
||||||
|
|
||||||
|
// Remove widget
|
||||||
|
removeMediaLoader(mockNode)
|
||||||
|
|
||||||
|
expect(mockNode.widgets).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles node without widgets gracefully', () => {
|
||||||
|
const { removeMediaLoader } = useNodeMediaUpload()
|
||||||
|
const nodeWithoutWidgets = { id: 1 } as LGraphNode
|
||||||
|
|
||||||
|
expect(() => removeMediaLoader(nodeWithoutWidgets)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not remove non-matching widgets', () => {
|
||||||
|
const { removeMediaLoader } = useNodeMediaUpload()
|
||||||
|
const otherWidget = { name: 'other-widget' }
|
||||||
|
mockNode.widgets! = [otherWidget] as any
|
||||||
|
|
||||||
|
removeMediaLoader(mockNode)
|
||||||
|
|
||||||
|
expect(mockNode.widgets).toHaveLength(1)
|
||||||
|
expect(mockNode.widgets![0]).toBe(otherWidget)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls widget onRemove when removing', () => {
|
||||||
|
const { removeMediaLoader } = useNodeMediaUpload()
|
||||||
|
const onRemove = vi.fn()
|
||||||
|
mockNode.widgets! = [
|
||||||
|
{
|
||||||
|
name: '$$node-media-loader',
|
||||||
|
onRemove
|
||||||
|
}
|
||||||
|
] as any
|
||||||
|
|
||||||
|
removeMediaLoader(mockNode)
|
||||||
|
|
||||||
|
expect(onRemove).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user