mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-21 21:09:00 +00:00
Compare commits
6 Commits
johnpaul/c
...
feat/app-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
548ee12e82 | ||
|
|
c73cda9798 | ||
|
|
450c3b74e6 | ||
|
|
e5e0c22454 | ||
|
|
c1feb75eb9 | ||
|
|
34390412dc |
@@ -3,6 +3,7 @@ import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { buildDropIndicator } from '@/components/builder/dropIndicatorUtil'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
@@ -12,15 +13,11 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
@@ -101,30 +98,11 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
})
|
||||
|
||||
function getDropIndicator(node: LGraphNode) {
|
||||
if (node.type !== 'LoadImage') return undefined
|
||||
|
||||
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
|
||||
|
||||
const { filename, subfolder, type } = stringValue
|
||||
? parseImageWidgetValue(stringValue)
|
||||
: { filename: '', subfolder: '', type: 'input' }
|
||||
|
||||
const buildImageUrl = () => {
|
||||
if (!filename) return undefined
|
||||
const params = new URLSearchParams({ filename, subfolder, type })
|
||||
appendCloudResParam(params, filename)
|
||||
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
|
||||
}
|
||||
|
||||
const imageUrl = buildImageUrl()
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl,
|
||||
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined),
|
||||
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
|
||||
}
|
||||
return buildDropIndicator(node, {
|
||||
imageLabel: mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
videoLabel: mobile ? undefined : t('linearMode.dragAndDropVideo'),
|
||||
openMaskEditor: maskEditor.openMaskEditor
|
||||
})
|
||||
}
|
||||
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
|
||||
138
src/components/builder/dropIndicatorUtil.test.ts
Normal file
138
src/components/builder/dropIndicatorUtil.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import { buildDropIndicator } from './dropIndicatorUtil'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { apiURL: (path: string) => `http://localhost:8188${path}` }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { getPreviewFormatParam: () => '&format=webp' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn()
|
||||
}))
|
||||
|
||||
function makeNode(type: string, widgetValue?: unknown): LGraphNode {
|
||||
return {
|
||||
type,
|
||||
widgets:
|
||||
widgetValue !== undefined
|
||||
? [{ value: widgetValue }, { callback: vi.fn() }]
|
||||
: undefined
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('buildDropIndicator', () => {
|
||||
it('returns undefined for unsupported node types', () => {
|
||||
expect(buildDropIndicator(makeNode('KSampler'), {})).toBeUndefined()
|
||||
expect(buildDropIndicator(makeNode('CLIPTextEncode'), {})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns image indicator for LoadImage node with filename', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadImage', 'photo.png'), {
|
||||
imageLabel: 'Upload'
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.iconClass).toBe('icon-[lucide--image]')
|
||||
expect(result!.imageUrl).toContain('/view?')
|
||||
expect(result!.imageUrl).toContain('filename=photo.png')
|
||||
expect(result!.label).toBe('Upload')
|
||||
})
|
||||
|
||||
it('downloads the original image, not the preview rendition', async () => {
|
||||
const { downloadFile } = vi.mocked(
|
||||
await import('@/base/common/downloadUtil')
|
||||
)
|
||||
const result = buildDropIndicator(makeNode('LoadImage', 'photo.png'), {})
|
||||
|
||||
expect(result!.imageUrl).toContain('&format=webp')
|
||||
expect(result!.onDownload).toBeDefined()
|
||||
|
||||
result!.onDownload!()
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
expect.not.stringContaining('format=webp')
|
||||
)
|
||||
})
|
||||
|
||||
it('returns image indicator with no imageUrl when widget has no value', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadImage', ''), {})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.imageUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns image indicator with no imageUrl when widgets are missing', () => {
|
||||
const node = { type: 'LoadImage' } as unknown as LGraphNode
|
||||
const result = buildDropIndicator(node, {})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.imageUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes onMaskEdit when imageUrl exists and openMaskEditor is provided', () => {
|
||||
const openMaskEditor = vi.fn()
|
||||
const node = makeNode('LoadImage', 'photo.png')
|
||||
const result = buildDropIndicator(node, { openMaskEditor })
|
||||
|
||||
expect(result!.onMaskEdit).toBeDefined()
|
||||
result!.onMaskEdit!()
|
||||
expect(openMaskEditor).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('omits onMaskEdit when no imageUrl', () => {
|
||||
const openMaskEditor = vi.fn()
|
||||
const result = buildDropIndicator(makeNode('LoadImage', ''), {
|
||||
openMaskEditor
|
||||
})
|
||||
|
||||
expect(result!.onMaskEdit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns video indicator for LoadVideo node with filename', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadVideo', 'clip.mp4'), {
|
||||
videoLabel: 'Upload Video'
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.iconClass).toBe('icon-[lucide--video]')
|
||||
expect(result!.videoUrl).toContain('/view?')
|
||||
expect(result!.videoUrl).toContain('filename=clip.mp4')
|
||||
expect(result!.label).toBe('Upload Video')
|
||||
expect(result!.onMaskEdit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns video indicator with no videoUrl when widget has no value', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadVideo', ''), {})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.videoUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses subfolder and type from widget value', () => {
|
||||
const result = buildDropIndicator(
|
||||
makeNode('LoadImage', 'sub/folder/image.png [output]'),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(result!.imageUrl).toContain('filename=image.png')
|
||||
expect(result!.imageUrl).toContain('subfolder=sub%2Ffolder')
|
||||
expect(result!.imageUrl).toContain('type=output')
|
||||
})
|
||||
|
||||
it('invokes widget callback on onClick', () => {
|
||||
const node = makeNode('LoadImage', 'photo.png')
|
||||
const result = buildDropIndicator(node, {})
|
||||
|
||||
result!.onClick!({} as MouseEvent)
|
||||
expect(node.widgets![1].callback).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
125
src/components/builder/dropIndicatorUtil.ts
Normal file
125
src/components/builder/dropIndicatorUtil.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
|
||||
export interface DropIndicatorData {
|
||||
iconClass: string
|
||||
imageUrl?: string
|
||||
videoUrl?: string
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
onMaskEdit?: () => void
|
||||
onDownload?: () => void
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
function parseNodeMediaValue(node: LGraphNode) {
|
||||
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
|
||||
return stringValue
|
||||
? parseImageWidgetValue(stringValue)
|
||||
: { filename: '', subfolder: '', type: 'input' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DropZone indicator for LoadImage or LoadVideo nodes.
|
||||
* Returns undefined for other node types.
|
||||
*/
|
||||
export function buildDropIndicator(
|
||||
node: LGraphNode,
|
||||
options: {
|
||||
imageLabel?: string
|
||||
videoLabel?: string
|
||||
openMaskEditor?: (node: LGraphNode) => void
|
||||
}
|
||||
): DropIndicatorData | undefined {
|
||||
if (node.type === 'LoadImage') {
|
||||
return buildImageDropIndicator(node, options)
|
||||
}
|
||||
|
||||
if (node.type === 'LoadVideo') {
|
||||
return buildVideoDropIndicator(node, options)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Build indicator data for a LoadImage node, including preview URL,
|
||||
* mask editor action, and download/remove using the original asset URL. */
|
||||
function buildImageDropIndicator(
|
||||
node: LGraphNode,
|
||||
options: {
|
||||
imageLabel?: string
|
||||
openMaskEditor?: (node: LGraphNode) => void
|
||||
}
|
||||
): DropIndicatorData {
|
||||
const { filename, subfolder, type } = parseNodeMediaValue(node)
|
||||
|
||||
const rawParams = filename
|
||||
? new URLSearchParams({ filename, subfolder, type })
|
||||
: undefined
|
||||
|
||||
const imageUrl = rawParams
|
||||
? (() => {
|
||||
const params = new URLSearchParams(rawParams)
|
||||
appendCloudResParam(params, filename)
|
||||
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
|
||||
})()
|
||||
: undefined
|
||||
|
||||
const originalUrl = rawParams ? api.apiURL(`/view?${rawParams}`) : undefined
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl,
|
||||
label: options.imageLabel,
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined),
|
||||
onMaskEdit:
|
||||
imageUrl && options.openMaskEditor
|
||||
? () => options.openMaskEditor!(node)
|
||||
: undefined,
|
||||
onDownload: originalUrl ? () => downloadFile(originalUrl) : undefined,
|
||||
onRemove: imageUrl
|
||||
? () => {
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
imageWidget.value = ''
|
||||
imageWidget.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Build indicator data for a LoadVideo node with video preview URL
|
||||
* and download/remove actions. */
|
||||
function buildVideoDropIndicator(
|
||||
node: LGraphNode,
|
||||
options: { videoLabel?: string }
|
||||
): DropIndicatorData {
|
||||
const { filename, subfolder, type } = parseNodeMediaValue(node)
|
||||
|
||||
const videoUrl = filename
|
||||
? api.apiURL(`/view?${new URLSearchParams({ filename, subfolder, type })}`)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--video]',
|
||||
videoUrl,
|
||||
label: options.videoLabel,
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined),
|
||||
onDownload: videoUrl ? () => downloadFile(videoUrl) : undefined,
|
||||
onRemove: videoUrl
|
||||
? () => {
|
||||
const videoWidget = node.widgets?.find((w) => w.name === 'video')
|
||||
if (videoWidget) {
|
||||
videoWidget.value = ''
|
||||
videoWidget.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
VisuallyHidden
|
||||
} from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
@@ -20,6 +21,16 @@ const { src, alt = '' } = defineProps<{
|
||||
alt?: string
|
||||
}>()
|
||||
|
||||
const isVideo = computed(() => {
|
||||
const videoExt = /\.(mp4|webm|mov)/i
|
||||
return (
|
||||
videoExt.test(src) ||
|
||||
videoExt.test(
|
||||
new URL(src, location.href).searchParams.get('filename') ?? ''
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
@@ -46,7 +57,15 @@ const { t } = useI18n()
|
||||
<i class="icon-[lucide--x] size-5" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<video
|
||||
v-if="isVideo"
|
||||
:src
|
||||
controls
|
||||
autoplay
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src
|
||||
:alt
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
|
||||
|
||||
@@ -3282,6 +3282,7 @@
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
"dragAndDropVideo": "Click to browse or drag a video",
|
||||
"mobileControls": "Edit & Run",
|
||||
"runCount": "Number of runs",
|
||||
"rerun": "Rerun",
|
||||
|
||||
@@ -22,9 +22,12 @@ const {
|
||||
dropIndicator?: {
|
||||
iconClass?: string
|
||||
imageUrl?: string
|
||||
videoUrl?: string
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
onMaskEdit?: () => void
|
||||
onDownload?: () => void
|
||||
onRemove?: () => void
|
||||
}
|
||||
forceHovered?: boolean
|
||||
}>()
|
||||
@@ -92,7 +95,8 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
data-slot="drop-zone-indicator"
|
||||
:class="
|
||||
cn(
|
||||
'm-3 block h-25 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
|
||||
'm-3 block w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
|
||||
dropIndicator.videoUrl ? 'h-52' : 'h-25',
|
||||
dropIndicator.onClick && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
@@ -104,18 +108,35 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
cn(
|
||||
'flex h-full max-w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-[7px] p-3 text-center text-sm/tight transition-colors',
|
||||
isHovered &&
|
||||
!dropIndicator.imageUrl &&
|
||||
!(dropIndicator.imageUrl || dropIndicator.videoUrl) &&
|
||||
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div v-if="dropIndicator.imageUrl" class="max-h-full max-w-full">
|
||||
<div
|
||||
v-if="dropIndicator.imageUrl"
|
||||
class="flex size-full items-center justify-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
class="max-h-full max-w-full rounded-md object-contain"
|
||||
:alt="dropIndicator.label ?? ''"
|
||||
:src="dropIndicator.imageUrl"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="dropIndicator.videoUrl"
|
||||
class="flex size-full items-center justify-center overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<video
|
||||
class="max-h-full max-w-full rounded-md object-contain"
|
||||
:src="dropIndicator.videoUrl"
|
||||
preload="metadata"
|
||||
controls
|
||||
loop
|
||||
playsinline
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i
|
||||
@@ -130,7 +151,7 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
</template>
|
||||
</div>
|
||||
</component>
|
||||
<template v-if="dropIndicator.imageUrl">
|
||||
<template v-if="dropIndicator.imageUrl || dropIndicator.videoUrl">
|
||||
<div
|
||||
class="absolute top-2 right-5 z-10 flex gap-1 opacity-0 transition-opacity duration-200 group-focus-within/dropzone:opacity-100 group-hover/dropzone:opacity-100"
|
||||
>
|
||||
@@ -145,6 +166,7 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
<i class="icon-[comfy--mask] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="dropIndicator.imageUrl"
|
||||
type="button"
|
||||
:aria-label="t('mediaAsset.actions.zoom')"
|
||||
:title="t('mediaAsset.actions.zoom')"
|
||||
@@ -153,8 +175,29 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="dropIndicator.onDownload"
|
||||
type="button"
|
||||
:aria-label="t('g.downloadImage')"
|
||||
:title="t('g.downloadImage')"
|
||||
class="flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background transition-colors hover:bg-base-foreground/90"
|
||||
@click.stop="dropIndicator.onDownload()"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="dropIndicator.onRemove"
|
||||
type="button"
|
||||
:aria-label="t('g.removeImage')"
|
||||
:title="t('g.removeImage')"
|
||||
class="flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background transition-colors hover:bg-base-foreground/90"
|
||||
@click.stop="dropIndicator.onRemove()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ImageLightbox
|
||||
v-if="dropIndicator.imageUrl"
|
||||
v-model="lightboxOpen"
|
||||
:src="dropIndicator.imageUrl"
|
||||
:alt="dropIndicator.label ?? ''"
|
||||
|
||||
Reference in New Issue
Block a user