Files
RenewCMS/web/src/components/ImageUploader.vue
T

806 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import {
IMAGE_TYPE_REJECTION_TEXT,
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<{
articleId: number
initialFile?: File | null
}>()
const emit = defineEmits<{
uploaded: [location: string, imageId: number]
close: []
}>()
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
const uploadPercent = computed(() => {
if (progress.value.total === 0) {
return 0
}
return Math.round((progress.value.loaded / progress.value.total) * 100)
})
const rejectedTitle = computed(() =>
chosenFile.value ? rejectionTitle(chosenFile.value.name) : '',
)
const rejectedText = computed(() => {
if (!chosenFile.value) {
return ''
}
if (rejectionKind.value === 'size') {
return sizeRejectionText(chosenFile.value.name, chosenFile.value.size)
}
return IMAGE_TYPE_REJECTION_TEXT
})
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const showCancel = computed(
() =>
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)
closeTimer = null
}
}
async function runUpload(file: File) {
lastUploadedFile.value = file
const outcome = await start(props.articleId, file)
if (outcome.kind === 'success') {
succeeded.value = true
emit('uploaded', outcome.location, outcome.id)
closeTimer = setTimeout(() => {
open.value = false
}, 600)
return
}
if (outcome.kind === 'rejected') {
rejectionKind.value = isSizeRejectionBody(outcome.body) ? 'size' : 'type'
phase.value = 'rejected'
return
}
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)
if (!result.ok) {
rejectionKind.value = result.kind
phase.value = 'rejected'
return
}
rejectionKind.value = null
succeeded.value = false
if (file.type === 'image/svg+xml') {
phase.value = 'uploading'
runUpload(file)
return
}
openCropStep(file)
}
function onFileChange() {
if (pickedFile.value) {
handleFile(pickedFile.value)
}
}
function onDragOver() {
if (phase.value === 'idle') {
phase.value = 'dragging-over'
}
}
function onDragLeave() {
if (phase.value === 'dragging-over') {
phase.value = 'idle'
}
}
function onDropOnZone() {
if (phase.value === 'dragging-over') {
phase.value = 'idle'
}
}
function chooseAnotherFile() {
phase.value = 'idle'
chosenFile.value = null
pickedFile.value = null
rejectionKind.value = null
closeSourceBitmap()
}
function tryAgain() {
if (!lastUploadedFile.value) {
return
}
succeeded.value = false
phase.value = 'uploading'
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 }
}
function bindPointerDrag(onMove: (event: MouseEvent | TouchEvent) => void, onEnd: () => void) {
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onEnd)
window.addEventListener('touchmove', onMove)
window.addEventListener('touchend', onEnd)
}
function unbindPointerDrag(onMove: (event: MouseEvent | TouchEvent) => void, onEnd: () => void) {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onEnd)
window.removeEventListener('touchmove', onMove)
window.removeEventListener('touchend', onEnd)
}
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 } }
bindPointerDrag(onFrameDragMove, 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
unbindPointerDrag(onFrameDragMove, 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,
}
bindPointerDrag(onHandleDragMove, 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
unbindPointerDrag(onHandleDragMove, onHandleDragEnd)
}
const ARROW_KEY_DELTAS: Record<string, [number, number]> = {
ArrowRight: [1, 0],
ArrowLeft: [-1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
}
function onFrameKeydown(event: KeyboardEvent) {
const delta = ARROW_KEY_DELTAS[event.key]
if (!delta) {
return
}
event.preventDefault()
const [dx, dy] = delta
rect.value = event.shiftKey
? resizeRect(
rect.value,
dx,
dy,
boundsWidth.value,
boundsHeight.value,
lockedRatioValue.value,
)
: moveRect(rect.value, dx, dy, 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'
chosenFile.value = null
pickedFile.value = null
rejectionKind.value = null
succeeded.value = false
if (props.initialFile) {
handleFile(props.initialFile)
}
return
}
if (phase.value === 'uploading' && !succeeded.value) {
abort()
}
clearCloseTimer()
closeSourceBitmap()
emit('close')
})
onUnmounted(() => {
closeSourceBitmap()
})
</script>
<template>
<UModal
v-model:open="open"
:title="modalTitle"
:description="modalDescription"
:ui="{
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',
}"
>
<template #body>
<div
v-if="phase === 'idle' || phase === 'dragging-over'"
data-testid="image-dropzone"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDropOnZone"
>
<UFileUpload
v-model="pickedFile"
data-testid="image-file-input"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
:multiple="false"
layout="list"
:reset="true"
@change="onFileChange"
>
<template #label>
{{ phase === 'dragging-over' ? 'Drop to upload' : 'Drop an image here' }}
</template>
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
<template #actions="{ open: openDialog }">
<UButton color="neutral" variant="outline" @click="openDialog()">
Choose file
</UButton>
</template>
</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"
>
<UIcon
:name="succeeded ? 'i-lucide-circle-check' : 'i-lucide-image-plus'"
:class="succeeded ? 'size-6 text-success' : 'size-6 text-dimmed'"
/>
<p class="text-sm text-highlighted">
<template v-if="succeeded">Image added</template>
<template v-else>{{ chosenFile?.name }} {{ uploadPercent }}%</template>
</p>
<UProgress
v-if="!succeeded"
:model-value="uploadPercent"
size="xs"
class="w-full"
role="progressbar"
:aria-valuenow="uploadPercent"
/>
</div>
<p v-if="!succeeded" class="text-sm text-toned">
{{ formatUploadProgress(progress.loaded, progress.total) }}
</p>
</div>
<div v-else-if="phase === 'rejected'" class="flex flex-col gap-1">
<div
class="flex flex-col items-center gap-3 rounded-md border border-error px-6 py-8 text-center"
>
<UIcon name="i-lucide-file-x" class="size-6 text-error" />
<p class="text-sm font-semibold text-highlighted">{{ rejectedTitle }}</p>
<p class="text-sm text-toned">{{ rejectedText }}</p>
</div>
</div>
<div v-else-if="phase === 'failed'" class="flex flex-col gap-1">
<p class="text-sm font-semibold text-highlighted">Upload failed</p>
<p class="text-sm text-toned">
The server didn't accept the file. Your article hasn't changed.
</p>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton v-if="showCancel" color="neutral" variant="outline" @click="cancel">
{{ cancelLabel }}
</UButton>
<UButton v-if="phase === 'rejected'" @click="chooseAnotherFile">
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>
</template>