mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
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:
61
src/components/common/ImageLightbox.test.ts
Normal file
61
src/components/common/ImageLightbox.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
57
src/components/common/ImageLightbox.vue
Normal file
57
src/components/common/ImageLightbox.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user