806 lines
22 KiB
Vue
806 lines
22 KiB
Vue
<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>
|