refactor(LivePreview): use useImage and cache last good dimensions

Replace manual @load/@error handlers and imageError ref with useImage
from @vueuse/core. Source is wrapped in computed() so useImage re-runs
when imageUrl changes.

Cache the last successfully loaded naturalWidth/Height in refs that
update only when isReady fires. This avoids the placeholder text
flickering back to 'Calculating dimensions' each time imageUrl changes
during live preview streaming.

Test mocks useImage at module level and drives state/isReady/error
manually while preserving @testing-library/vue assertions.
This commit is contained in:
bymyself
2026-05-04 11:00:26 -07:00
committed by DrJKL
parent 6f7ec5ba34
commit 84f0871277
2 changed files with 104 additions and 47 deletions

View File

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

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