feat: media picker and image removal
This commit is contained in:
1 parent
008b0dfbb9
commit
b3793d5dbb
54 files changed
+3769
-1412
No files matched your search
@@ -1,13 +1,21 @@
|
||||
<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,
|
||||
isSizeRejectionBody,
|
||||
rejectionTitle,
|
||||
sizeRejectionText,
|
||||
validateImageFile,
|
||||
} from '@/lib/imageUpload'
|
||||
import { libraryMatchText } from '@/lib/mediaFormat'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
import {
|
||||
ASPECT_RATIOS,
|
||||
WIDTH_PRESETS,
|
||||
@@ -35,17 +43,38 @@ import {
|
||||
type Corner = 'tl' | 'tr' | 'bl' | 'br'
|
||||
import { useImageUpload } from '@/composables/useImageUpload'
|
||||
|
||||
const props = defineProps<{
|
||||
articleId: number
|
||||
initialFile?: File | null
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initialFile?: File | null
|
||||
inArticleIds?: number[]
|
||||
}>(),
|
||||
{ initialFile: null, inArticleIds: () => [] },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: [location: string, imageId: number]
|
||||
uploaded: [item: MediaItem]
|
||||
inserted: [item: MediaItem, alt: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
type Phase = 'idle' | 'dragging-over' | 'crop' | 'uploading' | 'rejected' | 'failed'
|
||||
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 })
|
||||
|
||||
@@ -55,6 +84,11 @@ 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)
|
||||
@@ -67,6 +101,8 @@ 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(() => {
|
||||
@@ -90,15 +126,43 @@ const rejectedText = computed(() => {
|
||||
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(
|
||||
() =>
|
||||
phase.value !== 'failed' &&
|
||||
phase.value !== 'crop' &&
|
||||
!(phase.value === 'uploading' && succeeded.value),
|
||||
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(() => {
|
||||
@@ -110,11 +174,13 @@ const modalDescription = computed(() => {
|
||||
|
||||
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'
|
||||
}`,
|
||||
`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,
|
||||
)
|
||||
@@ -164,20 +230,24 @@ function clearCloseTimer() {
|
||||
|
||||
async function runUpload(file: File) {
|
||||
lastUploadedFile.value = file
|
||||
const outcome = await start(props.articleId, file)
|
||||
const outcome = await start(file)
|
||||
if (outcome.kind === 'success') {
|
||||
succeeded.value = true
|
||||
emit('uploaded', outcome.location, outcome.id)
|
||||
emit('uploaded', outcome.item)
|
||||
closeTimer = setTimeout(() => {
|
||||
open.value = false
|
||||
}, 600)
|
||||
return
|
||||
}
|
||||
if (outcome.kind === 'rejected') {
|
||||
rejectionKind.value = isSizeRejectionBody(outcome.body) ? 'size' : 'type'
|
||||
rejectionKind.value = outcome.reason
|
||||
phase.value = 'rejected'
|
||||
return
|
||||
}
|
||||
if (outcome.kind === 'handled') {
|
||||
open.value = false
|
||||
return
|
||||
}
|
||||
phase.value = 'failed'
|
||||
}
|
||||
|
||||
@@ -244,7 +314,32 @@ async function openCropStep(file: File) {
|
||||
await loadPreviewBitmap(file)
|
||||
}
|
||||
|
||||
function handleFile(file: 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) {
|
||||
@@ -254,12 +349,75 @@ function handleFile(file: File) {
|
||||
}
|
||||
rejectionKind.value = null
|
||||
succeeded.value = false
|
||||
if (file.type === 'image/svg+xml') {
|
||||
phase.value = 'uploading'
|
||||
runUpload(file)
|
||||
const match = await findExisting(file)
|
||||
if (!open.value || chosenFile.value !== file) {
|
||||
return
|
||||
}
|
||||
openCropStep(file)
|
||||
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() {
|
||||
@@ -291,6 +449,7 @@ function chooseAnotherFile() {
|
||||
chosenFile.value = null
|
||||
pickedFile.value = null
|
||||
rejectionKind.value = null
|
||||
existing.value = null
|
||||
closeSourceBitmap()
|
||||
}
|
||||
|
||||
@@ -517,9 +676,18 @@ watch(open, (isOpen) => {
|
||||
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) {
|
||||
@@ -542,262 +710,375 @@ onUnmounted(() => {
|
||||
: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: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
body: 'p-0 sm:p-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 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 === '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"
|
||||
v-else-if="phase === 'idle' || phase === 'dragging-over'"
|
||||
data-testid="image-dropzone"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDropOnZone"
|
||||
>
|
||||
<div
|
||||
ref="imageBoxRef"
|
||||
class="relative h-full max-w-full"
|
||||
:style="{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }"
|
||||
:aria-label="`${chosenFile?.name}, ${boundsWidth} × ${boundsHeight} px`"
|
||||
role="img"
|
||||
<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"
|
||||
>
|
||||
<div class="absolute inset-0 overflow-hidden bg-(--color-sand-200)">
|
||||
<canvas ref="previewCanvasRef" class="absolute inset-0 h-full w-full" />
|
||||
<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
|
||||
class="absolute"
|
||||
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}%`,
|
||||
boxShadow: '0 0 0 2000px rgb(10 10 10 / 0.55)',
|
||||
}"
|
||||
/>
|
||||
@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
|
||||
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"
|
||||
role="radiogroup"
|
||||
aria-label="Aspect ratio"
|
||||
class="flex w-full border border-accented sm:w-max"
|
||||
>
|
||||
<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"
|
||||
<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)"
|
||||
>
|
||||
{{ dimsLabel }}
|
||||
</span>
|
||||
{{ ratio }}
|
||||
</button>
|
||||
</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"
|
||||
<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"
|
||||
>
|
||||
Rotate
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-lucide-undo-2"
|
||||
@click="resetCrop"
|
||||
>
|
||||
Reset
|
||||
</UButton>
|
||||
<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
|
||||
role="radiogroup"
|
||||
aria-label="Aspect ratio"
|
||||
class="flex w-full border border-accented sm:w-max"
|
||||
v-if="showNarrowNote"
|
||||
role="status"
|
||||
class="flex gap-2 bg-(--rc-info-soft) p-2.5"
|
||||
>
|
||||
<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>
|
||||
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
|
||||
<span class="text-toned">{{ narrowNoteText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-medium text-highlighted">Width</span>
|
||||
<div v-else-if="phase === 'uploading'" class="flex flex-col gap-3">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Width"
|
||||
class="flex w-full border border-accented sm:w-max"
|
||||
class="flex flex-col items-center gap-3 rounded-md border border-default px-6 py-8 text-center"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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 v-if="!succeeded" class="text-sm text-toned">
|
||||
{{ formatUploadProgress(progress.loaded, progress.total) }}
|
||||
</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 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>
|
||||
|
||||
<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 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" @click="cancel">
|
||||
<UButton
|
||||
v-if="showCancel"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:class="FOOTER_BUTTON_CLASS"
|
||||
class="text-highlighted"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
</UButton>
|
||||
<UButton v-if="phase === 'rejected'" @click="chooseAnotherFile">
|
||||
<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="phase === 'failed'" @click="tryAgain"> Try again </UButton>
|
||||
<template v-if="phase === 'crop'">
|
||||
<UButton color="neutral" variant="outline" @click="chooseAnotherFile">
|
||||
<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 @click="confirmCropUpload">Upload image</UButton>
|
||||
<UButton block :class="FOOTER_BUTTON_CLASS" @click="confirmCropUpload">
|
||||
Upload image
|
||||
</UButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in new issue
Block a user