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:
Koshi
2026-03-21 02:31:26 +01:00
parent 2eb2514d25
commit 4b74c0182a
4 changed files with 127 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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