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

+553 -13
View File
@@ -1,13 +1,38 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import {
IMAGE_TYPE_REJECTION_TEXT,
formatMegabytes,
formatUploadProgress,
isSizeRejectionBody,
rejectionTitle,
sizeRejectionText,
validateImageFile,
} from '@/lib/imageUpload'
import {
ASPECT_RATIOS,
WIDTH_PRESETS,
type AspectRatioLabel,
type CropRect,
type WidthPresetLabel,
clampRect,
computeOutputSize,
estimateOutputBytes,
formatEstimatedSize,
moveRect,
nextRotation,
ratioValueFor,
readNaturalImageSize,
rectForRatio,
renderCroppedImage,
resizeRect,
resizeRectTo,
rotateRectBySteps,
rotatedDimensions,
wholeRect,
widthPresetPixels,
} from '@/lib/imageCrop'
type Corner = 'tl' | 'tr' | 'bl' | 'br'
import { useImageUpload } from '@/composables/useImageUpload'
const props = defineProps<{
@@ -20,16 +45,27 @@ const emit = defineEmits<{
close: []
}>()
type Phase = 'idle' | 'dragging-over' | 'uploading' | 'rejected' | 'failed'
type Phase = 'idle' | 'dragging-over' | 'crop' | 'uploading' | 'rejected' | 'failed'
const open = defineModel<boolean>('open', { default: false })
const phase = ref<Phase>('idle')
const chosenFile = ref<File | null>(null)
const lastUploadedFile = ref<File | null>(null)
const pickedFile = ref<File | null>(null)
const rejectionKind = ref<'type' | 'size' | null>(null)
const succeeded = ref(false)
const naturalWidth = ref(0)
const naturalHeight = ref(0)
const rotation = ref(0)
const ratioLabel = ref<AspectRatioLabel>('Original')
const widthLabel = ref<WidthPresetLabel>('1600 px')
const rect = ref<CropRect>({ x: 0, y: 0, width: 0, height: 0 })
const imageBoxRef = ref<HTMLElement | null>(null)
const previewCanvasRef = ref<HTMLCanvasElement | null>(null)
const sourceBitmap = ref<ImageBitmap | null>(null)
const { progress, start, abort } = useImageUpload()
let closeTimer: ReturnType<typeof setTimeout> | null = null
@@ -57,9 +93,68 @@ const rejectedText = computed(() => {
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const showCancel = computed(
() => phase.value !== 'failed' && !(phase.value === 'uploading' && succeeded.value),
() =>
phase.value !== 'failed' &&
phase.value !== 'crop' &&
!(phase.value === 'uploading' && succeeded.value),
)
const modalTitle = computed(() => (phase.value === 'crop' ? 'Crop and resize' : 'Add an image'))
const modalDescription = computed(() => {
if (phase.value !== 'crop' || !chosenFile.value) {
return undefined
}
return `${chosenFile.value.name} · ${formatEstimatedSize(chosenFile.value.size)}`
})
const modalContentClass = computed(
() =>
`divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 ${
phase.value === 'crop' ? 'sm:max-w-160' : 'sm:max-w-120'
}`,
)
const boundsWidth = computed(
() => rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value).width,
)
const boundsHeight = computed(
() => rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value).height,
)
const lockedRatioValue = computed(() =>
ratioValueFor(ratioLabel.value, boundsWidth.value, boundsHeight.value),
)
const cropWidth = computed(() => Math.round(rect.value.width))
const cropHeight = computed(() => Math.round(rect.value.height))
const outputSize = computed(() =>
computeOutputSize(cropWidth.value, cropHeight.value, widthPresetPixels(widthLabel.value)),
)
const outputSizeText = computed(() =>
formatEstimatedSize(estimateOutputBytes(outputSize.value.width, outputSize.value.height)),
)
const monoLine = computed(
() =>
`Uploads as ${outputSize.value.width} × ${outputSize.value.height} px · about ${outputSizeText.value}`,
)
const targetWidthPixels = computed(() => widthPresetPixels(widthLabel.value))
const showNarrowNote = computed(
() => targetWidthPixels.value !== null && targetWidthPixels.value > cropWidth.value,
)
const narrowNoteText = computed(
() =>
`The crop is ${cropWidth.value} px wide, so it's uploaded at that size. Enlarging it would make it blurry.`,
)
const dimsLabel = computed(() => `${cropWidth.value} × ${cropHeight.value}`)
function clearCloseTimer() {
if (closeTimer !== null) {
clearTimeout(closeTimer)
@@ -68,6 +163,7 @@ function clearCloseTimer() {
}
async function runUpload(file: File) {
lastUploadedFile.value = file
const outcome = await start(props.articleId, file)
if (outcome.kind === 'success') {
succeeded.value = true
@@ -85,6 +181,69 @@ async function runUpload(file: File) {
phase.value = 'failed'
}
function closeSourceBitmap() {
if (sourceBitmap.value) {
sourceBitmap.value.close()
sourceBitmap.value = null
}
}
function drawPreview() {
const canvas = previewCanvasRef.value
const bitmap = sourceBitmap.value
if (!canvas || !bitmap) {
return
}
const rotated = rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value)
const maxDimension = 1200
const scale = Math.min(1, maxDimension / Math.max(rotated.width, rotated.height))
canvas.width = Math.max(1, Math.round(rotated.width * scale))
canvas.height = Math.max(1, Math.round(rotated.height * scale))
const ctx = canvas.getContext('2d')
if (!ctx) {
return
}
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((rotation.value * Math.PI) / 180)
ctx.drawImage(
bitmap,
(-naturalWidth.value * scale) / 2,
(-naturalHeight.value * scale) / 2,
naturalWidth.value * scale,
naturalHeight.value * scale,
)
ctx.restore()
}
async function loadPreviewBitmap(file: File) {
closeSourceBitmap()
if (typeof createImageBitmap !== 'function') {
return
}
try {
sourceBitmap.value = await createImageBitmap(file)
} catch {
sourceBitmap.value = null
return
}
await nextTick()
drawPreview()
}
async function openCropStep(file: File) {
const size = await readNaturalImageSize(file)
naturalWidth.value = size.width
naturalHeight.value = size.height
rotation.value = 0
ratioLabel.value = 'Original'
widthLabel.value = '1600 px'
rect.value = wholeRect(size.width, size.height)
phase.value = 'crop'
await loadPreviewBitmap(file)
}
function handleFile(file: File) {
chosenFile.value = file
const result = validateImageFile(file)
@@ -95,8 +254,12 @@ function handleFile(file: File) {
}
rejectionKind.value = null
succeeded.value = false
phase.value = 'uploading'
runUpload(file)
if (file.type === 'image/svg+xml') {
phase.value = 'uploading'
runUpload(file)
return
}
openCropStep(file)
}
function onFileChange() {
@@ -128,21 +291,223 @@ function chooseAnotherFile() {
chosenFile.value = null
pickedFile.value = null
rejectionKind.value = null
closeSourceBitmap()
}
function tryAgain() {
if (!chosenFile.value) {
if (!lastUploadedFile.value) {
return
}
succeeded.value = false
phase.value = 'uploading'
runUpload(chosenFile.value)
runUpload(lastUploadedFile.value)
}
function cancel() {
open.value = false
}
function selectRatio(label: AspectRatioLabel) {
ratioLabel.value = label
if (label === 'Free') {
return
}
rect.value = rectForRatio(label, boundsWidth.value, boundsHeight.value)
}
function selectWidth(label: WidthPresetLabel) {
widthLabel.value = label
}
function rotateImage() {
const dims = rotatedDimensions(naturalWidth.value, naturalHeight.value, rotation.value)
rect.value = rotateRectBySteps(rect.value, dims.width, dims.height, 1)
rotation.value = nextRotation(rotation.value)
drawPreview()
}
function resetCrop() {
rotation.value = 0
ratioLabel.value = 'Original'
widthLabel.value = '1600 px'
rect.value = wholeRect(naturalWidth.value, naturalHeight.value)
drawPreview()
}
function resizeCropTo(width: number, height: number) {
rect.value = resizeRectTo(rect.value, width, height, boundsWidth.value, boundsHeight.value)
}
function stagePixelScale(): number {
const box = imageBoxRef.value?.getBoundingClientRect()
if (!box || box.width === 0) {
return 1
}
return boundsWidth.value / box.width
}
function pointFromEvent(event: MouseEvent | TouchEvent): { x: number; y: number } {
if ('touches' in event && event.touches.length > 0) {
return { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY }
}
if ('changedTouches' in event && event.changedTouches.length > 0) {
return { x: event.changedTouches[0]!.clientX, y: event.changedTouches[0]!.clientY }
}
const mouseEvent = event as MouseEvent
return { x: mouseEvent.clientX, y: mouseEvent.clientY }
}
let moveOrigin: { x: number; y: number; rect: CropRect } | null = null
function onFrameDragStart(event: MouseEvent | TouchEvent) {
event.preventDefault()
const point = pointFromEvent(event)
moveOrigin = { x: point.x, y: point.y, rect: { ...rect.value } }
window.addEventListener('mousemove', onFrameDragMove)
window.addEventListener('mouseup', onFrameDragEnd)
window.addEventListener('touchmove', onFrameDragMove)
window.addEventListener('touchend', onFrameDragEnd)
}
function onFrameDragMove(event: MouseEvent | TouchEvent) {
if (!moveOrigin) {
return
}
const point = pointFromEvent(event)
const scale = stagePixelScale()
const dx = (point.x - moveOrigin.x) * scale
const dy = (point.y - moveOrigin.y) * scale
rect.value = clampRect(
{ ...moveOrigin.rect, x: moveOrigin.rect.x + dx, y: moveOrigin.rect.y + dy },
boundsWidth.value,
boundsHeight.value,
)
}
function onFrameDragEnd() {
moveOrigin = null
window.removeEventListener('mousemove', onFrameDragMove)
window.removeEventListener('mouseup', onFrameDragEnd)
window.removeEventListener('touchmove', onFrameDragMove)
window.removeEventListener('touchend', onFrameDragEnd)
}
let resizeOrigin: {
x: number
y: number
rect: CropRect
corner: Corner
ratio: number | null
} | null = null
function onHandleDragStart(event: MouseEvent | TouchEvent, corner: Corner) {
event.preventDefault()
event.stopPropagation()
const point = pointFromEvent(event)
resizeOrigin = {
x: point.x,
y: point.y,
rect: { ...rect.value },
corner,
ratio: lockedRatioValue.value,
}
window.addEventListener('mousemove', onHandleDragMove)
window.addEventListener('mouseup', onHandleDragEnd)
window.addEventListener('touchmove', onHandleDragMove)
window.addEventListener('touchend', onHandleDragEnd)
}
function onHandleDragMove(event: MouseEvent | TouchEvent) {
if (!resizeOrigin) {
return
}
const point = pointFromEvent(event)
const scale = stagePixelScale()
const dx = (point.x - resizeOrigin.x) * scale
const dy = (point.y - resizeOrigin.y) * scale
const { corner, rect: startRect, ratio } = resizeOrigin
let { x, y, width, height } = startRect
if (corner.includes('r')) {
width = startRect.width + dx
} else {
width = startRect.width - dx
x = startRect.x + dx
}
if (corner.includes('b')) {
height = startRect.height + dy
} else {
height = startRect.height - dy
y = startRect.y + dy
}
if (ratio) {
height = width / ratio
if (corner.startsWith('t')) {
y = startRect.y + startRect.height - height
}
}
rect.value = clampRect({ x, y, width, height }, boundsWidth.value, boundsHeight.value)
}
function onHandleDragEnd() {
resizeOrigin = null
window.removeEventListener('mousemove', onHandleDragMove)
window.removeEventListener('mouseup', onHandleDragEnd)
window.removeEventListener('touchmove', onHandleDragMove)
window.removeEventListener('touchend', onHandleDragEnd)
}
function onFrameKeydown(event: KeyboardEvent) {
const step = 1
const lockedRatio = lockedRatioValue.value
if (event.key === 'ArrowRight') {
event.preventDefault()
rect.value = event.shiftKey
? resizeRect(rect.value, step, 0, boundsWidth.value, boundsHeight.value, lockedRatio)
: moveRect(rect.value, step, 0, boundsWidth.value, boundsHeight.value)
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
rect.value = event.shiftKey
? resizeRect(rect.value, -step, 0, boundsWidth.value, boundsHeight.value, lockedRatio)
: moveRect(rect.value, -step, 0, boundsWidth.value, boundsHeight.value)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
rect.value = event.shiftKey
? resizeRect(rect.value, 0, -step, boundsWidth.value, boundsHeight.value, lockedRatio)
: moveRect(rect.value, 0, -step, boundsWidth.value, boundsHeight.value)
} else if (event.key === 'ArrowDown') {
event.preventDefault()
rect.value = event.shiftKey
? resizeRect(rect.value, 0, step, boundsWidth.value, boundsHeight.value, lockedRatio)
: moveRect(rect.value, 0, step, boundsWidth.value, boundsHeight.value)
}
}
async function confirmCropUpload() {
if (!chosenFile.value) {
return
}
const file = chosenFile.value
const output = outputSize.value
const blob = await renderCroppedImage(file, {
rect: rect.value,
rotation: rotation.value,
naturalWidth: naturalWidth.value,
naturalHeight: naturalHeight.value,
outputWidth: output.width,
outputHeight: output.height,
mimeType: file.type,
})
const resizedFile = new File([blob], file.name, { type: file.type })
succeeded.value = false
phase.value = 'uploading'
runUpload(resizedFile)
}
defineExpose({
cropRect: rect,
resizeCropTo,
})
watch(open, (isOpen) => {
if (isOpen) {
phase.value = 'idle'
@@ -159,17 +524,22 @@ watch(open, (isOpen) => {
abort()
}
clearCloseTimer()
closeSourceBitmap()
emit('close')
})
onUnmounted(() => {
closeSourceBitmap()
})
</script>
<template>
<UModal
v-model:open="open"
title="Add an image"
:title="modalTitle"
:description="modalDescription"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-120 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
content: modalContentClass,
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
@@ -204,6 +574,171 @@ watch(open, (isOpen) => {
</UFileUpload>
</div>
<div v-else-if="phase === 'crop'" class="flex flex-col gap-4">
<div
class="flex h-60 items-center justify-center bg-elevated p-4 sm:h-85"
data-testid="crop-stage"
>
<div
ref="imageBoxRef"
class="relative h-full max-w-full"
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
role="img"
>
<div class="absolute inset-0 overflow-hidden bg-(--color-sand-200)">
<canvas ref="previewCanvasRef" class="absolute inset-0 h-full w-full" />
<div
class="absolute"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
top: `${(rect.y / boundsHeight) * 100}%`,
width: `${(rect.width / boundsWidth) * 100}%`,
height: `${(rect.height / boundsHeight) * 100}%`,
boxShadow: '0 0 0 2000px rgb(10 10 10 / 0.55)',
}"
/>
</div>
<div
data-testid="crop-frame"
tabindex="0"
role="group"
aria-label="Crop area. Arrow keys move it, Shift and arrow keys resize it."
class="absolute cursor-move touch-none outline outline-white"
:style="{
left: `${(rect.x / boundsWidth) * 100}%`,
top: `${(rect.y / boundsHeight) * 100}%`,
width: `${(rect.width / boundsWidth) * 100}%`,
height: `${(rect.height / boundsHeight) * 100}%`,
}"
@keydown="onFrameKeydown"
@mousedown="onFrameDragStart"
@touchstart="onFrameDragStart"
>
<div
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
style="left: 33.333%"
/>
<div
class="pointer-events-none absolute top-0 bottom-0 w-px bg-white/45"
style="left: 66.666%"
/>
<div
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
style="top: 33.333%"
/>
<div
class="pointer-events-none absolute left-0 right-0 h-px bg-white/45"
style="top: 66.666%"
/>
<div
v-for="corner in ['tl', 'tr', 'bl', 'br'] as Corner[]"
:key="corner"
data-testid="crop-handle"
class="absolute size-3 touch-none border border-ink-900 bg-white"
:class="{
'-top-1.5 -left-1.5 cursor-nwse-resize': corner === 'tl',
'-top-1.5 -right-1.5 cursor-nesw-resize': corner === 'tr',
'-bottom-1.5 -left-1.5 cursor-nesw-resize': corner === 'bl',
'-bottom-1.5 -right-1.5 cursor-nwse-resize': corner === 'br',
}"
@mousedown="onHandleDragStart($event, corner)"
@touchstart="onHandleDragStart($event, corner)"
/>
<span
class="absolute bottom-2 left-2 bg-ink-900/80 px-1.5 py-px font-mono text-xs whitespace-nowrap text-white"
>
{{ dimsLabel }}
</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-1">
<span class="flex-1 font-medium text-highlighted">Aspect ratio</span>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-rotate-ccw-square"
@click="rotateImage"
>
Rotate
</UButton>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-undo-2"
@click="resetCrop"
>
Reset
</UButton>
</div>
<div
role="radiogroup"
aria-label="Aspect ratio"
class="flex w-full border border-accented sm:w-max"
>
<button
v-for="ratio in ASPECT_RATIOS"
:key="ratio"
type="button"
role="radio"
:aria-checked="ratioLabel === ratio"
:tabindex="ratioLabel === ratio ? 0 : -1"
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
:class="
ratioLabel === ratio
? 'bg-primary text-inverted'
: 'bg-transparent text-toned'
"
@click="selectRatio(ratio)"
>
{{ ratio }}
</button>
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">Width</span>
<div
role="radiogroup"
aria-label="Width"
class="flex w-full border border-accented sm:w-max"
>
<button
v-for="widthOption in WIDTH_PRESETS"
:key="widthOption"
type="button"
role="radio"
:aria-checked="widthLabel === widthOption"
:tabindex="widthLabel === widthOption ? 0 : -1"
class="flex-1 px-3 py-1.5 font-medium whitespace-nowrap sm:flex-none"
:class="
widthLabel === widthOption
? 'bg-primary text-inverted'
: 'bg-transparent text-toned'
"
@click="selectWidth(widthOption)"
>
{{ widthOption }}
</button>
</div>
<span class="font-mono text-xs text-muted">{{ monoLine }}</span>
</div>
<div
v-if="showNarrowNote"
role="status"
class="flex gap-2 bg-(--rc-info-soft) p-2.5"
>
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
<span class="text-toned">{{ narrowNoteText }}</span>
</div>
</div>
<div v-else-if="phase === 'uploading'" class="flex flex-col gap-3">
<div
class="flex flex-col items-center gap-3 rounded-md border border-default px-6 py-8 text-center"
@@ -226,8 +761,7 @@ watch(open, (isOpen) => {
/>
</div>
<p v-if="!succeeded" class="text-sm text-toned">
Uploading {{ formatMegabytes(progress.loaded) }} of
{{ formatMegabytes(progress.total) }} MB
{{ formatUploadProgress(progress.loaded, progress.total) }}
</p>
</div>
@@ -257,6 +791,12 @@ watch(open, (isOpen) => {
Choose another file
</UButton>
<UButton v-if="phase === 'failed'" @click="tryAgain"> Try again </UButton>
<template v-if="phase === 'crop'">
<UButton color="neutral" variant="outline" @click="chooseAnotherFile">
Choose another file
</UButton>
<UButton @click="confirmCropUpload">Upload image</UButton>
</template>
</div>
</template>
</UModal>
@@ -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()
})
})
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
computeOutputSize,
estimateOutputBytes,
formatEstimatedSize,
rectForRatio,
rotateRectBySteps,
rotatedDimensions,
wholeRect,
} from '../imageCrop'
describe('rectForRatio', () => {
it('centers a 16:9 crop inside a 4032x3024 image', () => {
const rect = rectForRatio('16:9', 4032, 3024)
expect(rect).toEqual({ x: 0, y: 378, width: 4032, height: 2268 })
})
it('centers a 1:1 crop inside a 4032x3024 image', () => {
const rect = rectForRatio('1:1', 4032, 3024)
expect(rect).toEqual({ x: 504, y: 0, width: 3024, height: 3024 })
})
it('returns the whole image for Original', () => {
expect(rectForRatio('Original', 4032, 3024)).toEqual(wholeRect(4032, 3024))
})
})
describe('computeOutputSize', () => {
it('never upscales past the crop width', () => {
expect(computeOutputSize(640, 640, 1600)).toEqual({ width: 640, height: 640 })
})
it('caps to the width preset when the crop is larger', () => {
expect(computeOutputSize(4032, 3024, 1600)).toEqual({ width: 1600, height: 1200 })
})
})
describe('estimateOutputBytes', () => {
it('estimates 420 KB for the default 1600x1200 output', () => {
expect(formatEstimatedSize(estimateOutputBytes(1600, 1200))).toBe('420 KB')
})
})
describe('rotateRectBySteps', () => {
it('rotates a whole-image rect 90deg clockwise into the new bounds', () => {
const rotated = rotateRectBySteps(wholeRect(4032, 3024), 4032, 3024, 1)
expect(rotated).toEqual({ x: 0, y: 0, width: 3024, height: 4032 })
})
it('round-trips back to the original after four steps', () => {
const rect = { x: 42, y: 34, width: 500, height: 300 }
const rotated = rotateRectBySteps(rect, 4032, 3024, 4)
expect(rotated).toEqual(rect)
})
})
describe('rotatedDimensions', () => {
it('swaps dimensions for a 90deg rotation', () => {
expect(rotatedDimensions(4032, 3024, 90)).toEqual({ width: 3024, height: 4032 })
})
it('keeps dimensions for a 180deg rotation', () => {
expect(rotatedDimensions(4032, 3024, 180)).toEqual({ width: 4032, height: 3024 })
})
})
+245
View File
@@ -0,0 +1,245 @@
export interface CropRect {
x: number
y: number
width: number
height: number
}
export const ASPECT_RATIOS = ['Original', 'Free', '16:9', '4:3', '1:1'] as const
export type AspectRatioLabel = (typeof ASPECT_RATIOS)[number]
export const WIDTH_PRESETS = ['Original', '1600 px', '1200 px', '800 px'] as const
export type WidthPresetLabel = (typeof WIDTH_PRESETS)[number]
const FIXED_RATIO_VALUES: Partial<Record<AspectRatioLabel, number>> = {
'16:9': 16 / 9,
'4:3': 4 / 3,
'1:1': 1,
}
const WIDTH_PRESET_PIXELS: Record<WidthPresetLabel, number | null> = {
Original: null,
'1600 px': 1600,
'1200 px': 1200,
'800 px': 800,
}
export function widthPresetPixels(label: WidthPresetLabel): number | null {
return WIDTH_PRESET_PIXELS[label]
}
export function ratioValueFor(
ratio: AspectRatioLabel,
boundsWidth: number,
boundsHeight: number,
): number | null {
if (ratio === 'Free') {
return null
}
if (ratio === 'Original') {
return boundsWidth / boundsHeight
}
return FIXED_RATIO_VALUES[ratio] ?? null
}
export function wholeRect(width: number, height: number): CropRect {
return { x: 0, y: 0, width, height }
}
export function clampRect(rect: CropRect, boundsWidth: number, boundsHeight: number): CropRect {
const width = Math.round(Math.min(Math.max(rect.width, 1), boundsWidth))
const height = Math.round(Math.min(Math.max(rect.height, 1), boundsHeight))
const x = Math.round(Math.min(Math.max(rect.x, 0), boundsWidth - width))
const y = Math.round(Math.min(Math.max(rect.y, 0), boundsHeight - height))
return { x, y, width, height }
}
export function rectForRatio(
ratio: AspectRatioLabel,
boundsWidth: number,
boundsHeight: number,
): CropRect {
if (ratio === 'Original') {
return wholeRect(boundsWidth, boundsHeight)
}
const value = FIXED_RATIO_VALUES[ratio]
if (!value) {
return wholeRect(boundsWidth, boundsHeight)
}
const boundsRatio = boundsWidth / boundsHeight
let width: number
let height: number
if (value > boundsRatio) {
width = boundsWidth
height = Math.round(width / value)
} else {
height = boundsHeight
width = Math.round(height * value)
}
return {
x: Math.round((boundsWidth - width) / 2),
y: Math.round((boundsHeight - height) / 2),
width,
height,
}
}
export function moveRect(
rect: CropRect,
dx: number,
dy: number,
boundsWidth: number,
boundsHeight: number,
): CropRect {
return clampRect({ ...rect, x: rect.x + dx, y: rect.y + dy }, boundsWidth, boundsHeight)
}
export function resizeRect(
rect: CropRect,
dw: number,
dh: number,
boundsWidth: number,
boundsHeight: number,
lockedRatio: number | null,
): CropRect {
let width = rect.width + dw
let height = rect.height + dh
if (lockedRatio) {
if (dw !== 0) {
height = Math.round(width / lockedRatio)
} else if (dh !== 0) {
width = Math.round(height * lockedRatio)
}
}
return clampRect({ ...rect, width, height }, boundsWidth, boundsHeight)
}
export function resizeRectTo(
rect: CropRect,
width: number,
height: number,
boundsWidth: number,
boundsHeight: number,
): CropRect {
return clampRect({ ...rect, width, height }, boundsWidth, boundsHeight)
}
function rotateRectClockwiseOnce(rect: CropRect, width: number, height: number): CropRect {
return { x: height - rect.y - rect.height, y: rect.x, width: rect.height, height: rect.width }
}
export function rotateRectBySteps(
rect: CropRect,
width: number,
height: number,
steps: number,
): CropRect {
let r = rect
let w = width
let h = height
const n = ((steps % 4) + 4) % 4
for (let i = 0; i < n; i += 1) {
r = rotateRectClockwiseOnce(r, w, h)
;[w, h] = [h, w]
}
return r
}
export function rotatedDimensions(width: number, height: number, rotation: number) {
const turns = (((rotation / 90) % 4) + 4) % 4
return turns % 2 === 1 ? { width: height, height: width } : { width, height }
}
export function nextRotation(rotation: number): number {
return (rotation + 90) % 360
}
export function computeOutputSize(
cropWidth: number,
cropHeight: number,
targetWidth: number | null,
) {
const target = targetWidth === null ? cropWidth : targetWidth
const width = Math.min(target, cropWidth)
const height = Math.round((cropHeight * width) / cropWidth)
return { width, height }
}
const ESTIMATED_BYTES_PER_PIXEL = (420 * 1024) / (1600 * 1200)
export function estimateOutputBytes(width: number, height: number): number {
return Math.round(width * height * ESTIMATED_BYTES_PER_PIXEL)
}
export function formatEstimatedSize(bytes: number): string {
const kb = bytes / 1024
if (kb < 1000) {
return `${Math.round(kb)} KB`
}
return `${(kb / 1024).toFixed(1)} MB`
}
export interface NaturalImageSize {
width: number
height: number
}
export async function readNaturalImageSize(file: File): Promise<NaturalImageSize> {
const bitmap = await createImageBitmap(file)
const size = { width: bitmap.width, height: bitmap.height }
bitmap.close()
return size
}
export interface RenderCropOptions {
rect: CropRect
rotation: number
naturalWidth: number
naturalHeight: number
outputWidth: number
outputHeight: number
mimeType: string
}
export async function renderCroppedImage(file: File, options: RenderCropOptions): Promise<Blob> {
const bitmap = await createImageBitmap(file)
const rotated = rotatedDimensions(options.naturalWidth, options.naturalHeight, options.rotation)
const rotatedCanvas = document.createElement('canvas')
rotatedCanvas.width = rotated.width
rotatedCanvas.height = rotated.height
const rotatedCtx = rotatedCanvas.getContext('2d')
if (!rotatedCtx) {
throw new Error('canvas rendering is not supported')
}
rotatedCtx.translate(rotated.width / 2, rotated.height / 2)
rotatedCtx.rotate((options.rotation * Math.PI) / 180)
rotatedCtx.drawImage(bitmap, -options.naturalWidth / 2, -options.naturalHeight / 2)
const canvas = document.createElement('canvas')
canvas.width = options.outputWidth
canvas.height = options.outputHeight
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('canvas rendering is not supported')
}
ctx.drawImage(
rotatedCanvas,
options.rect.x,
options.rect.y,
options.rect.width,
options.rect.height,
0,
0,
options.outputWidth,
options.outputHeight,
)
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
return
}
reject(new Error('failed to render the cropped image'))
}, options.mimeType)
})
}
+16
View File
@@ -42,3 +42,19 @@ export function rejectionTitle(filename: string) {
export function sizeRejectionText(filename: string, bytes: number) {
return `${filename} is ${formatMegabytes(bytes)} MB. Images can be up to 10 MB.`
}
export function formatWholeKB(bytes: number): string {
const kb = bytes / 1024
const rounded = Math.round(kb * 10) / 10
if (rounded === 0) {
return '0.0'
}
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
export function formatUploadProgress(loadedBytes: number, totalBytes: number): string {
if (totalBytes < 1024 * 1024) {
return `Uploading ${formatWholeKB(loadedBytes)} of ${formatWholeKB(totalBytes)} KB`
}
return `Uploading ${formatMegabytes(loadedBytes)} of ${formatMegabytes(totalBytes)} MB`
}
@@ -10,6 +10,15 @@ import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
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[] = []
@@ -195,8 +204,8 @@ describe("Opening the uploader from the toolbar's image button shows the idle st
})
})
describe('Dropping a valid file onto the editor opens the uploader already uploading it', () => {
it('opens uploading, not idle, and sends the file', async () => {
describe('Dropping a valid file onto the editor opens the uploader already showing the crop step', () => {
it('opens the crop step, and confirming it sends the resized file', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
@@ -209,8 +218,12 @@ describe('Dropping a valid file onto the editor opens the uploader already uploa
wrapper.findComponent(ArticleEditor).element.dispatchEvent(dropEvent)
await flushPromises()
expect(document.body.textContent).toContain('Add an image')
expect(document.body.textContent).toContain('Crop and resize')
expect(document.body.textContent).not.toContain('Drop an image here')
findButton('Upload image')?.click()
await flushPromises()
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/article/12/image')
expect((latestXHR().sentBody?.get('file') as File).name).toBe('before.png')
@@ -235,6 +248,9 @@ describe('A successful upload inserts the image at the cursor and marks the draf
fileInput.dispatchEvent(new Event('change'))
await flushPromises()
findButton('Upload image')?.click()
await flushPromises()
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
latestXHR().resolve(
201,