Skip to navigation

Undo

An undo button for the crop, built from what each commit hands you.

Open
Download
0.0

Remember the crop before each gesture

details.previous holds the crop from before the gesture. One gesture is one commit, however many frames it took. The stack keeps the last 50.

remember skips commits with two reasons. history is the undo itself, and coverage is zriz fitting the crop to new limits.

const skipped = new Set(["history", "coverage"]);

const [history, setHistory] = useState<CropValue[]>([]);

const remember = (_: CropValue, details: CommitDetails) => {
  if (skipped.has(details.reason)) return;

  setHistory((stack) =>
    [...stack, details.previous].slice(-50)
  );
};

Step back

setValue() commits like any other change, with the reason you give it. A press during a gesture does nothing, because the gesture would overwrite the step and the stack would lose it. The button reads as off while the stack is empty, with aria-disabled, so it keeps focus when the last step goes.

For redo, keep a second stack. undo pushes actions.current.getValue() onto it before it steps back. A redo function pops it, pushes the current crop onto the undo stack and calls setValue() with history too. remember clears the redo stack after the reason check, so a new gesture drops it.

const undo = () => {
  const previous = history.at(-1);

  if (!previous || actions.current?.getState().interacting) {
    return;
  }

  setHistory(history.slice(0, -1));
  actions.current?.setValue(previous, { reason: "history" });
};

<button
  type="button"
  className="undo"
  aria-disabled={history.length === 0}
  onClick={undo}
>
  Undo
</button>

The browser dims only disabled, so paint the off state yourself. Save this as undo.css next to the editor, which imports it:

.undo[aria-disabled="true"] {
  opacity: 0.4;
}

Wire it to the cropper

onValueCommit hands every commit to remember. Crops from the last photo make no sense on the next one, so UndoEditor keys Editor by src. Each photo gets a fresh cropper at the full frame and an empty stack, and a late commit from the old cropper has no stack to reach. The old photo leaves the screen at once.

<Cropper.Root
  actionsRef={actions}
  src={src}
  onValueCommit={remember}
>

export const UndoEditor = ({ src }: { src: string }) => (
  <Editor key={src} src={src} />
);

The whole file

Editor adds the button under the dial:

"use client";

import type {
  CommitDetails,
  Cropper as CropperApi,
  CropValue,
} from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import "@zriz/react/styles.css";
import { useRef, useState } from "react";
import "./undo.css";

const skipped = new Set(["history", "coverage"]);

const Editor = ({ src }: { src: string }) => {
  const actions = useRef<CropperApi>(null);
  const [history, setHistory] = useState<CropValue[]>([]);

  const remember = (_: CropValue, details: CommitDetails) => {
    if (skipped.has(details.reason)) return;

    setHistory((stack) =>
      [...stack, details.previous].slice(-50)
    );
  };

  const undo = () => {
    const previous = history.at(-1);

    if (!previous || actions.current?.getState().interacting) {
      return;
    }

    setHistory(history.slice(0, -1));
    actions.current?.setValue(previous, { reason: "history" });
  };

  return (
    <Cropper.Root
      actionsRef={actions}
      src={src}
      onValueCommit={remember}
    >
      <Cropper.Viewport style={{ maxWidth: 640, height: 480 }}>
        <Cropper.Image />
        <Cropper.Mask />
        <Cropper.Area aria-label="Crop area" />
      </Cropper.Viewport>
      <Cropper.Dial
        aria-label="Straighten"
        style={{ maxWidth: 361 }}
      >
        <Cropper.DialTicks />
      </Cropper.Dial>
      <button
        type="button"
        className="undo"
        aria-disabled={history.length === 0}
        onClick={undo}
      >
        Undo
      </button>
    </Cropper.Root>
  );
};

export const UndoEditor = ({ src }: { src: string }) => (
  <Editor key={src} src={src} />
);

The demo above runs this file with a rotate button, so a turn is one more step to undo. It adds handles, a grid and reset, shows the angle to one decimal and uses the styles from the zriz site.