Compare commits

...

2 Commits

Author SHA1 Message Date
bymyself
3cefb3bb40 fix: resolve rebase conflicts and update test to use useImage mock 2026-03-12 04:40:57 -07:00
bymyself
7ff581df95 refactor: migrate 10 components from manual image loading to useImage composable
Replace @load/@error event handlers and manual refs with useImage from
@vueuse/core for consistent image loading, error detection, and
dimension extraction.

- ComfyImage: replace imageBroken ref + handleImageError with useImage
- UserAvatar: replace imageError ref + @error handler with useImage
- ImagePreview (vueNodes): replace handleImageLoad/handleImageError with
  useImage watches on isReady/error
- LivePreview: replace refs + watchers with useImage computed state
- PackBanner: replace isImageError ref + @error with useImage, split
  ternary into v-if/v-else
- QueueAssetPreview: replace imgRef + onImgLoad with useImage state
- Media3DTop: replace thumbnailError ref + @error with useImage
- ImagePreview (linearMode): replace templateRef + @load with useImage
- useImageCrop: replace handleImageLoad/handleImageError with useImage
  watches
- WidgetImageCrop: remove handleImageLoad/handleImageError from template

Update tests to mock useImage instead of triggering DOM events.

Amp-Thread-ID: https://ampcode.com/threads/T-019c83b6-c68c-7013-882b-113bc201b5b4
2026-03-12 04:36:53 -07:00
13 changed files with 202 additions and 197 deletions

View File

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

View File

@@ -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]')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
} }
} }

View File

@@ -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')
})
}) })

View File

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

View File

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