feat: image cropping / resizing T0
This commit is contained in:
1 parent
cb05b38f36
commit
e15f444603
18 files changed
+1869
-46
No files matched your search
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user