mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Add ImageLightbox and preview dblclick handling
Introduce an ImageLightbox and wire double-click preview behavior across the linear mode UI. AppTemplateView: import ImageLightbox, add selectedOutput prop, refactor zoneOutputs into liveZoneOutputs and a new zoneOutputs that injects a selected history item into the output zone, add lightbox state/openLightbox handler, wrap MediaOutputPreview in buttons to support @dblclick to open the lightbox, and render the lightbox component. LinearPreview: import ImageLightbox, add lightbox state, forward open-lightbox events from children to open the lightbox, and adjust conditional rendering to pass selectedOutput into AppTemplateView. OutputHistory: add an openLightbox emit and emit it on dblclick of a history item. Also add a ResultItemImpl type import and small UI/class tweaks to accommodate the new behavior. These changes let users open outputs in a full lightbox and preview a selected history item in the template's output zone.
This commit is contained in:
@@ -31,6 +31,8 @@ const {
|
||||
resizable?: boolean
|
||||
/** Zone IDs that have content — empty zones get no border in app mode. */
|
||||
filledZones?: Set<string>
|
||||
/** Extra CSS classes per zone ID, applied to the grid cell div. */
|
||||
zoneClasses?: Record<string, string>
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
@@ -227,7 +229,8 @@ function onRowResizeEnd(fractions: number[]) {
|
||||
? 'border-0'
|
||||
: 'border-2 border-solid border-border-subtle',
|
||||
highlightedZone === zone.id &&
|
||||
'border-primary-background bg-primary-background/10'
|
||||
'border-primary-background bg-primary-background/10',
|
||||
zoneClasses?.[zone.id]
|
||||
)
|
||||
"
|
||||
:data-zone-id="zone.id"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ImageLightbox from '@/components/common/ImageLightbox.vue'
|
||||
import LayoutZoneGrid from '@/components/builder/LayoutZoneGrid.vue'
|
||||
import PresetStrip from '@/components/builder/PresetStrip.vue'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
@@ -22,12 +23,17 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { selectedOutput } = defineProps<{
|
||||
selectedOutput?: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
const appModeStore = useAppModeStore()
|
||||
@@ -58,7 +64,7 @@ const presetStripZoneId = computed(() => {
|
||||
})
|
||||
|
||||
/** Per-zone output results — each zone gets its own assigned outputs. */
|
||||
const zoneOutputs = computed(() => {
|
||||
const liveZoneOutputs = computed(() => {
|
||||
const map = new Map<string, ReturnType<typeof flattenNodeOutput>>()
|
||||
|
||||
for (const zone of template.value.zones) {
|
||||
@@ -78,6 +84,19 @@ const zoneOutputs = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
/** When a history item is selected, show it in the first output zone. */
|
||||
const zoneOutputs = computed(() => {
|
||||
if (!selectedOutput) return liveZoneOutputs.value
|
||||
|
||||
const map = new Map(liveZoneOutputs.value)
|
||||
const outputZone =
|
||||
template.value.zones.find((z) => z.isOutput)?.id ??
|
||||
template.value.zones.at(-1)?.id ??
|
||||
''
|
||||
map.set(outputZone, [selectedOutput])
|
||||
return map
|
||||
})
|
||||
|
||||
/** Per-zone output node count for placeholders (before results arrive). */
|
||||
const zoneOutputNodeCount = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
@@ -186,8 +205,11 @@ const filledZones = computed(() => {
|
||||
const filled = new Set<string>()
|
||||
for (const zone of template.value.zones) {
|
||||
const hasWidgets = (zoneWidgets.value.get(zone.id)?.length ?? 0) > 0
|
||||
const hasSelectedOutput = selectedOutput && zone.isOutput
|
||||
const hasOutputs =
|
||||
zoneOutputs.value.has(zone.id) || zoneOutputNodeCount.value.has(zone.id)
|
||||
hasSelectedOutput ||
|
||||
zoneOutputs.value.has(zone.id) ||
|
||||
zoneOutputNodeCount.value.has(zone.id)
|
||||
const hasRun = zone.id === runControlsZoneId.value
|
||||
const hasPreset =
|
||||
appModeStore.presetsEnabled && zone.id === presetStripZoneId.value
|
||||
@@ -202,6 +224,25 @@ function zoneBorderClass(zoneId: string): string {
|
||||
return 'rounded-xl ring-2 ring-border-subtle ring-inset'
|
||||
}
|
||||
|
||||
/** Apply bg-black to grid cells that are showing output content. */
|
||||
const outputZoneClasses = computed(() => {
|
||||
const classes: Record<string, string> = {}
|
||||
for (const zone of template.value.zones) {
|
||||
if (zoneOutputs.value.has(zone.id)) {
|
||||
classes[zone.id] = 'bg-black'
|
||||
}
|
||||
}
|
||||
return classes
|
||||
})
|
||||
|
||||
const lightboxSrc = ref('')
|
||||
const lightboxOpen = ref(false)
|
||||
|
||||
function openLightbox(url: string) {
|
||||
lightboxSrc.value = url
|
||||
lightboxOpen.value = true
|
||||
}
|
||||
|
||||
async function runPrompt(e: Event) {
|
||||
const commandId =
|
||||
e instanceof MouseEvent && e.shiftKey
|
||||
@@ -243,11 +284,15 @@ async function runPrompt(e: Event) {
|
||||
v-if="(zoneOutputs.get(zone.id)?.length ?? 0) > 0"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<MediaOutputPreview
|
||||
<button
|
||||
v-for="(output, idx) in zoneOutputs.get(zone.id)"
|
||||
:key="idx"
|
||||
:output="output"
|
||||
/>
|
||||
type="button"
|
||||
class="cursor-pointer"
|
||||
@dblclick="output.url && openLightbox(output.url)"
|
||||
>
|
||||
<MediaOutputPreview :output="output" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -319,6 +364,7 @@ async function runPrompt(e: Event) {
|
||||
:dashed="false"
|
||||
:grid-overrides="appModeStore.gridOverrides"
|
||||
:filled-zones="filledZones"
|
||||
:zone-classes="outputZoneClasses"
|
||||
class="min-h-0"
|
||||
>
|
||||
<template #zone="{ zone }">
|
||||
@@ -329,7 +375,8 @@ async function runPrompt(e: Event) {
|
||||
'flex size-full min-h-0 flex-col',
|
||||
(appModeStore.zoneAlign[zone.id] ?? 'top') === 'bottom'
|
||||
? 'justify-end'
|
||||
: 'justify-start'
|
||||
: 'justify-start',
|
||||
zoneOutputs.has(zone.id) && 'rounded-xl bg-black'
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -337,14 +384,31 @@ async function runPrompt(e: Event) {
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-0 flex-col gap-2 overflow-y-auto p-2',
|
||||
'flex min-h-0 flex-col overflow-y-auto',
|
||||
zoneBorderClass(zone.id),
|
||||
zoneOutputs.has(zone.id) || zoneOutputNodeCount.has(zone.id)
|
||||
? 'flex-1'
|
||||
: ''
|
||||
: '',
|
||||
zoneOutputs.has(zone.id) ? 'bg-black' : 'gap-2 p-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- History selection: show in output zone when no builder outputs -->
|
||||
<button
|
||||
v-if="
|
||||
selectedOutput &&
|
||||
zone.isOutput &&
|
||||
!getZoneRenderItems(zone.id).some((i) => i.type === 'output')
|
||||
"
|
||||
type="button"
|
||||
class="min-h-0 flex-1 cursor-pointer overflow-hidden rounded-lg border border-warning-background/50 bg-black"
|
||||
@dblclick="selectedOutput.url && openLightbox(selectedOutput.url)"
|
||||
>
|
||||
<MediaOutputPreview
|
||||
:output="selectedOutput"
|
||||
class="size-full [&_span]:hidden"
|
||||
/>
|
||||
</button>
|
||||
<!-- Unified item order — deduplicated by node -->
|
||||
<template
|
||||
v-for="item in getZoneRenderItems(zone.id)"
|
||||
@@ -356,8 +420,9 @@ async function runPrompt(e: Event) {
|
||||
:class="
|
||||
cn(
|
||||
'min-h-0 flex-1 overflow-hidden rounded-lg border border-warning-background/50',
|
||||
!(zoneOutputs.get(zone.id)?.length ?? 0) &&
|
||||
'bg-warning-background/5 p-4'
|
||||
(zoneOutputs.get(zone.id)?.length ?? 0) > 0
|
||||
? 'bg-black'
|
||||
: 'bg-warning-background/5 p-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -365,12 +430,18 @@ async function runPrompt(e: Event) {
|
||||
v-if="(zoneOutputs.get(zone.id)?.length ?? 0) > 0"
|
||||
class="flex size-full flex-col gap-2"
|
||||
>
|
||||
<MediaOutputPreview
|
||||
<button
|
||||
v-for="(output, idx) in zoneOutputs.get(zone.id)"
|
||||
:key="idx"
|
||||
:output="output"
|
||||
class="min-h-0 flex-1"
|
||||
/>
|
||||
type="button"
|
||||
class="min-h-0 flex-1 cursor-pointer overflow-hidden"
|
||||
@dblclick="output.url && openLightbox(output.url)"
|
||||
>
|
||||
<MediaOutputPreview
|
||||
:output="output"
|
||||
class="size-full bg-black [&_div]:bg-black [&_span]:hidden"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -442,5 +513,6 @@ async function runPrompt(e: Event) {
|
||||
</div>
|
||||
</template>
|
||||
</LayoutZoneGrid>
|
||||
<ImageLightbox v-model="lightboxOpen" :src="lightboxSrc" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import ImageLightbox from '@/components/common/ImageLightbox.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -46,6 +47,8 @@ const selectedOutput = ref<ResultItemImpl>()
|
||||
const canShowPreview = ref(true)
|
||||
const latentPreview = ref<string>()
|
||||
const showSkeleton = ref(false)
|
||||
const lightboxSrc = ref('')
|
||||
const lightboxOpen = ref(false)
|
||||
|
||||
function handleSelection(sel: OutputSelection) {
|
||||
selectedItem.value = sel.asset
|
||||
@@ -140,20 +143,25 @@ async function rerun(e: Event) {
|
||||
]"
|
||||
/>
|
||||
</section>
|
||||
<ImagePreview
|
||||
v-if="canShowPreview && latentPreview"
|
||||
:mobile
|
||||
:src="latentPreview"
|
||||
<LinearArrange v-if="isArrangeMode" />
|
||||
<AppTemplateView
|
||||
v-else-if="hasAppContent"
|
||||
:selected-output="selectedOutput"
|
||||
/>
|
||||
<MediaOutputPreview
|
||||
v-else-if="selectedOutput"
|
||||
:output="selectedOutput"
|
||||
:mobile
|
||||
/>
|
||||
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
|
||||
<LinearArrange v-else-if="isArrangeMode" />
|
||||
<AppTemplateView v-else-if="hasAppContent" />
|
||||
<LinearWelcome v-else />
|
||||
<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>
|
||||
<div
|
||||
v-if="!mobile"
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
@@ -167,6 +175,12 @@ async function rerun(e: Event) {
|
||||
v-if="!isBuilderMode"
|
||||
class="z-10 min-w-0"
|
||||
@update-selection="handleSelection"
|
||||
@open-lightbox="
|
||||
(url) => {
|
||||
lightboxSrc = url
|
||||
lightboxOpen = true
|
||||
}
|
||||
"
|
||||
/>
|
||||
<LinearFeedback
|
||||
v-if="typeformWidgetId"
|
||||
@@ -177,5 +191,12 @@ async function rerun(e: Event) {
|
||||
<OutputHistory
|
||||
v-else-if="!isBuilderMode"
|
||||
@update-selection="handleSelection"
|
||||
@open-lightbox="
|
||||
(url) => {
|
||||
lightboxSrc = url
|
||||
lightboxOpen = true
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ImageLightbox v-model="lightboxOpen" :src="lightboxSrc" />
|
||||
</template>
|
||||
|
||||
@@ -40,6 +40,7 @@ const workflowStore = useWorkflowStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateSelection: [selection: OutputSelection]
|
||||
openLightbox: [url: string]
|
||||
}>()
|
||||
|
||||
const queueCount = computed(
|
||||
@@ -361,6 +362,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
|
||||
:class="itemClass"
|
||||
@click="store.select(`history:${asset.id}:${key}`)"
|
||||
@dblclick="output.url && $emit('openLightbox', output.url)"
|
||||
>
|
||||
<OutputHistoryItem :output="output" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user