mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
2 Commits
batch-disp
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cefb3bb40 | ||
|
|
7ff581df95 |
@@ -12,15 +12,8 @@
|
|||||||
class="comfy-image-blur"
|
class="comfy-image-blur"
|
||||||
:style="{ 'background-image': `url(${src})` }"
|
:style="{ 'background-image': `url(${src})` }"
|
||||||
:alt="alt"
|
:alt="alt"
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
:src="src"
|
|
||||||
class="comfy-image-main"
|
|
||||||
:class="classProp"
|
|
||||||
:alt="alt"
|
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
/>
|
||||||
|
<img :src="src" class="comfy-image-main" :class="classProp" :alt="alt" />
|
||||||
</span>
|
</span>
|
||||||
<div v-if="imageBroken" class="broken-image-placeholder">
|
<div v-if="imageBroken" class="broken-image-placeholder">
|
||||||
<i class="pi pi-image" />
|
<i class="pi pi-image" />
|
||||||
@@ -29,7 +22,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { useImage } from '@vueuse/core'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
src,
|
src,
|
||||||
@@ -46,10 +39,7 @@ const {
|
|||||||
alt?: string
|
alt?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const imageBroken = ref(false)
|
const { error: imageBroken } = useImage({ src, alt })
|
||||||
const handleImageError = () => {
|
|
||||||
imageBroken.value = true
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -3,12 +3,27 @@ import type { ComponentProps } from 'vue-component-type-helpers'
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import Avatar from 'primevue/avatar'
|
import Avatar from 'primevue/avatar'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createApp, nextTick } from 'vue'
|
import { createApp, nextTick, ref } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import UserAvatar from './UserAvatar.vue'
|
import UserAvatar from './UserAvatar.vue'
|
||||||
|
|
||||||
|
const mockImageError = ref(false)
|
||||||
|
|
||||||
|
vi.mock('@vueuse/core', async () => {
|
||||||
|
const actual = await vi.importActual('@vueuse/core')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useImage: () => ({
|
||||||
|
state: ref(undefined),
|
||||||
|
error: mockImageError,
|
||||||
|
isReady: ref(false),
|
||||||
|
isLoading: ref(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
@@ -27,6 +42,7 @@ describe('UserAvatar', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const app = createApp({})
|
const app = createApp({})
|
||||||
app.use(PrimeVue)
|
app.use(PrimeVue)
|
||||||
|
mockImageError.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
|
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
|
||||||
@@ -80,8 +96,8 @@ describe('UserAvatar', () => {
|
|||||||
const avatar = wrapper.findComponent(Avatar)
|
const avatar = wrapper.findComponent(Avatar)
|
||||||
expect(avatar.props('icon')).toBeNull()
|
expect(avatar.props('icon')).toBeNull()
|
||||||
|
|
||||||
// Simulate image load error
|
// Simulate useImage reporting an error
|
||||||
avatar.vm.$emit('error')
|
mockImageError.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
|
expect(avatar.props('icon')).toBe('icon-[lucide--user]')
|
||||||
|
|||||||
@@ -6,22 +6,22 @@
|
|||||||
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
|
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useImage } from '@vueuse/core'
|
||||||
import Avatar from 'primevue/avatar'
|
import Avatar from 'primevue/avatar'
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const { photoUrl, ariaLabel } = defineProps<{
|
const { photoUrl, ariaLabel } = defineProps<{
|
||||||
photoUrl?: string | null
|
photoUrl?: string | null
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const imageError = ref(false)
|
const { error: imageError } = useImage({
|
||||||
const handleImageError = () => {
|
src: photoUrl ?? '',
|
||||||
imageError.value = true
|
alt: ariaLabel ?? ''
|
||||||
}
|
})
|
||||||
const hasAvatar = computed(() => photoUrl && !imageError.value)
|
const hasAvatar = computed(() => photoUrl && !imageError.value)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -28,9 +28,7 @@
|
|||||||
:src="imageUrl"
|
:src="imageUrl"
|
||||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
class="block size-full object-contain select-none"
|
class="block size-full object-contain brightness-50 select-none"
|
||||||
@load="handleImageLoad"
|
|
||||||
@error="handleImageError"
|
|
||||||
@dragstart.prevent
|
@dragstart.prevent
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -131,8 +129,6 @@ const {
|
|||||||
cropBoxStyle,
|
cropBoxStyle,
|
||||||
resizeHandles,
|
resizeHandles,
|
||||||
|
|
||||||
handleImageLoad,
|
|
||||||
handleImageError,
|
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragMove,
|
handleDragMove,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
|
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
|
||||||
<img
|
<img
|
||||||
ref="imgRef"
|
|
||||||
:src="imageUrl"
|
:src="imageUrl"
|
||||||
:alt="name"
|
:alt="name"
|
||||||
class="size-full cursor-pointer object-contain"
|
class="size-full cursor-pointer object-contain"
|
||||||
@click="$emit('image-click')"
|
@click="$emit('image-click')"
|
||||||
@load="onImgLoad"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="timeLabel"
|
v-if="timeLabel"
|
||||||
@@ -40,11 +38,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { useImage } from '@vueuse/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false })
|
defineOptions({ inheritAttrs: false })
|
||||||
|
|
||||||
defineProps<{
|
const { imageUrl, name } = defineProps<{
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
name: string
|
name: string
|
||||||
timeLabel?: string
|
timeLabel?: string
|
||||||
@@ -52,14 +51,12 @@ defineProps<{
|
|||||||
|
|
||||||
defineEmits(['image-click'])
|
defineEmits(['image-click'])
|
||||||
|
|
||||||
const imgRef = ref<HTMLImageElement | null>(null)
|
const { state, isReady } = useImage({ src: imageUrl, alt: name })
|
||||||
const width = ref<number | null>(null)
|
|
||||||
const height = ref<number | null>(null)
|
|
||||||
|
|
||||||
const onImgLoad = () => {
|
const width = computed(() =>
|
||||||
const el = imgRef.value
|
isReady.value ? (state.value?.naturalWidth ?? null) : null
|
||||||
if (!el) return
|
)
|
||||||
width.value = el.naturalWidth || null
|
const height = computed(() =>
|
||||||
height.value = el.naturalHeight || null
|
isReady.value ? (state.value?.naturalHeight ?? null) : null
|
||||||
}
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useResizeObserver } from '@vueuse/core'
|
import { useImage, useResizeObserver } from '@vueuse/core'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
@@ -349,15 +349,23 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleImageLoad = () => {
|
const { isReady: imageIsReady, error: imageLoadError } = useImage(
|
||||||
isLoading.value = false
|
computed(() => ({ src: imageUrl.value ?? '' }))
|
||||||
updateDisplayedDimensions()
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const handleImageError = () => {
|
watch(imageIsReady, (ready) => {
|
||||||
isLoading.value = false
|
if (ready) {
|
||||||
imageUrl.value = null
|
isLoading.value = false
|
||||||
}
|
updateDisplayedDimensions()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(imageLoadError, (err) => {
|
||||||
|
if (err) {
|
||||||
|
isLoading.value = false
|
||||||
|
imageUrl.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const capturePointer = (e: PointerEvent) =>
|
const capturePointer = (e: PointerEvent) =>
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
@@ -596,8 +604,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
|||||||
cropBoxStyle,
|
cropBoxStyle,
|
||||||
resizeHandles,
|
resizeHandles,
|
||||||
|
|
||||||
handleImageLoad,
|
|
||||||
handleImageError,
|
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragMove,
|
handleDragMove,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
:src="thumbnailSrc"
|
:src="thumbnailSrc"
|
||||||
:alt="asset?.name"
|
:alt="asset?.name"
|
||||||
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||||
@error="thumbnailError = true"
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -20,16 +19,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { useImage } from '@vueuse/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
import type { AssetMeta } from '../schemas/mediaAssetSchema'
|
||||||
|
|
||||||
const { asset } = defineProps<{ asset: AssetMeta }>()
|
const { asset } = defineProps<{ asset: AssetMeta }>()
|
||||||
|
|
||||||
const thumbnailError = ref(false)
|
|
||||||
|
|
||||||
const thumbnailSrc = computed(() => {
|
const thumbnailSrc = computed(() => {
|
||||||
if (!asset?.src) return ''
|
if (!asset?.src) return ''
|
||||||
return asset.src.replace(/([?&]filename=)([^&]*)/, '$1$2.png')
|
return asset.src.replace(/([?&]filename=)([^&]*)/, '$1$2.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { error: thumbnailError } = useImage(
|
||||||
|
computed(() => ({ src: thumbnailSrc.value, alt: asset?.name ?? '' }))
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, useTemplateRef } from 'vue'
|
import { useImage } from '@vueuse/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
import ZoomPane from '@/components/ui/ZoomPane.vue'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
@@ -11,9 +12,10 @@ const { src } = defineProps<{
|
|||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const imageRef = useTemplateRef('imageRef')
|
const { state } = useImage({ src })
|
||||||
const width = ref('')
|
|
||||||
const height = ref('')
|
const width = computed(() => state.value?.naturalWidth?.toString() ?? '')
|
||||||
|
const height = computed(() => state.value?.naturalHeight?.toString() ?? '')
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<ZoomPane
|
<ZoomPane
|
||||||
@@ -21,32 +23,8 @@ const height = ref('')
|
|||||||
v-slot="slotProps"
|
v-slot="slotProps"
|
||||||
:class="cn('w-full flex-1', $attrs.class as string)"
|
:class="cn('w-full flex-1', $attrs.class as string)"
|
||||||
>
|
>
|
||||||
<img
|
<img :src v-bind="slotProps" class="size-full object-contain" />
|
||||||
ref="imageRef"
|
|
||||||
:src
|
|
||||||
v-bind="slotProps"
|
|
||||||
class="size-full object-contain"
|
|
||||||
@load="
|
|
||||||
() => {
|
|
||||||
if (!imageRef) return
|
|
||||||
width = `${imageRef.naturalWidth}`
|
|
||||||
height = `${imageRef.naturalHeight}`
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</ZoomPane>
|
</ZoomPane>
|
||||||
<img
|
<img v-else class="grow object-contain contain-size" :src />
|
||||||
v-else
|
|
||||||
ref="imageRef"
|
|
||||||
class="grow object-contain contain-size"
|
|
||||||
:src
|
|
||||||
@load="
|
|
||||||
() => {
|
|
||||||
if (!imageRef) return
|
|
||||||
width = `${imageRef.naturalWidth}`
|
|
||||||
height = `${imageRef.naturalHeight}`
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
|
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
|||||||
import type { VueWrapper } from '@vue/test-utils'
|
import type { VueWrapper } from '@vue/test-utils'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick, ref } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { downloadFile } from '@/base/common/downloadUtil'
|
import { downloadFile } from '@/base/common/downloadUtil'
|
||||||
@@ -13,6 +13,23 @@ vi.mock('@/base/common/downloadUtil', () => ({
|
|||||||
downloadFile: vi.fn()
|
downloadFile: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const mockImageState = ref<HTMLImageElement | undefined>(undefined)
|
||||||
|
const mockImageError = ref(false)
|
||||||
|
const mockImageIsReady = ref(false)
|
||||||
|
|
||||||
|
vi.mock('@vueuse/core', async () => {
|
||||||
|
const actual = await vi.importActual('@vueuse/core')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useImage: () => ({
|
||||||
|
state: mockImageState,
|
||||||
|
error: mockImageError,
|
||||||
|
isReady: mockImageIsReady,
|
||||||
|
isLoading: ref(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
@@ -47,6 +64,11 @@ describe('ImagePreview', () => {
|
|||||||
const wrapperRegistry = new Set<VueWrapper>()
|
const wrapperRegistry = new Set<VueWrapper>()
|
||||||
|
|
||||||
const mountImagePreview = (props = {}) => {
|
const mountImagePreview = (props = {}) => {
|
||||||
|
// Reset mock state before each mount
|
||||||
|
mockImageState.value = undefined
|
||||||
|
mockImageError.value = false
|
||||||
|
mockImageIsReady.value = false
|
||||||
|
|
||||||
const wrapper = mount(ImagePreview, {
|
const wrapper = mount(ImagePreview, {
|
||||||
props: { ...defaultProps, ...props },
|
props: { ...defaultProps, ...props },
|
||||||
global: {
|
global: {
|
||||||
@@ -320,8 +342,8 @@ describe('ImagePreview', () => {
|
|||||||
imageUrls: [sameUrl, sameUrl, sameUrl]
|
imageUrls: [sameUrl, sameUrl, sameUrl]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Simulate initial image load
|
// Simulate useImage reporting image as ready
|
||||||
await wrapper.find('img').trigger('load')
|
mockImageIsReady.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
|
||||||
|
|
||||||
@@ -349,9 +371,8 @@ describe('ImagePreview', () => {
|
|||||||
const urls = ['/api/view?filename=test.png&type=output']
|
const urls = ['/api/view?filename=test.png&type=output']
|
||||||
const wrapper = mountImagePreview({ imageUrls: urls })
|
const wrapper = mountImagePreview({ imageUrls: urls })
|
||||||
|
|
||||||
// Simulate image load completing
|
// Simulate useImage reporting image as ready
|
||||||
const img = wrapper.find('img')
|
mockImageIsReady.value = true
|
||||||
await img.trigger('load')
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Verify loader is hidden after load
|
// Verify loader is hidden after load
|
||||||
@@ -379,9 +400,8 @@ describe('ImagePreview', () => {
|
|||||||
const urls = ['/api/view?filename=test.png&type=output']
|
const urls = ['/api/view?filename=test.png&type=output']
|
||||||
const wrapper = mountImagePreview({ imageUrls: urls })
|
const wrapper = mountImagePreview({ imageUrls: urls })
|
||||||
|
|
||||||
// Simulate image load completing
|
// Simulate useImage reporting image as ready
|
||||||
const img = wrapper.find('img')
|
mockImageIsReady.value = true
|
||||||
await img.trigger('load')
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Verify loader is hidden
|
// Verify loader is hidden
|
||||||
|
|||||||
@@ -46,8 +46,6 @@
|
|||||||
(isHovered || isFocused) && 'opacity-60'
|
(isHovered || isFocused) && 'opacity-60'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@load="handleImageLoad"
|
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||||
@@ -123,7 +121,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTimeoutFn } from '@vueuse/core'
|
import { useImage, useTimeoutFn } from '@vueuse/core'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -157,7 +155,6 @@ const currentIndex = ref(0)
|
|||||||
const isHovered = ref(false)
|
const isHovered = ref(false)
|
||||||
const isFocused = ref(false)
|
const isFocused = ref(false)
|
||||||
const actualDimensions = ref<string | null>(null)
|
const actualDimensions = ref<string | null>(null)
|
||||||
const imageError = ref(false)
|
|
||||||
const showLoader = ref(false)
|
const showLoader = ref(false)
|
||||||
|
|
||||||
const imageWrapperEl = ref<HTMLDivElement>()
|
const imageWrapperEl = ref<HTMLDivElement>()
|
||||||
@@ -176,6 +173,14 @@ const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
|
|||||||
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
|
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
|
||||||
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
|
||||||
|
|
||||||
|
const {
|
||||||
|
state: imageState,
|
||||||
|
error: imageLoadError,
|
||||||
|
isReady: imageIsReady
|
||||||
|
} = useImage(computed(() => ({ src: currentImageUrl.value ?? '' })))
|
||||||
|
|
||||||
|
const imageError = computed(() => !!imageLoadError.value)
|
||||||
|
|
||||||
// Watch for URL changes and reset state
|
// Watch for URL changes and reset state
|
||||||
watch(
|
watch(
|
||||||
() => props.imageUrls,
|
() => props.imageUrls,
|
||||||
@@ -193,37 +198,36 @@ watch(
|
|||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset loading and error states when URLs change
|
|
||||||
actualDimensions.value = null
|
actualDimensions.value = null
|
||||||
|
|
||||||
imageError.value = false
|
|
||||||
if (newUrls.length > 0) startDelayedLoader()
|
if (newUrls.length > 0) startDelayedLoader()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event handlers
|
watch(imageIsReady, (ready) => {
|
||||||
const handleImageLoad = (event: Event) => {
|
if (ready) {
|
||||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
stopDelayedLoader()
|
||||||
const img = event.target
|
showLoader.value = false
|
||||||
stopDelayedLoader()
|
if (imageState.value?.naturalWidth && imageState.value?.naturalHeight) {
|
||||||
showLoader.value = false
|
actualDimensions.value = `${imageState.value.naturalWidth} x ${imageState.value.naturalHeight}`
|
||||||
imageError.value = false
|
}
|
||||||
if (img.naturalWidth && img.naturalHeight) {
|
if (props.nodeId && imageState.value) {
|
||||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
nodeOutputStore.syncLegacyNodeImgs(
|
||||||
|
props.nodeId,
|
||||||
|
imageState.value,
|
||||||
|
currentIndex.value
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (props.nodeId) {
|
watch(imageLoadError, (err) => {
|
||||||
nodeOutputStore.syncLegacyNodeImgs(props.nodeId, img, currentIndex.value)
|
if (err) {
|
||||||
|
stopDelayedLoader()
|
||||||
|
showLoader.value = false
|
||||||
|
actualDimensions.value = null
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
const handleImageError = () => {
|
|
||||||
stopDelayedLoader()
|
|
||||||
showLoader.value = false
|
|
||||||
imageError.value = true
|
|
||||||
actualDimensions.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditMask = () => {
|
const handleEditMask = () => {
|
||||||
if (!props.nodeId) return
|
if (!props.nodeId) return
|
||||||
@@ -262,7 +266,6 @@ const setCurrentIndex = (index: number) => {
|
|||||||
if (index >= 0 && index < props.imageUrls.length) {
|
if (index >= 0 && index < props.imageUrls.length) {
|
||||||
const urlChanged = props.imageUrls[index] !== currentImageUrl.value
|
const urlChanged = props.imageUrls[index] !== currentImageUrl.value
|
||||||
currentIndex.value = index
|
currentIndex.value = index
|
||||||
imageError.value = false
|
|
||||||
if (urlChanged) startDelayedLoader()
|
if (urlChanged) startDelayedLoader()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick, ref } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
|
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
|
||||||
|
|
||||||
|
const mockState = ref<HTMLImageElement | undefined>(undefined)
|
||||||
|
const mockError = ref(false)
|
||||||
|
const mockIsReady = ref(false)
|
||||||
|
|
||||||
|
vi.mock('@vueuse/core', async () => {
|
||||||
|
const actual = await vi.importActual('@vueuse/core')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useImage: () => ({
|
||||||
|
state: mockState,
|
||||||
|
error: mockError,
|
||||||
|
isReady: mockIsReady,
|
||||||
|
isLoading: ref(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const i18n = createI18n({
|
const i18n = createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
@@ -27,6 +44,11 @@ describe('LivePreview', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mountLivePreview = (props = {}) => {
|
const mountLivePreview = (props = {}) => {
|
||||||
|
// Reset mock state before each mount
|
||||||
|
mockState.value = undefined
|
||||||
|
mockError.value = false
|
||||||
|
mockIsReady.value = false
|
||||||
|
|
||||||
return mount(LivePreview, {
|
return mount(LivePreview, {
|
||||||
props: { ...defaultProps, ...props },
|
props: { ...defaultProps, ...props },
|
||||||
global: {
|
global: {
|
||||||
@@ -70,65 +92,46 @@ describe('LivePreview', () => {
|
|||||||
expect(img.attributes('alt')).toBe('Live sampling preview')
|
expect(img.attributes('alt')).toBe('Live sampling preview')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles image load event', async () => {
|
it('displays dimensions when image is ready', async () => {
|
||||||
const wrapper = mountLivePreview()
|
const wrapper = mountLivePreview()
|
||||||
const img = wrapper.find('img')
|
|
||||||
|
|
||||||
// Mock the naturalWidth and naturalHeight properties on the img element
|
// Simulate useImage reporting the image as ready with dimensions
|
||||||
Object.defineProperty(img.element, 'naturalWidth', {
|
const fakeImg = { naturalWidth: 512, naturalHeight: 512 }
|
||||||
writable: false,
|
mockState.value = fakeImg as HTMLImageElement
|
||||||
value: 512
|
mockIsReady.value = true
|
||||||
})
|
await nextTick()
|
||||||
Object.defineProperty(img.element, 'naturalHeight', {
|
|
||||||
writable: false,
|
|
||||||
value: 512
|
|
||||||
})
|
|
||||||
|
|
||||||
// Trigger the load event
|
|
||||||
await img.trigger('load')
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('512 x 512')
|
expect(wrapper.text()).toContain('512 x 512')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles image error state', async () => {
|
it('shows error state when image fails to load', async () => {
|
||||||
const wrapper = mountLivePreview()
|
const wrapper = mountLivePreview()
|
||||||
const img = wrapper.find('img')
|
|
||||||
|
|
||||||
// Trigger the error event
|
// Simulate useImage reporting an error
|
||||||
await img.trigger('error')
|
mockError.value = true
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
// Check that the image is hidden and error content is shown
|
|
||||||
expect(wrapper.find('img').exists()).toBe(false)
|
expect(wrapper.find('img').exists()).toBe(false)
|
||||||
expect(wrapper.text()).toContain('Image failed to load')
|
expect(wrapper.text()).toContain('Image failed to load')
|
||||||
|
expect(wrapper.text()).toContain('Error loading image')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resets state when imageUrl changes', async () => {
|
it('resets state when imageUrl changes', async () => {
|
||||||
const wrapper = mountLivePreview()
|
const wrapper = mountLivePreview()
|
||||||
const img = wrapper.find('img')
|
|
||||||
|
|
||||||
// Set error state via event
|
// Simulate error state
|
||||||
await img.trigger('error')
|
mockError.value = true
|
||||||
|
await nextTick()
|
||||||
expect(wrapper.text()).toContain('Error loading image')
|
expect(wrapper.text()).toContain('Error loading image')
|
||||||
|
|
||||||
// Change imageUrl prop
|
// Change imageUrl and reset mock state (simulating useImage auto-reset)
|
||||||
|
mockError.value = false
|
||||||
|
mockIsReady.value = false
|
||||||
|
mockState.value = undefined
|
||||||
await wrapper.setProps({ imageUrl: '/new-image.png' })
|
await wrapper.setProps({ imageUrl: '/new-image.png' })
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// State should be reset - dimensions text should show calculating
|
|
||||||
expect(wrapper.text()).toContain('Calculating dimensions')
|
expect(wrapper.text()).toContain('Calculating dimensions')
|
||||||
expect(wrapper.text()).not.toContain('Error loading image')
|
expect(wrapper.text()).not.toContain('Error loading image')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows error state when image fails to load', async () => {
|
|
||||||
const wrapper = mountLivePreview()
|
|
||||||
const img = wrapper.find('img')
|
|
||||||
|
|
||||||
// Trigger error event
|
|
||||||
await img.trigger('error')
|
|
||||||
|
|
||||||
// Should show error state instead of image
|
|
||||||
expect(wrapper.find('img').exists()).toBe(false)
|
|
||||||
expect(wrapper.text()).toContain('Image failed to load')
|
|
||||||
expect(wrapper.text()).toContain('Error loading image')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
:src="imageUrl"
|
:src="imageUrl"
|
||||||
:alt="$t('g.liveSamplingPreview')"
|
:alt="$t('g.liveSamplingPreview')"
|
||||||
class="pointer-events-none min-h-55 w-full flex-1 object-contain contain-size"
|
class="pointer-events-none min-h-55 w-full flex-1 object-contain contain-size"
|
||||||
@load="handleImageLoad"
|
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
/>
|
||||||
<div class="text-node-component-header-text mt-1 text-center text-xs">
|
<div class="text-node-component-header-text mt-1 text-center text-xs">
|
||||||
{{
|
{{
|
||||||
@@ -26,7 +24,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { useImage } from '@vueuse/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
interface LivePreviewProps {
|
interface LivePreviewProps {
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
@@ -34,29 +33,19 @@ interface LivePreviewProps {
|
|||||||
|
|
||||||
const props = defineProps<LivePreviewProps>()
|
const props = defineProps<LivePreviewProps>()
|
||||||
|
|
||||||
const actualDimensions = ref<string | null>(null)
|
const {
|
||||||
const imageError = ref(false)
|
state,
|
||||||
|
error: imageError,
|
||||||
|
isReady
|
||||||
|
} = useImage(computed(() => ({ src: props.imageUrl ?? '', alt: '' })))
|
||||||
|
|
||||||
watch(
|
const actualDimensions = computed(() => {
|
||||||
() => props.imageUrl,
|
if (
|
||||||
() => {
|
!isReady.value ||
|
||||||
// Reset states when URL changes
|
!state.value?.naturalWidth ||
|
||||||
actualDimensions.value = null
|
!state.value?.naturalHeight
|
||||||
imageError.value = false
|
)
|
||||||
}
|
return null
|
||||||
)
|
return `${state.value.naturalWidth} x ${state.value.naturalHeight}`
|
||||||
|
})
|
||||||
const handleImageLoad = (event: Event) => {
|
|
||||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
|
||||||
const img = event.target
|
|
||||||
imageError.value = false
|
|
||||||
if (img.naturalWidth && img.naturalHeight) {
|
|
||||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImageError = () => {
|
|
||||||
imageError.value = true
|
|
||||||
actualDimensions.value = null
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -21,21 +21,24 @@
|
|||||||
></div>
|
></div>
|
||||||
<!-- image -->
|
<!-- image -->
|
||||||
<img
|
<img
|
||||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
v-if="!isImageError"
|
||||||
|
:src="imgSrc"
|
||||||
:alt="nodePack.name + ' banner'"
|
:alt="nodePack.name + ' banner'"
|
||||||
:class="
|
class="relative z-10 size-full object-contain"
|
||||||
isImageError
|
/>
|
||||||
? 'relative z-10 size-full object-cover'
|
<img
|
||||||
: 'relative z-10 size-full object-contain'
|
v-else
|
||||||
"
|
:src="DEFAULT_BANNER"
|
||||||
@error="isImageError = true"
|
:alt="$t('g.defaultBanner')"
|
||||||
|
class="relative z-10 size-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { useImage } from '@vueuse/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import type { components } from '@/types/comfyRegistryTypes'
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
|
|
||||||
@@ -45,8 +48,10 @@ const { nodePack } = defineProps<{
|
|||||||
nodePack: components['schemas']['Node']
|
nodePack: components['schemas']['Node']
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isImageError = ref(false)
|
|
||||||
|
|
||||||
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
|
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
|
||||||
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
|
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
|
||||||
|
|
||||||
|
const { error: isImageError } = useImage(
|
||||||
|
computed(() => ({ src: imgSrc.value ?? '', alt: nodePack.name + ' banner' }))
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user