mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-20 14:54:12 +00:00
Planning to keep updates smaller and more contained in the interest of collaboration and velocity - The breadcrumb hamburger menu that provides workflow options is now displayed in linear mode - As part of this change, the reka-ui popover component now accepts primvevue format MenuItems - I prefer the format I had, but this makes transitioning stuff easier. - The simplified linear history is moved to always be horizontal and shown beneath previews. - The label has been removed from the "Give Feedback" button on desktop so it does not overlap - The full side toolbar is displayed in linear mode - This is temporary, but it gets the dead code pruned out now. - Lays some groundwork for selecting an asset from the assets panel to also select the item in the main linear panel - The api `promptQueued` event can now optionally include a promptIds, which list the ids for all jobs that were queued together as part of that batch - Update the max for the `number of generations` field to respect the recently updated cloud limits | Before | After | | ------ | ----- | | <img width="360" alt="before" src="https://github.com/user-attachments/assets/e632679c-d727-4882-841b-09e99a2f81a4" /> | <img width="360" alt="after" src="https://github.com/user-attachments/assets/a9bcd809-c314-49bd-a479-2448d1a88456"/>| ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8853-Linear-mode-arrangement-tweaks-3066d73d365081589355ef753513900b) by [Unito](https://www.unito.io)
301 lines
8.7 KiB
Vue
301 lines
8.7 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
useAsyncState,
|
|
useEventListener,
|
|
useInfiniteScroll
|
|
} from '@vueuse/core'
|
|
import { computed, ref, toRaw, toValue, useTemplateRef, watch } from 'vue'
|
|
import type { MaybeRef } from 'vue'
|
|
|
|
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
|
|
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
|
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
|
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
|
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|
import {
|
|
getMediaType,
|
|
mediaTypes
|
|
} from '@/renderer/extensions/linearMode/mediaTypes'
|
|
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
|
|
import { getJobDetail } from '@/services/jobOutputCache'
|
|
import { useQueueStore, ResultItemImpl } from '@/stores/queueStore'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
|
|
const outputs = useMediaAssets('output')
|
|
const {
|
|
progressBarContainerClass,
|
|
progressBarPrimaryClass,
|
|
progressBarSecondaryClass,
|
|
progressPercentStyle
|
|
} = useProgressBarBackground()
|
|
const { totalPercent, currentNodePercent } = useQueueProgress()
|
|
const queueStore = useQueueStore()
|
|
|
|
void outputs.fetchMediaList()
|
|
|
|
const emit = defineEmits<{
|
|
updateSelection: [
|
|
selection: [AssetItem | undefined, ResultItemImpl | undefined, boolean]
|
|
]
|
|
}>()
|
|
|
|
const selectedIndex = ref<[number, number]>([-1, 0])
|
|
|
|
function doEmit() {
|
|
const [index] = selectedIndex.value
|
|
emit('updateSelection', [
|
|
outputs.media.value[index],
|
|
selectedOutput.value,
|
|
selectedIndex.value[0] <= 0
|
|
])
|
|
}
|
|
|
|
const outputsRef = useTemplateRef('outputsRef')
|
|
useInfiniteScroll(outputsRef, outputs.loadMore, {
|
|
canLoadMore: () => outputs.hasMore.value
|
|
})
|
|
|
|
watch(selectedIndex, () => {
|
|
const [index, key] = selectedIndex.value
|
|
if (!outputsRef.value) return
|
|
|
|
const outputElement = outputsRef.value?.querySelectorAll(
|
|
`[data-output-index="${index}"]`
|
|
)?.[key]
|
|
if (!outputElement) return
|
|
|
|
//container: 'nearest' is nice, but bleeding edge and chrome only
|
|
outputElement.scrollIntoView({ block: 'nearest' })
|
|
})
|
|
|
|
function outputCount(item?: AssetItem) {
|
|
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
|
return user_metadata?.outputCount ?? 0
|
|
}
|
|
|
|
const outputsCache: Record<string, MaybeRef<ResultItemImpl[]>> = {}
|
|
|
|
function flattenNodeOutput([nodeId, nodeOutput]: [
|
|
string | number,
|
|
NodeExecutionOutput
|
|
]): ResultItemImpl[] {
|
|
const knownOutputs: Record<string, ResultItem[]> = {}
|
|
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
|
|
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
|
|
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
|
|
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
|
|
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
|
|
|
|
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
|
|
outputs.map(
|
|
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
|
|
)
|
|
)
|
|
}
|
|
|
|
function allOutputs(item?: AssetItem): MaybeRef<ResultItemImpl[]> {
|
|
if (item?.id && outputsCache[item.id]) return outputsCache[item.id]
|
|
|
|
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
|
|
if (!user_metadata) return []
|
|
if (
|
|
user_metadata.allOutputs &&
|
|
user_metadata.outputCount &&
|
|
user_metadata.outputCount < user_metadata.allOutputs.length
|
|
)
|
|
return user_metadata.allOutputs
|
|
|
|
const outputRef = useAsyncState(
|
|
getJobDetail(user_metadata.jobId).then((jobDetail) => {
|
|
if (!jobDetail?.outputs) return []
|
|
return Object.entries(jobDetail.outputs).flatMap(flattenNodeOutput)
|
|
}),
|
|
[]
|
|
).state
|
|
outputsCache[item!.id] = outputRef
|
|
return outputRef
|
|
}
|
|
|
|
const selectedOutput = computed(() => {
|
|
const [index, key] = selectedIndex.value
|
|
if (index < 0) return undefined
|
|
|
|
return toValue(allOutputs(outputs.media.value[index]))[key]
|
|
})
|
|
|
|
watch([selectedIndex, selectedOutput], doEmit)
|
|
watch(
|
|
() => outputs.media.value,
|
|
(newAssets, oldAssets) => {
|
|
if (
|
|
newAssets.length === oldAssets.length ||
|
|
(oldAssets.length === 0 && newAssets.length !== 1)
|
|
)
|
|
return
|
|
if (selectedIndex.value[0] <= 0) {
|
|
selectedIndex.value = [0, 0]
|
|
return
|
|
}
|
|
|
|
const oldId = toRaw(oldAssets[selectedIndex.value[0]]?.id)
|
|
const newIndex = toRaw(newAssets).findIndex((asset) => asset?.id === oldId)
|
|
|
|
if (newIndex === -1) selectedIndex.value = [0, 0]
|
|
else selectedIndex.value = [newIndex, selectedIndex.value[1]]
|
|
}
|
|
)
|
|
|
|
function gotoNextOutput() {
|
|
const [index, key] = selectedIndex.value
|
|
if (index < 0 || key < 0) {
|
|
selectedIndex.value = [0, 0]
|
|
return
|
|
}
|
|
if (key + 1 < outputCount(outputs.media.value[index])) {
|
|
selectedIndex.value = [index, key + 1]
|
|
return
|
|
}
|
|
if (outputs.media.value[index + 1]) {
|
|
selectedIndex.value = [index + 1, 0]
|
|
}
|
|
//do nothing, no next output
|
|
}
|
|
|
|
function gotoPreviousOutput() {
|
|
const [index, key] = selectedIndex.value
|
|
if (key > 0) {
|
|
selectedIndex.value = [index, key - 1]
|
|
return
|
|
}
|
|
|
|
if (index > 0) {
|
|
const len = outputCount(outputs.media.value[index - 1])
|
|
selectedIndex.value = [index - 1, len - 1]
|
|
return
|
|
}
|
|
|
|
selectedIndex.value = [0, 0]
|
|
}
|
|
|
|
let 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) gotoNextOutput()
|
|
else gotoPreviousOutput()
|
|
return
|
|
}
|
|
scrollOffset += e.deltaY
|
|
while (scrollOffset >= 60) {
|
|
scrollOffset -= 60
|
|
gotoNextOutput()
|
|
}
|
|
while (scrollOffset <= -60) {
|
|
scrollOffset += 60
|
|
gotoPreviousOutput()
|
|
}
|
|
},
|
|
{ capture: true, passive: false }
|
|
)
|
|
|
|
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
|
if (
|
|
(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') ||
|
|
e.target instanceof HTMLTextAreaElement ||
|
|
e.target instanceof HTMLInputElement
|
|
)
|
|
return
|
|
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
if (e.key === 'ArrowDown') gotoNextOutput()
|
|
else gotoPreviousOutput()
|
|
})
|
|
</script>
|
|
<template>
|
|
<article
|
|
ref="outputsRef"
|
|
data-testid="linear-outputs"
|
|
class="h-24 p-3 overflow-x-auto overflow-y-clip border-node-component-border flex items-center contain-size md:mx-15"
|
|
>
|
|
<section
|
|
v-if="
|
|
queueStore.runningTasks.length > 0 || queueStore.pendingTasks.length > 0
|
|
"
|
|
data-testid="linear-job"
|
|
class="py-3 h-24 aspect-square px-1 relative"
|
|
>
|
|
<i
|
|
v-if="queueStore.runningTasks.length > 0"
|
|
class="icon-[lucide--loader-circle] size-full animate-spin"
|
|
/>
|
|
<i v-else class="icon-[lucide--ellipsis] size-full animate-pulse" />
|
|
<div
|
|
v-if="
|
|
queueStore.runningTasks.length + queueStore.pendingTasks.length > 1
|
|
"
|
|
class="absolute top-0 right-0 p-1 min-w-5 h-5 flex justify-center items-center rounded-full bg-primary-background text-text-primary"
|
|
v-text="queueStore.runningTasks.length + queueStore.pendingTasks.length"
|
|
/>
|
|
<div class="absolute -bottom-1 w-full h-3 rounded-sm overflow-clip">
|
|
<div :class="progressBarContainerClass">
|
|
<div
|
|
:class="progressBarPrimaryClass"
|
|
:style="progressPercentStyle(totalPercent)"
|
|
/>
|
|
<div
|
|
:class="progressBarSecondaryClass"
|
|
:style="progressPercentStyle(currentNodePercent)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<template v-for="(item, index) in outputs.media.value" :key="index">
|
|
<div class="border-border-subtle border-l first:border-none h-21 m-3" />
|
|
<template v-for="(output, key) in toValue(allOutputs(item))" :key>
|
|
<img
|
|
v-if="getMediaType(output) === 'images'"
|
|
:class="
|
|
cn(
|
|
'p-1 rounded-lg aspect-square object-cover h-20',
|
|
index === selectedIndex[0] &&
|
|
key === selectedIndex[1] &&
|
|
'border-2'
|
|
)
|
|
"
|
|
:data-output-index="index"
|
|
:src="output.url"
|
|
@click="selectedIndex = [index, key]"
|
|
/>
|
|
<div
|
|
v-else
|
|
:class="
|
|
cn(
|
|
'p-1 rounded-lg aspect-square w-full',
|
|
index === selectedIndex[0] &&
|
|
key === selectedIndex[1] &&
|
|
'border-2'
|
|
)
|
|
"
|
|
:data-output-index="index"
|
|
@click="selectedIndex = [index, key]"
|
|
>
|
|
<i
|
|
:class="
|
|
cn(mediaTypes[getMediaType(output)]?.iconClass, 'size-full')
|
|
"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</article>
|
|
</template>
|