mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
8 Commits
v1.44.17
...
refactor/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b44f34ea | ||
|
|
33a067f6af | ||
|
|
4ef4aae1f2 | ||
|
|
6194a4ffaa | ||
|
|
0e07883c99 | ||
|
|
d27e1efeb9 | ||
|
|
bf8422554d | ||
|
|
c3ec8081b4 |
@@ -1,11 +1,26 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
error: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.error = ref<unknown>(null)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({ error: useImageMock.error })
|
||||
}
|
||||
})
|
||||
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -23,6 +38,10 @@ const i18n = createI18n({
|
||||
})
|
||||
|
||||
describe('UserAvatar', () => {
|
||||
beforeEach(() => {
|
||||
if (useImageMock.error) useImageMock.error.value = null
|
||||
})
|
||||
|
||||
function renderComponent(props: ComponentProps<typeof UserAvatar> = {}) {
|
||||
return render(UserAvatar, {
|
||||
global: {
|
||||
@@ -67,10 +86,10 @@ describe('UserAvatar', () => {
|
||||
photoUrl: 'https://example.com/broken-image.jpg'
|
||||
})
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('avatar-icon')).not.toBeInTheDocument()
|
||||
|
||||
await fireEvent.error(img)
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
|
||||
|
||||
@@ -11,22 +11,24 @@
|
||||
}"
|
||||
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(
|
||||
computed(() => ({
|
||||
src: photoUrl ?? '',
|
||||
alt: ariaLabel ?? ''
|
||||
}))
|
||||
)
|
||||
const hasAvatar = computed(() => photoUrl && !imageError.value)
|
||||
</script>
|
||||
|
||||
@@ -20,8 +20,6 @@ function createDefaultCropState() {
|
||||
isLockEnabled: ref(false),
|
||||
cropBoxStyle: ref({}),
|
||||
resizeHandles: ref([]),
|
||||
handleImageLoad: () => {},
|
||||
handleImageError: () => {},
|
||||
handleDragStart: () => {},
|
||||
handleDragMove: () => {},
|
||||
handleDragEnd: () => {},
|
||||
|
||||
@@ -29,8 +29,6 @@
|
||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||
draggable="false"
|
||||
class="block size-full object-contain select-none"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
|
||||
@@ -181,8 +179,6 @@ const {
|
||||
cropBoxStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragEnd,
|
||||
|
||||
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createApp, defineComponent, nextTick, reactive, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -16,10 +17,35 @@ import {
|
||||
createMockSubgraphNode
|
||||
} from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
import { imageCropLoadingAfterUrlChange, useImageCrop } from './useImageCrop'
|
||||
import { useImageCrop } from './useImageCrop'
|
||||
|
||||
const resizeObserverCallbacks: Array<() => void> = []
|
||||
|
||||
const useImageMockState = vi.hoisted(() => {
|
||||
return {
|
||||
state: null as null | {
|
||||
state: Ref<HTMLImageElement | undefined>
|
||||
isReady: Ref<boolean>
|
||||
error: Ref<unknown>
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function getUseImageMock() {
|
||||
if (!useImageMockState.state) {
|
||||
useImageMockState.state = {
|
||||
state: ref<HTMLImageElement | undefined>(undefined),
|
||||
isReady: ref(false),
|
||||
error: ref<unknown>(null)
|
||||
}
|
||||
}
|
||||
return useImageMockState.state
|
||||
}
|
||||
|
||||
function resetUseImageMock() {
|
||||
useImageMockState.state = null
|
||||
}
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
return {
|
||||
@@ -27,7 +53,8 @@ vi.mock('@vueuse/core', async () => {
|
||||
useResizeObserver: (_target: unknown, cb: () => void) => {
|
||||
resizeObserverCallbacks.push(cb)
|
||||
return { stop: vi.fn() }
|
||||
}
|
||||
},
|
||||
useImage: () => getUseImageMock()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -177,10 +204,33 @@ function setupImageLayout(vm: CropVm, nw: number, nh: number) {
|
||||
value: nh
|
||||
})
|
||||
}
|
||||
;(vm.handleImageLoad as () => void)()
|
||||
triggerImageLoad(nw, nh)
|
||||
flushResizeObservers()
|
||||
}
|
||||
|
||||
function triggerImageLoad(nw: number, nh: number) {
|
||||
const mock = getUseImageMock()
|
||||
const fakeImg = new Image()
|
||||
Object.defineProperty(fakeImg, 'naturalWidth', {
|
||||
configurable: true,
|
||||
value: nw
|
||||
})
|
||||
Object.defineProperty(fakeImg, 'naturalHeight', {
|
||||
configurable: true,
|
||||
value: nh
|
||||
})
|
||||
mock.state.value = fakeImg
|
||||
mock.error.value = null
|
||||
mock.isReady.value = true
|
||||
}
|
||||
|
||||
function triggerImageError() {
|
||||
const mock = getUseImageMock()
|
||||
mock.state.value = undefined
|
||||
mock.isReady.value = false
|
||||
mock.error.value = new Error('image failed to load')
|
||||
}
|
||||
|
||||
const harnessCleanups: Array<() => void> = []
|
||||
|
||||
async function mountHarness(nodeId: NodeId = 2 as NodeId) {
|
||||
@@ -202,28 +252,6 @@ async function flushTicks() {
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('imageCropLoadingAfterUrlChange', () => {
|
||||
it('clears loading when url becomes null', () => {
|
||||
expect(imageCropLoadingAfterUrlChange(null, 'https://a/b.png')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps loading off when url stays null', () => {
|
||||
expect(imageCropLoadingAfterUrlChange(null, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('starts loading when url changes to a new string', () => {
|
||||
expect(imageCropLoadingAfterUrlChange('https://b', 'https://a')).toBe(true)
|
||||
})
|
||||
|
||||
it('starts loading when first url is set', () => {
|
||||
expect(imageCropLoadingAfterUrlChange('https://a', undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when url is unchanged so caller can skip updating', () => {
|
||||
expect(imageCropLoadingAfterUrlChange('https://a', 'https://a')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useImageCrop', () => {
|
||||
let sourceNode: LGraphNode
|
||||
let cropNode: LGraphNode
|
||||
@@ -231,6 +259,7 @@ describe('useImageCrop', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resizeObserverCallbacks.length = 0
|
||||
resetUseImageMock()
|
||||
vi.clearAllMocks()
|
||||
outputStore = {
|
||||
nodeOutputs: reactive<Record<string, unknown>>({}),
|
||||
@@ -382,7 +411,8 @@ describe('useImageCrop', () => {
|
||||
configurable: true,
|
||||
value: 0
|
||||
})
|
||||
;(vm.handleImageLoad as () => void)()
|
||||
triggerImageLoad(0, 0)
|
||||
await flushTicks()
|
||||
vm.modelValue = { x: 0, y: 0, width: 100, height: 80 }
|
||||
const style = vm.cropBoxStyle as Record<string, string>
|
||||
expect(parseFloat(style.width)).toBeCloseTo(100, 1)
|
||||
@@ -410,14 +440,16 @@ describe('useImageCrop', () => {
|
||||
|
||||
expect(vm.imageUrl).toBe('https://example.com/b.png')
|
||||
expect(vm.isLoading).toBe(true)
|
||||
;(vm.handleImageLoad as () => void)()
|
||||
triggerImageLoad(800, 600)
|
||||
await flushTicks()
|
||||
expect(vm.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('clears imageUrl on image error', async () => {
|
||||
const vm = await mountHarness()
|
||||
expect(vm.imageUrl).toBeTruthy()
|
||||
;(vm.handleImageError as () => void)()
|
||||
triggerImageError()
|
||||
await flushTicks()
|
||||
expect(vm.imageUrl).toBeNull()
|
||||
expect(vm.isLoading).toBe(false)
|
||||
})
|
||||
@@ -600,6 +632,7 @@ describe('WidgetImageCrop', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resizeObserverCallbacks.length = 0
|
||||
resetUseImageMock()
|
||||
vi.clearAllMocks()
|
||||
const outputStore: MockOutputStore = {
|
||||
nodeOutputs: reactive<Record<string, unknown>>({}),
|
||||
@@ -688,7 +721,7 @@ describe('WidgetImageCrop', () => {
|
||||
configurable: true,
|
||||
value: 400
|
||||
})
|
||||
img.dispatchEvent(new Event('load'))
|
||||
triggerImageLoad(400, 400)
|
||||
await flushTicks()
|
||||
expect(screen.getByTestId('crop-overlay')).toBeTruthy()
|
||||
unmount()
|
||||
@@ -732,7 +765,7 @@ describe('WidgetImageCrop', () => {
|
||||
configurable: true,
|
||||
value: 400
|
||||
})
|
||||
img.dispatchEvent(new Event('load'))
|
||||
triggerImageLoad(400, 400)
|
||||
await flushTicks()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Lock aspect ratio' }))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -23,19 +23,6 @@ const CORNER_SIZE = 10
|
||||
const MIN_CROP_SIZE = 16
|
||||
const CROP_BOX_BORDER = 2
|
||||
|
||||
/**
|
||||
* Next `isLoading` when `imageUrl` transitions. `null` means do not change
|
||||
* `isLoading` (e.g. same URL).
|
||||
*/
|
||||
export function imageCropLoadingAfterUrlChange(
|
||||
url: string | null,
|
||||
previous: string | null | undefined
|
||||
): boolean | null {
|
||||
if (url == null) return false
|
||||
if (url !== previous) return true
|
||||
return null
|
||||
}
|
||||
|
||||
export const ASPECT_RATIOS = {
|
||||
'1:1': 1,
|
||||
'3:4': 3 / 4,
|
||||
@@ -193,17 +180,37 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
imageUrl.value = getInputImageUrl()
|
||||
}
|
||||
|
||||
const {
|
||||
state: imageState,
|
||||
isReady: imageIsReady,
|
||||
error: imageLoadError
|
||||
} = useImage(computed(() => ({ src: imageUrl.value ?? '', alt: '' })))
|
||||
|
||||
watch(imageUrl, (url, previous) => {
|
||||
const next = imageCropLoadingAfterUrlChange(url, previous)
|
||||
if (next !== null) {
|
||||
isLoading.value = next
|
||||
if (url == null) {
|
||||
isLoading.value = false
|
||||
} else if (url !== previous) {
|
||||
isLoading.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const updateDisplayedDimensions = () => {
|
||||
if (!imageEl.value || !containerEl.value) return
|
||||
watch([imageIsReady, imageState], ([ready, img]) => {
|
||||
if (!ready || !img) return
|
||||
isLoading.value = false
|
||||
updateDisplayedDimensions(img)
|
||||
})
|
||||
|
||||
watch(imageLoadError, (err) => {
|
||||
if (err) {
|
||||
isLoading.value = false
|
||||
imageUrl.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const updateDisplayedDimensions = (loadedImg?: HTMLImageElement | null) => {
|
||||
const img = loadedImg ?? imageEl.value
|
||||
if (!img || !containerEl.value) return
|
||||
|
||||
const img = imageEl.value
|
||||
const container = containerEl.value
|
||||
|
||||
naturalWidth.value = img.naturalWidth
|
||||
@@ -370,16 +377,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
)
|
||||
})
|
||||
|
||||
const handleImageLoad = () => {
|
||||
isLoading.value = false
|
||||
updateDisplayedDimensions()
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
isLoading.value = false
|
||||
imageUrl.value = null
|
||||
}
|
||||
|
||||
const capturePointer = (e: PointerEvent) => {
|
||||
if (e.target instanceof HTMLElement) e.target.setPointerCapture(e.pointerId)
|
||||
}
|
||||
@@ -617,8 +614,6 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
cropBoxStyle,
|
||||
resizeHandles,
|
||||
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleDragStart,
|
||||
handleDragMove,
|
||||
handleDragEnd,
|
||||
|
||||
126
src/renderer/extensions/linearMode/ImagePreview.test.ts
Normal file
126
src/renderer/extensions/linearMode/ImagePreview.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
state: null as Ref<HTMLImageElement | undefined> | null,
|
||||
isReady: null as Ref<boolean> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
|
||||
useImageMock.isReady = ref(false)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({
|
||||
state: useImageMock.state,
|
||||
isReady: useImageMock.isReady
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const executionStatusMock = vi.hoisted(() => ({
|
||||
message: null as Ref<string | null> | null
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/extensions/linearMode/useExecutionStatus', async () => {
|
||||
const { ref } = await import('vue')
|
||||
executionStatusMock.message = ref<string | null>(null)
|
||||
return {
|
||||
useExecutionStatus: () => ({
|
||||
executionStatusMessage: executionStatusMock.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
|
||||
|
||||
function renderImagePreview(props: Record<string, unknown> = {}) {
|
||||
return render(ImagePreview, {
|
||||
props: { src: 'https://example.com/image.png', ...props },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ZoomPane: {
|
||||
template: '<div data-testid="zoom-pane"><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setLoadedImage(width: number, height: number) {
|
||||
const fakeImage = { naturalWidth: width, naturalHeight: height } as
|
||||
| HTMLImageElement
|
||||
| undefined
|
||||
useImageMock.state!.value = fakeImage
|
||||
useImageMock.isReady!.value = true
|
||||
}
|
||||
|
||||
describe('ImagePreview (linearMode)', () => {
|
||||
beforeEach(() => {
|
||||
if (useImageMock.state) useImageMock.state.value = undefined
|
||||
if (useImageMock.isReady) useImageMock.isReady.value = false
|
||||
if (executionStatusMock.message) executionStatusMock.message.value = null
|
||||
})
|
||||
|
||||
it('renders src inside ZoomPane in desktop mode', () => {
|
||||
renderImagePreview()
|
||||
expect(screen.getByTestId('zoom-pane')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/image.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders bare img when mobile is true', () => {
|
||||
renderImagePreview({ mobile: true })
|
||||
expect(screen.queryByTestId('zoom-pane')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/image.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows dimensions once the image is ready', async () => {
|
||||
renderImagePreview()
|
||||
setLoadedImage(800, 600)
|
||||
await nextTick()
|
||||
expect(screen.getByText('800 x 600')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('appends label when provided alongside dimensions', async () => {
|
||||
renderImagePreview({ label: 'demo' })
|
||||
setLoadedImage(64, 32)
|
||||
await nextTick()
|
||||
expect(screen.getByText(/64 x 32/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/demo/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dimensions when showSize=false', async () => {
|
||||
renderImagePreview({ showSize: false })
|
||||
setLoadedImage(800, 600)
|
||||
await nextTick()
|
||||
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dimensions before the image is ready', () => {
|
||||
renderImagePreview()
|
||||
expect(screen.queryByText(/x/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows execution status message instead of dimensions when present', async () => {
|
||||
renderImagePreview()
|
||||
setLoadedImage(800, 600)
|
||||
executionStatusMock.message!.value = 'Generating…'
|
||||
await nextTick()
|
||||
expect(screen.getByText('Generating…')).toBeInTheDocument()
|
||||
expect(screen.queryByText('800 x 600')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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 { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
|
||||
@@ -16,15 +17,20 @@ const { src, showSize = true } = defineProps<{
|
||||
showSize?: boolean
|
||||
}>()
|
||||
|
||||
const imageRef = useTemplateRef('imageRef')
|
||||
const width = ref<number | null>(null)
|
||||
const height = ref<number | null>(null)
|
||||
const { state: imageState, isReady } = useImage(
|
||||
computed(() => ({ src, alt: '' }))
|
||||
)
|
||||
|
||||
function onImageLoad() {
|
||||
if (!imageRef.value || !showSize) return
|
||||
width.value = imageRef.value.naturalWidth
|
||||
height.value = imageRef.value.naturalHeight
|
||||
}
|
||||
const width = computed(() =>
|
||||
showSize && isReady.value && imageState.value
|
||||
? imageState.value.naturalWidth || null
|
||||
: null
|
||||
)
|
||||
const height = computed(() =>
|
||||
showSize && isReady.value && imageState.value
|
||||
? imageState.value.naturalHeight || null
|
||||
: null
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<ZoomPane
|
||||
@@ -32,21 +38,9 @@ function onImageLoad() {
|
||||
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="onImageLoad"
|
||||
/>
|
||||
<img :src v-bind="slotProps" class="size-full object-contain" />
|
||||
</ZoomPane>
|
||||
<img
|
||||
v-else
|
||||
ref="imageRef"
|
||||
class="grow object-contain contain-size"
|
||||
:src
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<img v-else class="grow object-contain contain-size" :src />
|
||||
<span
|
||||
v-if="executionStatusMessage"
|
||||
class="animate-pulse self-center text-muted md:z-10"
|
||||
|
||||
@@ -4,9 +4,24 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
error: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.error = ref<unknown>(null)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({ error: useImageMock.error })
|
||||
}
|
||||
})
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
|
||||
|
||||
@@ -77,6 +92,7 @@ describe('ImagePreview', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
if (useImageMock.error) useImageMock.error.value = null
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
draggable="false"
|
||||
class="pointer-events-none absolute inset-0 block size-full object-contain"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Floating Action Buttons (appear on hover and focus) -->
|
||||
@@ -166,7 +165,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { useImage, useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -205,7 +204,6 @@ const currentIndex = ref(0)
|
||||
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
|
||||
const galleryPanelEl = ref<HTMLDivElement>()
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const showLoader = ref(false)
|
||||
|
||||
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
|
||||
@@ -232,6 +230,20 @@ const gridCols = computed(() => {
|
||||
return 4
|
||||
})
|
||||
|
||||
// Use useImage for error detection only. Load handling stays on the rendered
|
||||
// <img> @load handler so syncLegacyNodeImgs receives the actual DOM element.
|
||||
const { error: imageError } = useImage(
|
||||
computed(() => ({ src: currentImageUrl.value, alt: imageAltText.value }))
|
||||
)
|
||||
|
||||
watch(imageError, (err) => {
|
||||
if (err) {
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
actualDimensions.value = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => imageUrls,
|
||||
(newUrls, oldUrls) => {
|
||||
@@ -248,11 +260,11 @@ watch(
|
||||
currentIndex.value = 0
|
||||
}
|
||||
|
||||
// Reset loading and error states when URLs change
|
||||
// Reset loading and dimensions when URLs change. `imageError` is reset
|
||||
// automatically by `useImage` when the source changes.
|
||||
actualDimensions.value = null
|
||||
|
||||
viewMode.value = defaultViewMode(newUrls)
|
||||
imageError.value = false
|
||||
if (newUrls.length > 0) startDelayedLoader()
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -263,7 +275,6 @@ function handleImageLoad(event: Event) {
|
||||
const img = event.target
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
imageError.value = false
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
|
||||
}
|
||||
@@ -273,13 +284,6 @@ function handleImageLoad(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
stopDelayedLoader()
|
||||
showLoader.value = false
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
|
||||
function handleEditMask() {
|
||||
if (!nodeId) return
|
||||
const node = resolveNode(Number(nodeId))
|
||||
@@ -304,7 +308,6 @@ function setCurrentIndex(index: number) {
|
||||
if (index >= 0 && index < imageUrls.length) {
|
||||
const urlChanged = imageUrls[index] !== currentImageUrl.value
|
||||
currentIndex.value = index
|
||||
imageError.value = false
|
||||
if (urlChanged) startDelayedLoader()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
state: null as Ref<HTMLImageElement | undefined> | null,
|
||||
isReady: null as Ref<boolean> | null,
|
||||
error: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.state = ref<HTMLImageElement | undefined>(undefined)
|
||||
useImageMock.isReady = ref(false)
|
||||
useImageMock.error = ref<unknown>(null)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({
|
||||
state: useImageMock.state,
|
||||
isReady: useImageMock.isReady,
|
||||
error: useImageMock.error
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
import LivePreview from '@/renderer/extensions/vueNodes/components/LivePreview.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
@@ -21,6 +44,19 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
function makeFakeLoadedImage(width: number, height: number): HTMLImageElement {
|
||||
const img = new Image()
|
||||
Object.defineProperty(img, 'naturalWidth', {
|
||||
configurable: true,
|
||||
value: width
|
||||
})
|
||||
Object.defineProperty(img, 'naturalHeight', {
|
||||
configurable: true,
|
||||
value: height
|
||||
})
|
||||
return img
|
||||
}
|
||||
|
||||
describe('LivePreview', () => {
|
||||
const defaultProps = {
|
||||
imageUrl: '/api/view?filename=test_sample.png&type=temp'
|
||||
@@ -43,6 +79,12 @@ describe('LivePreview', () => {
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useImageMock.state!.value = undefined
|
||||
useImageMock.isReady!.value = false
|
||||
useImageMock.error!.value = null
|
||||
})
|
||||
|
||||
it('renders preview when imageUrl provided', () => {
|
||||
renderLivePreview()
|
||||
|
||||
@@ -74,54 +116,62 @@ describe('LivePreview', () => {
|
||||
|
||||
it('handles image load event', async () => {
|
||||
const { container } = renderLivePreview()
|
||||
const img = screen.getByRole('img')
|
||||
|
||||
Object.defineProperty(img, 'naturalWidth', {
|
||||
writable: false,
|
||||
value: 512
|
||||
})
|
||||
Object.defineProperty(img, 'naturalHeight', {
|
||||
writable: false,
|
||||
value: 512
|
||||
})
|
||||
|
||||
await fireEvent.load(img)
|
||||
useImageMock.state!.value = makeFakeLoadedImage(512, 512)
|
||||
useImageMock.isReady!.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(container.textContent).toContain('512 x 512')
|
||||
})
|
||||
|
||||
it('keeps last good dimensions when imageUrl changes (no flicker)', async () => {
|
||||
const { container, rerender } = renderLivePreview()
|
||||
|
||||
useImageMock.state!.value = makeFakeLoadedImage(800, 600)
|
||||
useImageMock.isReady!.value = true
|
||||
await nextTick()
|
||||
expect(container.textContent).toContain('800 x 600')
|
||||
|
||||
// Simulate the source changing during live preview streaming. useImage
|
||||
// would normally reset isReady to false until the next image is ready.
|
||||
useImageMock.isReady!.value = false
|
||||
await rerender({
|
||||
imageUrl: '/api/view?filename=test_sample_2.png&type=temp'
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Dimensions should still display, not flicker back to "Calculating".
|
||||
expect(container.textContent).toContain('800 x 600')
|
||||
expect(container.textContent).not.toContain('Calculating dimensions')
|
||||
})
|
||||
|
||||
it('handles image error state', async () => {
|
||||
renderLivePreview()
|
||||
const img = screen.getByRole('img')
|
||||
|
||||
await fireEvent.error(img)
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
screen.getByText('Image failed to load')
|
||||
})
|
||||
|
||||
it('resets state when imageUrl changes', async () => {
|
||||
it('resets error state when imageUrl changes', async () => {
|
||||
const { container, rerender } = renderLivePreview()
|
||||
const img = screen.getByRole('img')
|
||||
|
||||
await fireEvent.error(img)
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
expect(container.textContent).toContain('Error loading image')
|
||||
|
||||
// useImage resets error automatically when src changes.
|
||||
useImageMock.error!.value = null
|
||||
await rerender({ imageUrl: '/new-image.png' })
|
||||
await nextTick()
|
||||
|
||||
expect(container.textContent).toContain('Calculating dimensions')
|
||||
expect(container.textContent).not.toContain('Error loading image')
|
||||
})
|
||||
|
||||
it('shows error state when image fails to load', async () => {
|
||||
const { container } = renderLivePreview()
|
||||
const img = screen.getByRole('img')
|
||||
|
||||
await fireEvent.error(img)
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
|
||||
@@ -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,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useImage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface LivePreviewProps {
|
||||
imageUrl: string
|
||||
@@ -34,29 +34,36 @@ interface LivePreviewProps {
|
||||
|
||||
const props = defineProps<LivePreviewProps>()
|
||||
|
||||
const actualDimensions = ref<string | null>(null)
|
||||
const imageError = ref(false)
|
||||
const { t } = useI18n()
|
||||
|
||||
watch(
|
||||
() => props.imageUrl,
|
||||
() => {
|
||||
// Reset error state when URL changes, but keep previous dimensions
|
||||
// to avoid flickering "Calculating dimensions" text during live preview
|
||||
imageError.value = false
|
||||
}
|
||||
const {
|
||||
state: imageState,
|
||||
isReady,
|
||||
error
|
||||
} = useImage(
|
||||
computed(() => ({ src: props.imageUrl, alt: t('g.liveSamplingPreview') }))
|
||||
)
|
||||
|
||||
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}`
|
||||
}
|
||||
}
|
||||
// Cache last successfully loaded dimensions so the placeholder text does not
|
||||
// flicker back to "Calculating dimensions" each time `imageUrl` changes during
|
||||
// live preview streaming. Update only when a new image is ready, never on
|
||||
// URL change alone.
|
||||
const cachedWidth = ref<number | null>(null)
|
||||
const cachedHeight = ref<number | null>(null)
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
actualDimensions.value = null
|
||||
}
|
||||
watch([isReady, imageState], ([ready, img]) => {
|
||||
if (!ready || !img) return
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
cachedWidth.value = img.naturalWidth
|
||||
cachedHeight.value = img.naturalHeight
|
||||
}
|
||||
})
|
||||
|
||||
const imageError = computed(() => !!error.value)
|
||||
|
||||
const actualDimensions = computed(() =>
|
||||
cachedWidth.value && cachedHeight.value
|
||||
? `${cachedWidth.value} x ${cachedHeight.value}`
|
||||
: null
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const useImageMock = vi.hoisted(() => ({
|
||||
error: null as Ref<unknown> | null
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual('@vueuse/core')
|
||||
const { ref } = await import('vue')
|
||||
useImageMock.error = ref<unknown>(null)
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
useImage: () => ({ error: useImageMock.error })
|
||||
}
|
||||
})
|
||||
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
defaultBanner: 'Default banner'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makePack(
|
||||
overrides: Partial<components['schemas']['Node']> = {}
|
||||
): components['schemas']['Node'] {
|
||||
return {
|
||||
id: 'pack-id',
|
||||
name: 'TestPack',
|
||||
...overrides
|
||||
} as components['schemas']['Node']
|
||||
}
|
||||
|
||||
function renderPackBanner(nodePack: components['schemas']['Node']) {
|
||||
return render(PackBanner, {
|
||||
props: { nodePack },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('PackBanner', () => {
|
||||
beforeEach(() => {
|
||||
if (useImageMock.error) useImageMock.error.value = null
|
||||
})
|
||||
|
||||
it('renders the default banner when both banner_url and icon are missing', () => {
|
||||
renderPackBanner(makePack())
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', DEFAULT_BANNER)
|
||||
expect(img).toHaveAttribute('alt', 'Default banner')
|
||||
})
|
||||
|
||||
it('renders the banner_url image when provided', () => {
|
||||
renderPackBanner(makePack({ banner_url: 'https://example.com/banner.png' }))
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/banner.png')
|
||||
expect(img).toHaveAttribute('alt', 'TestPack banner')
|
||||
})
|
||||
|
||||
it('falls back to icon when banner_url is missing but icon is set', () => {
|
||||
renderPackBanner(makePack({ icon: 'https://example.com/icon.svg' }))
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/icon.svg'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to default banner when image fails to load', async () => {
|
||||
renderPackBanner(makePack({ banner_url: 'https://example.com/broken.png' }))
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/broken.png'
|
||||
)
|
||||
|
||||
useImageMock.error!.value = new Event('error')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', DEFAULT_BANNER)
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@
|
||||
<div v-else class="relative size-full">
|
||||
<!-- blur background -->
|
||||
<div
|
||||
v-if="imgSrc"
|
||||
v-if="imgSrc && !isImageError"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
:style="{
|
||||
backgroundImage: `url(${imgSrc})`,
|
||||
@@ -21,21 +21,24 @@
|
||||
></div>
|
||||
<!-- image -->
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative z-10 size-full object-cover'
|
||||
: 'relative z-10 size-full object-contain'
|
||||
"
|
||||
@error="isImageError = true"
|
||||
v-if="isImageError"
|
||||
:src="DEFAULT_BANNER"
|
||||
:alt="bannerAlt"
|
||||
class="relative z-10 size-full object-cover"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="imgSrc"
|
||||
:alt="bannerAlt"
|
||||
class="relative z-10 size-full object-contain"
|
||||
/>
|
||||
</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,11 @@ 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 imgSrc = computed(() => nodePack.banner_url || nodePack.icon || '')
|
||||
const bannerAlt = computed(() => `${nodePack.name} banner`)
|
||||
|
||||
const { error: isImageError } = useImage(
|
||||
computed(() => ({ src: imgSrc.value, alt: bannerAlt.value }))
|
||||
)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user