mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +00:00
Add Vue Image Preview widget (#4116)
This commit is contained in:
210
src/components/graph/widgets/ImagePreviewWidget.vue
Normal file
210
src/components/graph/widgets/ImagePreviewWidget.vue
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<template>
|
||||||
|
<div class="image-preview-widget relative w-full">
|
||||||
|
<!-- Single image or grid view -->
|
||||||
|
<div
|
||||||
|
v-if="images.length > 0"
|
||||||
|
class="relative rounded-lg overflow-hidden bg-gray-100 dark-theme:bg-gray-800"
|
||||||
|
:style="{ minHeight: `${minHeight}px` }"
|
||||||
|
>
|
||||||
|
<!-- Single image view -->
|
||||||
|
<div
|
||||||
|
v-if="selectedImageIndex !== null && images[selectedImageIndex]"
|
||||||
|
class="relative flex items-center justify-center w-full h-full"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="images[selectedImageIndex].src"
|
||||||
|
:alt="`Preview ${selectedImageIndex + 1}`"
|
||||||
|
class="max-w-full max-h-full object-contain"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Action buttons overlay -->
|
||||||
|
<div class="absolute top-2 right-2 flex gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="images.length > 1"
|
||||||
|
icon="pi pi-times"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||||
|
@click="showGrid"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||||
|
@click="handleEdit"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-sun"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||||
|
@click="handleBrightness"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-download"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation for multiple images -->
|
||||||
|
<div
|
||||||
|
v-if="images.length > 1"
|
||||||
|
class="absolute bottom-2 right-2 bg-black/60 text-white px-2 py-1 rounded text-sm cursor-pointer hover:bg-black/80"
|
||||||
|
@click="nextImage"
|
||||||
|
>
|
||||||
|
{{ selectedImageIndex + 1 }}/{{ images.length }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid view for multiple images -->
|
||||||
|
<div
|
||||||
|
v-else-if="allowBatch && images.length > 1"
|
||||||
|
class="grid gap-1 p-2"
|
||||||
|
:style="gridStyle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(image, index) in images"
|
||||||
|
:key="index"
|
||||||
|
class="relative aspect-square bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden hover:ring-2 hover:ring-blue-500"
|
||||||
|
@click="selectImage(index)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="image.src"
|
||||||
|
:alt="`Thumbnail ${index + 1}`"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Single image in grid mode -->
|
||||||
|
<div v-else-if="images.length === 1" class="p-2">
|
||||||
|
<div
|
||||||
|
class="relative bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden"
|
||||||
|
@click="selectImage(0)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="images[0].src"
|
||||||
|
:alt="'Preview'"
|
||||||
|
class="w-full h-auto object-contain"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex items-center justify-center w-full bg-gray-100 dark-theme:bg-gray-800 rounded-lg"
|
||||||
|
:style="{ minHeight: `${minHeight}px` }"
|
||||||
|
>
|
||||||
|
<div class="text-gray-500 text-sm">No images to preview</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||||
|
|
||||||
|
interface ImageData {
|
||||||
|
src: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelValue = defineModel<string | string[]>({ required: true })
|
||||||
|
const { widget } = defineProps<{
|
||||||
|
widget: ComponentWidget<string | string[]>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Widget configuration
|
||||||
|
const inputSpec = widget.inputSpec
|
||||||
|
const allowBatch = computed(() => Boolean(inputSpec.allow_batch))
|
||||||
|
const imageFolder = computed(() => inputSpec.image_folder || 'input')
|
||||||
|
|
||||||
|
// State
|
||||||
|
const selectedImageIndex = ref<number | null>(null)
|
||||||
|
const minHeight = 320
|
||||||
|
|
||||||
|
// Convert model value to image data
|
||||||
|
const images = computed<ImageData[]>(() => {
|
||||||
|
const value = modelValue.value
|
||||||
|
if (!value) return []
|
||||||
|
|
||||||
|
const paths = Array.isArray(value) ? value : [value]
|
||||||
|
return paths.map((path) => ({
|
||||||
|
src: path.startsWith('http')
|
||||||
|
? path
|
||||||
|
: `api/view?filename=${encodeURIComponent(path)}&type=${imageFolder.value}`, // TODO: add subfolder
|
||||||
|
width: undefined,
|
||||||
|
height: undefined
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Grid layout for batch images
|
||||||
|
const gridStyle = computed(() => {
|
||||||
|
const count = images.value.length
|
||||||
|
if (count <= 1) return {}
|
||||||
|
|
||||||
|
const cols = Math.ceil(Math.sqrt(count))
|
||||||
|
return {
|
||||||
|
gridTemplateColumns: `repeat(${cols}, 1fr)`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const selectImage = (index: number) => {
|
||||||
|
selectedImageIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const showGrid = () => {
|
||||||
|
selectedImageIndex.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextImage = () => {
|
||||||
|
if (images.value.length === 0) return
|
||||||
|
|
||||||
|
const current = selectedImageIndex.value ?? -1
|
||||||
|
const next = (current + 1) % images.value.length
|
||||||
|
selectedImageIndex.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageError = (event: Event) => {
|
||||||
|
const img = event.target as HTMLImageElement
|
||||||
|
img.style.display = 'none'
|
||||||
|
console.warn('Failed to load image:', img.src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub button handlers for now
|
||||||
|
const handleEdit = () => {
|
||||||
|
console.log('Edit button clicked - functionality to be implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBrightness = () => {
|
||||||
|
console.log('Brightness button clicked - functionality to be implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
console.log('Save button clicked - functionality to be implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize to show first image if available
|
||||||
|
if (images.value.length === 1) {
|
||||||
|
selectedImageIndex.value = 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.image-preview-widget {
|
||||||
|
/* Ensure proper dark theme styling */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="media-loader-widget w-full px-2">
|
<div class="media-loader-widget w-full px-2 max-h-44">
|
||||||
<div
|
<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="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="{
|
:class="{
|
||||||
|
|||||||
77
src/composables/node/useNodeImagePreview.ts
Normal file
77
src/composables/node/useNodeImagePreview.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
|
||||||
|
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
|
||||||
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
|
||||||
|
const IMAGE_PREVIEW_WIDGET_NAME = '$$node-image-preview'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for handling node-level operations for ImagePreview widget
|
||||||
|
*/
|
||||||
|
export function useNodeImagePreview() {
|
||||||
|
const imagePreviewWidget = useImagePreviewWidget()
|
||||||
|
|
||||||
|
const findImagePreviewWidget = (node: LGraphNode) =>
|
||||||
|
node.widgets?.find((w) => w.name === IMAGE_PREVIEW_WIDGET_NAME)
|
||||||
|
|
||||||
|
const addImagePreviewWidget = (
|
||||||
|
node: LGraphNode,
|
||||||
|
inputSpec?: Partial<InputSpec>
|
||||||
|
) =>
|
||||||
|
imagePreviewWidget(node, {
|
||||||
|
name: IMAGE_PREVIEW_WIDGET_NAME,
|
||||||
|
type: 'IMAGEPREVIEW',
|
||||||
|
allow_batch: true,
|
||||||
|
image_folder: 'input',
|
||||||
|
...inputSpec
|
||||||
|
} as InputSpec)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows image preview widget for a node
|
||||||
|
* @param node The graph node to show the widget for
|
||||||
|
* @param images The images to display (can be single image or array)
|
||||||
|
* @param options Configuration options
|
||||||
|
*/
|
||||||
|
function showImagePreview(
|
||||||
|
node: LGraphNode,
|
||||||
|
images: string | string[],
|
||||||
|
options: {
|
||||||
|
allow_batch?: boolean
|
||||||
|
image_folder?: string
|
||||||
|
imageInputName?: string
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const widget =
|
||||||
|
findImagePreviewWidget(node) ??
|
||||||
|
addImagePreviewWidget(node, {
|
||||||
|
allow_batch: options.allow_batch,
|
||||||
|
image_folder: options.image_folder || 'input'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set the widget value
|
||||||
|
widget.value = images
|
||||||
|
node.setDirtyCanvas?.(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes image preview widget from a node
|
||||||
|
* @param node The graph node to remove the widget from
|
||||||
|
*/
|
||||||
|
function removeImagePreview(node: LGraphNode) {
|
||||||
|
if (!node.widgets) return
|
||||||
|
|
||||||
|
const widgetIdx = node.widgets.findIndex(
|
||||||
|
(w) => w.name === IMAGE_PREVIEW_WIDGET_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
if (widgetIdx > -1) {
|
||||||
|
node.widgets[widgetIdx].onRemove?.()
|
||||||
|
node.widgets.splice(widgetIdx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showImagePreview,
|
||||||
|
removeImagePreview
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ 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'
|
||||||
@@ -37,28 +36,16 @@ 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 {
|
const { fileFilter, onUploadComplete, allow_batch, accept } = options
|
||||||
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' &&
|
||||||
@@ -81,21 +68,8 @@ export const useNodeImageUpload = (
|
|||||||
return validPaths
|
return validPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using the new MediaLoader widget, set it up and return early
|
// Note: MediaLoader widget functionality is handled directly by
|
||||||
if (useMediaLoaderWidget) {
|
// useImageUploadMediaWidget.ts to avoid circular dependencies
|
||||||
const { showMediaLoader } = useNodeMediaUpload()
|
|
||||||
const widget = showMediaLoader(node, {
|
|
||||||
fileFilter,
|
|
||||||
onUploadComplete,
|
|
||||||
allow_batch,
|
|
||||||
accept
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
openFileSelection: () => {},
|
|
||||||
handleUpload,
|
|
||||||
mediaLoaderWidget: widget
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traditional approach: Handle drag & drop
|
// Traditional approach: Handle drag & drop
|
||||||
useNodeDragAndDrop(node, {
|
useNodeDragAndDrop(node, {
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
|
||||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
|
||||||
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
import { useToastStore } from '@/stores/toastStore'
|
||||||
|
|
||||||
const MEDIA_LOADER_WIDGET_NAME = '$$node-media-loader'
|
const MEDIA_LOADER_WIDGET_NAME = '$$node-media-loader'
|
||||||
|
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||||
|
|
||||||
|
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('image', file)
|
||||||
|
if (isPasted) body.append('subfolder', 'pasted')
|
||||||
|
|
||||||
|
const resp = await api.fetchApi('/upload/image', {
|
||||||
|
method: 'POST',
|
||||||
|
body
|
||||||
|
})
|
||||||
|
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json()
|
||||||
|
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||||
|
}
|
||||||
|
|
||||||
interface MediaUploadOptions {
|
interface MediaUploadOptions {
|
||||||
fileFilter?: (file: File) => boolean
|
fileFilter?: (file: File) => boolean
|
||||||
@@ -26,8 +47,19 @@ export function useNodeMediaUpload() {
|
|||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
options: MediaUploadOptions
|
options: MediaUploadOptions
|
||||||
) => {
|
) => {
|
||||||
// Set up the file upload handling using existing logic
|
const isPastedFile = (file: File): boolean =>
|
||||||
const { handleUpload } = useNodeImageUpload(node, options)
|
file.name === 'image.png' &&
|
||||||
|
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
|
||||||
|
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const path = await uploadFile(file, isPastedFile(file))
|
||||||
|
if (!path) return
|
||||||
|
return path
|
||||||
|
} catch (error) {
|
||||||
|
useToastStore().addAlert(String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create the MediaLoader widget
|
// Create the MediaLoader widget
|
||||||
const widget = mediaLoaderWidget(node, {
|
const widget = mediaLoaderWidget(node, {
|
||||||
@@ -38,7 +70,11 @@ export function useNodeMediaUpload() {
|
|||||||
// Connect the widget to the upload handler
|
// Connect the widget to the upload handler
|
||||||
if (widget.options) {
|
if (widget.options) {
|
||||||
;(widget.options as any).onFilesSelected = async (files: File[]) => {
|
;(widget.options as any).onFilesSelected = async (files: File[]) => {
|
||||||
const paths = await Promise.all(files.map(handleUpload))
|
const filteredFiles = options.fileFilter
|
||||||
|
? files.filter(options.fileFilter)
|
||||||
|
: files
|
||||||
|
|
||||||
|
const paths = await Promise.all(filteredFiles.map(handleUpload))
|
||||||
const validPaths = paths.filter((p): p is string => !!p)
|
const validPaths = paths.filter((p): p is string => !!p)
|
||||||
if (validPaths.length) {
|
if (validPaths.length) {
|
||||||
options.onUploadComplete(validPaths)
|
options.onUploadComplete(validPaths)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const useBadgedNumberInput = (
|
|||||||
const {
|
const {
|
||||||
defaultValue = 0,
|
defaultValue = 0,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
minHeight = 40,
|
minHeight = 32,
|
||||||
serialize = true,
|
serialize = true,
|
||||||
mode = 'int'
|
mode = 'int'
|
||||||
} = options
|
} = options
|
||||||
@@ -117,6 +117,8 @@ export const useBadgedNumberInput = (
|
|||||||
|
|
||||||
// Optional: minimum height for the widget
|
// Optional: minimum height for the widget
|
||||||
getMinHeight: () => minHeight + PADDING,
|
getMinHeight: () => minHeight + PADDING,
|
||||||
|
// Lock maximum height to prevent oversizing
|
||||||
|
getMaxHeight: () => 45,
|
||||||
|
|
||||||
// Optional: whether to serialize this widget's value
|
// Optional: whether to serialize this widget's value
|
||||||
serialize
|
serialize
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
|||||||
getValue: () => widgetValue.value,
|
getValue: () => widgetValue.value,
|
||||||
setValue: (value: string[]) => {
|
setValue: (value: string[]) => {
|
||||||
widgetValue.value = value
|
widgetValue.value = value
|
||||||
}
|
},
|
||||||
|
// Optional: minimum height for the widget (multiselect needs minimal height)
|
||||||
|
getMinHeight: () => 32,
|
||||||
|
// Lock maximum height to prevent oversizing
|
||||||
|
getMaxHeight: () => 45,
|
||||||
|
// Optional: whether to serialize this widget's value
|
||||||
|
serialize: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
addWidget(node, widget as BaseDOMWidget<object | string>)
|
addWidget(node, widget as BaseDOMWidget<object | string>)
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import { addValueControlWidgets } from '@/scripts/widgets'
|
|||||||
|
|
||||||
import { useRemoteWidget } from './useRemoteWidget'
|
import { useRemoteWidget } from './useRemoteWidget'
|
||||||
|
|
||||||
const PADDING = 8
|
|
||||||
|
|
||||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||||
if (inputSpec.default) return inputSpec.default
|
if (inputSpec.default) return inputSpec.default
|
||||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||||
@@ -51,8 +49,10 @@ export const useDropdownComboWidget = (
|
|||||||
widgetValue.value = value
|
widgetValue.value = value
|
||||||
},
|
},
|
||||||
|
|
||||||
// Optional: minimum height for the widget (dropdown needs some height)
|
// Optional: minimum height for the widget (dropdown needs minimal height)
|
||||||
getMinHeight: () => 48 + PADDING,
|
getMinHeight: () => 48,
|
||||||
|
// Lock maximum height to prevent oversizing
|
||||||
|
getMaxHeight: () => 64,
|
||||||
|
|
||||||
// Optional: whether to serialize this widget's value
|
// Optional: whether to serialize this widget's value
|
||||||
serialize: true
|
serialize: true
|
||||||
|
|||||||
@@ -1,317 +1,53 @@
|
|||||||
import {
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
BaseWidget,
|
import { ref } from 'vue'
|
||||||
type CanvasPointer,
|
|
||||||
type LGraphNode,
|
|
||||||
LiteGraph
|
|
||||||
} from '@comfyorg/litegraph'
|
|
||||||
import type {
|
|
||||||
IBaseWidget,
|
|
||||||
IWidgetOptions
|
|
||||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
|
||||||
|
|
||||||
|
import ImagePreviewWidget from '@/components/graph/widgets/ImagePreviewWidget.vue'
|
||||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
import { app } from '@/scripts/app'
|
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||||
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
|
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||||
import { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
|
||||||
import { useCanvasStore } from '@/stores/graphStore'
|
|
||||||
import { useSettingStore } from '@/stores/settingStore'
|
|
||||||
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
|
|
||||||
|
|
||||||
const renderPreview = (
|
const PADDING = 8
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
node: LGraphNode,
|
export const useImagePreviewWidget = (
|
||||||
shiftY: number
|
options: { defaultValue?: string | string[] } = {}
|
||||||
) => {
|
) => {
|
||||||
const canvas = useCanvasStore().getCanvas()
|
|
||||||
const mouse = canvas.graph_mouse
|
|
||||||
|
|
||||||
if (!canvas.pointer_is_down && node.pointerDown) {
|
|
||||||
if (
|
|
||||||
mouse[0] === node.pointerDown.pos[0] &&
|
|
||||||
mouse[1] === node.pointerDown.pos[1]
|
|
||||||
) {
|
|
||||||
node.imageIndex = node.pointerDown.index
|
|
||||||
}
|
|
||||||
node.pointerDown = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const imgs = node.imgs ?? []
|
|
||||||
let { imageIndex } = node
|
|
||||||
const numImages = imgs.length
|
|
||||||
if (numImages === 1 && !imageIndex) {
|
|
||||||
// This skips the thumbnail render section below
|
|
||||||
node.imageIndex = imageIndex = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
|
||||||
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
|
|
||||||
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
|
|
||||||
const dw = node.size[0]
|
|
||||||
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
|
|
||||||
|
|
||||||
if (imageIndex == null) {
|
|
||||||
// No image selected; draw thumbnails of all
|
|
||||||
let cellWidth: number
|
|
||||||
let cellHeight: number
|
|
||||||
let shiftX: number
|
|
||||||
let cell_padding: number
|
|
||||||
let cols: number
|
|
||||||
|
|
||||||
const compact_mode = is_all_same_aspect_ratio(imgs)
|
|
||||||
if (!compact_mode) {
|
|
||||||
// use rectangle cell style and border line
|
|
||||||
cell_padding = 2
|
|
||||||
// Prevent infinite canvas2d scale-up
|
|
||||||
const largestDimension = imgs.reduce(
|
|
||||||
(acc, current) =>
|
|
||||||
Math.max(acc, current.naturalWidth, current.naturalHeight),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
const fakeImgs = []
|
|
||||||
fakeImgs.length = imgs.length
|
|
||||||
fakeImgs[0] = {
|
|
||||||
naturalWidth: largestDimension,
|
|
||||||
naturalHeight: largestDimension
|
|
||||||
}
|
|
||||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
|
||||||
fakeImgs,
|
|
||||||
dw,
|
|
||||||
dh
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
cell_padding = 0
|
|
||||||
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
|
|
||||||
imgs,
|
|
||||||
dw,
|
|
||||||
dh
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
let anyHovered = false
|
|
||||||
node.imageRects = []
|
|
||||||
for (let i = 0; i < numImages; i++) {
|
|
||||||
const img = imgs[i]
|
|
||||||
const row = Math.floor(i / cols)
|
|
||||||
const col = i % cols
|
|
||||||
const x = col * cellWidth + shiftX
|
|
||||||
const y = row * cellHeight + shiftY
|
|
||||||
if (!anyHovered) {
|
|
||||||
anyHovered = LiteGraph.isInsideRectangle(
|
|
||||||
mouse[0],
|
|
||||||
mouse[1],
|
|
||||||
x + node.pos[0],
|
|
||||||
y + node.pos[1],
|
|
||||||
cellWidth,
|
|
||||||
cellHeight
|
|
||||||
)
|
|
||||||
if (anyHovered) {
|
|
||||||
node.overIndex = i
|
|
||||||
let value = 110
|
|
||||||
if (canvas.pointer_is_down) {
|
|
||||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
|
||||||
node.pointerDown = { index: i, pos: [...mouse] }
|
|
||||||
}
|
|
||||||
value = 125
|
|
||||||
}
|
|
||||||
ctx.filter = `contrast(${value}%) brightness(${value}%)`
|
|
||||||
canvas.canvas.style.cursor = 'pointer'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
node.imageRects.push([x, y, cellWidth, cellHeight])
|
|
||||||
|
|
||||||
const wratio = cellWidth / img.width
|
|
||||||
const hratio = cellHeight / img.height
|
|
||||||
const ratio = Math.min(wratio, hratio)
|
|
||||||
|
|
||||||
const imgHeight = ratio * img.height
|
|
||||||
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
|
|
||||||
const imgWidth = ratio * img.width
|
|
||||||
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
|
|
||||||
|
|
||||||
ctx.drawImage(
|
|
||||||
img,
|
|
||||||
imgX + cell_padding,
|
|
||||||
imgY + cell_padding,
|
|
||||||
imgWidth - cell_padding * 2,
|
|
||||||
imgHeight - cell_padding * 2
|
|
||||||
)
|
|
||||||
if (!compact_mode) {
|
|
||||||
// rectangle cell and border line style
|
|
||||||
ctx.strokeStyle = '#8F8F8F'
|
|
||||||
ctx.lineWidth = 1
|
|
||||||
ctx.strokeRect(
|
|
||||||
x + cell_padding,
|
|
||||||
y + cell_padding,
|
|
||||||
cellWidth - cell_padding * 2,
|
|
||||||
cellHeight - cell_padding * 2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.filter = 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anyHovered) {
|
|
||||||
node.pointerDown = null
|
|
||||||
node.overIndex = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Draw individual
|
|
||||||
const img = imgs[imageIndex]
|
|
||||||
let w = img.naturalWidth
|
|
||||||
let h = img.naturalHeight
|
|
||||||
|
|
||||||
const scaleX = dw / w
|
|
||||||
const scaleY = dh / h
|
|
||||||
const scale = Math.min(scaleX, scaleY, 1)
|
|
||||||
|
|
||||||
w *= scale
|
|
||||||
h *= scale
|
|
||||||
|
|
||||||
const x = (dw - w) / 2
|
|
||||||
const y = (dh - h) / 2 + shiftY
|
|
||||||
ctx.drawImage(img, x, y, w, h)
|
|
||||||
|
|
||||||
// Draw image size text below the image
|
|
||||||
if (allowImageSizeDraw) {
|
|
||||||
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.font = '10px sans-serif'
|
|
||||||
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
|
|
||||||
const textY = y + h + 10
|
|
||||||
ctx.fillText(sizeText, x + w / 2, textY)
|
|
||||||
}
|
|
||||||
|
|
||||||
const drawButton = (
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
sz: number,
|
|
||||||
text: string
|
|
||||||
): boolean => {
|
|
||||||
const hovered = LiteGraph.isInsideRectangle(
|
|
||||||
mouse[0],
|
|
||||||
mouse[1],
|
|
||||||
x + node.pos[0],
|
|
||||||
y + node.pos[1],
|
|
||||||
sz,
|
|
||||||
sz
|
|
||||||
)
|
|
||||||
let fill = '#333'
|
|
||||||
let textFill = '#fff'
|
|
||||||
let isClicking = false
|
|
||||||
if (hovered) {
|
|
||||||
canvas.canvas.style.cursor = 'pointer'
|
|
||||||
if (canvas.pointer_is_down) {
|
|
||||||
fill = '#1e90ff'
|
|
||||||
isClicking = true
|
|
||||||
} else {
|
|
||||||
fill = '#eee'
|
|
||||||
textFill = '#000'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.fillStyle = fill
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.roundRect(x, y, sz, sz, [4])
|
|
||||||
ctx.fill()
|
|
||||||
ctx.fillStyle = textFill
|
|
||||||
ctx.font = '12px Arial'
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.fillText(text, x + 15, y + 20)
|
|
||||||
|
|
||||||
return isClicking
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(numImages > 1)) return
|
|
||||||
|
|
||||||
const imageNum = (node.imageIndex ?? 0) + 1
|
|
||||||
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
|
|
||||||
const i = imageNum >= numImages ? 0 : imageNum
|
|
||||||
if (!node.pointerDown || node.pointerDown.index !== i) {
|
|
||||||
node.pointerDown = { index: i, pos: [...mouse] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
|
|
||||||
if (!node.pointerDown || node.pointerDown.index !== null) {
|
|
||||||
node.pointerDown = { index: null, pos: [...mouse] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ImagePreviewWidget extends BaseWidget {
|
|
||||||
constructor(
|
|
||||||
node: LGraphNode,
|
|
||||||
name: string,
|
|
||||||
options: IWidgetOptions<string | object>
|
|
||||||
) {
|
|
||||||
const widget: IBaseWidget = {
|
|
||||||
name,
|
|
||||||
options,
|
|
||||||
type: 'custom',
|
|
||||||
/** Dummy value to satisfy type requirements. */
|
|
||||||
value: '',
|
|
||||||
y: 0
|
|
||||||
}
|
|
||||||
super(widget, node)
|
|
||||||
|
|
||||||
// Don't serialize the widget value
|
|
||||||
this.serialize = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override drawWidget(ctx: CanvasRenderingContext2D): void {
|
|
||||||
renderPreview(ctx, this.node, this.y)
|
|
||||||
}
|
|
||||||
|
|
||||||
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
|
|
||||||
pointer.onDragStart = () => {
|
|
||||||
const { canvas } = app
|
|
||||||
const { graph } = canvas
|
|
||||||
canvas.emitBeforeChange()
|
|
||||||
graph?.beforeChange()
|
|
||||||
// Ensure that dragging is properly cleaned up, on success or failure.
|
|
||||||
pointer.finally = () => {
|
|
||||||
canvas.isDragging = false
|
|
||||||
graph?.afterChange()
|
|
||||||
canvas.emitAfterChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.processSelect(node, pointer.eDown)
|
|
||||||
canvas.isDragging = true
|
|
||||||
}
|
|
||||||
|
|
||||||
pointer.onDragEnd = (e) => {
|
|
||||||
const { canvas } = app
|
|
||||||
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
|
|
||||||
canvas.graph?.snapToGrid(canvas.selectedItems)
|
|
||||||
|
|
||||||
canvas.setDirty(true, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override onClick(): void {}
|
|
||||||
|
|
||||||
override computeLayoutSize() {
|
|
||||||
return {
|
|
||||||
minHeight: 220,
|
|
||||||
minWidth: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useImagePreviewWidget = () => {
|
|
||||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||||
node: LGraphNode,
|
node: LGraphNode,
|
||||||
inputSpec: InputSpec
|
inputSpec: InputSpec
|
||||||
) => {
|
) => {
|
||||||
return node.addCustomWidget(
|
// Initialize widget value
|
||||||
new ImagePreviewWidget(node, inputSpec.name, {
|
const widgetValue = ref<string | string[]>(
|
||||||
serialize: false
|
options.defaultValue ?? (inputSpec.allow_batch ? [] : '')
|
||||||
})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Create the Vue-based widget instance
|
||||||
|
const widget = new ComponentWidgetImpl<string | string[]>({
|
||||||
|
node,
|
||||||
|
name: inputSpec.name,
|
||||||
|
component: ImagePreviewWidget,
|
||||||
|
inputSpec,
|
||||||
|
options: {
|
||||||
|
// Required: getter for widget value
|
||||||
|
getValue: () => widgetValue.value,
|
||||||
|
|
||||||
|
// Required: setter for widget value
|
||||||
|
setValue: (value: string | string[]) => {
|
||||||
|
widgetValue.value = value
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optional: minimum height for the widget
|
||||||
|
getMinHeight: () => 320 + PADDING,
|
||||||
|
getMaxHeight: () => 512 + PADDING,
|
||||||
|
|
||||||
|
// Optional: whether to serialize this widget's value
|
||||||
|
serialize: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register the widget with the node
|
||||||
|
addWidget(node, widget as any)
|
||||||
|
|
||||||
|
return widget
|
||||||
}
|
}
|
||||||
|
|
||||||
return widgetConstructor
|
return widgetConstructor
|
||||||
|
|||||||
@@ -1,21 +1,44 @@
|
|||||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
|
||||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||||
import { useValueTransform } from '@/composables/useValueTransform'
|
import { useValueTransform } from '@/composables/useValueTransform'
|
||||||
import type { ResultItem } from '@/schemas/apiSchema'
|
import type { ResultItem } from '@/schemas/apiSchema'
|
||||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||||
|
import { useToastStore } from '@/stores/toastStore'
|
||||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||||
import { addToComboValues } from '@/utils/litegraphUtil'
|
import { addToComboValues } from '@/utils/litegraphUtil'
|
||||||
|
|
||||||
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
|
||||||
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
|
||||||
|
const PASTED_IMAGE_EXPIRY_MS = 2000
|
||||||
|
|
||||||
|
const uploadFile = async (file: File, isPasted: boolean) => {
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('image', file)
|
||||||
|
if (isPasted) body.append('subfolder', 'pasted')
|
||||||
|
|
||||||
|
const resp = await api.fetchApi('/upload/image', {
|
||||||
|
method: 'POST',
|
||||||
|
body
|
||||||
|
})
|
||||||
|
|
||||||
|
if (resp.status !== 200) {
|
||||||
|
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json()
|
||||||
|
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||||
|
}
|
||||||
|
|
||||||
type InternalFile = string | ResultItem
|
type InternalFile = string | ResultItem
|
||||||
type InternalValue = InternalFile | InternalFile[]
|
type InternalValue = InternalFile | InternalFile[]
|
||||||
@@ -43,6 +66,7 @@ export const useImageUploadMediaWidget = () => {
|
|||||||
const isVideo = !!inputOptions.video_upload
|
const isVideo = !!inputOptions.video_upload
|
||||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||||
|
const { showImagePreview } = useNodeImagePreview()
|
||||||
|
|
||||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||||
// @ts-expect-error InputSpec is not typed correctly
|
// @ts-expect-error InputSpec is not typed correctly
|
||||||
@@ -72,7 +96,8 @@ export const useImageUploadMediaWidget = () => {
|
|||||||
|
|
||||||
// Handle widget dimensions based on input options
|
// Handle widget dimensions based on input options
|
||||||
const getMinHeight = () => {
|
const getMinHeight = () => {
|
||||||
let baseHeight = 200
|
// Use smaller height for MediaLoader upload widget
|
||||||
|
let baseHeight = 176
|
||||||
|
|
||||||
// Handle multiline attribute for expanded height
|
// Handle multiline attribute for expanded height
|
||||||
if (inputOptions.multiline) {
|
if (inputOptions.multiline) {
|
||||||
@@ -92,6 +117,19 @@ export const useImageUploadMediaWidget = () => {
|
|||||||
return baseHeight + 8 // Add padding
|
return baseHeight + 8 // Add padding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMaxHeight = () => {
|
||||||
|
// Lock maximum height to prevent oversizing of upload widget
|
||||||
|
if (inputOptions.multiline || inputOptions.min_height) {
|
||||||
|
// Allow more height for special cases
|
||||||
|
return Math.max(200, getMinHeight())
|
||||||
|
}
|
||||||
|
// Lock standard upload widget to ~80px max
|
||||||
|
return 80
|
||||||
|
}
|
||||||
|
|
||||||
|
// State for MediaLoader widget
|
||||||
|
const uploadedFiles = ref<string[]>([])
|
||||||
|
|
||||||
// Create the MediaLoader widget directly
|
// Create the MediaLoader widget directly
|
||||||
const uploadWidget = new ComponentWidgetImpl<string[], { accept?: string }>(
|
const uploadWidget = new ComponentWidgetImpl<string[], { accept?: string }>(
|
||||||
{
|
{
|
||||||
@@ -103,33 +141,48 @@ export const useImageUploadMediaWidget = () => {
|
|||||||
accept
|
accept
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
getValue: () => [],
|
getValue: () => uploadedFiles.value,
|
||||||
setValue: () => {},
|
setValue: (value: string[]) => {
|
||||||
|
uploadedFiles.value = value
|
||||||
|
},
|
||||||
getMinHeight,
|
getMinHeight,
|
||||||
|
getMaxHeight, // Lock maximum height to prevent oversizing
|
||||||
serialize: false,
|
serialize: false,
|
||||||
onFilesSelected: async (files: File[]) => {
|
onFilesSelected: async (files: File[]) => {
|
||||||
// Use the existing upload infrastructure
|
const isPastedFile = (file: File): boolean =>
|
||||||
const { handleUpload } = useNodeImageUpload(node, {
|
file.name === 'image.png' &&
|
||||||
// @ts-expect-error InputSpec is not typed correctly
|
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
|
||||||
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
|
const handleUpload = async (file: File) => {
|
||||||
for (const file of files) {
|
try {
|
||||||
if (fileFilter(file)) {
|
const path = await uploadFile(file, isPastedFile(file))
|
||||||
await handleUpload(file)
|
if (!path) return
|
||||||
|
return path
|
||||||
|
} catch (error) {
|
||||||
|
useToastStore().addAlert(String(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter and upload files
|
||||||
|
const filteredFiles = files.filter(fileFilter)
|
||||||
|
const paths = await Promise.all(filteredFiles.map(handleUpload))
|
||||||
|
const validPaths = paths.filter((p): p is string => !!p)
|
||||||
|
|
||||||
|
if (validPaths.length) {
|
||||||
|
validPaths.forEach((path) =>
|
||||||
|
addToComboValues(fileComboWidget, path)
|
||||||
|
)
|
||||||
|
|
||||||
|
const output = allow_batch ? validPaths : validPaths[0]
|
||||||
|
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||||
|
fileComboWidget.value = output
|
||||||
|
|
||||||
|
// Update widget value to show file names
|
||||||
|
uploadedFiles.value = Array.isArray(output) ? output : [output]
|
||||||
|
|
||||||
|
// Trigger the combo widget callback to update all dependent widgets
|
||||||
|
fileComboWidget.callback?.(output)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
@@ -138,11 +191,27 @@ export const useImageUploadMediaWidget = () => {
|
|||||||
// Register the widget with the node
|
// Register the widget with the node
|
||||||
addWidget(node, uploadWidget as any)
|
addWidget(node, uploadWidget as any)
|
||||||
|
|
||||||
|
// Store the original callback if it exists
|
||||||
|
const originalCallback = fileComboWidget.callback
|
||||||
|
|
||||||
// Add our own callback to the combo widget to render an image when it changes
|
// Add our own callback to the combo widget to render an image when it changes
|
||||||
fileComboWidget.callback = function () {
|
fileComboWidget.callback = function (value?: any) {
|
||||||
|
// Call original callback first if it exists
|
||||||
|
originalCallback?.call(this, value)
|
||||||
|
|
||||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||||
isAnimated
|
isAnimated
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Use Vue widget for image preview, fallback to DOM widget for video
|
||||||
|
if (!isVideo) {
|
||||||
|
showImagePreview(node, fileComboWidget.value, {
|
||||||
|
allow_batch: allow_batch as boolean,
|
||||||
|
image_folder: image_folder as string,
|
||||||
|
imageInputName: imageInputName as string
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
node.graph?.setDirtyCanvas(true)
|
node.graph?.setDirtyCanvas(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +222,17 @@ export const useImageUploadMediaWidget = () => {
|
|||||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||||
isAnimated
|
isAnimated
|
||||||
})
|
})
|
||||||
showPreview({ block: false })
|
|
||||||
|
// Use appropriate preview method
|
||||||
|
if (isVideo) {
|
||||||
|
showPreview({ block: false })
|
||||||
|
} else {
|
||||||
|
showImagePreview(node, fileComboWidget.value, {
|
||||||
|
allow_batch: allow_batch as boolean,
|
||||||
|
image_folder: image_folder as string,
|
||||||
|
imageInputName: imageInputName as string
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return { widget: uploadWidget }
|
return { widget: uploadWidget }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { LGraphNode } from '@comfyorg/litegraph'
|
|||||||
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||||
|
|
||||||
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
|
||||||
|
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||||
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
|
||||||
import { useValueTransform } from '@/composables/useValueTransform'
|
import { useValueTransform } from '@/composables/useValueTransform'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
@@ -41,6 +42,7 @@ export const useImageUploadWidget = () => {
|
|||||||
const isVideo = !!inputOptions.video_upload
|
const isVideo = !!inputOptions.video_upload
|
||||||
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
|
||||||
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
|
||||||
|
const { showImagePreview } = useNodeImagePreview()
|
||||||
|
|
||||||
const fileFilter = isVideo ? isVideoFile : isImageFile
|
const fileFilter = isVideo ? isVideoFile : isImageFile
|
||||||
// @ts-expect-error InputSpec is not typed correctly
|
// @ts-expect-error InputSpec is not typed correctly
|
||||||
@@ -96,6 +98,16 @@ export const useImageUploadWidget = () => {
|
|||||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||||
isAnimated
|
isAnimated
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Use Vue widget for image preview, fallback to DOM widget for video
|
||||||
|
if (!isVideo) {
|
||||||
|
showImagePreview(node, fileComboWidget.value, {
|
||||||
|
allow_batch: allow_batch as boolean,
|
||||||
|
image_folder: image_folder as string,
|
||||||
|
imageInputName: imageInputName as string
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
node.graph?.setDirtyCanvas(true)
|
node.graph?.setDirtyCanvas(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +118,17 @@ export const useImageUploadWidget = () => {
|
|||||||
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
|
||||||
isAnimated
|
isAnimated
|
||||||
})
|
})
|
||||||
showPreview({ block: false })
|
|
||||||
|
// Use appropriate preview method
|
||||||
|
if (isVideo) {
|
||||||
|
showPreview({ block: false })
|
||||||
|
} else {
|
||||||
|
showImagePreview(node, fileComboWidget.value, {
|
||||||
|
allow_batch: allow_batch as boolean,
|
||||||
|
image_folder: image_folder as string,
|
||||||
|
imageInputName: imageInputName as string
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return { widget: uploadWidget }
|
return { widget: uploadWidget }
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ export const useMediaLoaderWidget = (options: MediaLoaderOptions = {}) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Optional: minimum height for the widget
|
// Optional: minimum height for the widget
|
||||||
getMinHeight: () => (options.minHeight ?? 500) + PADDING,
|
// getMinHeight: () => (options.minHeight ?? 64) + PADDING,
|
||||||
|
getMaxHeight: () => 225 + PADDING,
|
||||||
|
getMinHeight: () => 176 + PADDING,
|
||||||
|
|
||||||
// Optional: whether to serialize this widget's value
|
// Optional: whether to serialize this widget's value
|
||||||
serialize: true,
|
serialize: true,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
|
||||||
import { useImageUploadMediaWidget } from '@/composables/widgets/useImageUploadMediaWidget'
|
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 { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
|
||||||
@@ -292,5 +293,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
|||||||
COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget()),
|
COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget()),
|
||||||
IMAGEUPLOAD: useImageUploadMediaWidget(),
|
IMAGEUPLOAD: useImageUploadMediaWidget(),
|
||||||
MEDIA_LOADER: transformWidgetConstructorV2ToV1(useMediaLoaderWidget()),
|
MEDIA_LOADER: transformWidgetConstructorV2ToV1(useMediaLoaderWidget()),
|
||||||
|
IMAGEPREVIEW: transformWidgetConstructorV2ToV1(useImagePreviewWidget()),
|
||||||
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput())
|
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -552,7 +552,13 @@ export const useLitegraphService = () => {
|
|||||||
showAnimatedPreview(this)
|
showAnimatedPreview(this)
|
||||||
} else {
|
} else {
|
||||||
removeAnimatedPreview(this)
|
removeAnimatedPreview(this)
|
||||||
showCanvasImagePreview(this)
|
// Only show canvas image preview if we don't already have a Vue image preview widget
|
||||||
|
const hasVueImagePreview = this.widgets?.some(
|
||||||
|
(w) => w.name === '$$node-image-preview' || w.type === 'IMAGEPREVIEW'
|
||||||
|
)
|
||||||
|
if (!hasVueImagePreview) {
|
||||||
|
showCanvasImagePreview(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
55
tests-ui/composables/useImagePreviewWidget.test.ts
Normal file
55
tests-ui/composables/useImagePreviewWidget.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
|
||||||
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/scripts/domWidget', () => ({
|
||||||
|
ComponentWidgetImpl: vi.fn().mockImplementation(() => ({
|
||||||
|
name: 'test-widget',
|
||||||
|
value: ''
|
||||||
|
})),
|
||||||
|
addWidget: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useImagePreviewWidget', () => {
|
||||||
|
const mockNode = {
|
||||||
|
id: 'test-node',
|
||||||
|
widgets: []
|
||||||
|
} as unknown as LGraphNode
|
||||||
|
|
||||||
|
const mockInputSpec: InputSpec = {
|
||||||
|
name: 'image_preview',
|
||||||
|
type: 'IMAGEPREVIEW',
|
||||||
|
allow_batch: true,
|
||||||
|
image_folder: 'input'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates widget constructor with default options', () => {
|
||||||
|
const constructor = useImagePreviewWidget()
|
||||||
|
expect(constructor).toBeDefined()
|
||||||
|
expect(typeof constructor).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates widget with custom default value', () => {
|
||||||
|
const constructor = useImagePreviewWidget({
|
||||||
|
defaultValue: 'test-image.png'
|
||||||
|
})
|
||||||
|
expect(constructor).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates widget with array default value for batch mode', () => {
|
||||||
|
const constructor = useImagePreviewWidget({
|
||||||
|
defaultValue: ['image1.png', 'image2.png']
|
||||||
|
})
|
||||||
|
expect(constructor).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls constructor with node and inputSpec', () => {
|
||||||
|
const constructor = useImagePreviewWidget()
|
||||||
|
const widget = constructor(mockNode, mockInputSpec)
|
||||||
|
|
||||||
|
expect(widget).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
69
tests-ui/composables/useNodeImagePreview.test.ts
Normal file
69
tests-ui/composables/useNodeImagePreview.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/composables/widgets/useImagePreviewWidget', () => ({
|
||||||
|
useImagePreviewWidget: vi.fn(() =>
|
||||||
|
vi.fn(() => ({
|
||||||
|
name: '$$node-image-preview',
|
||||||
|
value: '',
|
||||||
|
onRemove: vi.fn()
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useNodeImagePreview', () => {
|
||||||
|
const mockNode = {
|
||||||
|
id: 'test-node',
|
||||||
|
widgets: [],
|
||||||
|
setDirtyCanvas: vi.fn()
|
||||||
|
} as unknown as LGraphNode
|
||||||
|
|
||||||
|
it('provides showImagePreview and removeImagePreview functions', () => {
|
||||||
|
const { showImagePreview, removeImagePreview } = useNodeImagePreview()
|
||||||
|
|
||||||
|
expect(showImagePreview).toBeDefined()
|
||||||
|
expect(removeImagePreview).toBeDefined()
|
||||||
|
expect(typeof showImagePreview).toBe('function')
|
||||||
|
expect(typeof removeImagePreview).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows image preview for single image', () => {
|
||||||
|
const { showImagePreview } = useNodeImagePreview()
|
||||||
|
|
||||||
|
showImagePreview(mockNode, 'test-image.png')
|
||||||
|
|
||||||
|
expect(mockNode.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows image preview for multiple images', () => {
|
||||||
|
const { showImagePreview } = useNodeImagePreview()
|
||||||
|
|
||||||
|
showImagePreview(mockNode, ['image1.png', 'image2.png'], {
|
||||||
|
allow_batch: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockNode.setDirtyCanvas).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes image preview widget', () => {
|
||||||
|
const mockWidget = {
|
||||||
|
name: '$$node-image-preview',
|
||||||
|
onRemove: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeWithWidget = {
|
||||||
|
...mockNode,
|
||||||
|
widgets: [mockWidget]
|
||||||
|
} as unknown as LGraphNode
|
||||||
|
|
||||||
|
const { removeImagePreview } = useNodeImagePreview()
|
||||||
|
|
||||||
|
removeImagePreview(nodeWithWidget)
|
||||||
|
|
||||||
|
expect(mockWidget.onRemove).toHaveBeenCalled()
|
||||||
|
expect(nodeWithWidget.widgets).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user