mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
App Mode dragAndDrop, text output, and scroll shadows (#10122)
- When in app mode, workflows can be loaded by dragging and dropping as elsewhere. - Dragging a file which is supported by a selected app input to the center panel will apply drop effects on the specific input - This overrides the loading of workflows - There's not currently an indicator for where the image will go. This is being considered for a followup PR - Outputs can be dragged from the assets panel onto nodes - This fixes behaviour outside of app mode as well - Has some thorny implementation specifics - Non-core nodes may not be able to accept these inputs without an update - Node DragOver filtering has reduced functionality when dragging from the assets pane. Nodes may have the blue border without being able to accept a drag operation. - When dropped onto the canvas, the workflow will load (a fix), but the workflow name will be the url of the image preview - The entire card is used for the drag preview <img width="329" height="380" alt="image" src="https://github.com/user-attachments/assets/2945f9a3-3e77-4e14-a812-4a361976390d" /> - Adds a new scroll-shadows tailwind util as an indicator that more content is available by scrolling. - Since a primary goal was preventing API costs overflowing, I've made the indicator fairly strong. This can be tuned later if needed  - Initial support for text outputs in App Mode - Also causes jobs with text outputs to incorrectly display in the assets panel with a generic 'check' icon instead of a text specific icon. This will need a dedicated pass, but shouldn't be overly onerous in the interim. <img width="1209" height="735" alt="text output" src="https://github.com/user-attachments/assets/fcd1cf9f-5d5c-434c-acd0-58d248237b99" /> NOTE: Displaying text outputs conflicted with the changes in #9622. I'll leave text output still disabled in this PR and open a new PR for reconciling text as an output so it can go through dedicated review. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10122-App-Mode-dragAndDrop-text-output-and-scroll-shadows-3256d73d3650810caaf8d75de94388c9) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -1901,3 +1901,37 @@ audio.comfy-audio.empty-audio-widget {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-shadows-* {
|
||||
overflow: auto;
|
||||
|
||||
background:
|
||||
/* Shadow Cover TOP */
|
||||
linear-gradient(--value(--color-*) 30%, transparent) center top,
|
||||
/* Shadow Cover BOTTOM */
|
||||
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
|
||||
/* Shadow TOP */
|
||||
radial-gradient(
|
||||
farthest-side at 50% 0,
|
||||
color-mix(in oklab, --value(--color-*), #777777 35%),
|
||||
60%,
|
||||
transparent
|
||||
)
|
||||
center top,
|
||||
/* Shadow BOTTOM */
|
||||
radial-gradient(
|
||||
farthest-side at 50% 100%,
|
||||
color-mix(in oklab, --value(--color-*), #777777 35%),
|
||||
60%,
|
||||
transparent
|
||||
)
|
||||
center bottom;
|
||||
|
||||
background-repeat: no-repeat;
|
||||
background-size:
|
||||
300% 40px,
|
||||
300% 40px,
|
||||
300% 14px,
|
||||
300% 14px;
|
||||
background-attachment: local, local, scroll, scroll;
|
||||
}
|
||||
|
||||
@@ -137,6 +137,21 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragDrop(e: DragEvent) {
|
||||
for (const { nodeData } of mappedSelections.value) {
|
||||
if (!nodeData?.onDragOver?.(e)) continue
|
||||
|
||||
const rawResult = nodeData?.onDragDrop?.(e)
|
||||
if (rawResult === false) continue
|
||||
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if ((await rawResult) === true) return
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ handleDragDrop })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<template>
|
||||
<span role="status" class="inline-flex">
|
||||
<i
|
||||
v-if="variant === 'loader'"
|
||||
aria-hidden="true"
|
||||
:class="cn('icon-[lucide--loader]', sizeClass)"
|
||||
>
|
||||
<div
|
||||
class="size-full animate-spin bg-conic from-base-foreground from-10% to-muted-foreground to-10%"
|
||||
/>
|
||||
</i>
|
||||
<i
|
||||
v-else
|
||||
aria-hidden="true"
|
||||
:class="cn('icon-[lucide--loader-circle] animate-spin', sizeClass)"
|
||||
/>
|
||||
@@ -15,6 +25,7 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { size } = defineProps<{
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
variant?: 'loader-circle' | 'loader'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DragAndDropOptions<T> {
|
||||
|
||||
/**
|
||||
* Adds drag and drop file handling to a node
|
||||
* Will also resolve 'text/uri-list' to a file before passing
|
||||
*/
|
||||
export const useNodeDragAndDrop = <T>(
|
||||
node: LGraphNode,
|
||||
@@ -21,27 +22,55 @@ export const useNodeDragAndDrop = <T>(
|
||||
const hasFiles = (items: DataTransferItemList) =>
|
||||
!!Array.from(items).find((f) => f.kind === 'file')
|
||||
|
||||
const filterFiles = (files: FileList) => Array.from(files).filter(fileFilter)
|
||||
const filterFiles = (files: FileList | File[]) =>
|
||||
Array.from(files).filter(fileFilter)
|
||||
|
||||
const hasValidFiles = (files: FileList) => filterFiles(files).length > 0
|
||||
|
||||
const isDraggingFiles = (e: DragEvent | undefined) => {
|
||||
if (!e?.dataTransfer?.items) return false
|
||||
return onDragOver?.(e) ?? hasFiles(e.dataTransfer.items)
|
||||
return (
|
||||
onDragOver?.(e) ??
|
||||
(hasFiles(e.dataTransfer.items) ||
|
||||
e?.dataTransfer?.types?.includes('text/uri-list'))
|
||||
)
|
||||
}
|
||||
|
||||
const isDraggingValidFiles = (e: DragEvent | undefined) => {
|
||||
if (!e?.dataTransfer?.files) return false
|
||||
return hasValidFiles(e.dataTransfer.files)
|
||||
if (e?.dataTransfer?.files?.length)
|
||||
return hasValidFiles(e.dataTransfer.files)
|
||||
|
||||
return !!e?.dataTransfer?.getData('text/uri-list')
|
||||
}
|
||||
|
||||
node.onDragOver = isDraggingFiles
|
||||
|
||||
node.onDragDrop = function (e: DragEvent) {
|
||||
node.onDragDrop = async function (e: DragEvent) {
|
||||
if (!isDraggingValidFiles(e)) return false
|
||||
|
||||
const files = filterFiles(e.dataTransfer!.files)
|
||||
void onDrop(files)
|
||||
if (files.length) {
|
||||
await onDrop(files)
|
||||
return true
|
||||
}
|
||||
|
||||
const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '')
|
||||
if (!uri || uri.origin !== location.origin) return false
|
||||
|
||||
try {
|
||||
const resp = await fetch(uri)
|
||||
const fileName = uri?.searchParams?.get('filename')
|
||||
if (!fileName || !resp.ok) return false
|
||||
|
||||
const blob = await resp.blob()
|
||||
const file = new File([blob], fileName, { type: blob.type })
|
||||
const uriFiles = filterFiles([file])
|
||||
if (!uriFiles.length) return false
|
||||
|
||||
await onDrop(uriFiles)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
)
|
||||
"
|
||||
:data-selected="selected"
|
||||
:draggable="true"
|
||||
@click.stop="$emit('click')"
|
||||
@contextmenu.prevent.stop="
|
||||
asset ? emit('context-menu', $event, asset) : undefined
|
||||
"
|
||||
@dragstart="dragStart"
|
||||
>
|
||||
<!-- Top Area: Media Preview -->
|
||||
<div class="relative aspect-square overflow-hidden p-0">
|
||||
@@ -304,4 +306,15 @@ const handleImageLoaded = (width: number, height: number) => {
|
||||
const handleOutputCountClick = () => {
|
||||
emit('output-count-click')
|
||||
}
|
||||
function dragStart(e: DragEvent) {
|
||||
if (!asset?.preview_url) return
|
||||
|
||||
const { dataTransfer } = e
|
||||
if (!dataTransfer) return
|
||||
|
||||
const url = URL.parse(asset.preview_url, location.href)
|
||||
if (!url) return
|
||||
|
||||
dataTransfer.items.add(url.toString(), 'text/uri-list')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
:src="asset.src"
|
||||
:alt="getAssetDisplayName(asset)"
|
||||
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
:draggable="false"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeout } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
@@ -33,8 +33,8 @@ const { toastTo, mobile } = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ navigateOutputs: [] }>()
|
||||
defineExpose({ runButtonClick, handleDragDrop })
|
||||
|
||||
//NOTE: due to batching, will never be greater than 2
|
||||
const pendingJobQueues = ref(0)
|
||||
@@ -42,6 +42,7 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
|
||||
8000,
|
||||
{ controls: true, immediate: false }
|
||||
)
|
||||
const widgetListRef = useTemplateRef('widgetListRef')
|
||||
|
||||
//TODO: refactor out of this file.
|
||||
//code length is small, but changes should propagate
|
||||
@@ -70,8 +71,9 @@ async function runButtonClick(e: Event) {
|
||||
pendingJobQueues.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ runButtonClick })
|
||||
function handleDragDrop(e: DragEvent) {
|
||||
return widgetListRef.value?.handleDragDrop(e)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
@@ -96,9 +98,9 @@ defineExpose({ runButtonClick })
|
||||
>
|
||||
<section
|
||||
data-testid="linear-widgets"
|
||||
class="grow overflow-y-auto contain-size"
|
||||
class="grow scroll-shadows-comfy-menu-bg overflow-y-auto contain-size"
|
||||
>
|
||||
<AppModeWidgetList :mobile />
|
||||
<AppModeWidgetList ref="widgetListRef" :mobile />
|
||||
</section>
|
||||
<Teleport
|
||||
v-if="!jobToastTimeout || pendingJobQueues > 0"
|
||||
|
||||
@@ -43,9 +43,12 @@ const attrs = useAttrs()
|
||||
<article
|
||||
v-else-if="getMediaType(output) === 'text'"
|
||||
:class="
|
||||
cn('m-auto my-12 w-full max-w-lg overflow-y-auto', attrs.class as string)
|
||||
cn(
|
||||
'm-auto my-12 size-full max-w-2xl scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap',
|
||||
attrs.class as string
|
||||
)
|
||||
"
|
||||
v-text="output.url"
|
||||
v-text="output.content"
|
||||
/>
|
||||
<Preview3d
|
||||
v-else-if="getMediaType(output) === '3d'"
|
||||
|
||||
@@ -40,7 +40,7 @@ const creditsBadges = computed(() =>
|
||||
</Button>
|
||||
</template>
|
||||
<section
|
||||
class="max-h-(--reka-popover-content-available-height) overflow-y-auto"
|
||||
class="max-h-(--reka-popover-content-available-height) scroll-shadows-comfy-menu-bg overflow-y-auto"
|
||||
>
|
||||
<PartnerNodeItem
|
||||
v-for="[title, price, key] in creditsBadges"
|
||||
@@ -73,7 +73,7 @@ const creditsBadges = computed(() =>
|
||||
<i v-else class="ml-auto icon-[lucide--chevron-down]" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="overflow-y-auto">
|
||||
<CollapsibleContent class="scroll-shadows-comfy-menu-bg overflow-y-auto">
|
||||
<PartnerNodeItem
|
||||
v-for="[title, price, key] in creditsBadges"
|
||||
:key
|
||||
|
||||
@@ -11,7 +11,7 @@ function makeOutput(
|
||||
|
||||
describe(flattenNodeOutput, () => {
|
||||
it('returns empty array for output with no known media types', () => {
|
||||
const result = flattenNodeOutput(['1', makeOutput({ text: 'hello' })])
|
||||
const result = flattenNodeOutput(['1', makeOutput({ unknown: 'hello' })])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ function makeAsset(
|
||||
id,
|
||||
name: `${id}.png`,
|
||||
tags: [],
|
||||
preview_url: `/view?filename=${id}.png`,
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId: '1',
|
||||
|
||||
@@ -124,7 +124,8 @@ export function useOutputHistory(): {
|
||||
if (
|
||||
user_metadata.allOutputs?.length &&
|
||||
(!user_metadata.outputCount ||
|
||||
user_metadata.outputCount <= user_metadata.allOutputs.length)
|
||||
user_metadata.outputCount <= user_metadata.allOutputs.length) &&
|
||||
item.preview_url
|
||||
) {
|
||||
const reversed = user_metadata.allOutputs.toReversed()
|
||||
resolvedCache.set(item.id, reversed)
|
||||
|
||||
@@ -189,20 +189,22 @@ describe('TaskItemImpl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should produce no previewable outputs for text-only preview_output', () => {
|
||||
it.skip('should parse text outputs', () => {
|
||||
const job: JobListItem = {
|
||||
...createHistoryJob(0, 'text-job'),
|
||||
preview_output: {
|
||||
nodeId: '5',
|
||||
mediaType: 'text'
|
||||
mediaType: 'text',
|
||||
content: 'test'
|
||||
} satisfies JobListItem['preview_output']
|
||||
}
|
||||
|
||||
const task = new TaskItemImpl(job)
|
||||
|
||||
expect(task.flatOutputs).toHaveLength(0)
|
||||
expect(task.previewableOutputs).toHaveLength(0)
|
||||
expect(task.previewOutput).toBeUndefined()
|
||||
expect(task.flatOutputs).toHaveLength(1)
|
||||
expect(task.flatOutputs[0].filename).toBe('')
|
||||
expect(task.previewableOutputs).toHaveLength(1)
|
||||
expect(task.previewOutput?.content).toBe('test')
|
||||
})
|
||||
|
||||
describe('error extraction getters', () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ResultItemInit extends ResultItem {
|
||||
format?: string
|
||||
frame_rate?: number
|
||||
display_name?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
export class ResultItemImpl {
|
||||
@@ -55,6 +56,9 @@ export class ResultItemImpl {
|
||||
format?: string
|
||||
frame_rate?: number
|
||||
|
||||
// text specific field
|
||||
content?: string
|
||||
|
||||
constructor(obj: ResultItemInit) {
|
||||
this.filename = obj.filename ?? ''
|
||||
this.subfolder = obj.subfolder ?? ''
|
||||
@@ -67,6 +71,7 @@ export class ResultItemImpl {
|
||||
|
||||
this.format = obj.format
|
||||
this.frame_rate = obj.frame_rate
|
||||
this.content = obj.content
|
||||
}
|
||||
|
||||
get urlParams(): URLSearchParams {
|
||||
@@ -94,6 +99,7 @@ export class ResultItemImpl {
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
if (!this.filename) return ''
|
||||
return api.apiURL('/view?' + this.urlParams)
|
||||
}
|
||||
|
||||
@@ -216,9 +222,14 @@ export class ResultItemImpl {
|
||||
get is3D(): boolean {
|
||||
return getMediaTypeFromFilename(this.filename) === '3D'
|
||||
}
|
||||
get isText(): boolean {
|
||||
return this.mediaType === 'text'
|
||||
}
|
||||
|
||||
get supportsPreview(): boolean {
|
||||
return this.isImage || this.isVideo || this.isAudio || this.is3D
|
||||
return (
|
||||
this.isImage || this.isVideo || this.isAudio || this.is3D || this.isText
|
||||
)
|
||||
}
|
||||
|
||||
static filterPreviewable(
|
||||
@@ -267,7 +278,7 @@ export class TaskItemImpl {
|
||||
return parseTaskOutput(this.outputs)
|
||||
}
|
||||
|
||||
/** All outputs that support preview (images, videos, audio, 3D) */
|
||||
/** All outputs that support preview (images, videos, audio, 3D, text) */
|
||||
get previewableOutputs(): readonly ResultItemImpl[] {
|
||||
return ResultItemImpl.filterPreviewable(this.flatOutputs)
|
||||
}
|
||||
|
||||
@@ -91,10 +91,17 @@ const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
const bottomRightRef = useTemplateRef('bottomRightRef')
|
||||
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
|
||||
function dragDrop(e: DragEvent) {
|
||||
const { dataTransfer } = e
|
||||
if (!dataTransfer) return
|
||||
|
||||
linearWorkflowRef.value?.handleDragDrop(e)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<MobileDisplay v-if="mobileDisplay" />
|
||||
<div v-else class="absolute size-full">
|
||||
<div v-else class="absolute size-full" @dragover.prevent>
|
||||
<div
|
||||
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
|
||||
>
|
||||
@@ -144,6 +151,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
id="linearCenterPanel"
|
||||
:size="CENTER_PANEL_SIZE"
|
||||
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
|
||||
@drop="dragDrop"
|
||||
>
|
||||
<LinearProgressBar
|
||||
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
|
||||
|
||||
Reference in New Issue
Block a user