Image cropper
A picture behind a fixed frame that you drag and zoom into place, then cut out as a file; for avatars, covers and thumbnails.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add image-cropperpnpm dlx ermineui add image-cropperyarn dlx ermineui add image-cropperbunx ermineui add image-cropperThat copies 3 files into src/components/ui/image-cropper/, and the 1 component it is built from. Files you already have are kept.
Use
import { ImageCropper, clampOffset, sourceRect } from '@/components/ui/image-cropper'<EmojiPicker v-model:recent="recent" @select="(emoji) => (message += emoji)" />
<ImageCropper ref="cropper" v-model="crop" :src="pictureUrl" round />
<Button @click="async () => upload(await cropper.crop({ width: 512 }))">Save</Button>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
ImageCropper.vue | src/components/ui/image-cropper/ImageCropper.vue |
crop.ts | src/components/ui/image-cropper/crop.ts |
index.ts | src/components/ui/image-cropper/index.ts |
It is built from: Slider. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
ImageCropper.vue 257 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Slider } from '../slider'
import { clampOffset, coverScale, sourceRect, type CropState } from './crop'
const props = withDefaults(
defineProps<{
/** The picture to crop: an address, or an object URL made from a chosen file. */
src: string
/** Shape of the frame, width over height: 1 for a square, 16 / 9 for a cover. */
aspect?: number
/** Show the frame as a circle, for avatars. The file that comes out is still a square. */
round?: boolean
/** How far in the picture can be zoomed. */
maxZoom?: number
/** Accessible name of the area that moves the picture. */
label?: string
zoomLabel?: string
/** Needed to crop a picture from another site: set to "anonymous", and that site must allow it. */
crossorigin?: 'anonymous' | 'use-credentials'
}>(),
{ aspect: 1, maxZoom: 4, label: 'Position the picture', zoomLabel: 'Zoom' },
)
/** Where the picture sits and how far it is zoomed. Bind it to put a saved crop back. */
const state = defineModel<CropState>({ default: () => ({ x: 0, y: 0, zoom: 1 }) })
const frame = ref<HTMLElement | null>(null)
const image = ref<HTMLImageElement | null>(null)
const frameSize = reactive({ width: 0, height: 0 })
const natural = reactive({ width: 0, height: 0 })
const ready = computed(() => natural.width > 0 && frameSize.width > 0)
const scale = computed(() => (ready.value ? coverScale(natural, frameSize) * state.value.zoom : 1))
function set(next: Partial<CropState>) {
if (!ready.value) return
const zoom = Math.max(1, Math.min(next.zoom ?? state.value.zoom, props.maxZoom))
state.value = clampOffset({ x: next.x ?? state.value.x, y: next.y ?? state.value.y, zoom }, natural, frameSize)
}
function onLoad() {
natural.width = image.value?.naturalWidth ?? 0
natural.height = image.value?.naturalHeight ?? 0
set({})
}
// A different picture starts from the middle again.
watch(
() => props.src,
() => {
natural.width = natural.height = 0
state.value = { x: 0, y: 0, zoom: 1 }
},
)
let observer: ResizeObserver | undefined
onMounted(() => {
if (!frame.value) return
observer = new ResizeObserver(([entry]) => {
if (!entry) return
// The offset is in px of the frame, so it is scaled along with the frame when the layout changes.
const ratio = frameSize.width ? entry.contentRect.width / frameSize.width : 1
frameSize.width = entry.contentRect.width
frameSize.height = entry.contentRect.height
set({ x: state.value.x * ratio, y: state.value.y * ratio })
})
observer.observe(frame.value)
// A picture from the cache can be loaded before Vue is listening for it.
if (image.value?.complete) onLoad()
})
onBeforeUnmount(() => observer?.disconnect())
// Dragging, and pinching with two fingers.
const pointers = new Map<number, { x: number; y: number }>()
let pinchStart: { distance: number; zoom: number } | undefined
const spread = () => {
const [a, b] = [...pointers.values()]
return Math.hypot(a!.x - b!.x, a!.y - b!.y)
}
function onPointerDown(event: PointerEvent) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
pinchStart = pointers.size === 2 ? { distance: spread(), zoom: state.value.zoom } : undefined
}
function onPointerMove(event: PointerEvent) {
const previous = pointers.get(event.pointerId)
if (!previous) return
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (pointers.size === 2 && pinchStart) set({ zoom: pinchStart.zoom * (spread() / (pinchStart.distance || 1)) })
else if (pointers.size === 1) set({ x: state.value.x + event.clientX - previous.x, y: state.value.y + event.clientY - previous.y })
}
function onPointerUp(event: PointerEvent) {
pointers.delete(event.pointerId)
pinchStart = undefined
}
function onWheel(event: WheelEvent) {
set({ zoom: state.value.zoom * (event.deltaY < 0 ? 1.08 : 1 / 1.08) })
}
function onKeydown(event: KeyboardEvent) {
const step = event.shiftKey ? 32 : 8
const moves: Record<string, Partial<CropState>> = {
ArrowLeft: { x: state.value.x - step },
ArrowRight: { x: state.value.x + step },
ArrowUp: { y: state.value.y - step },
ArrowDown: { y: state.value.y + step },
'+': { zoom: state.value.zoom * 1.1 },
'=': { zoom: state.value.zoom * 1.1 },
'-': { zoom: state.value.zoom / 1.1 },
}
if (!moves[event.key]) return
event.preventDefault()
set(moves[event.key]!)
}
/**
* Cuts out what is inside the frame. `width` is how wide the result is in px; the height follows from the shape
* of the frame. Resolves to nothing if the picture has not loaded, or comes from a site that does not allow it.
*/
function crop(options: { width?: number; type?: string; quality?: number } = {}): Promise<Blob | null> {
if (!ready.value || !image.value) return Promise.resolve(null)
const source = sourceRect(state.value, natural, frameSize)
const canvas = document.createElement('canvas')
canvas.width = Math.round(options.width ?? Math.min(1024, source.width))
canvas.height = Math.round(canvas.width / props.aspect)
canvas.getContext('2d')?.drawImage(image.value, source.x, source.y, source.width, source.height, 0, 0, canvas.width, canvas.height)
return new Promise((resolve) => {
try {
canvas.toBlob(resolve, options.type ?? 'image/jpeg', options.quality ?? 0.9)
} catch {
// A picture from another site, loaded without permission, cannot be read back out of a canvas.
resolve(null)
}
})
}
function reset() {
set({ x: 0, y: 0, zoom: 1 })
}
defineExpose({ crop, reset })
</script>
<template>
<div class="er-image-cropper" :data-round="round ? '' : undefined">
<!-- A named, focusable area: the arrow keys move the picture, and plus and minus zoom it. -->
<div
ref="frame"
class="er-image-cropper-frame"
role="application"
tabindex="0"
:aria-label="label"
aria-description="Drag, or use the arrow keys, to move the picture. Plus and minus zoom."
:style="{ aspectRatio: String(aspect) }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@wheel.prevent="onWheel"
@keydown="onKeydown"
>
<img
ref="image"
:src="src"
alt=""
draggable="false"
:crossorigin="crossorigin"
:style="{ width: `${natural.width}px`, height: `${natural.height}px`, transform: `translate(-50%, -50%) translate(${state.x}px, ${state.y}px) scale(${scale})`, visibility: ready ? 'visible' : 'hidden' }"
@load="onLoad"
/>
<div class="er-image-cropper-mask" aria-hidden="true" />
</div>
<label class="er-image-cropper-zoom">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M11 4a7 7 0 1 0 0 14a7 7 0 0 0 0-14ZM20 20l-4-4M8 11h6" /></svg>
<Slider :model-value="state.zoom" :min="1" :max="maxZoom" :step="0.01" :aria-label="zoomLabel" :aria-valuetext="`${Math.round(state.zoom * 100)}%`" @update:model-value="set({ zoom: $event })" />
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M11 4a7 7 0 1 0 0 14a7 7 0 0 0 0-14ZM20 20l-4-4M8 11h6M11 8v6" /></svg>
</label>
</div>
</template>
<style>
@layer components {
.er-image-cropper {
display: grid;
gap: 0.75rem;
width: 100%;
}
.er-image-cropper-frame {
position: relative;
width: 100%;
border-radius: var(--er-radius-lg);
background: var(--er-muted);
overflow: hidden;
cursor: grab;
/* The picture is moved by dragging, so the page must not scroll or zoom under the same fingers. */
touch-action: none;
user-select: none;
}
.er-image-cropper-frame:active {
cursor: grabbing;
}
.er-image-cropper-frame:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-image-cropper-frame img {
position: absolute;
top: 50%;
left: 50%;
max-width: none;
pointer-events: none;
}
/* Thirds, to line things up by; and for a round crop, everything outside the circle dimmed. */
.er-image-cropper-mask {
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(to right, transparent calc(100% / 3 - 0.500px), oklch(1 0 0 / 35%) calc(100% / 3 - 0.500px), oklch(1 0 0 / 35%) calc(100% / 3 + 0.500px), transparent calc(100% / 3 + 0.500px), transparent calc(200% / 3 - 0.500px), oklch(1 0 0 / 35%) calc(200% / 3 - 0.500px), oklch(1 0 0 / 35%) calc(200% / 3 + 0.500px), transparent calc(200% / 3 + 0.500px)),
linear-gradient(to bottom, transparent calc(100% / 3 - 0.500px), oklch(1 0 0 / 35%) calc(100% / 3 - 0.500px), oklch(1 0 0 / 35%) calc(100% / 3 + 0.500px), transparent calc(100% / 3 + 0.500px), transparent calc(200% / 3 - 0.500px), oklch(1 0 0 / 35%) calc(200% / 3 - 0.500px), oklch(1 0 0 / 35%) calc(200% / 3 + 0.500px), transparent calc(200% / 3 + 0.500px));
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 40%);
pointer-events: none;
}
.er-image-cropper[data-round] .er-image-cropper-mask {
background: none;
border-radius: 50%;
box-shadow:
0 0 0 100vmax oklch(0 0 0 / 55%),
inset 0 0 0 2px oklch(1 0 0 / 70%);
}
.er-image-cropper-zoom {
display: flex;
align-items: center;
gap: 0.625rem;
color: var(--er-muted-foreground);
}
.er-image-cropper-zoom svg {
flex-shrink: 0;
width: 1rem;
height: 1rem;
}
}
</style>crop.ts 32 lines
export interface CropState {
/** How far the middle of the picture is from the middle of the frame, in px of the frame. */
x: number
y: number
/** 1 fills the frame exactly; 2 shows the picture at twice that size. */
zoom: number
}
interface Size {
width: number
height: number
}
/** The scale at which the picture just covers the frame, with nothing of the frame left empty. */
export const coverScale = (image: Size, frame: Size) => Math.max(frame.width / image.width, frame.height / image.height)
/** Pulls the picture back so that no edge of it comes inside the frame. */
export function clampOffset(state: CropState, image: Size, frame: Size): CropState {
const scale = coverScale(image, frame) * state.zoom
const maxX = Math.max(0, (image.width * scale - frame.width) / 2)
const maxY = Math.max(0, (image.height * scale - frame.height) / 2)
return { zoom: state.zoom, x: Math.max(-maxX, Math.min(state.x, maxX)), y: Math.max(-maxY, Math.min(state.y, maxY)) }
}
/** The part of the original picture that is inside the frame, in the picture's own px: what to cut out. */
export function sourceRect(state: CropState, image: Size, frame: Size): { x: number; y: number; width: number; height: number } {
const scale = coverScale(image, frame) * state.zoom
const width = frame.width / scale
const height = frame.height / scale
return { x: image.width / 2 - state.x / scale - width / 2, y: image.height / 2 - state.y / scale - height / 2, width, height }
}index.ts 4 lines
/** Image cropper — a picture behind a fixed frame that you drag and zoom into place, then cut out as a file; for avatars, covers and thumbnails. */
export { default as ImageCropper } from './ImageCropper.vue'
export { clampOffset, sourceRect, type CropState } from './crop'