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>
|
||||
|
||||
Reference in new issue
Block a user