GrImageCrop
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- an avatar from an uploaded file — the user brought a photograph of arbitrary proportions, and the profile needs a square;
- a cover for a fixed place — a product card or the header of a section, where the layout is designed for one aspect ratio;
- a crop from a camera shot — the
Blobfrom the device arrives whole, and what has to be shown is a face rather than the whole room; - reducing the weight of an attachment — the cut-out area is exported into
image/webpwith a given quality, and a kilobyte rather than a megabyte travels to the server.
When to take something else
| Need | Take |
|---|---|
| Show a picture with zoom and paging without changing it | GrImageViewer |
| Accept a file from the user: a drop zone, a queue, checks | GrFileUpload / GrFormFile |
| Show a ready avatar in the interface | GrAvatar |
The frame is stationary, the picture moves
The reverse model — the frame travelling over the picture — requires two gestures instead of one: first move the frame, then stretch it by a corner. On a phone the corner handles are smaller than a finger, and missing them means an accidental shift of the whole frame. Here the gesture is one: drag the picture, while the zoom lives in a control of its own.
The consequence for the consumer: the aspect ratio of the crop is set by them rather than by the
user. aspectRatio is a number (1, 16 / 9), because a '16:9' string would have to be parsed at
runtime, and an error in the format would surface already on the screen.
The crop is computed in the pixels of the source
Without output the result gets the size of the captured area of the source file rather than of
the window on the screen. The window is almost always smaller than the picture — an output by it
would silently halve the resolution, and that would be noticed on someone else’s retina screen.
If exactly a given size is needed (an avatar of 256×256), it is specified explicitly, and then
drawImage scales the area to it.
`output` is a bounding box rather than an exact size
output.width without height is an ordinary order (“an avatar 256 wide”), and the second side is
computed from the ratio of the captured area; taken from the source, it would stretch the picture.
Both sides are a frame the result is fitted into while keeping the proportions of the area. On the typical order (a 256×256 square from a square frame) that is exactly 256×256; the difference is visible where the ratios have diverged.
A circle is a mask rather than the shape of the result
shape="circle" dims everything outside the circle, so that the user sees what will end up in the
avatar. The export stays rectangular in the process: what makes an image round is the place where it
is shown (GrAvatar and its rounded), and a
PNG with transparent corners weighs more and is unsuitable on a backdrop of another colour.
A picture from a foreign domain breaks the export
The image is requested with crossOrigin="anonymous". If the server did not give away an
Access-Control-Allow-Origin, the canvas becomes unreadable, and toBlob fails with a
SecurityError — after the user has already chosen the crop. Everything on the screen up to that
moment is correct, so the reason cannot be guessed: the component emits error and prints a warning
in development that names what is happening straight out.
The practical conclusion: give away pictures for cropping from your own domain or from a storage with the CORS property.
The smoothing is switched off under the finger
The transform transition is switched on outside a gesture only. Under a finger the picture is
obliged to follow track for track — otherwise it “catches up” with the finger and the gesture feels
like sticking; whereas a step of the slider and the arrows without a transition look like a jerk.
Limits
- there is no rotation and no mirroring. Cropping and transformation are different tasks; a rotation would require a second axis of control and a keyboard of its own;
- it does not compress to a target weight.
output.qualityis a parameter of the codec rather than a budget in kilobytes: fitting the quality to a limit is left to the application; - one crop at a time. Batch processing of a gallery is the scenario of an uploader rather than of this component.
Install
npm i @feugene/granularity-mediaImport
import { GrImageCrop } from '@feugene/granularity-media/components/GrImageCrop'API
The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.
Examples 3
Avatar Preview
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, useTemplateRef } from 'vue'
import { GrAvatar, GrCard } from '@feugene/granularity'
/**
* Один кадр — три файла, и каждый показан там, где он потом и появится.
*
* Приложения хранят аватар не одной картинкой: в шапку идёт крупный, в строку
* списка — мелкий, и отдавать 256 px туда, где рисуется 24, значит возить лишние
* килобайты на каждой строке. Размер задаёт `output`, а кадр остаётся тем же.
*
* Второе, что видно только так: главное сомнение при кадрировании — «а как это
* будет смотреться маленьким». В кружке 24 px сразу заметно, что в кадр попало
* лишнее.
*/
const source = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900">
<rect width="1200" height="900" fill="#1e293b" />
<circle cx="640" cy="380" r="180" fill="#fbbf24" />
<rect x="470" y="560" width="340" height="340" rx="170" fill="#38bdf8" />
<rect x="80" y="80" width="220" height="740" rx="24" fill="#34d399" fill-opacity="0.35" />
<rect x="900" y="80" width="220" height="740" rx="24" fill="#f472b6" fill-opacity="0.35" />
</svg>
`)}`
interface Variant {
key: 'large' | 'medium' | 'small'
label: string
px: number
url: string | null
weight: number
}
const variants = ref<Variant[]>([
{ key: 'large', label: 'Крупный', px: 256, url: null, weight: 0 },
{ key: 'medium', label: 'Средний', px: 96, url: null, weight: 0 },
{ key: 'small', label: 'Мелкий', px: 32, url: null, weight: 0 },
])
const outputWidth = ref(256)
const cropper = useTemplateRef('cropper')
let pending: ReturnType<typeof setTimeout> | null = null
/** Номер запроса: поздний ответ не должен перетирать свежие варианты. */
let request = 0
function urlFor(key: Variant['key']): string | undefined {
return variants.value.find(item => item.key === key)?.url ?? undefined
}
/**
* Варианты пересобираются с задержкой: три `crop()` подряд рисуют холст и
* кодируют файл, и делать это на каждый пиксель перетаскивания значит греть
* процессор ради кадров, которых никто не увидит.
*/
function scheduleVariants() {
if (pending)
clearTimeout(pending)
pending = setTimeout(async () => {
const current = ++request
for (const variant of variants.value) {
// Размер задаётся пропом — тем же способом, что и в приложении.
outputWidth.value = variant.px
await nextTick()
const blob = await cropper.value?.crop()
if (!blob || current !== request)
return
if (variant.url)
URL.revokeObjectURL(variant.url)
variant.url = URL.createObjectURL(blob)
variant.weight = blob.size
}
}, 300)
}
onBeforeUnmount(() => {
if (pending)
clearTimeout(pending)
for (const variant of variants.value) {
if (variant.url)
URL.revokeObjectURL(variant.url)
}
})
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,300px)_minmax(0,1fr)]">
<div class="grid gap-3">
<GrImageCrop
ref="cropper"
:src="source"
shape="circle"
:aspect-ratio="1"
:output="{ width: outputWidth }"
@change="scheduleVariants"
@load="scheduleVariants"
/>
<p class="showcase-demo-text text-sm">
Тяните картинку и меняйте увеличение — три файла справа пересобираются следом.
</p>
</div>
<div class="grid content-start gap-4">
<GrCard padding="md">
<div class="flex flex-wrap items-end gap-4">
<div v-for="variant in variants" :key="variant.key" class="grid justify-items-center gap-1">
<img
v-if="variant.url"
:src="variant.url"
:alt="`${variant.label} вариант`"
class="rounded-[var(--gr-radius-full)] object-cover"
:style="{ width: `${Math.min(variant.px, 96)}px`, height: `${Math.min(variant.px, 96)}px` }"
>
<span class="showcase-demo-text text-xs">
{{ variant.label }} · {{ variant.px }} px
</span>
<span class="showcase-demo-text text-xs">
{{ (variant.weight / 1024).toFixed(1) }} КБ
</span>
</div>
</div>
</GrCard>
<GrCard padding="md">
<div class="flex items-center gap-3">
<GrAvatar :src="urlFor('medium')" size="lg" />
<div class="grid">
<span class="text-[length:var(--gr-text-sm)] leading-[var(--gr-leading-sm)] font-600">Иван Петров</span>
<span class="showcase-demo-text text-xs">Шапка профиля — сюда идёт средний</span>
</div>
</div>
</GrCard>
<GrCard padding="md">
<div class="grid gap-2">
<div v-for="row in ['Отчёт за август', 'Договор №14', 'Заявка на отпуск']" :key="row" class="flex items-center gap-2">
<GrAvatar :src="urlFor('small')" size="xs" />
<span class="showcase-demo-text text-sm">{{ row }}</span>
</div>
<span class="showcase-demo-text text-xs">
Строка списка — мелкий: 256 px здесь означал бы лишние килобайты на каждой строке
</span>
</div>
</GrCard>
</div>
</div>
</template>Basic
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { GrButton, GrRadioGroup } from '@feugene/granularity'
/**
* Картинка синтезированная: демо обязано работать без сети и без файла на
* диске, а кадрировать нужно что-то заведомо не квадратное — иначе не видно,
* что именно выбирает пользователь.
*/
const source = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
<rect width="1600" height="900" fill="#0f172a" />
<circle cx="1180" cy="300" r="220" fill="#38bdf8" fill-opacity="0.5" />
<circle cx="480" cy="620" r="160" fill="#f472b6" fill-opacity="0.55" />
<rect x="120" y="140" width="520" height="26" rx="13" fill="white" fill-opacity="0.7" />
<rect x="120" y="196" width="360" height="20" rx="10" fill="white" fill-opacity="0.4" />
<text x="120" y="470" fill="white" font-size="128" font-family="Arial, sans-serif" font-weight="700">1600 × 900</text>
</svg>
`)}`
const shape = ref<'circle' | 'rect'>('circle')
const zoom = ref(1)
const result = ref<string | null>(null)
const resultSize = ref(0)
const cropper = useTemplateRef('cropper')
const shapeOptions = [
{ value: 'circle', label: 'Круг' },
{ value: 'rect', label: 'Прямоугольник' },
] satisfies Array<{ value: 'circle' | 'rect', label: string }>
const weight = computed(() => `${(resultSize.value / 1024).toFixed(1)} КБ`)
async function takeFrame() {
const blob = await cropper.value?.crop()
if (!blob)
return
if (result.value)
URL.revokeObjectURL(result.value)
result.value = URL.createObjectURL(blob)
resultSize.value = blob.size
}
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<div class="grid gap-3">
<GrImageCrop
ref="cropper"
v-model:zoom="zoom"
:src="source"
:shape="shape"
:aspect-ratio="1"
:output="{ width: 256, height: 256, type: 'image/png' }"
/>
<GrRadioGroup v-model="shape" :options="shapeOptions" variant="button" size="sm" />
<GrButton size="sm" @click="takeFrame">
Вырезать кадр
</GrButton>
</div>
<div class="showcase-demo-panel grid content-start gap-3 rounded-[var(--gr-radius-lg)] border p-4">
<p class="showcase-demo-text text-sm">
Рамка неподвижна: пользователь тянет картинку под ней и меняет увеличение.
Клавиатурой — стрелки и <code>+</code>/<code>-</code>, <code>Home</code> сбрасывает.
</p>
<template v-if="result">
<img :src="result" alt="Вырезанный кадр" class="h-32 w-32 rounded-[var(--gr-radius-full)] object-cover">
<p class="showcase-demo-text text-sm">
Результат: 256 × 256, {{ weight }}. Круг — это маска показа, а сам файл прямоугольный.
</p>
</template>
<p v-else class="showcase-demo-text text-sm">
Нажмите «Вырезать кадр» — здесь появится результат.
</p>
</div>
</div>
</template>Weight
<script setup lang="ts">
import { onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
import { GrSegmented, GrSlider } from '@feugene/granularity'
/**
* Сколько на самом деле весит результат.
*
* `output.type` и `output.quality` описанием пропа не объяснишь: разница между
* webp и jpeg на одной картинке — это два числа, и увидеть их можно только
* рядом. Заодно видно, что у png качество не спрашивают вовсе.
*/
const source = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1000" viewBox="0 0 1400 1000">
<defs>
<radialGradient id="g" cx="35%" cy="30%">
<stop offset="0%" stop-color="#fde68a" />
<stop offset="55%" stop-color="#fb7185" />
<stop offset="100%" stop-color="#1e1b4b" />
</radialGradient>
</defs>
<rect width="1400" height="1000" fill="url(#g)" />
<circle cx="1050" cy="260" r="180" fill="#22d3ee" fill-opacity="0.55" />
<circle cx="360" cy="760" r="220" fill="#a78bfa" fill-opacity="0.5" />
<rect x="120" y="120" width="520" height="26" rx="13" fill="white" fill-opacity="0.75" />
</svg>
`)}`
const format = ref<'image/webp' | 'image/jpeg' | 'image/png'>('image/webp')
const quality = ref(0.8)
const weight = ref<number | null>(null)
const preview = ref<string | null>(null)
const cropper = useTemplateRef('cropper')
const formatOptions = [
{ value: 'image/webp', label: 'webp' },
{ value: 'image/jpeg', label: 'jpeg' },
{ value: 'image/png', label: 'png' },
] satisfies Array<{ value: 'image/webp' | 'image/jpeg' | 'image/png', label: string }>
let pending: ReturnType<typeof setTimeout> | null = null
/**
* Номер запроса: кодирование асинхронно, и два вызова в полёте возвращаются в
* произвольном порядке. Без этого счётчика вес png успевал перезаписаться
* ответом от jpeg — на экране оставалось число от предыдущего формата.
*/
let request = 0
function scheduleMeasure() {
if (pending)
clearTimeout(pending)
pending = setTimeout(async () => {
const current = ++request
const blob = await cropper.value?.crop()
if (!blob || current !== request)
return
weight.value = blob.size
if (preview.value)
URL.revokeObjectURL(preview.value)
preview.value = URL.createObjectURL(blob)
}, 250)
}
watch([format, quality], scheduleMeasure)
onBeforeUnmount(() => {
if (pending)
clearTimeout(pending)
if (preview.value)
URL.revokeObjectURL(preview.value)
})
</script>
<template>
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid gap-3">
<GrImageCrop
ref="cropper"
:src="source"
:aspect-ratio="4 / 3"
:output="{ width: 1024, type: format, quality }"
@change="scheduleMeasure"
@load="scheduleMeasure"
/>
<GrSegmented v-model="format" :options="formatOptions" size="sm" />
<div class="grid gap-1">
<label class="showcase-demo-text text-sm">
Качество: {{ format === 'image/png' ? 'не применяется' : quality.toFixed(2) }}
</label>
<GrSlider
v-model="quality"
:min="0.3"
:max="1"
:step="0.05"
size="sm"
:disabled="format === 'image/png'"
aria-label="Качество кодирования"
/>
</div>
</div>
<div class="showcase-demo-panel grid content-start gap-3 rounded-[var(--gr-radius-lg)] border p-4">
<p v-if="weight" class="text-[length:var(--gr-text-lg)] leading-[var(--gr-leading-base)] font-600">
{{ (weight / 1024).toFixed(1) }} КБ
</p>
<p class="showcase-demo-text text-sm">
Кадр 1024 px по ширине. Один и тот же кадр в webp обычно вдвое легче jpeg того же
качества, а png не сжимает с потерями вовсе — <code>quality</code> он игнорирует,
поэтому ползунок для него выключен.
</p>
<img v-if="preview" :src="preview" alt="Результат кадрирования" class="w-full rounded-[var(--gr-radius-md)]">
<p class="showcase-demo-text text-sm">
Это и есть ответ на вопрос, что ставить в <code>output</code>: для фотографий — webp
с качеством около 0.8, для скриншотов с текстом — png.
</p>
</div>
</div>
</template>