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
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 |
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 tooutput— 256 × 256 WebP, whatever the size of the shot — andreset()starts over.Profile photoDrag the photo to centre your face, then zoomNo image selectedHow it looks in the appMOMina OrtizFinance lead · NorthwindMOMina approved invoice INV-0418Initials stand in until a photo is savedOne 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. Theemptyslot speaks while there is no photo.loadreports the size of the original andchangethe 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 appearsNo cover yetA receipt for an expense
The
controlsslot replaces the zoom slider: three fixed steps suit a receipt better than a smooth slider, and the slot gets the zoom and its setter.outputasks for a JPEG 1200 px wide at quality 0.8 — readable and light; the height follows the frame, 1200 × 1600. Once attached the crop isdisabled: the photo stops moving and the frame leaves the Tab order.Taxi to the airport · €38.40Expense report OCT-14 · receipt required Fit the receipt in the frame — the total must be readableNo image selected
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
aspectRatio | number | undefined | 1 | Ratio of frame width to height: `1` for an avatar, `16 / 9` for a cover. |
output | GrImageCropOutput | undefined | undefined | The 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. |
size | GrImageCropSize | undefined | undefined | The size step: the controls and the type size of the captions. `md` by default; taken from `GrConfigProvider`. |
disabled | boolean | undefined | false | Cropping is locked: the image does not move or zoom, the frame is out of the Tab order. |
ariaLabel | string | undefined | undefined | The name of the group with the frame and the slider. Not set — from the locale ("Image crop"). |
src | string | Blob | File | null | undefined | null | A file from `GrFileUpload`, a blob from the camera, or a ready URL. |
shape | GrImageCropShape | 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-model | number | undefined | undefined | Zoom 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. |
maxZoom | number | undefined | 4 | The greatest zoom relative to the fitted frame. `4` by default. |
Slots
| Slot | Type | Description |
|---|---|---|
controls | { zoom: number; setZoom: (value: number) => void; } | Replacement for the built-in zoom slider. |
empty | any | What to show while there is no image. |
Events
| Event | Signature | Description |
|---|---|---|
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
| Methods | Signature | Description |
|---|---|---|
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 | () => void | Return the frame to the centre and the zoom to one. |
rect | () => GrCropRect | The 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 type { GrImageCropConfigurableProps, GrCropSize, GrCropOffset } from '@feugene/granularity-media/components/GrImageCrop'Filedist/types/components/GrImageCrop/config.d.ts
const grImageCropConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrImageCrop">;Filedist/types/components/GrImageCrop/defaults.d.ts
interface GrImageCropConfigurableProps {
size?: GrImageCropSize;
}Filedist/types/components/GrImageCrop/cropGeometry.d.ts
interface GrCropSize {
width: number;
height: number;
}interface GrCropOffset {
x: number;
y: number;
}interface GrCropRect {
sx: number;
sy: number;
sw: number;
sh: number;
}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;function cropRect(image: GrCropSize, viewport: GrCropSize, zoom: number, offset: GrCropOffset): GrCropRect;function viewportFor(width: number, aspectRatio: number): GrCropSize;Filedist/types/components/GrImageCrop/grImageCropStyles.d.ts
type GrImageCropSize = 'xs' | 'sm' | 'md' | 'lg';type GrImageCropShape = 'rect' | 'circle';const grImageCropSafelist: string[];Filedist/types/components/GrImageCrop/GrImageCrop.vue.d.ts
interface GrImageCropOutput {
width?: number;
height?: number;
type?: string;
quality?: number;
}interface GrImageCropProps {
src?: string | File | Blob | null;
aspectRatio?: number;
shape?: GrImageCropShape;
zoom?: number;
maxZoom?: number;
output?: GrImageCropOutput;
size?: GrImageCropSize;
disabled?: boolean;
ariaLabel?: string;
}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.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.
Props in this example