import { useRef, useState } from 'react' import { ArrowLeft, CloudUpload, Eye, Plus, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { MediaSelector, type MediaTab } from '@/components/widgets/mediaSelector/MediaSelector' import { useCanvasScale } from '@/hooks/use-canvas-scale' import { useExpandedImagePreview } from '@/hooks/use-expanded-image-preview' import type { ReactWidgetProps } from '@/lib/create-react-widget' import { LocaleContext, useT } from '@/lib/i18n' import { mediaContentToViewUrl } from '@/lib/media-url' import { createTaskImage, splitSelectedTaskMedia, uploadTaskImageFile } from '@/lib/task-image-utils' import { invalidateMediaListCache } from '@/stores/media-list-store' import type { MultiTrackSourceType, MultiTrackTaskImage } from '@/types/multitrack' export interface ImageData { images: MultiTrackTaskImage[] } const MAX_IMAGES = 25 function selectorTab(image?: MultiTrackTaskImage): MediaTab { if (image?.source_type === 'output') return 'outputs' if (image?.source_type === 'local') return 'local' if (image?.source_type === 'url') return 'url' return 'inputs' } function sourceType(path: string, source?: 'input' | 'output' | 'local'): MultiTrackSourceType { if (source) return source return /^https?:\/\//i.test(path) ? 'url' : 'input' } export function MultiImagesLoaderWidget({ value, onChange, app }: Readonly>) { const locale = app?.ui?.settings?.settingsValues?.['Comfy.Locale'] const data: ImageData = value && Array.isArray(value.images) ? value : { images: [] } const images = data.images.slice(0, MAX_IMAGES) const [selectorOpen, setSelectorOpen] = useState(false) const [replaceId, setReplaceId] = useState(null) const [preview, setPreview] = useState(null) const [dragOver, setDragOver] = useState(false) const dragId = useRef(null) const scale = useCanvasScale(app) const isNodeV2 = Boolean(app?.ui?.settings?.settingsValues?.['Comfy.VueNodes.Enabled']) const activeImage = images.find((image) => image.id === replaceId) const columns = Math.min(5, Math.max(1, Math.ceil(Math.sqrt(images.length + (images.length < MAX_IMAGES ? 1 : 0))))) const controlSize = Math.min(32, Math.max(22, 26 / Math.max(scale, 0.5))) function commit(next: MultiTrackTaskImage[]) { onChange({ images: next.slice(0, MAX_IMAGES) }) } function select(path: string, source?: 'input' | 'output' | 'local') { const selected = splitSelectedTaskMedia(path).slice(0, replaceId ? 1 : MAX_IMAGES - images.length) if (!selected.length) return const incoming = selected.map((item) => createTaskImage(item, sourceType(item, source))) commit(replaceId ? images.map((image) => image.id === replaceId ? { ...incoming[0], id: replaceId } : image) : [...images, ...incoming]) setReplaceId(null) setSelectorOpen(false) } async function dropFiles(files: FileList | null) { if (!files?.length) return const selected = Array.from(files).filter((file) => file.type.startsWith('image/')).slice(0, MAX_IMAGES - images.length) const results = await Promise.allSettled(selected.map(uploadTaskImageFile)) const uploaded: MultiTrackTaskImage[] = [] for (const result of results) { if (result.status === 'fulfilled') uploaded.push(result.value) else console.error('[MultiImagesLoaderWidget] image upload failed:', result.reason) } if (uploaded.length) { invalidateMediaListCache('inputs') commit([...images, ...uploaded]) } } return ( ) } interface ContentProps { images: MultiTrackTaskImage[] columns: number controlSize: number isNodeV2: boolean selectorOpen: boolean setSelectorOpen: (open: boolean) => void replaceId: string | null setReplaceId: (id: string | null) => void activeImage?: MultiTrackTaskImage preview: MultiTrackTaskImage | null setPreview: (image: MultiTrackTaskImage | null) => void dragOver: boolean setDragOver: (over: boolean) => void dragId: React.RefObject commit: (images: MultiTrackTaskImage[]) => void select: (path: string, source?: 'input' | 'output' | 'local') => void dropFiles: (files: FileList | null) => Promise } function MultiImagesLoaderContent(props: Readonly) { const t = useT() const { images, columns, controlSize } = props const previewUrl = props.preview && mediaContentToViewUrl({ ...props.preview, source_type: props.preview.source_type ?? 'input' }) const preview = useExpandedImagePreview(props.preview?.id ?? null, () => props.setPreview(null)) return (
{ event.preventDefault(); props.setDragOver(true) }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) props.setDragOver(false) }} onDrop={(event) => { event.preventDefault() props.setDragOver(false) void props.dropFiles(event.dataTransfer.files) }} > { props.setSelectorOpen(open) if (!open) props.setReplaceId(null) }}> {images.length === 0 ? (
{t('multitrack.selectImage')} {t('multitrack.imageDropHint')}
) : (
{images.map((image, index) => { const url = mediaContentToViewUrl({ ...image, source_type: image.source_type ?? 'input' }) const label = image.file_name ?? image.file_path ?? image.local_path ?? image.url ?? String(index + 1) return (
{ props.dragId.current = image.id }} onDragEnd={() => { props.dragId.current = null }} onDrop={(event) => { if (event.dataTransfer.files.length > 0 || !props.dragId.current) return event.preventDefault() event.stopPropagation() props.setDragOver(false) const sourceId = props.dragId.current if (sourceId === image.id) return const next = [...images] const from = next.findIndex((item) => item.id === sourceId) if (from < 0) return const target = next.findIndex((item) => item.id === image.id) const [moved] = next.splice(from, 1) next.splice(target, 0, moved) props.commit(next) }}> {index + 1}
) })} {images.length < MAX_IMAGES && }
)}
{images.length > 0 && (
{t('multiImagesLoader.imageCount', { n: images.length })}
)} {previewUrl && (
{Math.round(preview.zoom * 100)}% {props.preview?.file_name
)}
) }