Compare commits

...

2 Commits

Author SHA1 Message Date
Kelly Yang
d32981f2e9 test: address image crop E2E review feedback
Align Image Crop tests with reviewer feedback by improving polling stability, ratio-lock coverage, and realistic image-load failure simulation. Also export and document MIN_CROP_SIZE for shared test assertions and improve loading-state accessibility.
2026-04-13 22:59:14 -07:00
Kelly Yang
ec6d393f0d test: expand Image Crop E2E and fix loading overlay deadlock
- Add Level 1–3 Playwright coverage (empty state, clamps, resize handles)
- Fix WidgetImageCrop: render img while loading; overlay loading mask
- Add imageUrl watch via imageCropLoadingAfterUrlChange + Vitest
- Add crop-resize-* data-testids for stable selectors
2026-04-13 12:38:11 -07:00
4 changed files with 850 additions and 90 deletions

View File

@@ -1,3 +1,7 @@
import type { Locator } from '@playwright/test'
import { MIN_CROP_SIZE } from '@/composables/useImageCrop'
import {
comfyExpect as expect,
comfyPageFixture as test
@@ -6,6 +10,111 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
type CropValue = { x: number; y: number; width: number; height: number } | null
const POINTER_OPTS = { bubbles: true, cancelable: true, pointerId: 1 } as const
async function getCropValue(
comfyPage: ComfyPage,
nodeId: number
): Promise<CropValue> {
return comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(id)
const w = n?.widgets?.find((x) => x.type === 'imagecrop')
const v = w?.value
if (v && typeof v === 'object' && 'x' in v) {
const crop = v as {
x: number
y: number
width: number
height: number
}
return {
x: crop.x,
y: crop.y,
width: crop.width,
height: crop.height
}
}
return null
}, nodeId)
}
async function setCropBounds(
comfyPage: ComfyPage,
nodeId: number,
bounds: { x: number; y: number; width: number; height: number }
) {
await comfyPage.page.evaluate(
({ id, b }) => {
const n = window.app!.graph.getNodeById(id)
const w = n?.widgets?.find((x) => x.type === 'imagecrop')
if (w) {
w.value = { ...b }
w.callback?.(b)
}
},
{ id: nodeId, b: bounds }
)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
}
async function waitForImageNaturalSize(
img: Locator,
options?: { timeout?: number }
) {
await expect
.poll(
() =>
img.evaluate(
(el: HTMLImageElement) => el.naturalWidth > 0 && el.naturalHeight > 0
),
{
message: 'image naturalWidth and naturalHeight should be ready',
...(options?.timeout != null ? { timeout: options.timeout } : {})
}
)
.toBe(true)
}
async function dragOnLocator(
comfyPage: ComfyPage,
target: Locator,
deltaClientX: number,
deltaClientY: number
) {
await expect
.poll(
async () => {
const b = await target.boundingBox()
return b !== null && b.width > 0 && b.height > 0
},
{ message: 'drag target should have a laid-out bounding box' }
)
.toBe(true)
const box = await target.boundingBox()
if (!box) throw new Error('drag target has no bounding box')
const x0 = box.x + box.width / 2
const y0 = box.y + box.height / 2
await target.dispatchEvent('pointerdown', {
...POINTER_OPTS,
clientX: x0,
clientY: y0
})
await comfyPage.nextFrame()
await target.dispatchEvent('pointermove', {
...POINTER_OPTS,
clientX: x0 + deltaClientX,
clientY: y0 + deltaClientY
})
await comfyPage.nextFrame()
await target.dispatchEvent('pointerup', {
...POINTER_OPTS,
clientX: x0 + deltaClientX,
clientY: y0 + deltaClientY
})
await comfyPage.nextFrame()
}
test.describe('Image Crop', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
@@ -22,12 +131,21 @@ test.describe('Image Crop', { tag: '@widget' }, () => {
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node, 'image crop node should render').toBeVisible()
await expect.soft(node.getByTestId('crop-empty-icon')).toBeVisible()
await expect.soft(node).toContainText('No input image connected')
await expect.soft(node.getByTestId('crop-overlay')).toHaveCount(0)
await expect.soft(node.locator('img')).toHaveCount(0)
await expect
.soft(node.getByTestId('crop-empty-icon'), 'empty state icon')
.toBeVisible()
await expect
.soft(node, 'empty state copy')
.toContainText('No input image connected')
await expect
.soft(node.getByTestId('crop-overlay'), 'no overlay without image')
.toHaveCount(0)
await expect.soft(node.locator('img'), 'no preview img').toHaveCount(0)
await expect
.soft(node.getByTestId('crop-resize-right'), 'no resize handles')
.toBeHidden()
}
)
@@ -36,50 +154,63 @@ test.describe('Image Crop', { tag: '@widget' }, () => {
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node, 'image crop node should render').toBeVisible()
await expect(node.getByText('Ratio')).toBeVisible()
await expect(node.getByText('Ratio'), 'ratio label').toBeVisible()
await expect(
node.locator('button:has(.icon-\\[lucide--lock-open\\])')
node.locator('button:has(.icon-\\[lucide--lock-open\\])'),
'ratio unlock button'
).toBeVisible()
await expect(node.locator('input')).toHaveCount(4)
await expect(
node.locator('input'),
'bounding box numeric inputs'
).toHaveCount(4)
}
)
test(
'Empty state matches screenshot baseline',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node, 'image crop node should render').toBeVisible()
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(node).toHaveScreenshot('image-crop-empty-state.png', {
maxDiffPixelRatio: 0.05
})
}
)
test('Pointer drag on empty state does not change crop widget value', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const before = await getCropValue(comfyPage, 1)
expect(before, 'Fixture should define imagecrop bounds').not.toBeNull()
const empty = node.getByTestId('crop-empty-state')
await dragOnLocator(comfyPage, empty, 40, 30)
await expect
.poll(() => getCropValue(comfyPage, 1), {
message: 'empty-state drag should not mutate crop value'
})
.toStrictEqual(before)
})
})
test.describe(
'with source image after execution',
{ tag: ['@widget', '@slow'] },
() => {
async function getCropValue(comfyPage: ComfyPage): Promise<CropValue> {
return comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(2)
const w = n?.widgets?.find((w) => w.type === 'imagecrop')
const v = w?.value
if (v && typeof v === 'object' && 'x' in v) {
const crop = v as {
x: number
y: number
width: number
height: number
}
return {
x: crop.x,
y: crop.y,
width: crop.width,
height: crop.height
}
}
return null
})
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_with_source')
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
await expect(
comfyPage.vueNodes.getNodeLocator('2').locator('img')
comfyPage.vueNodes.getNodeLocator('2').locator('img'),
'source image preview should appear after run'
).toBeVisible({ timeout: 30_000 })
})
@@ -90,11 +221,19 @@ test.describe('Image Crop', { tag: '@widget' }, () => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const img = node.locator('img')
await expect
.poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth))
.toBeGreaterThan(0)
await waitForImageNaturalSize(img)
await expect(
node.getByTestId('crop-overlay'),
'crop overlay should show when image is ready'
).toBeVisible()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'unlocked ratio should expose eight handles'
).toHaveCount(8)
await expect(node.getByTestId('crop-overlay')).toBeVisible()
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(node).toHaveScreenshot('image-crop-with-source.png', {
@@ -109,54 +248,623 @@ test.describe('Image Crop', { tag: '@widget' }, () => {
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const cropBox = node.getByTestId('crop-overlay')
await expect
.poll(
async () => {
const b = await cropBox.boundingBox()
return b !== null && b.width > 0 && b.height > 0 ? b : null
},
{ message: 'crop overlay should have a stable bounding box' }
)
.not.toBeNull()
const box = await cropBox.boundingBox()
if (!box) throw new Error('Crop box not found')
const valueBefore = await getCropValue(comfyPage)
const valueBefore = await getCropValue(comfyPage, 2)
if (!valueBefore)
throw new Error('Widget value missing — check fixture setup')
const startX = box.x + box.width / 2
const startY = box.y + box.height / 2
const pointerOpts = { bubbles: true, cancelable: true, pointerId: 1 }
await cropBox.dispatchEvent('pointerdown', {
...pointerOpts,
...POINTER_OPTS,
clientX: startX,
clientY: startY
})
await comfyPage.nextFrame()
await cropBox.dispatchEvent('pointermove', {
...pointerOpts,
...POINTER_OPTS,
clientX: startX + 15,
clientY: startY + 10
})
await comfyPage.nextFrame()
await cropBox.dispatchEvent('pointermove', {
...pointerOpts,
...POINTER_OPTS,
clientX: startX + 30,
clientY: startY + 20
})
await cropBox.dispatchEvent('pointerup', {
...pointerOpts,
...POINTER_OPTS,
clientX: startX + 30,
clientY: startY + 20
})
await comfyPage.nextFrame()
await expect(async () => {
const valueAfter = await getCropValue(comfyPage)
expect(valueAfter?.x).toBeGreaterThan(valueBefore.x)
expect(valueAfter?.y).toBeGreaterThan(valueBefore.y)
expect(valueAfter?.width).toBe(valueBefore.width)
expect(valueAfter?.height).toBe(valueBefore.height)
}).toPass({ timeout: 5000 })
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (
!v ||
v.x <= valueBefore.x ||
v.y <= valueBefore.y ||
v.width !== valueBefore.width ||
v.height !== valueBefore.height
) {
return null
}
return v
},
{
timeout: 5000,
message: 'crop drag should increase x/y without changing size'
}
)
.not.toBeNull()
await expect(node).toHaveScreenshot('image-crop-after-drag.png', {
maxDiffPixelRatio: 0.05
})
}
)
test('Drag clamps crop box to the right and bottom image edge', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const img = node.locator('img')
await waitForImageNaturalSize(img)
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
nw: el.naturalWidth,
nh: el.naturalHeight
}))
await setCropBounds(comfyPage, 2, {
x: nw - 100,
y: nh - 90,
width: 70,
height: 70
})
const cropBox = node.getByTestId('crop-overlay')
await dragOnLocator(comfyPage, cropBox, 400, 200)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
return v ? v.x + v.width : 0
},
{ message: 'crop drag should clamp to image right edge' }
)
.toBeLessThanOrEqual(nw)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
return v ? v.y + v.height : 0
},
{ message: 'crop drag should clamp to image bottom edge' }
)
.toBeLessThanOrEqual(nh)
})
test('Drag clamps crop box to the top-left image corner', async ({
comfyPage
}) => {
await setCropBounds(comfyPage, 2, {
x: 8,
y: 8,
width: 120,
height: 100
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('2')
.getByTestId('crop-overlay')
await dragOnLocator(comfyPage, cropBox, -400, -350)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.x ?? -1, {
message: 'crop drag should clamp x to the image'
})
.toBeGreaterThanOrEqual(0)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.y ?? -1, {
message: 'crop drag should clamp y to the image'
})
.toBeGreaterThanOrEqual(0)
})
test('Resize from right edge increases width only', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 40,
y: 40,
width: 160,
height: 120
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const handle = node.getByTestId('crop-resize-right')
await dragOnLocator(comfyPage, handle, 55, 0)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
return (
v.width > before.width &&
v.x === before.x &&
v.y === before.y &&
v.height === before.height
)
},
{ message: 'right-edge resize should grow width only' }
)
.toBe(true)
})
test('Resize from left edge decreases x and increases width', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 200,
y: 100,
width: 300,
height: 200
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-left'),
-50,
0
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
return (
v.x < before.x &&
v.width > before.width &&
v.y === before.y &&
v.height === before.height
)
},
{ message: 'left-edge resize should move x and grow width only' }
)
.toBe(true)
})
test('Resize from bottom edge increases height only', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 50,
y: 50,
width: 140,
height: 110
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-bottom'),
0,
50
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
return (
v.height > before.height &&
v.x === before.x &&
v.y === before.y &&
v.width === before.width
)
},
{ message: 'bottom-edge resize should grow height only' }
)
.toBe(true)
})
test('Resize from top edge decreases y and increases height', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 60,
y: 120,
width: 160,
height: 140
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-top'),
0,
-45
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
return (
v.y < before.y &&
v.height > before.height &&
v.x === before.x &&
v.width === before.width
)
},
{ message: 'top-edge resize should move y and grow height only' }
)
.toBe(true)
})
test(
'Resize from SE corner increases width and height',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 70,
y: 80,
width: 130,
height: 110
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-se'),
40,
35
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
return (
v.width > before.width &&
v.height > before.height &&
v.x === before.x &&
v.y === before.y
)
},
{ message: 'SE corner resize should grow width and height' }
)
.toBe(true)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(node).toHaveScreenshot('image-crop-resize-se.png', {
maxDiffPixelRatio: 0.05
})
}
)
test(
'Resize from NW corner moves top-left and grows box',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 140,
y: 130,
width: 160,
height: 140
})
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-nw'),
-45,
-40
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v) return false
return (
v.x < before.x &&
v.y < before.y &&
v.width > before.width &&
v.height > before.height
)
},
{ message: 'NW corner resize should move origin and grow box' }
)
.toBe(true)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(node).toHaveScreenshot('image-crop-resize-nw.png', {
maxDiffPixelRatio: 0.05
})
}
)
test('Resize enforces minimum crop dimensions', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 80,
y: 80,
width: 50,
height: 50
})
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-right'),
-200,
0
)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.width ?? 0, {
message: 'crop width should respect minimum size'
})
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
await expect
.poll(async () => (await getCropValue(comfyPage, 2))?.height ?? 0, {
message: 'crop height should respect minimum size'
})
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
})
test('Resize clamps to image boundaries on the right and bottom edges', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const img = node.locator('img')
await waitForImageNaturalSize(img)
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
nw: el.naturalWidth,
nh: el.naturalHeight
}))
await setCropBounds(comfyPage, 2, {
x: nw - 120,
y: 40,
width: 80,
height: 90
})
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-right'),
400,
0
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
return v ? v.x + v.width : 0
},
{ message: 'right-edge resize should not extend past image width' }
)
.toBeLessThanOrEqual(nw)
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-bottom'),
0,
400
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
return v ? v.y + v.height : 0
},
{
message: 'bottom-edge resize should not extend past image height'
}
)
.toBeLessThanOrEqual(nh)
})
test(
'Eight resize handles are visible when ratio is unlocked',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'unlocked ratio should expose edge and corner handles'
).toHaveCount(8)
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(node).toHaveScreenshot('image-crop-eight-handles.png', {
maxDiffPixelRatio: 0.05
})
}
)
test('Four corner resize handles are visible when aspect ratio is locked', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
await expect(
node
.locator('[data-testid^="crop-resize-"]')
.filter({ visible: true }),
'locked ratio should only show corner handles'
).toHaveCount(4)
})
test('Resize with locked aspect ratio keeps width and height proportional', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await setCropBounds(comfyPage, 2, {
x: 70,
y: 80,
width: 160,
height: 120
})
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
const before = await getCropValue(comfyPage, 2)
if (!before) throw new Error('missing crop')
const ratio = before.width / before.height
await dragOnLocator(
comfyPage,
node.getByTestId('crop-resize-se'),
48,
36
)
await expect
.poll(
async () => {
const v = await getCropValue(comfyPage, 2)
if (!v || v.width <= before.width || v.height <= before.height) {
return null
}
const r = v.width / v.height
if (Math.abs(r - ratio) > 0.06) return null
return v
},
{ message: 'locked ratio resize should preserve aspect ratio' }
)
.not.toBeNull()
})
test('Broken image URL resets widget to empty state', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const img = node.locator('img')
await waitForImageNaturalSize(img)
let failExamplePng = false
await comfyPage.page.route('**/api/view**', async (route) => {
const u = route.request().url()
if (failExamplePng && u.includes('example.png')) {
await route.abort('failed')
return
}
await route.continue()
})
try {
failExamplePng = true
const runDone = comfyPage.runButton.click()
await expect(
node.getByTestId('crop-empty-state'),
'failed view fetch should show empty state'
).toBeVisible({ timeout: 15_000 })
await expect(
node.getByTestId('crop-overlay'),
'crop overlay should hide when image fails'
).toHaveCount(0)
await runDone
} finally {
await comfyPage.page.unroute('**/api/view**')
}
})
}
)
test.describe(
'with source image (slow view)',
{ tag: ['@widget', '@slow'] },
() => {
test('Shows loading text while the view image is delayed', async ({
comfyPage
}) => {
// Slow only example.png view fetches — simulates network latency for the
// loading overlay. Delay lives in the route handler (not
// page.waitForTimeout in the test body).
await comfyPage.page.route('**/api/view**', async (route) => {
const url = route.request().url()
if (!url.includes('example.png')) {
await route.continue()
return
}
await new Promise<void>((resolve) => {
setTimeout(resolve, 500)
})
await route.continue()
})
try {
await comfyPage.workflow.loadWorkflow(
'widgets/image_crop_with_source'
)
await comfyPage.vueNodes.waitForNodes()
const node = comfyPage.vueNodes.getNodeLocator('2')
const runDone = comfyPage.runButton.click()
await expect(
node.getByText('Loading...'),
'delayed view should show loading overlay'
).toBeVisible({ timeout: 10_000 })
await runDone
const img = node.locator('img')
await waitForImageNaturalSize(img, { timeout: 30_000 })
await expect(
node.getByText('Loading...'),
'loading overlay should hide after delayed image loads'
).toBeHidden()
} finally {
await comfyPage.page.unroute('**/api/view**')
}
})
}
)
})

View File

@@ -10,12 +10,8 @@
ref="containerEl"
class="relative min-h-0 flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
>
<div v-if="isLoading" class="flex size-full items-center justify-center">
<span class="text-sm">{{ $t('imageCrop.loading') }}</span>
</div>
<div
v-else-if="!imageUrl"
v-if="!imageUrl"
class="flex size-full flex-col items-center justify-center text-center"
data-testid="crop-empty-state"
>
@@ -26,48 +22,58 @@
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
</div>
<img
v-else
ref="imageEl"
:src="imageUrl"
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
<template v-else>
<img
ref="imageEl"
:src="imageUrl"
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
<div
v-if="imageUrl && !isLoading"
:class="
cn(
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
isDisabled && 'pointer-events-none opacity-60'
)
"
:style="cropBoxStyle"
data-testid="crop-overlay"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
/>
<template v-for="handle in resizeHandles" :key="handle.direction">
<div
v-show="imageUrl && !isLoading"
v-if="isLoading"
aria-live="polite"
class="absolute inset-0 z-10 flex size-full items-center justify-center bg-node-component-surface/90"
>
<span class="text-sm">{{ $t('imageCrop.loading') }}</span>
</div>
<div
v-if="!isLoading"
:class="
cn(
'absolute',
handle.class,
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
isDisabled && 'pointer-events-none opacity-60'
)
"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
:style="cropBoxStyle"
data-testid="crop-overlay"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
/>
<template v-for="handle in resizeHandles" :key="handle.direction">
<div
v-show="!isLoading"
:data-testid="`crop-resize-${handle.direction}`"
:class="
cn(
'absolute',
handle.class,
isDisabled && 'pointer-events-none opacity-60'
)
"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</template>
</template>
</div>

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { imageCropLoadingAfterUrlChange } from '@/composables/useImageCrop'
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)
})
})

View File

@@ -19,9 +19,23 @@ type ResizeDirection =
const HANDLE_SIZE = 8
const CORNER_SIZE = 10
const MIN_CROP_SIZE = 16
/** Minimum crop width/height in source image pixel space. */
export 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,
@@ -179,6 +193,13 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
imageUrl.value = getInputImageUrl()
}
watch(imageUrl, (url, previous) => {
const next = imageCropLoadingAfterUrlChange(url, previous)
if (next !== null) {
isLoading.value = next
}
})
const updateDisplayedDimensions = () => {
if (!imageEl.value || !containerEl.value) return