Saving state
Keep the crop for each photo in local storage and bring it back on the next visit.
Read the saved crop before the cropper mounts
Each photo gets its own key, built from an id you pass, such as the photo's id in your database. parse turns the stored string back into a crop, or returns null when there is none. Value shows what it does with broken input.
parse and serialize come from @zriz/core, which quick start installs.
Root reads defaultValue once, when it mounts. The component renders nothing until it has read storage for this id. That empty render unmounts the old cropper, so each photo starts fresh.
Storage can refuse a read, such as in a sandboxed iframe. read treats that as no saved crop, so the cropper still opens.
const read = (key: string) => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
const key = `zriz-crop:${id}`;
const [saved, setSaved] = useState<Saved>();
useEffect(() => {
setSaved({ id, value: parse(read(key)) });
}, [id, key]);
if (saved?.id !== id) return null;Start from it and save each commit
serialize writes a JSON string with a version field and stores the crop as fractions of the photo, so it fits any viewport size. To keep it in a database instead, send the same string with fetch.
Reset goes back to defaultValue, which is now the saved crop. To keep Reset on the whole photo, pass saved.value ?? undefined as value instead. It stays the same object, so it moves the crop only once, see value.
The recipe skips coverage commits. That is zriz fitting the crop to a new photo, and nobody chose it, so it must not replace the saved crop. A full quota throws on write. Without the try, zriz rethrows that as an uncaught error, so the try keeps your error reporting quiet and the crop goes on working unsaved.
<Cropper.Root
src={src}
defaultValue={saved.value ?? undefined}
onValueCommit={(value, details) => {
if (details.reason === "coverage") return;
try {
localStorage.setItem(key, serialize(value));
} catch (error) {
console.warn("the crop was not saved", error);
}
}}
>
<Cropper.Viewport style={{ maxWidth: 640, height: 480 }}>
<Cropper.Image />
<Cropper.Mask />
<Cropper.Area aria-label="Crop area" />
</Cropper.Viewport>
</Cropper.Root>The whole file
SavedCrop takes a photo URL and an id to store its crop under:
"use client";
import { parse, serialize } from "@zriz/core";
import type { CropValue } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import "@zriz/react/styles.css";
import { useEffect, useState } from "react";
type Props = { id: string; src: string };
type Saved = { id: string; value: CropValue | null };
const read = (key: string) => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
export const SavedCrop = ({ id, src }: Props) => {
const key = `zriz-crop:${id}`;
const [saved, setSaved] = useState<Saved>();
useEffect(() => {
setSaved({ id, value: parse(read(key)) });
}, [id, key]);
if (saved?.id !== id) return null;
return (
<Cropper.Root
src={src}
defaultValue={saved.value ?? undefined}
onValueCommit={(value, details) => {
if (details.reason === "coverage") return;
try {
localStorage.setItem(key, serialize(value));
} catch (error) {
console.warn("the crop was not saved", error);
}
}}
>
<Cropper.Viewport style={{ maxWidth: 640, height: 480 }}>
<Cropper.Image />
<Cropper.Mask />
<Cropper.Area aria-label="Crop area" />
</Cropper.Viewport>
</Cropper.Root>
);
};The demo above saves its crop in this browser under zriz-docs:saving-state: and an id for the photo. Change the crop and reload the page, and it comes back. The demo never renders on the server, so it reads storage as it mounts and skips the empty render.
It adds the dial, rotate, flip, handles, a grid and reset, shows the angle to one decimal and uses the styles from the zriz site.