feat: App mode - add lightbox to view image in drop zone (#9888)

## Summary

Adds a button to view image in lightbox in app mode

## Changes

- **What**: 
- Add generic image lightbox component
- Add zoom button to dropzone
- Move buttons to outside image layer to top right of drop zone

## Screenshots (if applicable)

<img width="1164" height="838" alt="image"
src="https://github.com/user-attachments/assets/c92f2227-9dc0-49bd-bb27-5211e22060be"
/>
<img width="290" height="199" alt="image"
src="https://github.com/user-attachments/assets/90424b8e-c502-4d65-ad21-545d5add6d0b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9888-feat-App-mode-add-lightbox-to-view-image-in-drop-zone-3226d73d365081c387a6c2dd24dc4100)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-19 16:22:06 +00:00
committed by GitHub
parent be6c64c75b
commit 57783fffcf
4 changed files with 186 additions and 44 deletions

View File

@@ -0,0 +1,61 @@
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import ImageLightbox from './ImageLightbox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function findCloseButton() {
const el = document.body.querySelector('[aria-label="g.close"]')
return el ? new DOMWrapper(el) : null
}
describe(ImageLightbox, () => {
let wrapper: VueWrapper
afterEach(() => {
wrapper.unmount()
})
function mountComponent(props: { src: string; alt?: string }, open = true) {
wrapper = mount(ImageLightbox, {
global: { plugins: [i18n] },
props: { ...props, modelValue: open },
attachTo: document.body
})
return wrapper
}
it('renders the image with correct src and alt when open', async () => {
mountComponent({ src: '/test.png', alt: 'Test image' })
await flushPromises()
const img = document.body.querySelector('img')
expect(img).toBeTruthy()
expect(img?.getAttribute('src')).toBe('/test.png')
expect(img?.getAttribute('alt')).toBe('Test image')
})
it('does not render dialog content when closed', async () => {
mountComponent({ src: '/test.png' }, false)
await flushPromises()
expect(document.body.querySelector('img')).toBeNull()
})
it('emits update:modelValue false when close button is clicked', async () => {
mountComponent({ src: '/test.png' })
await flushPromises()
const closeButton = findCloseButton()
expect(closeButton).toBeTruthy()
await closeButton!.trigger('click')
await flushPromises()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
})
})

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
VisuallyHidden
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const open = defineModel<boolean>({ default: false })
const { src, alt = '' } = defineProps<{
src: string
alt?: string
}>()
const { t } = useI18n()
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-30 bg-black/60 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<DialogContent
class="fixed top-1/2 left-1/2 z-1700 -translate-1/2 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-50 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-50"
@escape-key-down="open = false"
>
<VisuallyHidden>
<DialogTitle>{{ alt || t('g.imageLightbox') }}</DialogTitle>
<DialogDescription v-if="alt">{{ alt }}</DialogDescription>
</VisuallyHidden>
<DialogClose as-child>
<Button
:aria-label="t('g.close')"
size="icon"
variant="muted-textonly"
class="absolute -top-2 -right-2 z-10 translate-x-full text-white hover:text-white/80"
>
<i class="icon-[lucide--x] size-5" />
</Button>
</DialogClose>
<img
:src
:alt
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
/>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -31,6 +31,7 @@
"audioProgress": "Audio progress",
"viewImageOfTotal": "View image {index} of {total}",
"viewVideoOfTotal": "View video {index} of {total}",
"imageLightbox": "Image preview",
"imagePreview": "Image preview - Use arrow keys to navigate between images",
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
"galleryImage": "Gallery image",

View File

@@ -3,8 +3,11 @@ import { useDropZone } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ImageLightbox from '@/components/common/ImageLightbox.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
const { t } = useI18n()
const {
@@ -28,6 +31,7 @@ const {
const dropZoneRef = ref<HTMLElement | null>(null)
const canAcceptDrop = ref(false)
const pointerStart = ref<{ x: number; y: number } | null>(null)
const lightboxOpen = ref(false)
function onPointerDown(e: PointerEvent) {
pointerStart.value = { x: e.clientX, y: e.clientY }
@@ -78,6 +82,7 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
<div
v-if="onDragOver && onDragDrop"
ref="dropZoneRef"
v-bind="$attrs"
data-slot="drop-zone"
:class="
cn(
@@ -87,65 +92,83 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
"
>
<slot />
<component
:is="indicatorTag"
v-if="dropIndicator"
:type="dropIndicator?.onClick ? 'button' : undefined"
:aria-label="dropIndicator?.onClick ? dropIndicator.label : undefined"
data-slot="drop-zone-indicator"
:class="
cn(
'group/dropzone m-3 block h-42 min-h-32 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
dropIndicator?.onClick && 'cursor-pointer'
)
"
@pointerdown="onPointerDown"
@click.prevent="onIndicatorClick"
>
<div
<div v-if="dropIndicator" class="group/dropzone relative">
<component
:is="indicatorTag"
:type="dropIndicator.onClick ? 'button' : undefined"
:aria-label="dropIndicator.onClick ? dropIndicator.label : undefined"
data-slot="drop-zone-indicator"
:class="
cn(
'flex h-full max-w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-[7px] p-3 text-center text-sm/tight transition-colors',
isHovered &&
!dropIndicator?.imageUrl &&
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
'm-3 block h-25 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
dropIndicator.onClick && 'cursor-pointer'
)
"
@pointerdown="onPointerDown"
@click.prevent="onIndicatorClick"
>
<div
v-if="dropIndicator?.imageUrl"
class="relative max-h-full max-w-full"
:class="
cn(
'flex h-full max-w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-[7px] p-3 text-center text-sm/tight transition-colors',
isHovered &&
!dropIndicator.imageUrl &&
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
)
"
>
<div v-if="dropIndicator.imageUrl" class="max-h-full max-w-full">
<img
class="max-h-full max-w-full rounded-md object-contain"
:alt="dropIndicator.label ?? ''"
:src="dropIndicator.imageUrl"
/>
</div>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i
v-if="dropIndicator.iconClass"
:class="
cn(
'size-4 text-component-node-foreground-secondary',
dropIndicator.iconClass
)
"
/>
</template>
</div>
</component>
<template v-if="dropIndicator.imageUrl">
<div
class="absolute top-2 right-5 z-10 flex gap-1 opacity-0 transition-opacity duration-200 group-focus-within/dropzone:opacity-100 group-hover/dropzone:opacity-100"
>
<img
class="max-h-full max-w-full rounded-md object-contain"
:alt="dropIndicator?.label ?? ''"
:src="dropIndicator?.imageUrl"
/>
<button
v-if="dropIndicator?.onMaskEdit"
v-if="dropIndicator.onMaskEdit"
type="button"
:aria-label="t('maskEditor.openMaskEditor')"
:title="t('maskEditor.openMaskEditor')"
class="absolute top-2 right-2 flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background opacity-0 transition-colors duration-200 group-hover/dropzone:opacity-100 hover:bg-base-foreground/90"
@click.stop="dropIndicator?.onMaskEdit?.()"
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="dropIndicator.onMaskEdit()"
>
<i class="icon-[comfy--mask] size-4" />
</button>
<button
type="button"
: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" />
</button>
</div>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i
v-if="dropIndicator.iconClass"
:class="
cn(
'size-4 text-component-node-foreground-secondary',
dropIndicator.iconClass
)
"
/>
</template>
</div>
</component>
<ImageLightbox
v-model="lightboxOpen"
:src="dropIndicator.imageUrl"
:alt="dropIndicator.label ?? ''"
/>
</template>
</div>
</div>
<slot v-else />
</template>