GrImageCrop

  • media
  • Медиа
  • API · 9 props

Берут, когда аватар из загруженного файла.

import { GrImageCrop } from '@feugene/granularity-media/components/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 app
    MOMina OrtizFinance lead · Northwind
    MOMina approved invoice INV-0418
    Initials stand in until a photo is saved
  • Одно фото для карточки и баннера

    Пропорцию задаёт страница через aspect-ratio — у карточки и баннера раскладка фиксирована, и растягивать рамку пользователь не должен. Слот empty говорит, пока фото нет. load сообщает размер оригинала, а change — кадр в его пикселях: именно это уходит в сервис картинок, а не размер окна на экране.

    Desk lamp, black · listing coverOne studio shot, cut for each place it appears
    No 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

PropTypeпо умолчаниюОписание
aspectRationumber | undefined1Отношение ширины кадра к высоте: `1` — аватар, `16 / 9` — обложка.
outputGrImageCropOutput | undefinedundefinedРазмер и формат результата. Одна сторона — вторая считается из пропорций кадра; обе — габарит, в который кадр вписывается, не искажаясь. Не задан — захваченная область в пикселях исходника, PNG.
sizeGrImageCropSize | undefinedundefinedСтупень размера: элементы управления и кегль подписей. По умолчанию `md`; берётся из `GrConfigProvider`.
disabledboolean | undefinedfalseКадрирование заперто: картинка не двигается и не увеличивается, рамка вне порядка Tab.
ariaLabelstring | undefinedundefinedИмя группы с рамкой и слайдером. Не задано — из локали («Image crop»).
srcstring | Blob | File | null | undefinednullФайл из `GrFileUpload`, blob из камеры или готовый адрес.
shapeGrImageCropShape | undefined"rect"Форма рамки: `rect` или `circle` — круглая маска для аватара. Маска только показывает, что останется на виду; результат всё равно прямоугольный.
zoomv-modelnumber | undefinedundefinedУвеличение относительно вписанного кадра, не меньше единицы. Проп необязателен: без `v-model:zoom` компонент ведёт увеличение сам — иначе встроенный слайдер оказывался бы мёртвым у всех, кто модель не подключил, а это самый частый случай.
maxZoomnumber | undefined4Наибольшее увеличение относительно вписанного кадра. По умолчанию `4`.

Слоты

SlotTypeОписание
controls{ zoom: number; setZoom: (value: number) => void; }Замена встроенного слайдера увеличения.
emptyanyЧто показать, пока картинки нет.

События

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

grImageCropConfigconst

Слайдер увеличения — GrSlider ядра, и ребро объявлено: granum подмешивает safelist и CSS только тем компонентам, что попали в селекцию, и без графа потребитель, выбравший один GrImageCrop, получил бы дорожку без ползунка. Слот #controls этого не отменяет: подмену делает потребитель, а по умолчанию слайдер рендерится.

const grImageCropConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrImageCrop">;

Файлdist/types/components/GrImageCrop/defaults.d.ts

GrImageCropConfigurablePropsinterface

Настраиваемые через GrConfigProvider пропы. Аугментация реестра идёт по месту объявления — через @feugene/granularity/composables/useGrComponentConfig, а не через реэкспорт: иначе слияние отваливается молча.

interface GrImageCropConfigurableProps {
    size?: GrImageCropSize;
}

Файлdist/types/components/GrImageCrop/cropGeometry.d.ts

GrCropSizeinterface

Геометрия кропа: где именно вырезается кадр. Модель — «рамка неподвижна, двигается изображение»: окно кропа занимает весь видимый прямоугольник, а пользователь тянет и масштабирует картинку под ним. Обратная модель (рамка ездит по картинке) требует от пользователя двух жестов вместо одного и на телефоне промахивается по ручкам рамки. Всё здесь — чистые функции над числами: ни DOM, ни canvas. Кроп ошибается незаметно (кадр съезжает на пару процентов, и это видно только на результате), поэтому арифметика вынесена туда, где её можно проверить без рендера.

interface GrCropSize {
    width: number;
    height: number;
}
GrCropOffsetinterface
interface GrCropOffset {
    x: number;
    y: number;
}
GrCropRectinterface

Прямоугольник исходного изображения, попадающий в кадр.

interface GrCropRect {
    sx: number;
    sy: number;
    sw: number;
    sh: number;
}
coverScalefunction

Масштаб, при котором изображение покрывает окно целиком. Именно max, а не min: при min картинка вписалась бы внутрь окна и по краям остались бы поля, то есть кадр содержал бы пустоту, которой в исходнике нет.

function coverScale(image: GrCropSize, viewport: GrCropSize): number;
displaySizefunction

Размер изображения на экране при данном увеличении.

function displaySize(image: GrCropSize, viewport: GrCropSize, zoom: number): GrCropSize;
offsetBoundsfunction

Насколько изображение может съехать, не обнажив край окна. Предел считается от **отображаемого** размера, а не от исходного: при увеличении запас растёт, и жёсткая граница в пикселях исходника заперла бы увеличенную картинку в центре.

function offsetBounds(image: GrCropSize, viewport: GrCropSize, zoom: number): GrCropOffset;
clampOffsetfunction

Смещение, подрезанное до пределов: за ними в кадр попала бы пустота.

function clampOffset(offset: GrCropOffset, image: GrCropSize, viewport: GrCropSize, zoom: number): GrCropOffset;
cropRectfunction

Область исходного изображения под окном кропа. Смещение подрезается здесь же, а не доверяется вызывающему: zoom меняется колесом и слайдером, и уменьшение всегда сокращает пределы — смещение, законное миг назад, после него выводит кадр за край. Соотношение сторон области — ровно соотношение окна: окно в точном соотношении даёт точную область (frameFor).

function cropRect(image: GrCropSize, viewport: GrCropSize, zoom: number, offset: GrCropOffset): GrCropRect;
viewportForfunction

Высота окна при заданном соотношении сторон. Соотношение задаётся числом, а не строкой '16:9': строку пришлось бы разбирать в рантайме, а ошибка формата всплыла бы уже на экране.

function viewportFor(width: number, aspectRatio: number): GrCropSize;

Файлdist/types/components/GrImageCrop/grImageCropStyles.d.ts

GrImageCropSizetype
type GrImageCropSize = 'xs' | 'sm' | 'md' | 'lg';
GrImageCropShapetype
type GrImageCropShape = 'rect' | 'circle';
grImageCropSafelistconst

Safelist пуст: всё, что рисует кадрирование, лежит целыми литералами здесь и в шаблоне, и granum извлекает это сам — модуль уезжает в общий dist/chunks/, но сборка доходит до него по графу бандла компонента.

const grImageCropSafelist: string[];

Файлdist/types/components/GrImageCrop/GrImageCrop.vue.d.ts

GrImageCropOutputinterface
interface GrImageCropOutput {
    /** Ширина результата. По умолчанию — ширина захваченной области исходника. */
    width?: number;
    /** Высота результата. Вместе с `width` — габарит, в который кадр вписывается. */
    height?: number;
    /** MIME результата: `image/png` (по умолчанию), `image/jpeg`, `image/webp`. */
    type?: string;
    /** Качество сжатия от `0` до `1` для JPEG и WebP. */
    quality?: number;
}
GrImageCropPropsinterface
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;
}
GrImageCropEmitsinterface
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 — параметр кодека, а не бюджет в килобайтах: подбор качества под лимит остаётся за приложением;
  • один кадр за раз. Пакетная обработка галереи — сценарий загрузчика, а не этого компонента.