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
- 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 todefaultValue - defaultValue Partial<CropValue>
- Merges over the core
defaultValue. The cropper starts here andreset()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
- getValue () => CropValue
- Returns the live value, frames inside a gesture included
- getState () => CropperSnapshot
- Returns the snapshot that
useCropperStatereads, see hooks - getCropState () => CropState | null
- Returns the crop in image pixels, or
nullbefore a photo - getZoom () => number
- Returns the zoom, 1 before a photo loads. 1 is the largest crop that fits
- getMaxZoom () => number
- Returns the
maxZoomoption, 15 by default - getRotationLimit () => number
- Returns 45, or less when
minSizeleaves too little room - getPixelCrop (scale?) => ExportPlan | null
- Returns the export plan, or
nullbefore a photo, see exporting
Change the crop
- 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'saspectandfocalare 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 asnull - setOptions (options) => void
- Changes
disabled,maxZoom,minSize,focalLengthand the two callbacks. The type takes only those six.image,value,defaultValue,clockandpluginsstay as created. A newfocalLengthtakes effect on the nextsetValue(),reset()orsetImage(). CallsetImage()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" });- 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);
};- begin (reason, event?, owner?) => boolean
- Starts a gesture and stops any animation that holds the commit. Returns
falsewhen 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 returnsfalse. Blocking tasks, the default, hold the commit back. Returns a cancel function - invalidate () => void
- Asks for one more
frameevent - 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.
- 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, emitsdestroy, detaches the photo and drops every listener. A change or commit still waiting is dropped, and a running gesture ends with nogestureend, so callstop()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.
- image Size
- The photo's size before any turn
- turns 0 | 1 | 2 | 3
- Quarter turns, 0 to 3
- flip boolean
truewhile 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
nullwhen free - perspective { vertical, horizontal, focal }
- The tilt in degrees, with
focalin pixels