feat: image cropping / resizing T0

This commit is contained in:
fsylvain committed 2026-09-28 07:46:12 +02:00
1 parent cb05b38f36
commit e15f444603
18 files changed
+1869 -46

No files matched your search

@@ -13,6 +13,15 @@ vi.mock('@/lib/imageUpload', async (importOriginal) => {
}
})
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
}
})
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = []
@@ -141,6 +150,7 @@ async function chooseFile(file: File) {
input.dispatchEvent(new Event('change'))
await nextTick()
await nextTick()
await flushPromises()
}
function findButton(text: string) {
@@ -153,6 +163,12 @@ async function clickButton(text: string) {
findButton(text)?.click()
await nextTick()
await nextTick()
await flushPromises()
}
async function chooseRasterFileAndConfirmUpload(file: File) {
await chooseFile(file)
await clickButton('Upload image')
}
const RealXMLHttpRequest = global.XMLHttpRequest
@@ -247,25 +263,25 @@ describe('"Choose another file" from the rejected state returns to idle', () =>
})
describe('A valid upload shows progress and its cancel action reads "Cancel upload"', () => {
it('sends the file and reports live progress', async () => {
it('sends the resized file and reports live progress', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
const xhr = latestXHR()
expect(xhr.url).toBe('/v1/article/12/image')
expect(xhr.sentBody?.get('file')).toBeInstanceOf(File)
expect(document.body.textContent).toContain('north-wall.jpg 0%')
expect(document.body.textContent).toContain('Uploading 0.0 of 3.9 MB')
expect(document.body.textContent).toContain('Uploading 0.0 of 420 KB')
expect(findButton('Cancel upload')).toBeTruthy()
xhr.fireProgress(mib(2.4), mib(3.9))
xhr.fireProgress(180 * 1024, 420 * 1024)
await nextTick()
expect(document.body.textContent).toContain('north-wall.jpg 62%')
expect(document.body.textContent).toContain('Uploading 2.4 of 3.9 MB')
expect(document.body.textContent).toContain('north-wall.jpg 43%')
expect(document.body.textContent).toContain('Uploading 180 of 420 KB')
wrapper.unmount()
})
})
@@ -274,7 +290,7 @@ describe('Closing the dialog during an upload cancels it', () => {
it('aborts the in-flight XMLHttpRequest and closes the modal', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
await clickButton('Cancel upload')
@@ -290,7 +306,7 @@ describe('A successful upload emits the location and auto-closes 600 ms later',
vi.useFakeTimers()
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
xhr.resolve(201, JSON.stringify({ location: '/static/uploadedImages/north-wall.jpg' }))
@@ -319,7 +335,7 @@ describe('A server-side upload failure keeps the article unchanged and offers a
])('shows the failed state for $name', async ({ trigger }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
trigger(xhr)
@@ -339,7 +355,7 @@ describe('"Try again" re-attempts the upload with the same file', () => {
it('resends the same file under field "file"', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
latestXHR().resolve(500, '')
await flushPromises()
@@ -359,7 +375,7 @@ describe('A server 400 for a type the client should have caught shows the same r
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('notes.pdf', 'image/png', 1.2))
await chooseRasterFileAndConfirmUpload(fileFixture('notes.pdf', 'image/png', 1.2))
const xhr = latestXHR()
xhr.resolve(400, 'the file must be a PNG, JPEG, WEBP, or SVG image')
@@ -380,7 +396,7 @@ describe('A server 400 for a size the client should have caught shows the size-n
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('north-wall.tif', 'image/png', 14.2))
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.tif', 'image/png', 14.2))
const xhr = latestXHR()
xhr.resolve(400, 'the file exceeds the maximum upload size')
@@ -0,0 +1,429 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ImageUploader from '../ImageUploader.vue'
import { renderCroppedImage } from '@/lib/imageCrop'
import type { CropRect } from '@/lib/imageCrop'
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async (file: File) => imageSizeFor(file)),
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
}
})
const imageSizes: Record<string, { width: number; height: number }> = {
'north-wall.jpg': { width: 4032, height: 3024 },
'sunset.jpg': { width: 3000, height: 2000 },
}
function imageSizeFor(file: File) {
return imageSizes[file.name] ?? { width: 4032, height: 3024 }
}
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = []
method = ''
url = ''
status = 0
responseText = ''
withCredentials = false
sentBody: FormData | null = null
private listeners: Record<string, Array<(event?: unknown) => void>> = {}
private uploadListeners: Record<string, (event: { loaded: number; total: number }) => void> = {}
upload = {
addEventListener: (
type: string,
cb: (event: { loaded: number; total: number }) => void,
) => {
this.uploadListeners[type] = cb
},
}
constructor() {
FakeXMLHttpRequest.instances.push(this)
}
open(method: string, url: string) {
this.method = method
this.url = url
}
send(body: FormData) {
this.sentBody = body
}
abort() {}
addEventListener(type: string, cb: (event?: unknown) => void) {
this.listeners[type] = this.listeners[type] || []
this.listeners[type].push(cb)
}
dispatch(type: string, event?: unknown) {
;(this.listeners[type] || []).forEach((cb) => cb(event))
}
fireProgress(loaded: number, total: number) {
this.uploadListeners.progress?.({ loaded, total })
}
resolve(status: number, body: string) {
this.status = status
this.responseText = body
this.dispatch('load')
}
}
function latestXHR() {
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
}
function mib(value: number) {
return Math.round(value * 1024 * 1024)
}
function fileFixture(name: string, type: string, sizeMB: number) {
const file = new File(['x'], name, { type })
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
return file
}
const Host = defineComponent({
components: { ImageUploader },
props: {
articleId: { type: Number, required: true },
},
emits: ['uploaded', 'close'],
setup(_props, { emit }) {
const open = ref(false)
return {
open,
onUploaded: (location: string) => emit('uploaded', location),
onClose: () => emit('close'),
}
},
template: `<ImageUploader v-model:open="open" :article-id="articleId" @uploaded="onUploaded" @close="onClose" />`,
})
function mountUploader() {
return mount(Host, {
props: { articleId: 12 },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
async function openModal(wrapper: ReturnType<typeof mountUploader>) {
;(wrapper.vm as unknown as { open: boolean }).open = true
await nextTick()
await nextTick()
}
function fileInput() {
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
}
async function chooseFile(file: File) {
const input = fileInput()
Object.defineProperty(input, 'files', {
value: { 0: file, length: 1 },
configurable: true,
})
input.dispatchEvent(new Event('change'))
await nextTick()
await nextTick()
await nextTick()
}
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButton(text)?.click()
await nextTick()
await nextTick()
await nextTick()
}
function radioPill(groupLabel: string, text: string) {
const group = document.body.querySelector<HTMLElement>(
`[role="radiogroup"][aria-label="${groupLabel}"]`,
)!
return Array.from(group.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
(pill) => pill.textContent?.trim() === text,
)!
}
async function selectRatioPill(text: string) {
radioPill('Aspect ratio', text).click()
await nextTick()
await nextTick()
}
async function selectWidthPill(text: string) {
radioPill('Width', text).click()
await nextTick()
await nextTick()
}
function cropFrame() {
return document.body.querySelector<HTMLElement>('[data-testid="crop-frame"]')!
}
function north_wall() {
return fileFixture('north-wall.jpg', 'image/jpeg', 3.9)
}
const RealXMLHttpRequest = global.XMLHttpRequest
beforeEach(() => {
FakeXMLHttpRequest.instances = []
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
})
afterEach(() => {
document.body.innerHTML = ''
global.XMLHttpRequest = RealXMLHttpRequest
})
describe('A raster file opens the crop-and-resize step instead of uploading immediately', () => {
it('opens "Crop and resize" with the opened defaults', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(document.body.textContent).toContain('Crop and resize')
expect(document.body.textContent).toContain('north-wall.jpg · 3.9 MB')
expect(radioPill('Aspect ratio', 'Original').getAttribute('aria-checked')).toBe('true')
expect(radioPill('Width', '1600 px').getAttribute('aria-checked')).toBe('true')
expect(document.body.textContent).toContain('Uploads as 1600 × 1200 px · about 420 KB')
expect(findButton('Choose another file')).toBeTruthy()
expect(findButton('Upload image')).toBeTruthy()
wrapper.unmount()
})
})
describe('An SVG skips the crop step and uploads immediately', () => {
it('sends the SVG right away', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/article/12/image')
expect(document.body.textContent).not.toContain('Crop and resize')
wrapper.unmount()
})
})
describe('Choosing an aspect ratio locks the frame to it', () => {
it.each([
{ ratio: '16:9', cropWidth: 4032, cropHeight: 2268, outWidth: 1600, outHeight: 900 },
{ ratio: '4:3', cropWidth: 4032, cropHeight: 3024, outWidth: 1600, outHeight: 1200 },
{ ratio: '1:1', cropWidth: 3024, cropHeight: 3024, outWidth: 1600, outHeight: 1600 },
])(
'locks the frame for $ratio',
async ({ ratio, cropWidth, cropHeight, outWidth, outHeight }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill(ratio)
const rect = (
wrapper.findComponent(ImageUploader).vm as unknown as { cropRect: CropRect }
).cropRect
expect(rect.width).toBe(cropWidth)
expect(rect.height).toBe(cropHeight)
expect(document.body.textContent).toContain(
`Uploads as ${outWidth} × ${outHeight} px · about`,
)
wrapper.unmount()
},
)
})
describe('Choosing a narrower width preset recomputes the output without upscaling', () => {
it('shows a smaller estimate', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectWidthPill('800 px')
expect(document.body.textContent).toContain('Uploads as 800 × 600 px · about')
wrapper.unmount()
})
})
describe('A crop narrower than the chosen width is uploaded at its own size, not upscaled', () => {
it('shows the info row and keeps the crop size', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('1:1')
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
resizeCropTo: (w: number, h: number) => void
}
uploader.resizeCropTo(640, 640)
await nextTick()
expect(document.body.textContent).toContain('Uploads as 640 × 640 px · about')
expect(document.body.textContent).toContain(
"The crop is 640 px wide, so it's uploaded at that size. Enlarging it would make it blurry.",
)
wrapper.unmount()
})
})
describe('Free ratio removes the aspect-ratio constraint', () => {
it('accepts an independent width and height', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('Free')
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
resizeCropTo: (w: number, h: number) => void
cropRect: CropRect
}
uploader.resizeCropTo(1000, 400)
await nextTick()
expect(uploader.cropRect.width).toBe(1000)
expect(uploader.cropRect.height).toBe(400)
wrapper.unmount()
})
})
describe('Arrow keys move the crop frame and Shift+arrow keys resize it', () => {
it('moves 1px on ArrowRight and resizes 1px on Shift+ArrowRight', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('1:1')
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
cropRect: CropRect
}
const before = { ...uploader.cropRect }
cropFrame().dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
)
await nextTick()
expect(uploader.cropRect.x).toBe(before.x + 1)
expect(uploader.cropRect.width).toBe(before.width)
expect(document.body.textContent).toContain('Uploads as 1600 × 1600 px · about')
cropFrame().dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true, bubbles: true }),
)
await nextTick()
expect(uploader.cropRect.width).toBe(before.width + 1)
wrapper.unmount()
})
})
describe('Rotate cycles the image in 90° steps and Reset returns to the opened defaults', () => {
it('rotates then resets', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('16:9')
await selectWidthPill('800 px')
await clickButton('Rotate')
expect(document.body.textContent).toContain('Uploads as')
await clickButton('Reset')
expect(radioPill('Aspect ratio', 'Original').getAttribute('aria-checked')).toBe('true')
expect(radioPill('Width', '1600 px').getAttribute('aria-checked')).toBe('true')
expect(document.body.textContent).toContain('Uploads as 1600 × 1200 px · about 420 KB')
wrapper.unmount()
})
})
describe('"Choose another file" returns to the file picker without uploading', () => {
it('shows the idle state', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await clickButton('Choose another file')
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(document.body.textContent).toContain('Drop an image here')
wrapper.unmount()
})
})
describe("A newly chosen file opens a fresh crop step, not the previous file's adjustments", () => {
it('reopens at defaults for the new file', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await selectRatioPill('16:9')
await selectWidthPill('800 px')
await clickButton('Choose another file')
await chooseFile(fileFixture('sunset.jpg', 'image/jpeg', 2.1))
expect(radioPill('Aspect ratio', 'Original').getAttribute('aria-checked')).toBe('true')
expect(radioPill('Width', '1600 px').getAttribute('aria-checked')).toBe('true')
const uploader = wrapper.findComponent(ImageUploader).vm as unknown as {
cropRect: CropRect
}
expect(uploader.cropRect.width).toBe(3000)
expect(uploader.cropRect.height).toBe(2000)
wrapper.unmount()
})
})
describe('"Upload image" uploads the cropped, resized file, not the original', () => {
it('sends the resized file and shows resized progress', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseFile(north_wall())
await clickButton('Upload image')
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
const xhr = latestXHR()
const sentFile = xhr.sentBody?.get('file') as File
expect(sentFile.size).toBe(430080)
expect(vi.mocked(renderCroppedImage)).toHaveBeenCalledWith(
expect.any(File),
expect.objectContaining({
outputWidth: 1600,
outputHeight: 1200,
rect: expect.objectContaining({ width: 4032, height: 3024 }),
}),
)
expect(document.body.textContent).toContain('Uploading 0.0 of 420 KB')
xhr.fireProgress(180 * 1024, 420 * 1024)
await nextTick()
expect(document.body.textContent).toContain('north-wall.jpg 43%')
expect(document.body.textContent).toContain('Uploading 180 of 420 KB')
wrapper.unmount()
})
})