Add image cropping to uploads (profile, event, …) #120

Open
opened 2026-06-17 17:17:32 +00:00 by padreug · 0 comments
Owner

From the 17 Jun design review: when adding any photo (profile, event, etc.), let the user crop the area they want.

Current state

src/modules/base/components/ImageUpload.vue (shared across profile/event/market) already does client-side resize + re-encode (1920px max edge, WebP, ~1MB) via the image service — but there's no interactive crop. No crop library is installed. The pipeline is: handleFileSelect/handleDropuploadFiles(files)imageService.uploadImages(files, { compress }).

Proposed approach

Insert a crop step between file-select and the existing compress→upload:

  1. After selection, open a crop modal with an aspect preset per context (avatar = 1:1, event banner = 16:9, free otherwise).
  2. Output the cropped region as a WebP blob → feed into the existing uploadFiles() → resize/encode → upload (unchanged downstream).

Decision (resolved 2026-06-24): Cropper.js v2, integrated directly — no wrapper

The original suggestion (vue-advanced-cropper, "recommended / maintained") was wrong on maintenance and is dropped. Library health was re-checked against the npm registry (not SEO comparison articles, which are stale here):

candidate latest last publish license verdict
cropperjs 2.1.1 2026-04-06 MIT chosen — actively maintained, 1.67M dl/wk, framework-agnostic web-component engine
vue-advanced-cropper 2.8.9 2024-06-09 MIT ~2 yr stale (was the old "recommended")
vue-picture-cropper 1.0.0 2026-02-24 MIT fresh Vue wrapper, but rides cropperjs ^1 (v1, maintenance-mode) — does NOT get you v2
@pqina/pintura 8.98.1 2026-06-23 proprietary/paid most polished, but not FOSS → fails ethos
vue-cropper / vue3-cropperjs / vue-cropperjs 2021–2024 MIT stale/niche

Why direct, not a wrapper: the only maintained Vue wrapper (vue-picture-cropper) is pinned to cropperjs v1; there is no maintained Vue wrapper on v2. Cropper.js v2 is a TypeScript rewrite shipped as framework-agnostic Web Components, so the cleanest path is to drive it from a small Vue component via a ref + lifecycle — one maximally-supported MIT dependency, no wrapper layer to go stale, integration we control.

Verified against v2 source/docs:

  • import Cropper from 'cropperjs' (default export = Cropper class). new Cropper(img, { container }).
  • cropper.getCropperSelection()CropperSelection; selection.aspectRatio / selection.initialCoverage; await selection.$toCanvas()HTMLCanvasElementcanvas.toBlob(cb, 'image/webp', q).
  • No CSS import (elements self-style via shadow DOM).
  • Because we instantiate programmatically (the <cropper-*> elements are created in JS, never written in a Vue template), no app.config.compilerOptions.isCustomElement change is needed — the Vue template compiler never sees custom elements.
  • Add: pnpm add cropperjs@^2.

Integration sketch

1. New ImageCropDialog.vue (base module) — a thin Cropper.js v2 wrapper:

<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import Cropper from 'cropperjs' // v2 — shadow-DOM styled, no CSS import
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'

const props = defineProps<{
  open: boolean
  src: string              // object URL of the picked file
  aspectRatio?: number     // 1 = avatar, 16/9 = banner; omit = free crop
  fileName?: string
}>()
const emit = defineEmits<{
  (e: 'update:open', v: boolean): void
  (e: 'cropped', file: File): void
}>()

const imgRef = ref<HTMLImageElement>()
let cropper: Cropper | null = null

function destroy() { cropper?.getCropperCanvas()?.remove(); cropper = null }
function build() {
  if (!imgRef.value) return
  destroy()
  cropper = new Cropper(imgRef.value, { container: imgRef.value.parentElement! })
  const sel = cropper.getCropperSelection()
  if (sel) {
    if (props.aspectRatio) sel.aspectRatio = props.aspectRatio
    sel.initialCoverage = 0.9
  }
}
// rebuild after the keyed <img> mounts/changes; flush:'post' so the ref exists
watch(() => [props.open, props.src], ([open]) => (open && props.src ? build() : destroy()), { flush: 'post' })
onBeforeUnmount(destroy)

async function confirm() {
  const sel = cropper?.getCropperSelection()
  if (!sel) return
  const canvas = await sel.$toCanvas()                 // cropped region → canvas
  canvas.toBlob((blob) => {
    if (!blob) return
    emit('cropped', new File([blob], props.fileName ?? 'crop.webp', { type: blob.type }))
    emit('update:open', false)
  }, 'image/webp', 0.92)
}
</script>

<template>
  <Dialog :open="open" @update:open="(v) => emit('update:open', v)">
    <DialogContent class="max-w-lg">
      <DialogHeader><DialogTitle>Crop image</DialogTitle></DialogHeader>
      <div class="relative max-h-[60vh] overflow-hidden rounded-md bg-muted">
        <!-- Cropper injects its web components into this div; :key rebuilds on a new src -->
        <img :key="src" ref="imgRef" :src="src" alt="" class="block max-w-full" />
      </div>
      <DialogFooter>
        <Button variant="outline" @click="emit('update:open', false)">Cancel</Button>
        <Button @click="confirm">Crop</Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
</template>

2. Wire into ImageUpload.vue — intercept selected files when cropping is enabled, then hand the cropped File(s) to the existing uploadFiles() (downstream untouched):

// new props
//   crop?: boolean            -> route picked files through the crop modal
//   cropAspectRatio?: number  -> 1 (avatar) | 16/9 (banner) | undefined (free)

const cropQueue = ref<File[]>([]); const cropped = ref<File[]>([])
const cropOpen = ref(false); const cropSrc = ref('')

function beginCropOrUpload(files: File[]) {
  if (!props.crop || files.length === 0) return uploadFiles(files)  // unchanged path
  cropped.value = []; cropQueue.value = [...files]; nextCrop()
}
function nextCrop() {
  const next = cropQueue.value.shift()
  if (!next) return uploadFiles(cropped.value)        // queue drained → existing pipeline
  cropSrc.value = URL.createObjectURL(next); cropOpen.value = true
}
function onCropped(file: File) {
  URL.revokeObjectURL(cropSrc.value); cropped.value.push(file); nextCrop()
}
// In handleFileSelect()/handleDrop(): replace `uploadFiles(files)` with `beginCropOrUpload(files)`.
<ImageCropDialog v-model:open="cropOpen" :src="cropSrc" :aspect-ratio="cropAspectRatio" @cropped="onCropped" />

Call sites: avatar (ProfileSettings) → <ImageUpload crop :crop-aspect-ratio="1" />; event banner → :crop-aspect-ratio="16/9"; market/free → crop with no ratio. Multi-image uploads crop sequentially via the queue.

Notes / open implementation details

  • Lifecycle is the fiddly part: tear down the injected <cropper-canvas> and rebuild on each new src (handled above via :key + getCropperCanvas().remove()). Worth a careful review.
  • The existing mobile form-submission defenses in ImageUpload.vue stay; the crop dialog sits inside the same component so those guards still wrap the flow.
  • Bundle: one MIT dep (cropperjs@^2); tree-shakeable, import-on-demand.

From the 2026-06-17 webapp design review. Decision corrected 2026-06-24 after re-verifying library maintenance against the npm registry.

From the 17 Jun design review: when adding any photo (profile, event, etc.), let the user crop the area they want. ## Current state `src/modules/base/components/ImageUpload.vue` (shared across profile/event/market) already does client-side **resize + re-encode** (1920px max edge, WebP, ~1MB) via the image service — but there's **no interactive crop**. No crop library is installed. The pipeline is: `handleFileSelect`/`handleDrop` → `uploadFiles(files)` → `imageService.uploadImages(files, { compress })`. ## Proposed approach Insert a crop step between file-select and the existing compress→upload: 1. After selection, open a crop modal with an aspect preset per context (avatar = 1:1, event banner = 16:9, free otherwise). 2. Output the cropped region as a **WebP blob → feed into the existing `uploadFiles()` → resize/encode → upload (unchanged downstream)**. ## Decision (resolved 2026-06-24): **Cropper.js v2, integrated directly** — no wrapper The original suggestion (`vue-advanced-cropper`, *"recommended / maintained"*) was **wrong on maintenance** and is dropped. Library health was re-checked against the npm registry (not SEO comparison articles, which are stale here): | candidate | latest | last publish | license | verdict | |---|---|---|---|---| | **cropperjs** | **2.1.1** | **2026-04-06** | MIT | ✅ **chosen** — actively maintained, 1.67M dl/wk, framework-agnostic web-component engine | | vue-advanced-cropper | 2.8.9 | 2024-06-09 | MIT | ❌ ~2 yr stale (was the old "recommended") | | vue-picture-cropper | 1.0.0 | 2026-02-24 | MIT | ❌ fresh Vue wrapper, **but rides cropperjs `^1`** (v1, maintenance-mode) — does NOT get you v2 | | @pqina/pintura | 8.98.1 | 2026-06-23 | proprietary/paid | ❌ most polished, but **not FOSS** → fails ethos | | vue-cropper / vue3-cropperjs / vue-cropperjs | — | 2021–2024 | MIT | ❌ stale/niche | **Why direct, not a wrapper:** the only maintained Vue wrapper (`vue-picture-cropper`) is pinned to cropperjs **v1**; there is no maintained Vue wrapper on **v2**. Cropper.js v2 is a TypeScript rewrite shipped as framework-agnostic Web Components, so the cleanest path is to drive it from a small Vue component via a ref + lifecycle — one maximally-supported MIT dependency, no wrapper layer to go stale, integration we control. Verified against v2 source/docs: - `import Cropper from 'cropperjs'` (default export = `Cropper` class). `new Cropper(img, { container })`. - `cropper.getCropperSelection()` → `CropperSelection`; `selection.aspectRatio` / `selection.initialCoverage`; `await selection.$toCanvas()` → `HTMLCanvasElement` → `canvas.toBlob(cb, 'image/webp', q)`. - **No CSS import** (elements self-style via shadow DOM). - Because we instantiate **programmatically** (the `<cropper-*>` elements are created in JS, never written in a Vue template), **no `app.config.compilerOptions.isCustomElement` change is needed** — the Vue template compiler never sees custom elements. - Add: `pnpm add cropperjs@^2`. ## Integration sketch **1. New `ImageCropDialog.vue`** (base module) — a thin Cropper.js v2 wrapper: ```vue <script setup lang="ts"> import { ref, watch, onBeforeUnmount } from 'vue' import Cropper from 'cropperjs' // v2 — shadow-DOM styled, no CSS import import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' const props = defineProps<{ open: boolean src: string // object URL of the picked file aspectRatio?: number // 1 = avatar, 16/9 = banner; omit = free crop fileName?: string }>() const emit = defineEmits<{ (e: 'update:open', v: boolean): void (e: 'cropped', file: File): void }>() const imgRef = ref<HTMLImageElement>() let cropper: Cropper | null = null function destroy() { cropper?.getCropperCanvas()?.remove(); cropper = null } function build() { if (!imgRef.value) return destroy() cropper = new Cropper(imgRef.value, { container: imgRef.value.parentElement! }) const sel = cropper.getCropperSelection() if (sel) { if (props.aspectRatio) sel.aspectRatio = props.aspectRatio sel.initialCoverage = 0.9 } } // rebuild after the keyed <img> mounts/changes; flush:'post' so the ref exists watch(() => [props.open, props.src], ([open]) => (open && props.src ? build() : destroy()), { flush: 'post' }) onBeforeUnmount(destroy) async function confirm() { const sel = cropper?.getCropperSelection() if (!sel) return const canvas = await sel.$toCanvas() // cropped region → canvas canvas.toBlob((blob) => { if (!blob) return emit('cropped', new File([blob], props.fileName ?? 'crop.webp', { type: blob.type })) emit('update:open', false) }, 'image/webp', 0.92) } </script> <template> <Dialog :open="open" @update:open="(v) => emit('update:open', v)"> <DialogContent class="max-w-lg"> <DialogHeader><DialogTitle>Crop image</DialogTitle></DialogHeader> <div class="relative max-h-[60vh] overflow-hidden rounded-md bg-muted"> <!-- Cropper injects its web components into this div; :key rebuilds on a new src --> <img :key="src" ref="imgRef" :src="src" alt="" class="block max-w-full" /> </div> <DialogFooter> <Button variant="outline" @click="emit('update:open', false)">Cancel</Button> <Button @click="confirm">Crop</Button> </DialogFooter> </DialogContent> </Dialog> </template> ``` **2. Wire into `ImageUpload.vue`** — intercept selected files when cropping is enabled, then hand the cropped File(s) to the *existing* `uploadFiles()` (downstream untouched): ```ts // new props // crop?: boolean -> route picked files through the crop modal // cropAspectRatio?: number -> 1 (avatar) | 16/9 (banner) | undefined (free) const cropQueue = ref<File[]>([]); const cropped = ref<File[]>([]) const cropOpen = ref(false); const cropSrc = ref('') function beginCropOrUpload(files: File[]) { if (!props.crop || files.length === 0) return uploadFiles(files) // unchanged path cropped.value = []; cropQueue.value = [...files]; nextCrop() } function nextCrop() { const next = cropQueue.value.shift() if (!next) return uploadFiles(cropped.value) // queue drained → existing pipeline cropSrc.value = URL.createObjectURL(next); cropOpen.value = true } function onCropped(file: File) { URL.revokeObjectURL(cropSrc.value); cropped.value.push(file); nextCrop() } // In handleFileSelect()/handleDrop(): replace `uploadFiles(files)` with `beginCropOrUpload(files)`. ``` ```html <ImageCropDialog v-model:open="cropOpen" :src="cropSrc" :aspect-ratio="cropAspectRatio" @cropped="onCropped" /> ``` **Call sites:** avatar (`ProfileSettings`) → `<ImageUpload crop :crop-aspect-ratio="1" />`; event banner → `:crop-aspect-ratio="16/9"`; market/free → `crop` with no ratio. Multi-image uploads crop sequentially via the queue. ## Notes / open implementation details - **Lifecycle** is the fiddly part: tear down the injected `<cropper-canvas>` and rebuild on each new `src` (handled above via `:key` + `getCropperCanvas().remove()`). Worth a careful review. - The existing **mobile form-submission defenses** in `ImageUpload.vue` stay; the crop dialog sits inside the same component so those guards still wrap the flow. - Bundle: one MIT dep (`cropperjs@^2`); tree-shakeable, import-on-demand. _From the 2026-06-17 webapp design review. Decision corrected 2026-06-24 after re-verifying library maintenance against the npm registry._
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/webapp#120
No description provided.