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"
:style="{ 'background-image': `url(${src})` }"
: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>
<div v-if="imageBroken" class="broken-image-placeholder">
<i class="pi pi-image" />
@@ -29,7 +22,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useImage } from '@vueuse/core'
const {
src,
@@ -46,10 +39,7 @@ const {
alt?: string
}>()
const imageBroken = ref(false)
const handleImageError = () => {
imageBroken.value = true
}
const { error: imageBroken } = useImage({ src, alt })
</script>
<style scoped>

View File

@@ -3,12 +3,27 @@ import type { ComponentProps } from 'vue-component-type-helpers'
import { mount } from '@vue/test-utils'
import Avatar from 'primevue/avatar'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
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({
legacy: false,
locale: 'en',
@@ -27,6 +42,7 @@ describe('UserAvatar', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
mockImageError.value = false
})
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
@@ -80,8 +96,8 @@ describe('UserAvatar', () => {
const avatar = wrapper.findComponent(Avatar)
expect(avatar.props('icon')).toBeNull()
// Simulate image load error
avatar.vm.$emit('error')
// Simulate useImage reporting an error
mockImageError.value = true
await nextTick()
expect(avatar.props('icon')).toBe('icon-[lucide--user]')

View File

@@ -6,22 +6,22 @@
:pt:icon:class="{ 'size-4': !hasAvatar }"
shape="circle"
:aria-label="ariaLabel ?? $t('auth.login.userAvatar')"
@error="handleImageError"
/>
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import Avatar from 'primevue/avatar'
import { computed, ref } from 'vue'
import { computed } from 'vue'
const { photoUrl, ariaLabel } = defineProps<{
photoUrl?: string | null
ariaLabel?: string
}>()
const imageError = ref(false)
const handleImageError = () => {
imageError.value = true
}
const { error: imageError } = useImage({
src: photoUrl ?? '',
alt: ariaLabel ?? ''
})
const hasAvatar = computed(() => photoUrl && !imageError.value)
</script>

View File

@@ -28,9 +28,7 @@
:src="imageUrl"
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none"
@load="handleImageLoad"
@error="handleImageError"
class="block size-full object-contain brightness-50 select-none"
@dragstart.prevent
/>
@@ -131,8 +129,6 @@ const {
cropBoxStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,

View File

@@ -3,12 +3,10 @@
<div class="p-3">
<div class="relative aspect-square w-full overflow-hidden rounded-lg">
<img
ref="imgRef"
:src="imageUrl"
:alt="name"
class="size-full cursor-pointer object-contain"
@click="$emit('image-click')"
@load="onImgLoad"
/>
<div
v-if="timeLabel"
@@ -40,11 +38,12 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
defineOptions({ inheritAttrs: false })
defineProps<{
const { imageUrl, name } = defineProps<{
imageUrl: string
name: string
timeLabel?: string
@@ -52,14 +51,12 @@ defineProps<{
defineEmits(['image-click'])
const imgRef = ref<HTMLImageElement | null>(null)
const width = ref<number | null>(null)
const height = ref<number | null>(null)
const { state, isReady } = useImage({ src: imageUrl, alt: name })
const onImgLoad = () => {
const el = imgRef.value
if (!el) return
width.value = el.naturalWidth || null
height.value = el.naturalHeight || null
}
const width = computed(() =>
isReady.value ? (state.value?.naturalWidth ?? null) : null
)
const height = computed(() =>
isReady.value ? (state.value?.naturalHeight ?? null) : null
)
</script>

View File

@@ -1,4 +1,4 @@
import { useResizeObserver } from '@vueuse/core'
import { useImage, useResizeObserver } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
@@ -349,15 +349,23 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
)
})
const handleImageLoad = () => {
isLoading.value = false
updateDisplayedDimensions()
}
const { isReady: imageIsReady, error: imageLoadError } = useImage(
computed(() => ({ src: imageUrl.value ?? '' }))
)
const handleImageError = () => {
isLoading.value = false
imageUrl.value = null
}
watch(imageIsReady, (ready) => {
if (ready) {
isLoading.value = false
updateDisplayedDimensions()
}
})
watch(imageLoadError, (err) => {
if (err) {
isLoading.value = false
imageUrl.value = null
}
})
const capturePointer = (e: PointerEvent) =>
(e.target as HTMLElement).setPointerCapture(e.pointerId)
@@ -596,8 +604,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
cropBoxStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,

View File

@@ -5,7 +5,6 @@
:src="thumbnailSrc"
:alt="asset?.name"
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
@error="thumbnailError = true"
/>
<div
v-else
@@ -20,16 +19,19 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{ asset: AssetMeta }>()
const thumbnailError = ref(false)
const thumbnailSrc = computed(() => {
if (!asset?.src) return ''
return asset.src.replace(/([?&]filename=)([^&]*)/, '$1$2.png')
})
const { error: thumbnailError } = useImage(
computed(() => ({ src: thumbnailSrc.value, alt: asset?.name ?? '' }))
)
</script>

View File

@@ -1,5 +1,6 @@
<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 { cn } from '@/utils/tailwindUtil'
@@ -11,9 +12,10 @@ const { src } = defineProps<{
mobile?: boolean
}>()
const imageRef = useTemplateRef('imageRef')
const width = ref('')
const height = ref('')
const { state } = useImage({ src })
const width = computed(() => state.value?.naturalWidth?.toString() ?? '')
const height = computed(() => state.value?.naturalHeight?.toString() ?? '')
</script>
<template>
<ZoomPane
@@ -21,32 +23,8 @@ const height = ref('')
v-slot="slotProps"
:class="cn('w-full flex-1', $attrs.class as string)"
>
<img
ref="imageRef"
:src
v-bind="slotProps"
class="size-full object-contain"
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
/>
<img :src v-bind="slotProps" class="size-full object-contain" />
</ZoomPane>
<img
v-else
ref="imageRef"
class="grow object-contain contain-size"
:src
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
/>
<img v-else class="grow object-contain contain-size" :src />
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
</template>

View File

@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
@@ -13,6 +13,23 @@ vi.mock('@/base/common/downloadUtil', () => ({
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({
legacy: false,
locale: 'en',
@@ -47,6 +64,11 @@ describe('ImagePreview', () => {
const wrapperRegistry = new Set<VueWrapper>()
const mountImagePreview = (props = {}) => {
// Reset mock state before each mount
mockImageState.value = undefined
mockImageError.value = false
mockImageIsReady.value = false
const wrapper = mount(ImagePreview, {
props: { ...defaultProps, ...props },
global: {
@@ -320,8 +342,8 @@ describe('ImagePreview', () => {
imageUrls: [sameUrl, sameUrl, sameUrl]
})
// Simulate initial image load
await wrapper.find('img').trigger('load')
// Simulate useImage reporting image as ready
mockImageIsReady.value = true
await nextTick()
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 wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
// Simulate useImage reporting image as ready
mockImageIsReady.value = true
await nextTick()
// Verify loader is hidden after load
@@ -379,9 +400,8 @@ describe('ImagePreview', () => {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
// Simulate useImage reporting image as ready
mockImageIsReady.value = true
await nextTick()
// Verify loader is hidden

View File

@@ -46,8 +46,6 @@
(isHovered || isFocused) && 'opacity-60'
)
"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover and focus) -->
@@ -123,7 +121,7 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { useImage, useTimeoutFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -157,7 +155,6 @@ const currentIndex = ref(0)
const isHovered = ref(false)
const isFocused = ref(false)
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const imageWrapperEl = ref<HTMLDivElement>()
@@ -176,6 +173,14 @@ const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 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(
() => props.imageUrls,
@@ -193,37 +198,36 @@ watch(
currentIndex.value = 0
}
// Reset loading and error states when URLs change
actualDimensions.value = null
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ immediate: true }
)
// Event handlers
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
stopDelayedLoader()
showLoader.value = false
imageError.value = false
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
watch(imageIsReady, (ready) => {
if (ready) {
stopDelayedLoader()
showLoader.value = false
if (imageState.value?.naturalWidth && imageState.value?.naturalHeight) {
actualDimensions.value = `${imageState.value.naturalWidth} x ${imageState.value.naturalHeight}`
}
if (props.nodeId && imageState.value) {
nodeOutputStore.syncLegacyNodeImgs(
props.nodeId,
imageState.value,
currentIndex.value
)
}
}
})
if (props.nodeId) {
nodeOutputStore.syncLegacyNodeImgs(props.nodeId, img, currentIndex.value)
watch(imageLoadError, (err) => {
if (err) {
stopDelayedLoader()
showLoader.value = false
actualDimensions.value = null
}
}
const handleImageError = () => {
stopDelayedLoader()
showLoader.value = false
imageError.value = true
actualDimensions.value = null
}
})
const handleEditMask = () => {
if (!props.nodeId) return
@@ -262,7 +266,6 @@ const setCurrentIndex = (index: number) => {
if (index >= 0 && index < props.imageUrls.length) {
const urlChanged = props.imageUrls[index] !== currentImageUrl.value
currentIndex.value = index
imageError.value = false
if (urlChanged) startDelayedLoader()
}
}

View File

@@ -1,11 +1,28 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
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({
legacy: false,
locale: 'en',
@@ -27,6 +44,11 @@ describe('LivePreview', () => {
}
const mountLivePreview = (props = {}) => {
// Reset mock state before each mount
mockState.value = undefined
mockError.value = false
mockIsReady.value = false
return mount(LivePreview, {
props: { ...defaultProps, ...props },
global: {
@@ -70,65 +92,46 @@ describe('LivePreview', () => {
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 img = wrapper.find('img')
// Mock the naturalWidth and naturalHeight properties on the img element
Object.defineProperty(img.element, 'naturalWidth', {
writable: false,
value: 512
})
Object.defineProperty(img.element, 'naturalHeight', {
writable: false,
value: 512
})
// Trigger the load event
await img.trigger('load')
// Simulate useImage reporting the image as ready with dimensions
const fakeImg = { naturalWidth: 512, naturalHeight: 512 }
mockState.value = fakeImg as HTMLImageElement
mockIsReady.value = true
await nextTick()
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 img = wrapper.find('img')
// Trigger the error event
await img.trigger('error')
// Simulate useImage reporting an 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.text()).toContain('Image failed to load')
expect(wrapper.text()).toContain('Error loading image')
})
it('resets state when imageUrl changes', async () => {
const wrapper = mountLivePreview()
const img = wrapper.find('img')
// Set error state via event
await img.trigger('error')
// Simulate error state
mockError.value = true
await nextTick()
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 nextTick()
// State should be reset - dimensions text should show calculating
expect(wrapper.text()).toContain('Calculating dimensions')
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"
:alt="$t('g.liveSamplingPreview')"
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">
{{
@@ -26,7 +24,8 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
interface LivePreviewProps {
imageUrl: string
@@ -34,29 +33,19 @@ interface LivePreviewProps {
const props = defineProps<LivePreviewProps>()
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const {
state,
error: imageError,
isReady
} = useImage(computed(() => ({ src: props.imageUrl ?? '', alt: '' })))
watch(
() => props.imageUrl,
() => {
// Reset states when URL changes
actualDimensions.value = null
imageError.value = false
}
)
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
}
const actualDimensions = computed(() => {
if (
!isReady.value ||
!state.value?.naturalWidth ||
!state.value?.naturalHeight
)
return null
return `${state.value.naturalWidth} x ${state.value.naturalHeight}`
})
</script>

View File

@@ -21,21 +21,24 @@
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
v-if="!isImageError"
:src="imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative z-10 size-full object-cover'
: 'relative z-10 size-full object-contain'
"
@error="isImageError = true"
class="relative z-10 size-full object-contain"
/>
<img
v-else
:src="DEFAULT_BANNER"
:alt="$t('g.defaultBanner')"
class="relative z-10 size-full object-cover"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useImage } from '@vueuse/core'
import { computed } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
@@ -45,8 +48,10 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const isImageError = ref(false)
const showDefaultBanner = 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>