Skip to navigation

Cropper

createCropper makes the store behind every Root. Use it for vanilla JS, or to test your crop logic without a browser.

Import it from @zriz/core, installed as in exporting, give it the photo's size and write to it:

import { createCropper } from "@zriz/core";

const cropper = createCropper({
  image: { width: 4032, height: 3024 },
});

cropper.setRotation(7);

A write changes getValue() at once. change and commit fire on the next frame. In React, Root makes the cropper for you, and hooks shows how to reach it.

Options

Option
image Size | null = null
Sets the photo's natural size. Most writes wait for it. A side of about 1e18 or more can drop the tilt and open level
value CropValue
Sets the start in place of defaultValue. reset() still goes to defaultValue
defaultValue Partial<CropValue>
Merges over the core defaultValue. The cropper starts here and reset() returns here. Read once
minSize Size = { width: 1, height: 1 }
Sets the smallest crop in source pixels, along the photo's own sides
maxZoom number = 15
Sets the largest zoom. 1 is the largest crop
focalLength number = 26
Sets the lens as a 35 mm equivalent, for perspective correction. At a strong tilt, zriz lengthens a lens too short to keep the photo in front of it. A length that overflows falls back to 26 mm
disabled boolean = false
Refuses gestures and most writes
clock Clock = frameClock()
Schedules frames. Tests pass a manual one, see test without a browser below
onValueChange (value, details) => void
Runs at most once per frame while the crop moves
onValueCommit (value, details) => void
Runs when a gesture or write commits
plugins readonly CropperPlugin[] = []
Adds behaviour, see add a plugin and clean it up below

minSize also caps the angle once its diagonal is longer than a side of the photo. Only its size next to the photo counts, not the photo's pixel count. While disabled, setValue(), setImage() and setOptions() still work. Both callbacks are on events.

Read the crop

Method
getValue () => CropValue
Returns the live value, frames inside a gesture included
getState () => CropperSnapshot
Returns the snapshot that useCropperState reads, see hooks
getCropState () => CropState | null
Returns the crop in image pixels, or null before a photo
getZoom () => number
Returns the zoom, 1 before a photo loads. 1 is the largest crop that fits
getMaxZoom () => number
Returns the maxZoom option, 15 by default
getRotationLimit () => number
Returns 45, or less when minSize leaves too little room
getPixelCrop (scale?) => ExportPlan | null
Returns the export plan, or null before a photo, see exporting

Change the crop

Method
setValue (value, options?) => void
Merges the fields you pass into the current value
setRotation (degrees, options?) => void
Straightens the photo
setZoom (zoom, options?) => void
Zooms around anchor, a point from 0 to 1 on the photo, or the crop's centre
panBy (dx, dy, options?) => void
Moves the crop by fractions of the photo's width and height
setAspect (aspect, options?) => void
Locks the crop to an aspect
setCropState (state, options?) => void
Sets the crop in image pixels. keep: "center" holds the centre. The default, keep: "size", holds the size. A state from a photo of another size does nothing, and the state's aspect and focal are ignored
rotate90 (direction?, options?) => void
Turns counter-clockwise by default, or clockwise with "cw". A numeric aspect turns with it, so 4:3 becomes 3:4
flip (axis?, options?) => void
Mirrors left to right by default. "y" mirrors top to bottom
reset (options?) => void
Returns to defaultValue, with or without a photo
setImage (size | null) => void
Sets the photo's natural size. A new size refits the crop with reason coverage. A size that is not finite and positive counts as null
setOptions (options) => void
Changes disabled, maxZoom, minSize, focalLength and the two callbacks. The type takes only those six. image, value, defaultValue, clock and plugins stay as created. A new focalLength takes effect on the next setValue(), reset() or setImage(). Call setImage() for a new photo

Every write keeps the crop valid, see value. Before the cropper has a photo, only setValue(), reset(), setImage() and setOptions() do anything.

Tell listeners why

Every write except setImage() and setOptions() takes action options last. Outside a gesture, setValue(), setRotation(), setZoom() and setCropState() report imperative unless you pass a reason. Here the write straightens the photo 5°, and on the next frame the commit logs 5 history:

import { createCropper } from "@zriz/core";

const cropper = createCropper({
  image: { width: 4032, height: 3024 },
  onValueCommit: (value, details) => {
    console.log(value.rotation, details.reason);
  },
});

cropper.setRotation(5, { reason: "history" });
Option
reason ChangeReason
Names the change for listeners, see events
event Event
Passes the DOM event behind the change
emit boolean
With false, no change or commit fires, and the new value counts as committed. It also drops a change still waiting from the same frame

Drive the crop from your own input

Call begin() when your input takes hold and end() when it lets go. Every write between them is one gesture with one commit. Pass the same owner object to both, so no other input can end your gesture:

import { createCropper } from "@zriz/core";

const cropper = createCropper({
  image: { width: 4032, height: 3024 },
});
const owner = {};

export const bind = (input: HTMLInputElement) => {
  input.addEventListener("pointerdown", (event) => {
    cropper.begin("dial", event, owner);
  });

  input.addEventListener("input", () => {
    cropper.setRotation(input.valueAsNumber);
  });

  const end = (event: PointerEvent) => {
    cropper.end(event, owner);
  };

  input.addEventListener("pointerup", end);
  input.addEventListener("pointercancel", end);
};
Method
begin (reason, event?, owner?) => boolean
Starts a gesture and stops any animation that holds the commit. Returns false when the cropper is disabled or another owner holds a gesture. A dial glide or a wheel turn gives way to it
end (event?, owner?) => void
Ends the gesture. The commit follows once blocking tasks stop
cancel (event?, owner?) => void
Ends the gesture and restores the crop from before it
run (task, blocking?) => () => void
Calls task(time, delta) every frame until it returns false. Blocking tasks, the default, hold the commit back. Returns a cancel function
invalidate () => void
Asks for one more frame event
on (type, listener) => () => void
Listens to one event, see events. Returns a remove function
subscribe (listener) => () => void
Runs the listener when the snapshot changes

Test without a browser

In Node, the default clock waits about 16 ms per frame on setTimeout. Pass a clock of your own and step the frames yourself, so every event fires when your test says:

import { createCropper } from "@zriz/core";
import type { Clock } from "@zriz/core";

const queue = new Map<number, (time: number) => void>();
let id = 0;
let now = 0;

const clock: Clock = {
  request: (step) => {
    id += 1;
    queue.set(id, step);

    return id;
  },
  cancel: (handle) => {
    queue.delete(handle);
  },
};

const advance = (ms: number) => {
  now += ms;
  const steps = [...queue.values()];

  queue.clear();
  for (const step of steps) step(now);
};

const commits: number[] = [];
const cropper = createCropper({
  clock,
  image: { width: 4032, height: 3024 },
  onValueCommit: (value) => commits.push(value.rotation),
});

cropper.setRotation(7);
advance(16);
console.log(commits);

setRotation() changes getValue() at once, and advance(16) runs the frame that sends the commit. The log reads [7].

Add a plugin and clean it up

A plugin is an object with a name, an init and an optional destroy. init runs right after createCropper makes the cropper. Root and useCropper take plugins too, read them once and run init once on the client, after mount, never during a render.

import { createCropper } from "@zriz/core";
import type { CropperPlugin } from "@zriz/core";

let off = () => {};

const logger: CropperPlugin = {
  name: "logger",
  init: (cropper) => {
    off = cropper.on("commit", (value) => console.log(value));
  },
  destroy: () => off(),
};

const cropper = createCropper({ plugins: [logger] });

cropper.destroy();

destroy runs only when you call destroy(). Root never calls it. When the component that made the cropper unmounts, Root or the one that called useCropper, it calls stop() instead, on purpose: StrictMode and <Activity> unmount a component and mount the same cropper again, and a destroyed cropper cannot come back.

If a plugin holds a timer or a socket, call destroy() yourself when you are done with the cropper, for example through actionsRef.

Method
stop () => void
Ends any gesture, drops all motion, commits what is there and emits stop. The cropper keeps working
destroy () => void
Runs each plugin's destroy, emits destroy, detaches the photo and drops every listener. A change or commit still waiting is dropped, and a running gesture ends with no gestureend, so call stop() first to send them. The cropper is finished, and every later write is ignored

Read the crop in image pixels

getCropState() and useCropperFrame give a CropState, and setCropState() takes one. crop.x and crop.y are not a corner. They are the crop's centre measured from the photo's centre, so they can be negative, with x to the right and y down, after the quarter turns.

Field
image Size
The photo's size before any turn
turns 0 | 1 | 2 | 3
Quarter turns, 0 to 3
flip boolean
true while mirrored
angle number
The straighten angle in degrees
crop { x, y, width, height }
The crop in image pixels, after the quarter turns
aspect number | null
The ratio as a number, or null when free
perspective { vertical, horizontal, focal }
The tilt in degrees, with focal in pixels