GrImageCrop
Берут, когда аватар из загруженного файла.
Когда брать
- аватар из загруженного файла — пользователь принёс фотографию произвольных пропорций, а профилю нужен квадрат;
- обложка под фиксированное место — карточка товара или шапка раздела, где вёрстка рассчитана на одно соотношение сторон;
- кадр из снимка камеры —
Blobс устройства приходит целиком, а показать нужно лицо, а не всю комнату; - уменьшение веса вложения — вырезанная область экспортируется в
image/webpс заданным качеством, и на сервер уезжает килобайт вместо мегабайта.
Когда взять другое
| Нужно | Берите |
|---|---|
| Показать картинку с зумом и листанием, не меняя её | GrImageViewer |
| Принять файл от пользователя: зона переноса, очередь, проверки | GrFileUpload / GrFormFile |
| Показать готовую аватарку в интерфейсе | GrAvatar |
Рамка неподвижна, двигается картинка
Обратная модель — рамка ездит по картинке — требует двух жестов вместо одного: сначала подвинуть рамку, потом растянуть её за угол. На телефоне угловые ручки меньше пальца, и промах по ним означает случайный сдвиг всей рамки. Здесь жест один: тянуть картинку, а увеличение живёт отдельным контролом.
Следствие для потребителя: соотношение сторон кадра задаёт он, а не пользователь.
aspectRatio — число (1, 16 / 9), потому что строку '16:9' пришлось бы
разбирать в рантайме, и ошибка формата всплыла бы уже на экране.
Кадр считается в пикселях исходника
Без output результат получает размер захваченной области исходного файла, а
не окна на экране. Окно почти всегда меньше картинки — вывод по нему молча
ополовинил бы разрешение, и заметили бы это на чужом ретина-экране.
Нужен ровно заданный размер (аватар 256×256) — он указывается явно, и тогда
drawImage масштабирует область под него.
`output` — габарит, а не точный размер
output.width без height — обычный заказ («аватар шириной 256»), и вторая
сторона считается из соотношения захваченной области; взятая из исходника, она
растянула бы картинку.
Обе стороны — рамка, в которую результат вписывается, сохраняя пропорции области. На типовом заказе (квадрат 256×256 из квадратной рамки) это ровно 256×256; отличие видно там, где соотношения разошлись.
Круг — это маска, а не форма результата
shape="circle" затемняет всё вне круга, чтобы пользователь видел, что попадёт в
аватарку. Экспорт при этом остаётся прямоугольным: круглым изображение делает то
место, где оно показывается (GrAvatar
и его rounded), а PNG с прозрачными углами весит больше и не годится на
подложке другого цвета.
Картинка с чужого домена ломает экспорт
Изображение запрашивается с crossOrigin="anonymous". Если сервер не отдал
Access-Control-Allow-Origin, холст становится непригодным для чтения, и
toBlob падает SecurityError — уже после того, как пользователь выбрал кадр.
На экране до этого момента всё правильно, поэтому причину не угадать: компонент
эмитит error и печатает в разработке предупреждение, которое называет
происходящее прямо.
Практический вывод: картинки для кропа отдавайте со своего домена или со свойством CORS у хранилища.
Плавность выключена под пальцем
Переход transform включён только вне жеста. Под пальцем картинка обязана идти
след в след — иначе она «догоняет» палец и жест ощущается как залипание; а вот
шаг слайдера и стрелки без перехода выглядят рывком.
Границы
- нет поворота и зеркала. Кадрирование и трансформация — разные задачи; поворот потребовал бы второй оси управления и своей клавиатуры;
- не сжимает до целевого веса.
output.quality— параметр кодека, а не бюджет в килобайтах: подбор качества под лимит остаётся за приложением; - один кадр за раз. Пакетная обработка галереи — сценарий загрузчика, а не этого компонента.
Установка
npm i @feugene/granularity-mediaИмпорт
import { GrImageCrop } from '@feugene/granularity-media/components/GrImageCrop'API
API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.
Примеры 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>