1087 lines
29 KiB
Vue
1087 lines
29 KiB
Vue
<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>
|