Events
Two callbacks on Root report crop changes: onValueChange for live UI and onValueCommit for saving.
<Cropper.Root
src={src}
onValueChange={(value) => setAngle(value.rotation)}
onValueCommit={(value) => save(value)}
/>onValueChange runs at most once per frame while the crop moves. onValueCommit runs once when a gesture or a write is done. createCropper takes the same two options.
Save when the user lets go
A drag, a pinch or a turn of the dial commits once, when the finger lifts. A thrown dial commits when it stops. A key on Area, Dial or Zoom commits once when it comes up, however long it was held, or when focus leaves. A write such as rotate90() commits on the next frame, and the turn you see animates after it. A gesture that ends where it started does not commit. The first photo, or a photo after null, fits the crop without a commit. A later photo commits only when fitting it changes the value, such as a locked aspect on a photo of another shape, and that commit has the reason coverage. To save a photo nobody touched, read getValue() or getPixelCrop() from the cropper in actionsRef, see cropper.
stop() and unmount commit synchronously. React warns if onValueCommit calls flushSync inside that unmount, so leave flushSync out of the handler.
<Cropper.Root
src={src}
onValueCommit={(value, details) => {
upload(value, details.pixelCrop);
}}
/>Follow one gesture from start to commit
Log both callbacks, then throw the dial:
<Cropper.Root
src={src}
onValueChange={(_, d) => console.log(d.reason, d.phase)}
onValueCommit={(_, d) => console.log(d.reason, "commit")}
/>| Log | When |
|---|---|
| dial start | The first frame the dial turns |
| dial move | Every later frame of the drag and of the throw |
| dial end | The last frame of the throw |
| dial commit | onValueCommit, in the same frame |
A drag that stops before the finger lifts has no end, and its last move goes straight to the commit. A write outside a gesture logs one instant change, then its commit.
Reject a change
Call details.cancel() and the crop snaps back to where the last change left it. This keeps the angle within 10° each way:
<Cropper.Root
src={src}
onValueChange={(value, details) => {
if (Math.abs(value.rotation) > 10) details.cancel();
}}
/>Change details
The second argument of onValueChange:
- reason ChangeReason
- Names what moved the crop, see reasons below
- phase ChangePhase
- Is
start,move,endorinstant, see the order above - changed readonly ValueField[]
- Lists the fields that moved. Never empty
- previous CropValue
- Holds what the last change left
- event Event | undefined
- Holds the DOM event behind the change
- cancel () => void
- Rejects this change
- isCanceled boolean
- Is
trueoncecancel()ran
Commit details
The second argument of onValueCommit:
- reason ChangeReason
- Names the gesture or write behind the commit
- previous CropValue
- Holds the last committed value. Push it to a stack for undo
- pixelCrop ExportPlan | null
- Holds the export plan at scale 1 in source pixels, or
nullbefore a photo loads. Use it for your own tools. Server crop sends the value fromgetValue()and recomputes the plan on the server, see exporting - event Event | undefined
- Holds the event that ended the gesture, or the one passed with the write
Reasons
Every change and commit names its reason. Inside a gesture, a write without its own reason reports the gesture's reason.
| Reason | Sent by |
|---|---|
| pan | One finger or pointer dragging the photo, and panBy() |
| pinch | Two fingers pinching or twisting, or a trackpad pinch in Safari |
| wheel | A mouse wheel or trackpad scrolling or zooming the photo |
| resize | A handle being dragged |
| dial | The dial being dragged or thrown |
| keyboard | Keys on Area and Dial. Keys on Zoom report zoom-control on each change and keyboard on the commit |
| zoom-control | The Zoom slider, ZoomStep buttons and useZoom |
| aspect | AspectItem buttons, setAspect() and the aspect prop |
| orientation | The Orientation button, which swaps portrait and landscape |
| rotate-90 | Rotate buttons and rotate90() |
| flip | Flip buttons and flip() |
| reset | Reset and reset() |
| coverage | A later photo of another size, or new options, that refit the crop. The first photo sends nothing |
| history | Your own writes only. zriz never sends it, so undo can skip those writes. Root animates them like reset() |
| controlled | The rotation and zoom props, on the commit and on a change when zriz applied another value, and shape="circle" setting the aspect to 1 without a morph. A new value prop sends nothing |
| imperative | setValue(), setRotation(), setZoom() and setCropState() called outside a gesture without a reason |
Listen on the cropper
The cropper emits the two callbacks as change and commit, plus six more events. Under Root or useCropper, change also fires when a controlled prop lands as passed, which onValueChange skips. In React, make the cropper with useCropper and listen in an effect: useEffect(() => cropper.on("settle", onSettle), [cropper]), see hooks. createCropper comes from @zriz/core, installed as in exporting. on() returns a function that removes the listener:
import { createCropper } from "@zriz/core";
const cropper = createCropper();
const off = cropper.on("settle", () => {
console.log("the crop is still");
});
off();| Event | Listener gets | What it does |
|---|---|---|
| gesturestart | reason, event | Fires when an input takes hold |
| gestureend | reason, event | Fires when end() runs. For a thrown dial, that is when the throw stops |
| settle | nothing | Fires each time the last animation stops, springs included. stop() ends motion with no settle, so listen to stop for that case |
| frame | { time, value, state } | Fires on every frame that moved. useCropperFrame wraps it |
| stop | nothing | Fires when stop() ends all motion |
| destroy | nothing | Fires once, when destroy() runs |
A listener that throws stops nothing. The other listeners still run, the crop keeps working, and the error comes back as an uncaught error on a microtask.