mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 13:41:59 +00:00
Previously, a pending, but not yet running job was displayed as an empty skeleton. It is instead updated to display an animated `lucide--loader` icon. <img width="1544" height="1347" alt="image" src="https://github.com/user-attachments/assets/4f82185c-97dc-44af-8ea1-a012fb992fe2" /> As this shifts the activeQueueItem component to display even when only the single pending item is in the queue, the count badge is also update to not display when this is the case. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10382-Display-a-separate-indicator-for-pending-jobs-in-app-mode-32a6d73d36508189b3a9ff4b84993a98) by [Unito](https://www.unito.io)
372 lines
10 KiB
Vue
372 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
useEventListener,
|
|
useInfiniteScroll,
|
|
useResizeObserver
|
|
} from '@vueuse/core'
|
|
import { storeToRefs } from 'pinia'
|
|
import type { ComponentPublicInstance } from 'vue'
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
ref,
|
|
toValue,
|
|
useTemplateRef,
|
|
watch,
|
|
watchEffect
|
|
} from 'vue'
|
|
|
|
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
|
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
|
|
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
|
|
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
|
import type {
|
|
OutputSelection,
|
|
SelectionValue
|
|
} from '@/renderer/extensions/linearMode/linearModeTypes'
|
|
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
|
|
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
|
import { useAppModeStore } from '@/stores/appModeStore'
|
|
import { useQueueStore } from '@/stores/queueStore'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
|
|
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
|
|
useOutputHistory()
|
|
const { hasOutputs } = storeToRefs(useAppModeStore())
|
|
const queueStore = useQueueStore()
|
|
const store = useLinearOutputStore()
|
|
const workflowStore = useWorkflowStore()
|
|
|
|
const emit = defineEmits<{
|
|
updateSelection: [selection: OutputSelection]
|
|
}>()
|
|
|
|
const queueCount = computed(
|
|
() => queueStore.runningTasks.length + queueStore.pendingTasks.length
|
|
)
|
|
|
|
const itemClass = cn(
|
|
'shrink-0 cursor-pointer rounded-lg border-2 border-transparent p-1 outline-none',
|
|
'relative data-[state=checked]:border-interface-panel-job-progress-border'
|
|
)
|
|
|
|
const hasActiveContent = computed(
|
|
() => store.activeWorkflowInProgressItems.length > 0
|
|
)
|
|
|
|
const visibleHistory = computed(() =>
|
|
outputs.media.value.filter((a) => allOutputs(a).length > 0)
|
|
)
|
|
|
|
const selectableItems = computed(() => {
|
|
const items: SelectionValue[] = []
|
|
if (mayBeActiveWorkflowPending.value) {
|
|
items.push({ id: 'slot:pending', kind: 'inProgress', itemId: 'pending' })
|
|
}
|
|
for (const item of store.activeWorkflowInProgressItems) {
|
|
items.push({
|
|
id: `slot:${item.id}`,
|
|
kind: 'inProgress',
|
|
itemId: item.id
|
|
})
|
|
}
|
|
for (const asset of outputs.media.value) {
|
|
const outs = allOutputs(asset)
|
|
for (let k = 0; k < outs.length; k++) {
|
|
items.push({
|
|
id: `history:${asset.id}:${k}`,
|
|
kind: 'history',
|
|
assetId: asset.id,
|
|
key: k
|
|
})
|
|
}
|
|
}
|
|
return items
|
|
})
|
|
|
|
const selectionMap = computed(
|
|
() => new Map(selectableItems.value.map((v) => [v.id, v]))
|
|
)
|
|
|
|
const selectedValue = computed(() => {
|
|
if (!store.selectedId) return undefined
|
|
return selectionMap.value.get(store.selectedId)
|
|
})
|
|
|
|
function itemAttrs(id: string) {
|
|
const selected = store.selectedId === id
|
|
return {
|
|
'data-state': selected ? 'checked' : 'unchecked',
|
|
tabindex: selected ? 0 : -1
|
|
}
|
|
}
|
|
|
|
const selectedItemEl = ref<Element | null>(null)
|
|
|
|
function selectedRef(id: string) {
|
|
return store.selectedId === id
|
|
? (el: Element | ComponentPublicInstance | null) => {
|
|
selectedItemEl.value = el instanceof Element ? el : null
|
|
}
|
|
: undefined
|
|
}
|
|
|
|
function doEmit() {
|
|
const sel = selectedValue.value
|
|
if (!sel) {
|
|
emit('updateSelection', { canShowPreview: true })
|
|
return
|
|
}
|
|
if (sel.kind === 'inProgress') {
|
|
const item = store.activeWorkflowInProgressItems.find(
|
|
(i) => i.id === sel.itemId
|
|
)
|
|
if (!item || item.state === 'skeleton') {
|
|
emit('updateSelection', { canShowPreview: true, showSkeleton: true })
|
|
} else if (item.state === 'latent') {
|
|
emit('updateSelection', {
|
|
canShowPreview: true,
|
|
latentPreviewUrl: item.latentPreviewUrl
|
|
})
|
|
} else {
|
|
emit('updateSelection', {
|
|
output: item.output,
|
|
canShowPreview: true
|
|
})
|
|
}
|
|
return
|
|
}
|
|
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
|
|
const output = asset ? allOutputs(asset)[sel.key] : undefined
|
|
const isFirst = outputs.media.value[0]?.id === sel.assetId
|
|
emit('updateSelection', {
|
|
asset,
|
|
output,
|
|
canShowPreview: isFirst
|
|
})
|
|
}
|
|
|
|
watchEffect(doEmit)
|
|
|
|
// On load or workflow tab switch, select the most recent item.
|
|
// Prefer in-progress items for this workflow, then history, skipping
|
|
// the global pending slot which may belong to another workflow.
|
|
watch(
|
|
() => workflowStore.activeWorkflow?.path,
|
|
(path) => {
|
|
if (!path) return
|
|
const inProgress = store.activeWorkflowInProgressItems
|
|
if (inProgress.length > 0) {
|
|
store.selectAsLatest(`slot:${inProgress[0].id}`)
|
|
} else if (hasOutputs.value) {
|
|
selectFirstHistory()
|
|
} else {
|
|
store.selectAsLatest(null)
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
// Keep history selection stable on media changes
|
|
watch(
|
|
() => outputs.media.value,
|
|
(newAssets, oldAssets) => {
|
|
if (
|
|
newAssets.length === oldAssets.length ||
|
|
(oldAssets.length === 0 && newAssets.length !== 1)
|
|
)
|
|
return
|
|
|
|
if (store.selectedId?.startsWith('slot:')) return
|
|
|
|
const sv = store.selectedId
|
|
? selectionMap.value.get(store.selectedId)
|
|
: undefined
|
|
|
|
if (!sv || sv.kind !== 'history') {
|
|
if (hasOutputs.value) selectFirstHistory()
|
|
return
|
|
}
|
|
|
|
const wasFirst = sv.assetId === oldAssets[0]?.id
|
|
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
|
|
if (hasOutputs.value) selectFirstHistory()
|
|
}
|
|
}
|
|
)
|
|
|
|
const outputsRef = useTemplateRef('outputsRef')
|
|
|
|
// Track scrollWidth so we can compensate when items are prepended on the left.
|
|
let lastScrollWidth = 0
|
|
useResizeObserver(outputsRef, () => {
|
|
lastScrollWidth = outputsRef.value?.scrollWidth ?? 0
|
|
})
|
|
watch(
|
|
() => visibleHistory.value[0]?.id,
|
|
() => {
|
|
const el = outputsRef.value
|
|
if (!el || el.scrollLeft === 0) {
|
|
lastScrollWidth = el?.scrollWidth ?? 0
|
|
return
|
|
}
|
|
nextTick(() => {
|
|
const delta = el.scrollWidth - lastScrollWidth
|
|
if (delta !== 0) el.scrollLeft += delta
|
|
lastScrollWidth = el.scrollWidth
|
|
})
|
|
}
|
|
)
|
|
|
|
useInfiniteScroll(outputsRef, outputs.loadMore, {
|
|
canLoadMore: () => outputs.hasMore.value
|
|
})
|
|
|
|
function navigateToAdjacent(direction: 1 | -1) {
|
|
const items = selectableItems.value
|
|
if (items.length === 0) return
|
|
const currentId = store.selectedId
|
|
const idx = currentId ? items.findIndex((i) => i.id === currentId) : -1
|
|
const nextIdx =
|
|
idx === -1 ? 0 : Math.max(0, Math.min(items.length - 1, idx + direction))
|
|
store.select(items[nextIdx].id)
|
|
nextTick(() => {
|
|
selectedItemEl.value?.scrollIntoView({
|
|
block: 'nearest',
|
|
inline: 'nearest'
|
|
})
|
|
})
|
|
}
|
|
|
|
const pointer = new CanvasPointer(document.body)
|
|
let scrollOffset = 0
|
|
useEventListener(
|
|
document.body,
|
|
'wheel',
|
|
function (e: WheelEvent) {
|
|
if (!e.ctrlKey && !e.metaKey) return
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
if (!pointer.isTrackpadGesture(e)) {
|
|
if (e.deltaY > 0) navigateToAdjacent(1)
|
|
else navigateToAdjacent(-1)
|
|
return
|
|
}
|
|
scrollOffset += e.deltaY
|
|
while (scrollOffset >= 60) {
|
|
scrollOffset -= 60
|
|
navigateToAdjacent(1)
|
|
}
|
|
while (scrollOffset <= -60) {
|
|
scrollOffset += 60
|
|
navigateToAdjacent(-1)
|
|
}
|
|
},
|
|
{ capture: true, passive: false }
|
|
)
|
|
|
|
useEventListener(
|
|
outputsRef,
|
|
'wheel',
|
|
function (e: WheelEvent) {
|
|
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
|
e.preventDefault()
|
|
if (outputsRef.value) outputsRef.value.scrollLeft += e.deltaY
|
|
},
|
|
{ passive: false }
|
|
)
|
|
|
|
const keyHandlers: Record<string, 1 | -1> = {
|
|
ArrowUp: -1,
|
|
ArrowDown: 1,
|
|
ArrowLeft: -1,
|
|
ArrowRight: 1
|
|
}
|
|
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
|
if (
|
|
!(e.key in keyHandlers) ||
|
|
e.target instanceof HTMLTextAreaElement ||
|
|
e.target instanceof HTMLInputElement
|
|
)
|
|
return
|
|
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
navigateToAdjacent(keyHandlers[e.key])
|
|
})
|
|
</script>
|
|
<template>
|
|
<div
|
|
role="group"
|
|
class="flex h-21 min-w-0 items-start justify-center px-4 py-3 pb-4"
|
|
>
|
|
<div
|
|
v-if="queueCount > 0 || hasActiveContent"
|
|
class="flex h-15 shrink-0 items-start gap-0.5"
|
|
>
|
|
<OutputHistoryActiveQueueItem
|
|
v-if="queueCount > 1 || queueStore.pendingTasks.length"
|
|
class="mr-3"
|
|
:queue-count="queueCount"
|
|
/>
|
|
|
|
<div
|
|
v-if="mayBeActiveWorkflowPending"
|
|
:ref="selectedRef('slot:pending')"
|
|
v-bind="itemAttrs('slot:pending')"
|
|
:class="itemClass"
|
|
@click="store.select('slot:pending')"
|
|
>
|
|
<OutputPreviewItem />
|
|
</div>
|
|
|
|
<div
|
|
v-for="item in store.activeWorkflowInProgressItems"
|
|
:key="`${item.id}-${item.state}`"
|
|
:ref="selectedRef(`slot:${item.id}`)"
|
|
v-bind="itemAttrs(`slot:${item.id}`)"
|
|
:class="itemClass"
|
|
@click="store.select(`slot:${item.id}`)"
|
|
>
|
|
<OutputPreviewItem
|
|
v-if="item.state !== 'image' || !item.output"
|
|
:latent-preview="item.latentPreviewUrl"
|
|
/>
|
|
<OutputHistoryItem v-else :output="item.output" />
|
|
</div>
|
|
|
|
<div
|
|
v-if="hasActiveContent && visibleHistory.length > 0"
|
|
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
|
/>
|
|
</div>
|
|
|
|
<article
|
|
ref="outputsRef"
|
|
data-testid="linear-outputs"
|
|
class="min-w-0 overflow-x-auto overflow-y-clip"
|
|
>
|
|
<div class="flex h-15 w-fit items-start gap-0.5">
|
|
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
|
|
<div
|
|
v-if="aIdx > 0"
|
|
class="mx-4 h-12 shrink-0 border-l border-border-default"
|
|
/>
|
|
<div
|
|
v-for="(output, key) in toValue(allOutputs(asset))"
|
|
:key
|
|
:ref="selectedRef(`history:${asset.id}:${key}`)"
|
|
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
|
|
:class="itemClass"
|
|
@click="store.select(`history:${asset.id}:${key}`)"
|
|
>
|
|
<OutputHistoryItem :output="output" />
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</template>
|