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

1087 lines
29 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 MediaPicker from '@/components/MediaPicker.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useLibraryList } from '@/composables/useLibraryList'
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
import { oldestOf } from '@/composables/useMediaUpload'
import { hashFile } from '@/lib/fileHash'
import {
IMAGE_TYPE_REJECTION_TEXT,
formatUploadProgress,
rejectionTitle,
sizeRejectionText,
validateImageFile,
} from '@/lib/imageUpload'
import { libraryMatchText } from '@/lib/mediaFormat'
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
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 = withDefaults(
defineProps<{
initialFile?: File | null
inArticleIds?: number[]
}>(),
{ initialFile: null, inArticleIds: () => [] },
)
const emit = defineEmits<{
uploaded: [item: MediaItem]
inserted: [item: MediaItem, alt: string]
close: []
}>()
type Phase = 'idle' | 'dragging-over' | 'duplicate' | 'crop' | 'uploading' | 'rejected' | 'failed'
type Tab = 'upload' | 'library'
const DROP_ZONE_UI = {
base: 'min-h-45 rounded-none border-accented p-4 text-sm',
wrapper: 'gap-2 p-0',
avatar: 'size-6 bg-transparent',
icon: 'size-6 text-toned',
label: 'mt-0 font-medium text-highlighted',
description: 'mt-0 max-w-75 text-xs/5 text-toned',
}
const FOOTER_BUTTON_CLASS = 'h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3'
const TAB_ITEMS = [
{ label: 'Upload', value: 'upload', icon: 'i-lucide-upload' },
{ label: 'Library', value: 'library', icon: 'i-lucide-images' },
]
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 tab = ref<Tab>('library')
const existing = ref<MediaItem | null>(null)
const selected = ref<MediaItem | null>(null)
const alt = ref('')
let tabTouched = 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()
const { request } = useApi()
const library = useLibraryList()
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 onLibraryTab = computed(() => tab.value === 'library')
const showTabs = computed(
() =>
onLibraryTab.value ||
phase.value === 'idle' ||
phase.value === 'dragging-over' ||
phase.value === 'duplicate',
)
const tabModel = computed({
get: () => tab.value,
set: (value: string) => selectTab(value === 'library' ? 'library' : 'upload'),
})
const duplicateText = computed(() =>
existing.value && chosenFile.value
? libraryMatchText(chosenFile.value.name, existing.value)
: '',
)
const cancelLabel = computed(() => (phase.value === 'uploading' ? 'Cancel upload' : 'Cancel'))
const showCancel = computed(
() =>
onLibraryTab.value ||
(phase.value !== 'failed' &&
phase.value !== 'crop' &&
!(phase.value === 'uploading' && succeeded.value)),
)
const showFailedActions = computed(() => !onLibraryTab.value && phase.value === 'failed')
const showRejectedActions = computed(() => !onLibraryTab.value && phase.value === 'rejected')
const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'crop')
const wideModal = computed(() => showTabs.value || phase.value === 'crop')
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: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 ${
showTabs.value ? 'sm:top-10' : 'sm:top-35'
} ${wideModal.value ? 'sm:max-w-160' : 'sm:max-w-120'}`,
)
const bodyClass = computed(() => (showTabs.value ? 'px-6 pt-4' : 'px-6 pt-5'))
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(file)
if (outcome.kind === 'success') {
succeeded.value = true
emit('uploaded', outcome.item)
closeTimer = setTimeout(() => {
open.value = false
}, 600)
return
}
if (outcome.kind === 'rejected') {
rejectionKind.value = outcome.reason
phase.value = 'rejected'
return
}
if (outcome.kind === 'handled') {
open.value = false
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)
}
async function findExisting(file: File): Promise<MediaItem | null | 'handled'> {
const hash = await hashFile(file)
if (hash === null) {
return null
}
try {
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
return oldestOf(response.items) ?? null
} catch (error) {
if (error instanceof ApiError && classifyMediaError(error) === 'handled') {
return 'handled'
}
return null
}
}
function continueWithFile(file: File) {
if (file.type === 'image/svg+xml') {
phase.value = 'uploading'
runUpload(file)
return
}
openCropStep(file)
}
async 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
const match = await findExisting(file)
if (!open.value || chosenFile.value !== file) {
return
}
if (match === 'handled') {
open.value = false
return
}
tab.value = 'upload'
if (match) {
existing.value = match
phase.value = 'duplicate'
return
}
continueWithFile(file)
}
function uploadAnyway() {
if (chosenFile.value) {
existing.value = null
continueWithFile(chosenFile.value)
}
}
function selectTab(next: Tab) {
tabTouched = true
tab.value = next
if (next === 'library') {
library.ensureLoaded()
}
}
function useExisting() {
if (!existing.value) {
return
}
selectItem(existing.value)
selectTab('library')
phase.value = 'idle'
existing.value = null
}
function selectItem(item: MediaItem) {
if (selected.value?.id === item.id) {
return
}
selected.value = item
alt.value = ''
}
async function searchLibrary(query: string) {
selected.value = null
alt.value = ''
await library.search(query)
}
function insertSelected() {
if (!selected.value) {
return
}
emit('inserted', selected.value, alt.value)
open.value = false
}
async function openLibraryFirst() {
await library.load()
if (!tabTouched && library.status.value === 'ready' && library.items.value.length === 0) {
tab.value = 'upload'
}
}
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
existing.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
existing.value = null
selected.value = null
alt.value = ''
tabTouched = false
library.reset()
if (props.initialFile) {
tab.value = 'upload'
handleFile(props.initialFile)
return
}
tab.value = 'library'
openLibraryFirst()
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,
header: 'min-h-12 items-start px-6 pt-5 pb-0 sm:px-6',
close: 'top-5 end-6',
title: 'text-lg/6 font-semibold',
body: 'p-0 sm:p-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<div v-if="showTabs" class="mt-4 border-b border-default px-6">
<UTabs
v-model="tabModel"
:items="TAB_ITEMS"
variant="link"
color="neutral"
:content="false"
class="-mb-px"
:ui="{
list: 'gap-5 border-b-0 p-0',
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-sm`,
leadingIcon: 'size-3.5',
}"
/>
</div>
<div :class="bodyClass">
<MediaPicker
v-if="onLibraryTab"
v-model:alt="alt"
:items="library.items.value"
:status="library.status.value"
:show-skeleton="library.showSkeleton.value"
:loading-more="library.loadingMore.value"
:has-more="library.nextCursor.value !== null"
:total="library.total.value"
:query="library.query.value"
:first-new-id="library.firstNewId.value"
:in-article-ids="inArticleIds"
:selected="selected"
@search="searchLibrary"
@select="selectItem"
@load-more="library.loadMore"
@retry="library.load()"
@upload="selectTab('upload')"
/>
<div
v-else-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"
icon="i-lucide-image-plus"
:reset="true"
:ui="DROP_ZONE_UI"
@change="onFileChange"
>
<template #label>
{{
phase === 'dragging-over'
? 'Drop to upload'
: 'Drop an image here, or choose a file'
}}
</template>
<template #description>PNG, JPEG, WEBP, SVG · max 10 MB</template>
</UFileUpload>
</div>
<div
v-else-if="phase === 'duplicate' && existing"
data-testid="duplicate-card"
class="flex flex-col gap-3 border border-info bg-default p-4"
>
<div class="flex items-center gap-3">
<MediaThumb
:item="existing"
size="sm"
class="h-13.5 w-18 border border-default"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<p class="font-semibold text-highlighted">Already in your library</p>
<p class="text-xs text-toned">{{ duplicateText }}</p>
</div>
</div>
<div class="flex flex-col gap-2 md:flex-row">
<UButton class="h-11 justify-center md:h-8" @click="useExisting">
Use existing
</UButton>
<UButton
color="neutral"
variant="outline"
class="h-11 justify-center border border-accented bg-default text-highlighted ring-0 md:h-8"
@click="uploadAnyway"
>
Upload anyway
</UButton>
</div>
</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>
</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"
block
:class="FOOTER_BUTTON_CLASS"
class="text-highlighted"
@click="cancel"
>
{{ cancelLabel }}
</UButton>
<UButton
v-if="onLibraryTab"
block
:class="FOOTER_BUTTON_CLASS"
:disabled="!selected"
@click="insertSelected"
>
Insert image
</UButton>
<UButton
v-if="showRejectedActions"
block
:class="FOOTER_BUTTON_CLASS"
@click="chooseAnotherFile"
>
Choose another file
</UButton>
<UButton
v-if="showFailedActions"
block
:class="FOOTER_BUTTON_CLASS"
@click="tryAgain"
>
Try again
</UButton>
<template v-if="showCropActions">
<UButton
color="neutral"
variant="outline"
block
:class="FOOTER_BUTTON_CLASS"
class="text-highlighted"
@click="chooseAnotherFile"
>
Choose another file
</UButton>
<UButton block :class="FOOTER_BUTTON_CLASS" @click="confirmCropUpload">
Upload image
</UButton>
</template>
</div>
</template>
</UModal>
</template>