GrImageCrop

  • media
  • media
  • API · 9 props
import { GrImageCrop } from '@feugene/granularity-media/components/GrImageCrop'

Overview

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 Blob from 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/webp with a given quality, and a kilobyte rather than a megabyte travels to the server.

When to take something else

NeedTake
Show a picture with zoom and paging without changing itGrImageViewer
Accept a file from the user: a drop zone, a queue, checksGrFileUpload / GrFormFile
Show a ready avatar in the interfaceGrAvatar

Examples 3 each with a live demo and its code

  • A profile photo from an office shot

    The frame stands still and the photo moves — one gesture, no corner handles to miss on a phone. shape="circle" only masks what the avatar will show; the export stays a square. crop() cuts the frame in the pixels of the source and scales it to output — 256 × 256 WebP, whatever the size of the shot — and reset() starts over.

    Profile photoDrag the photo to centre your face, then zoom
    No image selected
    How it looks in the app
    MOMina OrtizFinance lead · Northwind
    MOMina approved invoice INV-0418
    Initials stand in until a photo is saved
  • One photo for a card and a banner

    The page sets the proportion with aspect-ratio — the card and the banner have fixed layouts, so the user does not stretch the frame. The empty slot speaks while there is no photo. load reports the size of the original and change the frame in its pixels: that is what goes to the image service, not the size of the window on screen.

    Desk lamp, black · listing coverOne studio shot, cut for each place it appears
    No cover yet
  • A receipt for an expense

    The controls slot replaces the zoom slider: three fixed steps suit a receipt better than a smooth slider, and the slot gets the zoom and its setter. output asks for a JPEG 1200 px wide at quality 0.8 — readable and light; the height follows the frame, 1200 × 1600. Once attached the crop is disabled: the photo stops moving and the frame leaves the Tab order.

    Taxi to the airport · €38.40Expense report OCT-14 · receipt required
    No image selected
    Fit the receipt in the frame — the total must be readable

API generated from the package · cannot drift

Props

PropTypedefaultDescription
aspectRationumber | undefined1Ratio of frame width to height: `1` for an avatar, `16 / 9` for a cover.
outputGrImageCropOutput | undefinedundefinedThe size and format of the result. One side — the other is computed from the proportions of the frame; both — a bounding box the frame fits into without distortion. Not set — the captured area in source pixels, PNG.
sizeGrImageCropSize | undefinedundefinedThe size step: the controls and the type size of the captions. `md` by default; taken from `GrConfigProvider`.
disabledboolean | undefinedfalseCropping is locked: the image does not move or zoom, the frame is out of the Tab order.
ariaLabelstring | undefinedundefinedThe name of the group with the frame and the slider. Not set — from the locale ("Image crop").
srcstring | Blob | File | null | undefinednullA file from `GrFileUpload`, a blob from the camera, or a ready URL.
shapeGrImageCropShape | undefined"rect"The shape of the frame: `rect` or `circle` — a round mask for an avatar. The mask only shows what will remain in view; the result is rectangular all the same.
zoomv-modelnumber | undefinedundefinedZoom relative to the fitted frame, never below one. The prop is optional: without `v-model:zoom` the component drives the zoom itself — otherwise the built-in slider would be dead for everyone who has not wired up the model, and that is the most common case.
maxZoomnumber | undefined4The greatest zoom relative to the fitted frame. `4` by default.

Slots

SlotTypeDescription
controls{ zoom: number; setZoom: (value: number) => void; }Replacement for the built-in zoom slider.
emptyanyWhat to show while there is no image.

Events

EventSignatureDescription
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

MethodsSignatureDescription
crop() => Promise<Blob | null>Crop the current frame into a `Blob` by `output` (PNG in source pixels by default). Returns `null` without an image or when the canvas is blocked — `error` arrives at the same time.
reset() => voidReturn the frame to the centre and the zoom to one.
rect() => GrCropRectThe current frame area in source pixels: `sx`, `sy`, `sw`, `sh` for `drawImage`.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrImageCropConfigurableProps, GrCropSize, GrCropOffset } from '@feugene/granularity-media/components/GrImageCrop'

Filedist/types/components/GrImageCrop/config.d.ts

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

Filedist/types/components/GrImageCrop/defaults.d.ts

GrImageCropConfigurablePropsinterface
interface GrImageCropConfigurableProps {
    size?: GrImageCropSize;
}

Filedist/types/components/GrImageCrop/cropGeometry.d.ts

GrCropSizeinterface
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
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
function cropRect(image: GrCropSize, viewport: GrCropSize, zoom: number, offset: GrCropOffset): GrCropRect;
viewportForfunction
function viewportFor(width: number, aspectRatio: number): GrCropSize;

Filedist/types/components/GrImageCrop/grImageCropStyles.d.ts

GrImageCropSizetype
type GrImageCropSize = 'xs' | 'sm' | 'md' | 'lg';
GrImageCropShapetype
type GrImageCropShape = 'rect' | 'circle';
grImageCropSafelistconst
const grImageCropSafelist: string[];

Filedist/types/components/GrImageCrop/GrImageCrop.vue.d.ts

GrImageCropOutputinterface
interface GrImageCropOutput {
    width?: number;
    height?: number;
    type?: string;
    quality?: number;
}
GrImageCropPropsinterface
interface GrImageCropProps {
    src?: string | File | Blob | null;
    aspectRatio?: number;
    shape?: GrImageCropShape;
    zoom?: number;
    maxZoom?: number;
    output?: GrImageCropOutput;
    size?: GrImageCropSize;
    disabled?: boolean;
    ariaLabel?: string;
}
GrImageCropEmitsinterface
interface GrImageCropEmits {
    (e: 'update:zoom', value: number): void;
    (e: 'change', rect: GrCropRect): void;
    (e: 'load', size: {
        width: number;
        height: number;
    }): void;
    (e: 'error', error: unknown): void;
}

Usage from the library prose

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 frame keeps its ratio without a picture too

The picture almost always arrives later than the page — from an uploader, from a camera, from a server. That is why the height of an empty frame is set by the same aspectRatio rather than by a minimum height of its own: a frame that changes its height when the picture arrives would shift everything under it. The #empty slot stands in the centre of the frame; content that is too tall is clipped rather than stretching it.

The crop is exactly in the ratio of the frame

The window is measured in fractional CSS pixels, and the content inside the border diverges from aspectRatio by a fraction of a pixel. The crop is computed not from the window but from the frame brought to the exact ratio and covering the window: a picture of the same ratio without zoom goes into the crop whole — 1600×1200 rather than 1600×1199.4 with a shift — and output.width: 1200 with aspectRatio = 3 / 4 gives exactly 1200×1600. The second side of the result is computed from aspectRatio rather than from the fractions of a pixel of the area.

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.quality is 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.