mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
feat: output grid and media preview improvements
Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -25,10 +25,15 @@ const {
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
onMaskEdit?: () => void
|
||||
onDownload?: () => void
|
||||
onRemove?: () => void
|
||||
}
|
||||
forceHovered?: boolean
|
||||
}>()
|
||||
|
||||
const actionButtonClass =
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-neutral-800 text-white shadow-md transition-colors hover:bg-neutral-700'
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
const canAcceptDrop = ref(false)
|
||||
const pointerStart = ref<{ x: number; y: number } | null>(null)
|
||||
@@ -164,23 +169,44 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('mediaAsset.actions.zoom')"
|
||||
:title="t('mediaAsset.actions.zoom')"
|
||||
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="lightboxOpen = true"
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
<i class="icon-[lucide--fullscreen] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="dropIndicator.onMaskEdit"
|
||||
type="button"
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('maskEditor.editMask')"
|
||||
:title="t('maskEditor.editMask')"
|
||||
@click.stop="dropIndicator.onMaskEdit()"
|
||||
>
|
||||
<i class="icon-[comfy--mask] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="dropIndicator.onDownload"
|
||||
type="button"
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.downloadImage')"
|
||||
:title="t('g.downloadImage')"
|
||||
@click.stop="dropIndicator.onDownload()"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="dropIndicator.onRemove"
|
||||
type="button"
|
||||
:class="actionButtonClass"
|
||||
:aria-label="t('g.removeImage')"
|
||||
:title="t('g.removeImage')"
|
||||
@click.stop="dropIndicator.onRemove()"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="dropIndicator.onMaskEdit"
|
||||
type="button"
|
||||
class="mx-3 flex w-[calc(100%-1.5rem)] cursor-pointer items-center justify-center gap-2 rounded-lg border border-node-component-border bg-component-node-widget-background p-2 text-sm text-component-node-foreground transition-colors hover:bg-component-node-widget-background-hovered"
|
||||
@click.stop="dropIndicator.onMaskEdit()"
|
||||
>
|
||||
<i class="icon-[comfy--mask] size-4" />
|
||||
{{ t('maskEditor.editMask') }}
|
||||
</button>
|
||||
<ImageLightbox
|
||||
v-if="dropIndicator.imageUrl"
|
||||
v-model="lightboxOpen"
|
||||
|
||||
@@ -38,7 +38,7 @@ const height = ref('')
|
||||
<img
|
||||
v-else
|
||||
ref="imageRef"
|
||||
class="grow object-contain contain-size"
|
||||
class="min-h-0 flex-1 object-contain"
|
||||
:src
|
||||
@load="
|
||||
() => {
|
||||
@@ -48,5 +48,9 @@ const height = ref('')
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
|
||||
<span
|
||||
v-if="!mobile"
|
||||
class="self-end pr-2 md:z-10"
|
||||
v-text="`${width} x ${height}`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -7,9 +7,11 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||
import OutputGrid from '@/renderer/extensions/linearMode/OutputGrid.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { setMode } = useAppMode()
|
||||
@@ -18,6 +20,23 @@ const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const { nodeIdToNodeLocatorId } = useWorkflowStore()
|
||||
|
||||
const isMultiOutput = computed(() => appModeStore.selectedOutputs.length > 1)
|
||||
|
||||
const outputsByNode = computed(() => {
|
||||
const map = new Map<string, ResultItemImpl | undefined>()
|
||||
for (const nodeId of appModeStore.selectedOutputs) {
|
||||
const locatorId = nodeIdToNodeLocatorId(nodeId)
|
||||
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
|
||||
if (!nodeOutput) {
|
||||
map.set(String(nodeId), undefined)
|
||||
continue
|
||||
}
|
||||
const results = flattenNodeOutput([nodeId, nodeOutput])
|
||||
map.set(String(nodeId), results[0])
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const existingOutput = computed(() => {
|
||||
for (const nodeId of appModeStore.selectedOutputs) {
|
||||
const locatorId = nodeIdToNodeLocatorId(nodeId)
|
||||
@@ -28,11 +47,25 @@ const existingOutput = computed(() => {
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
function handleReorder(fromIndex: number, toIndex: number) {
|
||||
const outputs = [...appModeStore.selectedOutputs]
|
||||
const [moved] = outputs.splice(fromIndex, 1)
|
||||
outputs.splice(toIndex, 0, moved)
|
||||
appModeStore.selectedOutputs = outputs
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OutputGrid
|
||||
v-if="isMultiOutput && hasOutputs"
|
||||
:outputs-by-node="outputsByNode"
|
||||
:output-count="appModeStore.selectedOutputs.length"
|
||||
builder-mode
|
||||
@reorder="handleReorder"
|
||||
/>
|
||||
<MediaOutputPreview
|
||||
v-if="existingOutput"
|
||||
v-else-if="existingOutput"
|
||||
:output="existingOutput"
|
||||
class="px-12 py-24"
|
||||
/>
|
||||
|
||||
@@ -11,13 +11,13 @@ import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAsse
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import AppTemplateView from '@/renderer/extensions/linearMode/AppTemplateView.vue'
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
|
||||
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
|
||||
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
|
||||
import LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
|
||||
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||
import OutputGrid from '@/renderer/extensions/linearMode/OutputGrid.vue'
|
||||
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
@@ -27,13 +27,8 @@ import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const hasAppContent = computed(
|
||||
() =>
|
||||
appModeStore.selectedInputs.length > 0 ||
|
||||
appModeStore.selectedOutputs.length > 0
|
||||
)
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
|
||||
useOutputHistory()
|
||||
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
|
||||
@@ -47,9 +42,38 @@ const selectedOutput = ref<ResultItemImpl>()
|
||||
const canShowPreview = ref(true)
|
||||
const latentPreview = ref<string>()
|
||||
const showSkeleton = ref(false)
|
||||
const lightboxSrc = ref('')
|
||||
const lightboxUrl = ref('')
|
||||
const lightboxOpen = ref(false)
|
||||
|
||||
function openLightbox(url: string) {
|
||||
if (mobile) {
|
||||
document
|
||||
.querySelectorAll<HTMLMediaElement>('video, audio')
|
||||
.forEach((el) => el.pause())
|
||||
}
|
||||
lightboxUrl.value = url
|
||||
lightboxOpen.value = true
|
||||
}
|
||||
|
||||
const isMultiOutput = computed(() => appModeStore.selectedOutputs.length > 1)
|
||||
|
||||
const outputsByNode = computed(() => {
|
||||
const map = new Map<string, ResultItemImpl>()
|
||||
if (!selectedItem.value) return map
|
||||
const outputs = allOutputs(selectedItem.value)
|
||||
const outputLookup = new Map<string, ResultItemImpl>()
|
||||
for (const output of outputs) {
|
||||
if (!outputLookup.has(String(output.nodeId))) {
|
||||
outputLookup.set(String(output.nodeId), output)
|
||||
}
|
||||
}
|
||||
for (const nodeId of appModeStore.selectedOutputs) {
|
||||
const output = outputLookup.get(String(nodeId))
|
||||
if (output) map.set(String(nodeId), output)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function handleSelection(sel: OutputSelection) {
|
||||
selectedItem.value = sel.asset
|
||||
selectedOutput.value = sel.output
|
||||
@@ -143,25 +167,31 @@ async function rerun(e: Event) {
|
||||
]"
|
||||
/>
|
||||
</section>
|
||||
<LinearArrange v-if="isArrangeMode" />
|
||||
<AppTemplateView
|
||||
v-else-if="hasAppContent"
|
||||
:selected-output="selectedOutput"
|
||||
<OutputGrid
|
||||
v-if="isMultiOutput && outputsByNode.size > 0"
|
||||
:outputs-by-node="outputsByNode"
|
||||
:output-count="appModeStore.selectedOutputs.length"
|
||||
:show-skeleton="showSkeleton"
|
||||
:mobile
|
||||
@open-lightbox="openLightbox"
|
||||
/>
|
||||
<template v-else>
|
||||
<ImagePreview
|
||||
v-if="canShowPreview && latentPreview"
|
||||
:mobile
|
||||
:src="latentPreview"
|
||||
/>
|
||||
<MediaOutputPreview
|
||||
v-else-if="selectedOutput"
|
||||
:output="selectedOutput"
|
||||
:mobile
|
||||
/>
|
||||
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
|
||||
<LinearWelcome v-else />
|
||||
</template>
|
||||
<ImagePreview
|
||||
v-else-if="canShowPreview && latentPreview"
|
||||
:mobile
|
||||
:src="latentPreview"
|
||||
/>
|
||||
<MediaOutputPreview
|
||||
v-else-if="selectedOutput"
|
||||
:output="selectedOutput"
|
||||
:mobile
|
||||
@dblclick="
|
||||
!mobile && selectedOutput.url && openLightbox(selectedOutput.url)
|
||||
"
|
||||
@click="mobile && selectedOutput.url && openLightbox(selectedOutput.url)"
|
||||
/>
|
||||
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
|
||||
<LinearArrange v-else-if="isArrangeMode" />
|
||||
<LinearWelcome v-else />
|
||||
<div
|
||||
v-if="!mobile"
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
@@ -175,12 +205,7 @@ async function rerun(e: Event) {
|
||||
v-if="!isBuilderMode"
|
||||
class="z-10 min-w-0"
|
||||
@update-selection="handleSelection"
|
||||
@open-lightbox="
|
||||
(url) => {
|
||||
lightboxSrc = url
|
||||
lightboxOpen = true
|
||||
}
|
||||
"
|
||||
@open-lightbox="openLightbox"
|
||||
/>
|
||||
<LinearFeedback
|
||||
v-if="typeformWidgetId"
|
||||
@@ -191,12 +216,7 @@ async function rerun(e: Event) {
|
||||
<OutputHistory
|
||||
v-else-if="!isBuilderMode"
|
||||
@update-selection="handleSelection"
|
||||
@open-lightbox="
|
||||
(url) => {
|
||||
lightboxSrc = url
|
||||
lightboxOpen = true
|
||||
}
|
||||
"
|
||||
@open-lightbox="openLightbox"
|
||||
/>
|
||||
<ImageLightbox v-model="lightboxOpen" :src="lightboxSrc" />
|
||||
<ImageLightbox v-model="lightboxOpen" :src="lightboxUrl" />
|
||||
</template>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { defineAsyncComponent, useAttrs } from 'vue'
|
||||
|
||||
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
|
||||
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
|
||||
import type { MediaOutputItem } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const Preview3d = defineAsyncComponent(
|
||||
@@ -14,7 +14,7 @@ const Preview3d = defineAsyncComponent(
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { output } = defineProps<{
|
||||
output: ResultItemImpl
|
||||
output: MediaOutputItem
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
@@ -30,6 +30,7 @@ const attrs = useAttrs()
|
||||
<VideoPreview
|
||||
v-else-if="getMediaType(output) === 'video'"
|
||||
:src="output.url"
|
||||
:mobile
|
||||
:class="
|
||||
cn('flex-1 object-contain md:p-3 md:contain-size', attrs.class as string)
|
||||
"
|
||||
|
||||
313
src/renderer/extensions/linearMode/OutputGrid.vue
Normal file
313
src/renderer/extensions/linearMode/OutputGrid.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
|
||||
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
outputsByNode,
|
||||
outputCount,
|
||||
showSkeleton = false,
|
||||
builderMode = false,
|
||||
mobile = false
|
||||
} = defineProps<{
|
||||
outputsByNode: Map<string, ResultItemImpl | undefined>
|
||||
outputCount: number
|
||||
showSkeleton?: boolean
|
||||
builderMode?: boolean
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reorder: [fromIndex: number, toIndex: number]
|
||||
openLightbox: [url: string]
|
||||
}>()
|
||||
|
||||
const AREA_NAMES = ['a', 'b', 'c', 'd']
|
||||
|
||||
const MEDIA_TYPE_META: Record<string, { label: string; icon: string }> = {
|
||||
images: { label: 'Image', icon: 'icon-[lucide--image]' },
|
||||
video: { label: 'Video', icon: 'icon-[lucide--film]' },
|
||||
audio: { label: 'Audio', icon: 'icon-[lucide--volume-2]' },
|
||||
text: { label: 'Text', icon: 'icon-[lucide--file-text]' },
|
||||
gltf: { label: '3D', icon: 'icon-[lucide--box]' }
|
||||
}
|
||||
|
||||
function getOutputLabel(
|
||||
nodeId: string,
|
||||
index: number
|
||||
): { label: string; icon: string } {
|
||||
const node = resolveNode(Number(nodeId))
|
||||
if (!node)
|
||||
return { label: `Output ${index + 1}`, icon: 'icon-[lucide--layout-grid]' }
|
||||
|
||||
const comfyClass = node.comfyClass ?? ''
|
||||
if (comfyClass.toLowerCase().includes('image') || comfyClass === 'SaveImage')
|
||||
return { label: node.title || 'Image', icon: MEDIA_TYPE_META.images.icon }
|
||||
if (comfyClass.toLowerCase().includes('video'))
|
||||
return { label: node.title || 'Video', icon: MEDIA_TYPE_META.video.icon }
|
||||
if (comfyClass.toLowerCase().includes('audio'))
|
||||
return { label: node.title || 'Audio', icon: MEDIA_TYPE_META.audio.icon }
|
||||
if (
|
||||
comfyClass.toLowerCase().includes('3d') ||
|
||||
comfyClass.toLowerCase().includes('gltf')
|
||||
)
|
||||
return { label: node.title || '3D', icon: MEDIA_TYPE_META.gltf.icon }
|
||||
if (comfyClass.toLowerCase().includes('text'))
|
||||
return { label: node.title || 'Text', icon: MEDIA_TYPE_META.text.icon }
|
||||
|
||||
return {
|
||||
label: node.title || `Output ${index + 1}`,
|
||||
icon: 'icon-[lucide--layout-grid]'
|
||||
}
|
||||
}
|
||||
|
||||
// Matches p-2 and gap-2 on the grid container
|
||||
const GRID_PADDING_PX = 8
|
||||
const GRID_GAP_PX = 8
|
||||
const MIN_RATIO = 0.2
|
||||
const MAX_RATIO = 0.8
|
||||
|
||||
const rowRatio = ref(0.5)
|
||||
const colRatio = ref(0.5)
|
||||
const gridRef = useTemplateRef('gridRef')
|
||||
const isResizing = ref(false)
|
||||
|
||||
/** CSS calc() — exactly centered in the gap between grid rows/columns. */
|
||||
function cssSplitPos(ratio: number) {
|
||||
const totalPad = GRID_PADDING_PX * 2
|
||||
const pct = ratio * 100
|
||||
return `calc(${GRID_PADDING_PX}px + (100% - ${totalPad + GRID_GAP_PX}px) * ${pct / 100} + ${GRID_GAP_PX / 2}px)`
|
||||
}
|
||||
|
||||
const rowHandleCssTop = computed(() => cssSplitPos(rowRatio.value))
|
||||
const colHandleCssLeft = computed(() => cssSplitPos(colRatio.value))
|
||||
|
||||
/** For 3 outputs, horizontal handle only spans the left column. */
|
||||
const rowHandleWidth = computed(() => {
|
||||
if (outputCount !== 3) return '100%'
|
||||
const totalPad = GRID_PADDING_PX * 2
|
||||
const pct = colRatio.value * 100
|
||||
return `calc((100% - ${totalPad + GRID_GAP_PX}px) * ${pct / 100} + ${GRID_PADDING_PX}px)`
|
||||
})
|
||||
|
||||
function gridStyleForCount(count: number) {
|
||||
const r = rowRatio.value
|
||||
const c = colRatio.value
|
||||
switch (count) {
|
||||
case 2:
|
||||
return { gridTemplate: `"a" ${r}fr "b" ${1 - r}fr / 1fr` }
|
||||
case 3:
|
||||
return {
|
||||
gridTemplate: `"a c" ${r}fr "b c" ${1 - r}fr / ${c}fr ${1 - c}fr`
|
||||
}
|
||||
case 4:
|
||||
return {
|
||||
gridTemplate: `"a b" ${r}fr "c d" ${1 - r}fr / ${c}fr ${1 - c}fr`
|
||||
}
|
||||
default:
|
||||
return { gridTemplate: '"a" 1fr / 1fr' }
|
||||
}
|
||||
}
|
||||
|
||||
const gridStyle = computed(() => {
|
||||
if (mobile) {
|
||||
const rows = AREA_NAMES.slice(0, outputCount)
|
||||
.map((a) => `"${a}" 1fr`)
|
||||
.join(' ')
|
||||
return { gridTemplate: `${rows} / 1fr` }
|
||||
}
|
||||
return gridStyleForCount(outputCount)
|
||||
})
|
||||
|
||||
function startResize(
|
||||
ratioRef: { value: number },
|
||||
axis: 'row' | 'col',
|
||||
e: MouseEvent
|
||||
) {
|
||||
e.preventDefault()
|
||||
isResizing.value = true
|
||||
const startPos = axis === 'row' ? e.clientY : e.clientX
|
||||
const startRatio = ratioRef.value
|
||||
const container = gridRef.value
|
||||
if (!container) return
|
||||
const size = axis === 'row' ? container.clientHeight : container.clientWidth
|
||||
|
||||
function onMouseMove(ev: MouseEvent) {
|
||||
const pos = axis === 'row' ? ev.clientY : ev.clientX
|
||||
const delta = pos - startPos
|
||||
ratioRef.value = Math.max(
|
||||
MIN_RATIO,
|
||||
Math.min(MAX_RATIO, startRatio + delta / size)
|
||||
)
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isResizing.value = false
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
function onRowResizeStart(e: MouseEvent) {
|
||||
startResize(rowRatio, 'row', e)
|
||||
}
|
||||
|
||||
function onColResizeStart(e: MouseEvent) {
|
||||
startResize(colRatio, 'col', e)
|
||||
}
|
||||
|
||||
const cells = computed(() => {
|
||||
const nodeIds = [...outputsByNode.keys()]
|
||||
return nodeIds.slice(0, 4).map((nodeId, i) => {
|
||||
const meta = getOutputLabel(nodeId, i)
|
||||
return {
|
||||
nodeId,
|
||||
label: meta.label,
|
||||
icon: meta.icon,
|
||||
output: outputsByNode.get(nodeId),
|
||||
area: AREA_NAMES[i]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const dragFromIndex = ref<number | null>(null)
|
||||
const dragOverIndex = ref<number | null>(null)
|
||||
|
||||
function onDragStart(index: number, e: DragEvent) {
|
||||
dragFromIndex.value = index
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(index: number, e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function onDrop(index: number) {
|
||||
if (dragFromIndex.value !== null && dragFromIndex.value !== index) {
|
||||
emit('reorder', dragFromIndex.value, index)
|
||||
}
|
||||
dragFromIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
dragFromIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
ref="gridRef"
|
||||
:class="
|
||||
cn(
|
||||
'relative grid min-h-0 flex-1 gap-2 overflow-hidden p-2',
|
||||
builderMode &&
|
||||
'pt-[calc(var(--workflow-tabs-height)+var(--spacing)*18)]',
|
||||
isResizing && 'select-none'
|
||||
)
|
||||
"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(cell, index) in cells"
|
||||
:key="cell.nodeId"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex min-h-0 min-w-0 flex-col items-center justify-center overflow-hidden rounded-lg',
|
||||
builderMode
|
||||
? 'border-2 border-dashed border-warning-background'
|
||||
: 'border border-border-subtle',
|
||||
dragOverIndex === index && 'ring-2 ring-primary-background'
|
||||
)
|
||||
"
|
||||
:style="{ gridArea: cell.area }"
|
||||
:draggable="builderMode"
|
||||
@dragstart="builderMode && onDragStart(index, $event)"
|
||||
@dragover="builderMode && onDragOver(index, $event)"
|
||||
@dragleave="builderMode && onDragLeave()"
|
||||
@drop="builderMode && onDrop(index)"
|
||||
@dragend="builderMode && onDragEnd()"
|
||||
@dblclick="
|
||||
!mobile && cell.output?.url && emit('openLightbox', cell.output.url)
|
||||
"
|
||||
@click="
|
||||
mobile && cell.output?.url && emit('openLightbox', cell.output.url)
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="builderMode || !cell.output"
|
||||
class="absolute top-0 left-0 z-10 flex items-center gap-1.5 rounded-br-lg bg-base-background/80 px-2.5 py-1 text-xxs text-muted-foreground"
|
||||
>
|
||||
<i :class="cn(cell.icon, 'size-3')" />
|
||||
{{ cell.label }}
|
||||
</div>
|
||||
|
||||
<MediaOutputPreview
|
||||
v-if="cell.output"
|
||||
:output="cell.output"
|
||||
:mobile
|
||||
class="size-full object-contain"
|
||||
/>
|
||||
<LatentPreview v-else-if="showSkeleton" />
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full flex-col items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<i :class="cn(cell.icon, 'size-8 opacity-30')" />
|
||||
<span v-if="builderMode" class="text-xs opacity-50">
|
||||
{{ t('linearMode.arrange.resultsLabel') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="mobile && cell.output" class="absolute inset-0 z-10" />
|
||||
</div>
|
||||
<!-- Horizontal resize handle (row split) -->
|
||||
<div
|
||||
v-if="outputCount >= 2 && !builderMode && !mobile"
|
||||
class="absolute left-0 z-20 h-2 cursor-row-resize"
|
||||
:style="{ top: rowHandleCssTop, width: rowHandleWidth }"
|
||||
@mousedown="onRowResizeStart"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'mx-auto h-px w-full bg-border-subtle/30 transition-colors',
|
||||
isResizing && 'bg-border-subtle'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<!-- Vertical resize handle (column split) -->
|
||||
<div
|
||||
v-if="outputCount >= 3 && !builderMode && !mobile"
|
||||
class="absolute top-0 z-20 h-full w-2 cursor-col-resize"
|
||||
:style="{ left: colHandleCssLeft }"
|
||||
@mousedown="onColResizeStart"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'my-auto h-full w-px bg-border-subtle/30 transition-colors',
|
||||
isResizing && 'bg-border-subtle'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
const { src } = defineProps<{
|
||||
const { src, mobile = false } = defineProps<{
|
||||
src: string
|
||||
mobile?: boolean
|
||||
}>()
|
||||
|
||||
const videoRef = useTemplateRef('videoRef')
|
||||
@@ -23,5 +24,9 @@ const height = ref('')
|
||||
}
|
||||
"
|
||||
/>
|
||||
<span class="z-10 self-center" v-text="`${width} x ${height}`" />
|
||||
<span
|
||||
v-if="!mobile"
|
||||
class="z-10 self-end pr-2"
|
||||
v-text="`${width} x ${height}`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,13 +8,12 @@ import type { InProgressItem } from '@/renderer/extensions/linearMode/linearMode
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
|
||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const { isAppMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const jobPreviewStore = useJobPreviewStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -117,13 +116,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const newOutputs = flattenNodeOutput([nodeId, detail.output])
|
||||
if (newOutputs.length === 0) return
|
||||
|
||||
// Skip output items for nodes not flagged as output nodes
|
||||
const outputNodeIds = appModeStore.selectedOutputs
|
||||
if (
|
||||
outputNodeIds.length > 0 &&
|
||||
!outputNodeIds.some((id) => String(id) === String(nodeId))
|
||||
)
|
||||
return
|
||||
// Track in-progress items for all output nodes, regardless of
|
||||
// which ones are selected for the grid view. This ensures the
|
||||
// full history shows every generated output.
|
||||
|
||||
const skeletonItem = inProgressItems.value.find(
|
||||
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
export interface MediaOutputItem {
|
||||
url: string
|
||||
content?: string
|
||||
isVideo: boolean
|
||||
isImage: boolean
|
||||
mediaType: string
|
||||
}
|
||||
|
||||
type StatItem = { content?: string; iconClass?: string }
|
||||
export const mediaTypes: Record<string, StatItem> = {
|
||||
@@ -26,7 +32,7 @@ export const mediaTypes: Record<string, StatItem> = {
|
||||
}
|
||||
}
|
||||
|
||||
export function getMediaType(output?: ResultItemImpl) {
|
||||
export function getMediaType(output?: MediaOutputItem) {
|
||||
if (!output) return ''
|
||||
if (output.isVideo) return 'video'
|
||||
if (output.isImage) return 'images'
|
||||
|
||||
61
src/renderer/extensions/linearMode/outputGridUtil.test.ts
Normal file
61
src/renderer/extensions/linearMode/outputGridUtil.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cssSplitPos, gridStyleForCount } from './outputGridUtil'
|
||||
|
||||
describe('cssSplitPos', () => {
|
||||
it('returns calc expression for ratio 0.5', () => {
|
||||
const result = cssSplitPos(0.5)
|
||||
expect(result).toBe('calc(8px + (100% - 24px) * 0.5 + 4px)')
|
||||
})
|
||||
|
||||
it('returns calc expression for ratio 0', () => {
|
||||
const result = cssSplitPos(0)
|
||||
expect(result).toBe('calc(8px + (100% - 24px) * 0 + 4px)')
|
||||
})
|
||||
|
||||
it('returns calc expression for ratio 1', () => {
|
||||
const result = cssSplitPos(1)
|
||||
expect(result).toBe('calc(8px + (100% - 24px) * 1 + 4px)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gridStyleForCount', () => {
|
||||
it('returns single column for count 1', () => {
|
||||
expect(gridStyleForCount(1, 0.5, 0.5)).toEqual({
|
||||
gridTemplate: '"a" 1fr / 1fr'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns two rows for count 2', () => {
|
||||
const result = gridStyleForCount(2, 0.5, 0.5)
|
||||
expect(result.gridTemplate).toBe('"a" 0.5fr "b" 0.5fr / 1fr')
|
||||
})
|
||||
|
||||
it('returns L-shape for count 3', () => {
|
||||
const result = gridStyleForCount(3, 0.5, 0.5)
|
||||
expect(result.gridTemplate).toBe('"a c" 0.5fr "b c" 0.5fr / 0.5fr 0.5fr')
|
||||
})
|
||||
|
||||
it('returns 2x2 grid for count 4', () => {
|
||||
const result = gridStyleForCount(4, 0.5, 0.5)
|
||||
expect(result.gridTemplate).toBe('"a b" 0.5fr "c d" 0.5fr / 0.5fr 0.5fr')
|
||||
})
|
||||
|
||||
it('respects custom row ratio', () => {
|
||||
const result = gridStyleForCount(2, 0.7, 0.5)
|
||||
expect(result.gridTemplate).toContain('0.7fr')
|
||||
expect(result.gridTemplate).toContain(`${1 - 0.7}fr`)
|
||||
})
|
||||
|
||||
it('respects custom column ratio', () => {
|
||||
const result = gridStyleForCount(4, 0.5, 0.3)
|
||||
expect(result.gridTemplate).toContain('0.3fr')
|
||||
expect(result.gridTemplate).toContain(`${1 - 0.3}fr`)
|
||||
})
|
||||
|
||||
it('defaults to single for count 0', () => {
|
||||
expect(gridStyleForCount(0, 0.5, 0.5)).toEqual({
|
||||
gridTemplate: '"a" 1fr / 1fr'
|
||||
})
|
||||
})
|
||||
})
|
||||
34
src/renderer/extensions/linearMode/outputGridUtil.ts
Normal file
34
src/renderer/extensions/linearMode/outputGridUtil.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Matches p-2 and gap-2 on the grid container
|
||||
const GRID_PADDING_PX = 8
|
||||
const GRID_GAP_PX = 8
|
||||
|
||||
/** CSS calc() — exactly centered in the gap between grid rows/columns. */
|
||||
export function cssSplitPos(ratio: number): string {
|
||||
const totalPad = GRID_PADDING_PX * 2
|
||||
const pct = ratio * 100
|
||||
return `calc(${GRID_PADDING_PX}px + (100% - ${totalPad + GRID_GAP_PX}px) * ${pct / 100} + ${GRID_GAP_PX / 2}px)`
|
||||
}
|
||||
|
||||
/** Build CSS grid-template for a given output count. */
|
||||
export function gridStyleForCount(
|
||||
count: number,
|
||||
rowRatio: number,
|
||||
colRatio: number
|
||||
): { gridTemplate: string } {
|
||||
const r = rowRatio
|
||||
const c = colRatio
|
||||
switch (count) {
|
||||
case 2:
|
||||
return { gridTemplate: `"a" ${r}fr "b" ${1 - r}fr / 1fr` }
|
||||
case 3:
|
||||
return {
|
||||
gridTemplate: `"a c" ${r}fr "b c" ${1 - r}fr / ${c}fr ${1 - c}fr`
|
||||
}
|
||||
case 4:
|
||||
return {
|
||||
gridTemplate: `"a b" ${r}fr "c d" ${1 - r}fr / ${c}fr ${1 - c}fr`
|
||||
}
|
||||
default:
|
||||
return { gridTemplate: '"a" 1fr / 1fr' }
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export function useOutputHistory(): {
|
||||
const linearStore = useLinearOutputStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
function matchesActiveWorkflow(task: { jobId: string | number }): boolean {
|
||||
@@ -63,14 +62,6 @@ export function useOutputHistory(): {
|
||||
hasActiveWorkflowJobs()
|
||||
)
|
||||
|
||||
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
|
||||
const nodeIds = appModeStore.selectedOutputs
|
||||
if (!nodeIds.length) return []
|
||||
return items.filter((r) =>
|
||||
nodeIds.some((id) => String(id) === String(r.nodeId))
|
||||
)
|
||||
}
|
||||
|
||||
const sessionMedia = computed(() => {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return []
|
||||
@@ -101,7 +92,7 @@ export function useOutputHistory(): {
|
||||
if (!item?.id) return []
|
||||
|
||||
const cached = resolvedCache.get(item.id)
|
||||
if (cached) return filterByOutputNodes(cached)
|
||||
if (cached) return cached
|
||||
|
||||
const user_metadata = getOutputAssetMetadata(item.user_metadata)
|
||||
if (!user_metadata) return []
|
||||
@@ -114,7 +105,7 @@ export function useOutputHistory(): {
|
||||
.map((i) => i.output!)
|
||||
if (ordered.length > 0) {
|
||||
resolvedCache.set(item.id, ordered)
|
||||
return filterByOutputNodes(ordered)
|
||||
return ordered
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,13 +120,13 @@ export function useOutputHistory(): {
|
||||
) {
|
||||
const reversed = user_metadata.allOutputs.toReversed()
|
||||
resolvedCache.set(item.id, reversed)
|
||||
return filterByOutputNodes(reversed)
|
||||
return reversed
|
||||
}
|
||||
|
||||
// Async fallback for multi-output jobs — fetch full /jobs/{id} detail.
|
||||
// This can be hit if the user executes the job then switches tabs.
|
||||
const existing = asyncRefs.get(item.id)
|
||||
if (existing) return filterByOutputNodes(existing.value)
|
||||
if (existing) return existing.value
|
||||
|
||||
const itemId = item.id
|
||||
const outputRef = useAsyncState(
|
||||
@@ -150,16 +141,26 @@ export function useOutputHistory(): {
|
||||
[]
|
||||
).state
|
||||
asyncRefs.set(item.id, outputRef)
|
||||
return filterByOutputNodes(outputRef.value)
|
||||
return outputRef.value
|
||||
}
|
||||
|
||||
function selectFirstHistory() {
|
||||
const first = outputs.media.value[0]
|
||||
if (first) {
|
||||
linearStore.selectAsLatest(`history:${first.id}:0`)
|
||||
} else {
|
||||
if (!first) {
|
||||
linearStore.selectAsLatest(null)
|
||||
return
|
||||
}
|
||||
// Prefer the first output that matches a user-selected output node
|
||||
const selectedNodeIds = useAppModeStore().selectedOutputs
|
||||
const outs = allOutputs(first)
|
||||
const preferredIdx = selectedNodeIds.length
|
||||
? outs.findIndex((o) =>
|
||||
selectedNodeIds.some((id) => String(id) === String(o.nodeId))
|
||||
)
|
||||
: -1
|
||||
linearStore.selectAsLatest(
|
||||
`history:${first.id}:${preferredIdx >= 0 ? preferredIdx : 0}`
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve in-progress items when history outputs are loaded.
|
||||
|
||||
Reference in New Issue
Block a user