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

![scroll-shadow_00001](https://github.com/user-attachments/assets/e683d329-4283-4d06-aa29-5eee48030f27)
- 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:
AustinMroz
2026-03-19 14:20:35 -07:00
committed by GitHub
parent 57783fffcf
commit 4eded7c82b
15 changed files with 157 additions and 26 deletions

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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"

View File

@@ -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'"

View File

@@ -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

View File

@@ -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([])
})

View File

@@ -124,6 +124,7 @@ function makeAsset(
id,
name: `${id}.png`,
tags: [],
preview_url: `/view?filename=${id}.png`,
user_metadata: {
jobId,
nodeId: '1',

View File

@@ -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)

View File

@@ -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', () => {

View File

@@ -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)
}

View File

@@ -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)]"