GrImageCrop
Берут, когда аватар из загруженного файла.
Обзор
Когда брать
- аватар из загруженного файла — пользователь принёс фотографию произвольных пропорций, а профилю нужен квадрат;
- обложка под фиксированное место — карточка товара или шапка раздела, где вёрстка рассчитана на одно соотношение сторон;
- кадр из снимка камеры —
Blobс устройства приходит целиком, а показать нужно лицо, а не всю комнату; - уменьшение веса вложения — вырезанная область экспортируется в
image/webpс заданным качеством, и на сервер уезжает килобайт вместо мегабайта.
Когда взять другое
| Нужно | Берите |
|---|---|
| Показать картинку с зумом и листанием, не меняя её | GrImageViewer |
| Принять файл от пользователя: зона переноса, очередь, проверки | GrFileUpload / GrFormFile |
| Показать готовую аватарку в интерфейсе | GrAvatar |
Примеры 3 у каждого — живое демо и его код
Фото профиля из снимка в офисе
Рамка стоит, двигается фото — один жест и никаких угловых ручек, по которым промахиваются на телефоне.
shape="circle"только маскирует то, что покажет аватар; экспорт остаётся квадратом.crop()вырезает кадр в пикселях исходника и масштабирует его подoutput— 256 × 256 WebP, каким бы ни был снимок, — аreset()начинает заново.Profile photoDrag the photo to centre your face, then zoomИзображение не выбраноHow it looks in the appMOMina OrtizFinance lead · NorthwindMOMina approved invoice INV-0418Initials stand in until a photo is savedОдно фото для карточки и баннера
Пропорцию задаёт страница через
aspect-ratio— у карточки и баннера раскладка фиксирована, и растягивать рамку пользователь не должен. Слотemptyговорит, пока фото нет.loadсообщает размер оригинала, аchange— кадр в его пикселях: именно это уходит в сервис картинок, а не размер окна на экране.Desk lamp, black · listing coverOne studio shot, cut for each place it appearsNo cover yetЧек к расходу
Слот
controlsзаменяет слайдер увеличения: три фиксированных шага подходят чеку лучше плавного слайдера, а слот получает увеличение и его сеттер.outputпросит JPEG шириной 1200 px с качеством 0.8 — читаемо и легко; высота следует рамке, 1200 × 1600. Прикреплённый чек —disabled: фото больше не двигается, а рамка уходит из порядка Tab.Taxi to the airport · €38.40Expense report OCT-14 · receipt required Fit the receipt in the frame — the total must be readableИзображение не выбрано
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
aspectRatio | number | undefined | 1 | Отношение ширины кадра к высоте: `1` — аватар, `16 / 9` — обложка. |
output | GrImageCropOutput | undefined | undefined | Размер и формат результата. Одна сторона — вторая считается из пропорций кадра; обе — габарит, в который кадр вписывается, не искажаясь. Не задан — захваченная область в пикселях исходника, PNG. |
size | GrImageCropSize | undefined | undefined | Ступень размера: элементы управления и кегль подписей. По умолчанию `md`; берётся из `GrConfigProvider`. |
disabled | boolean | undefined | false | Кадрирование заперто: картинка не двигается и не увеличивается, рамка вне порядка Tab. |
ariaLabel | string | undefined | undefined | Имя группы с рамкой и слайдером. Не задано — из локали («Image crop»). |
src | string | Blob | File | null | undefined | null | Файл из `GrFileUpload`, blob из камеры или готовый адрес. |
shape | GrImageCropShape | undefined | "rect" | Форма рамки: `rect` или `circle` — круглая маска для аватара. Маска только показывает, что останется на виду; результат всё равно прямоугольный. |
zoomv-model | number | undefined | undefined | Увеличение относительно вписанного кадра, не меньше единицы. Проп необязателен: без `v-model:zoom` компонент ведёт увеличение сам — иначе встроенный слайдер оказывался бы мёртвым у всех, кто модель не подключил, а это самый частый случай. |
maxZoom | number | undefined | 4 | Наибольшее увеличение относительно вписанного кадра. По умолчанию `4`. |
Слоты
| Slot | Type | Описание |
|---|---|---|
controls | { zoom: number; setZoom: (value: number) => void; } | Замена встроенного слайдера увеличения. |
empty | any | Что показать, пока картинки нет. |
События
| Event | Сигнатура | Описание |
|---|---|---|
error | (event: "error", error: unknown): void | — |
update:zoom | (event: "update:zoom", value: number): void | — |
change | (event: "change", rect: GrCropRect): void | — |
load | (event: "load", size: { width: number; height: number; }): void | — |
Методы
| Methods | Сигнатура | Описание |
|---|---|---|
crop | () => Promise<Blob | null> | Вырезать текущий кадр в `Blob` по `output` (по умолчанию PNG в пикселях исходника). Вернёт `null` без картинки или при запрете холста — тогда же придёт `error`. |
reset | () => void | Вернуть кадр к центру и увеличение к единице. |
rect | () => GrCropRect | Текущая область кадра в пикселях исходника: `sx`, `sy`, `sw`, `sh` для `drawImage`. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrImageCropConfigurableProps, GrCropSize, GrCropOffset } from '@feugene/granularity-media/components/GrImageCrop'Файлdist/types/components/GrImageCrop/config.d.ts
Слайдер увеличения — GrSlider ядра, и ребро объявлено: granum подмешивает
safelist и CSS только тем компонентам, что попали в селекцию, и без графа
потребитель, выбравший один GrImageCrop, получил бы дорожку без ползунка.
Слот #controls этого не отменяет: подмену делает потребитель, а по
умолчанию слайдер рендерится.
const grImageCropConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrImageCrop">;Файлdist/types/components/GrImageCrop/defaults.d.ts
Настраиваемые через GrConfigProvider пропы.
Аугментация реестра идёт по месту объявления — через
@feugene/granularity/composables/useGrComponentConfig, а не через реэкспорт:
иначе слияние отваливается молча.
interface GrImageCropConfigurableProps {
size?: GrImageCropSize;
}Файлdist/types/components/GrImageCrop/cropGeometry.d.ts
Геометрия кропа: где именно вырезается кадр. Модель — «рамка неподвижна, двигается изображение»: окно кропа занимает весь видимый прямоугольник, а пользователь тянет и масштабирует картинку под ним. Обратная модель (рамка ездит по картинке) требует от пользователя двух жестов вместо одного и на телефоне промахивается по ручкам рамки. Всё здесь — чистые функции над числами: ни DOM, ни canvas. Кроп ошибается незаметно (кадр съезжает на пару процентов, и это видно только на результате), поэтому арифметика вынесена туда, где её можно проверить без рендера.
interface GrCropSize {
width: number;
height: number;
}interface GrCropOffset {
x: number;
y: number;
}Прямоугольник исходного изображения, попадающий в кадр.
interface GrCropRect {
sx: number;
sy: number;
sw: number;
sh: number;
}Масштаб, при котором изображение покрывает окно целиком.
Именно max, а не min: при min картинка вписалась бы внутрь окна и по
краям остались бы поля, то есть кадр содержал бы пустоту, которой в исходнике
нет.
function coverScale(image: GrCropSize, viewport: GrCropSize): number;Размер изображения на экране при данном увеличении.
function displaySize(image: GrCropSize, viewport: GrCropSize, zoom: number): GrCropSize;Насколько изображение может съехать, не обнажив край окна. Предел считается от **отображаемого** размера, а не от исходного: при увеличении запас растёт, и жёсткая граница в пикселях исходника заперла бы увеличенную картинку в центре.
function offsetBounds(image: GrCropSize, viewport: GrCropSize, zoom: number): GrCropOffset;Смещение, подрезанное до пределов: за ними в кадр попала бы пустота.
function clampOffset(offset: GrCropOffset, image: GrCropSize, viewport: GrCropSize, zoom: number): GrCropOffset;Область исходного изображения под окном кропа.
Смещение подрезается здесь же, а не доверяется вызывающему: zoom меняется
колесом и слайдером, и уменьшение всегда сокращает пределы — смещение,
законное миг назад, после него выводит кадр за край.
Соотношение сторон области — ровно соотношение окна: окно в точном
соотношении даёт точную область (frameFor).
function cropRect(image: GrCropSize, viewport: GrCropSize, zoom: number, offset: GrCropOffset): GrCropRect;Высота окна при заданном соотношении сторон.
Соотношение задаётся числом, а не строкой '16:9': строку пришлось бы
разбирать в рантайме, а ошибка формата всплыла бы уже на экране.
function viewportFor(width: number, aspectRatio: number): GrCropSize;Файлdist/types/components/GrImageCrop/grImageCropStyles.d.ts
type GrImageCropSize = 'xs' | 'sm' | 'md' | 'lg';type GrImageCropShape = 'rect' | 'circle';Safelist пуст: всё, что рисует кадрирование, лежит целыми литералами здесь
и в шаблоне, и granum извлекает это сам — модуль уезжает в общий
dist/chunks/, но сборка доходит до него по графу бандла компонента.
const grImageCropSafelist: string[];Файлdist/types/components/GrImageCrop/GrImageCrop.vue.d.ts
interface GrImageCropOutput {
/** Ширина результата. По умолчанию — ширина захваченной области исходника. */
width?: number;
/** Высота результата. Вместе с `width` — габарит, в который кадр вписывается. */
height?: number;
/** MIME результата: `image/png` (по умолчанию), `image/jpeg`, `image/webp`. */
type?: string;
/** Качество сжатия от `0` до `1` для JPEG и WebP. */
quality?: number;
}interface GrImageCropProps {
/** Файл из `GrFileUpload`, blob из камеры или готовый адрес. */
src?: string | File | Blob | null;
/** Отношение ширины кадра к высоте: `1` — аватар, `16 / 9` — обложка. */
aspectRatio?: number;
/**
* Форма рамки: `rect` или `circle` — круглая маска для аватара. Маска только
* показывает, что останется на виду; результат всё равно прямоугольный.
*/
shape?: GrImageCropShape;
/**
* Увеличение относительно вписанного кадра, не меньше единицы.
*
* Проп необязателен: без `v-model:zoom` компонент ведёт увеличение сам —
* иначе встроенный слайдер оказывался бы мёртвым у всех, кто модель не
* подключил, а это самый частый случай.
*/
zoom?: number;
/** Наибольшее увеличение относительно вписанного кадра. По умолчанию `4`. */
maxZoom?: number;
/**
* Размер и формат результата. Одна сторона — вторая считается из пропорций
* кадра; обе — габарит, в который кадр вписывается, не искажаясь. Не задан —
* захваченная область в пикселях исходника, PNG.
*/
output?: GrImageCropOutput;
/**
* Ступень размера: элементы управления и кегль подписей. По умолчанию `md`;
* берётся из `GrConfigProvider`.
*/
size?: GrImageCropSize;
/** Кадрирование заперто: картинка не двигается и не увеличивается, рамка вне порядка Tab. */
disabled?: boolean;
/** Имя группы с рамкой и слайдером. Не задано — из локали («Image crop»). */
ariaLabel?: string;
}interface GrImageCropEmits {
/** Увеличение сменилось (`v-model:zoom`): слайдером, колесом или клавишами. */
(e: 'update:zoom', value: number): void;
/** Кадр изменился: сдвиг, увеличение или новая картинка. */
(e: 'change', rect: GrCropRect): void;
/** Картинка загрузилась — её собственный размер в пикселях. */
(e: 'load', size: {
width: number;
height: number;
}): void;
/** Картинка не загрузилась или холст не отдал результат (чужой домен без CORS). */
(e: 'error', error: unknown): void;
}Использование из прозы библиотеки
Рамка неподвижна, двигается картинка
Обратная модель — рамка ездит по картинке — требует двух жестов вместо одного: сначала подвинуть рамку, потом растянуть её за угол. На телефоне угловые ручки меньше пальца, и промах по ним означает случайный сдвиг всей рамки. Здесь жест один: тянуть картинку, а увеличение живёт отдельным контролом.
Следствие для потребителя: соотношение сторон кадра задаёт он, а не пользователь.
aspectRatio — число (1, 16 / 9), потому что строку '16:9' пришлось бы
разбирать в рантайме, и ошибка формата всплыла бы уже на экране.
Рамка держит соотношение и без картинки
Картинка почти всегда приходит позже страницы — из загрузчика, с камеры, с
сервера. Поэтому высоту пустой рамке задаёт тот же aspectRatio, а не своя
минимальная высота: рамка, меняющая высоту с приходом картинки, сдвигала бы всё,
что под ней. Слот #empty стоит по центру рамки; слишком высокое содержимое
обрезается, а не растягивает её.
Кадр — ровно в соотношении рамки
Окно меряется в дробных CSS-пикселях, и содержимое внутри бордера расходится с
aspectRatio на долю пикселя. Кадр считается не от окна, а от рамки, доведённой
до точного соотношения и накрывающей окно: картинка того же соотношения без
увеличения уходит в кадр целиком — 1600×1200, а не 1600×1199.4 со сдвигом, — а
output.width: 1200 при aspectRatio = 3 / 4 даёт ровно 1200×1600. Вторая
сторона результата считается из aspectRatio, а не из долей пикселя области.
Кадр считается в пикселях исходника
Без 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— параметр кодека, а не бюджет в килобайтах: подбор качества под лимит остаётся за приложением; - один кадр за раз. Пакетная обработка галереи — сценарий загрузчика, а не этого компонента.
Пропы этого примера