# Full editor

> [llms.txt](https://zriz.dev/llms.txt)

The crop area, the dial, zoom, aspect ratios, the buttons and a JPEG export in one component.

The page at https\://zriz.dev/docs/full-editor runs this cropper: a 3:2 photo, the straighten dial with rotate and flip, the aspect ratio track, and open, reset and download in the corners.

## Lay out the crop area

Give the viewport a size. The stylesheet paints the mask, the crop frame, the handles and the grid. Eight `Handle` parts let the visitor drag every corner and edge.

```tsx
const handles = [
  "top-left",
  "top",
  "top-right",
  "right",
  "bottom-right",
  "bottom",
  "bottom-left",
  "left",
] as const;

<Cropper.Viewport
  style={{ maxWidth: 640, height: 480, padding: 24 }}
>
  <Cropper.Image />
  <Cropper.Mask />
  <Cropper.Area aria-label="Crop area">
    <Cropper.Grid />
    {handles.map((position) => (
      <Cropper.Handle
        key={position}
        position={position}
      />
    ))}
  </Cropper.Area>
</Cropper.Viewport>
```

## Straighten with the dial

The dial fills its parent unless you give it a width, see [dial](https://zriz.dev/docs/dial.md).

`DialValue` prints the angle as a whole number. `format` adds the degree sign.

```tsx
<Cropper.DialValue
  format={(degrees) =>
    `${Math.sign(degrees) * Math.round(Math.abs(degrees))}°`
  }
/>
<Cropper.Dial
  aria-label="Straighten"
  style={{ maxWidth: 361 }}
>
  <Cropper.DialTicks />
</Cropper.Dial>
```

## Add a zoom slider

zriz ships `Zoom` and `ZoomThumb` with no paint. `track` draws a 2 px line and `thumb` draws the knob. zriz moves the knob from the left end at 1× to the right end at the largest zoom.

```tsx
const track = {
  width: 200,
  height: 24,
  backgroundImage:
    "linear-gradient(currentColor, currentColor)",
  backgroundSize: "100% 2px",
  backgroundPosition: "center",
  backgroundRepeat: "no-repeat",
};

const thumb = {
  top: 4,
  width: 16,
  height: 16,
  borderRadius: 8,
  background: "currentColor",
  translate: "-50%",
};

<Cropper.Zoom aria-label="Zoom" style={track}>
  <Cropper.ZoomThumb style={thumb} />
</Cropper.Zoom>
```

Forced colours mode removes a gradient and a plain background, so the slider would vanish. Save this as `forced-colors.css` next to the editor, which imports it. It brings both back in `CanvasText`. The styles above are inline, so it needs `!important`:

```css
@media (forced-colors: active) {
  [data-zriz-item="zoom"] {
    forced-color-adjust: none;
    background-image: linear-gradient(
      CanvasText,
      CanvasText
    ) !important;
  }
  [data-zriz-item="zoom-thumb"] {
    background: CanvasText !important;
  }
}
```

## Pick an aspect ratio

`AspectGroup` is one tab stop. [Aspect ratio](https://zriz.dev/docs/aspect-ratio.md) lists its keys.

`Orientation` swaps 4:5 for 5:4 and 16:9 for 9:16. It is off while the aspect is free, original or square. After a swap no item is checked, because the list has neither 5:4 nor 9:16. [Aspect ratio](https://zriz.dev/docs/aspect-ratio.md) flips the labels with the orientation so one item stays checked.

```tsx
const aspects = [
  { label: "Free", value: "free" },
  { label: "Original", value: "original" },
  { label: "Square", value: 1 },
  { label: "4:5", value: 4 / 5 },
  { label: "16:9", value: 16 / 9 },
] as const;

<Cropper.AspectGroup aria-label="Aspect ratio">
  {aspects.map((aspect) => (
    <Cropper.AspectItem
      key={aspect.label}
      value={aspect.value}
    >
      {aspect.label}
    </Cropper.AspectItem>
  ))}
</Cropper.AspectGroup>
<Cropper.Orientation>Orientation</Cropper.Orientation>
```

## Turn, flip and reset

The buttons come with no paint, so put your own icons or words inside. `Rotate` turns the photo 90° counter-clockwise, `Flip` mirrors it, and `Reset` puts the crop back where it started. `Reset` stays hidden until the crop changes.

```tsx
<Cropper.Rotate>Rotate</Cropper.Rotate>
<Cropper.Flip>Flip</Cropper.Flip>
<Cropper.Reset>Reset</Cropper.Reset>
```

## Describe it to screen readers

`Instructions` is hidden text. The crop area points to it with `aria-describedby`, so a screen reader reads it when the area gets focus.

The dial and the zoom slider read out their own values. The buttons do not, so `announce` speaks for them after each commit. It returns `null` for any other change. [Accessibility](https://zriz.dev/docs/accessibility.md) covers the keys and the labels.

```tsx
const spoken: Partial<Record<ChangeReason, string>> = {
  "rotate-90": "Turned 90 degrees",
  flip: "Flipped",
  orientation: "Orientation swapped",
  reset: "Crop reset",
};

const announce = (_: CropValue, details: CommitDetails) =>
  spoken[details.reason] ?? null;

const instructions = [
  "Drag the photo to move it.",
  "Arrows move the crop. Alt and arrows resize it.",
  "Plus and minus zoom, and 0 resets.",
].join(" ");

<Cropper.Instructions>
  {instructions}
</Cropper.Instructions>
<Cropper.Announcer announce={announce} />
```

## Export a JPEG

`exportCrop` makes a JPEG unless the crop is a circle. The button stays off until `onLoad` fires, because an export before that rejects. `exportCrop` comes from `@zriz/core/dom`.

An export can still fail on a very large photo, so `save` shows its own alert when it does. `onError` shows a second one when the photo does not open. [Errors](https://zriz.dev/docs/errors.md) lists the codes.

`key={src}` gives each photo a fresh cropper, and the check on `shown` turns the button off and hides both alerts when `src` changes.

`running` holds the export in flight. A second click does nothing, and the button keeps focus because `saving` sets `aria-disabled` and leaves it enabled. A new `src` aborts the export, so an old crop never reaches `onSave` after the photo changed.

```tsx
const running = useRef<AbortController | null>(null);

const [saving, setSaving] = useState(false);

const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
const [exportFailed, setExportFailed] = useState(false);
const [shown, setShown] = useState(src);

if (shown !== src) {
  setShown(src);
  setReady(false);
  setFailed(false);
  setExportFailed(false);
}

useEffect(() => () => running.current?.abort(), [src]);

const save = async () => {
  if (!actions.current || running.current) return;
  const controller = new AbortController();

  running.current = controller;
  setSaving(true);
  setExportFailed(false);

  try {
    const { blob } = await exportCrop(actions.current, {
      signal: controller.signal,
    });

    onSave(blob);
  } catch {
    setExportFailed(!controller.signal.aborted);
  } finally {
    running.current = null;
    setSaving(false);
  }
};

<Cropper.Root
  key={src}
  actionsRef={actions}
  src={src}
  onLoad={() => setReady(true)}
  onError={() => setFailed(true)}
>

<button
  type="button"
  disabled={!ready}
  aria-disabled={saving}
  onClick={save}
>
  Export
</button>

{failed && <p role="alert">This photo does not open.</p>}

{exportFailed && (
  <p role="alert">This crop did not save. Try again.</p>
)}
```

## The whole file

`Editor` takes a photo URL and hands `onSave` the JPEG:

```tsx
"use client";

import { exportCrop } from "@zriz/core/dom";
import type {
  ChangeReason,
  CommitDetails,
  Cropper as CropperApi,
  CropValue,
} from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import "@zriz/react/styles.css";
import { useEffect, useRef, useState } from "react";
import "./forced-colors.css";

const handles = [
  "top-left",
  "top",
  "top-right",
  "right",
  "bottom-right",
  "bottom",
  "bottom-left",
  "left",
] as const;

const track = {
  width: 200,
  height: 24,
  backgroundImage:
    "linear-gradient(currentColor, currentColor)",
  backgroundSize: "100% 2px",
  backgroundPosition: "center",
  backgroundRepeat: "no-repeat",
};

const thumb = {
  top: 4,
  width: 16,
  height: 16,
  borderRadius: 8,
  background: "currentColor",
  translate: "-50%",
};

const aspects = [
  { label: "Free", value: "free" },
  { label: "Original", value: "original" },
  { label: "Square", value: 1 },
  { label: "4:5", value: 4 / 5 },
  { label: "16:9", value: 16 / 9 },
] as const;

const spoken: Partial<Record<ChangeReason, string>> = {
  "rotate-90": "Turned 90 degrees",
  flip: "Flipped",
  orientation: "Orientation swapped",
  reset: "Crop reset",
};

const announce = (_: CropValue, details: CommitDetails) =>
  spoken[details.reason] ?? null;

const instructions = [
  "Drag the photo to move it.",
  "Arrows move the crop. Alt and arrows resize it.",
  "Plus and minus zoom, and 0 resets.",
].join(" ");

type Props = { src: string; onSave: (jpeg: Blob) => void };

export const Editor = ({ src, onSave }: Props) => {
  const actions = useRef<CropperApi>(null);
  const running = useRef<AbortController | null>(null);
  const [saving, setSaving] = useState(false);
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);
  const [exportFailed, setExportFailed] = useState(false);
  const [shown, setShown] = useState(src);

  if (shown !== src) {
    setShown(src);
    setReady(false);
    setFailed(false);
    setExportFailed(false);
  }

  useEffect(() => () => running.current?.abort(), [src]);

  const save = async () => {
    if (!actions.current || running.current) return;
    const controller = new AbortController();

    running.current = controller;
    setSaving(true);
    setExportFailed(false);

    try {
      const { blob } = await exportCrop(actions.current, {
        signal: controller.signal,
      });

      onSave(blob);
    } catch {
      setExportFailed(!controller.signal.aborted);
    } finally {
      running.current = null;
      setSaving(false);
    }
  };

  return (
    <Cropper.Root
      key={src}
      actionsRef={actions}
      src={src}
      onLoad={() => setReady(true)}
      onError={() => setFailed(true)}
    >
      <Cropper.Viewport
        style={{ maxWidth: 640, height: 480, padding: 24 }}
      >
        <Cropper.Image />
        <Cropper.Mask />
        <Cropper.Area aria-label="Crop area">
          <Cropper.Grid />
          {handles.map((position) => (
            <Cropper.Handle
              key={position}
              position={position}
            />
          ))}
        </Cropper.Area>
      </Cropper.Viewport>
      <Cropper.DialValue
        format={(degrees) =>
          `${Math.sign(degrees) * Math.round(Math.abs(degrees))}°`
        }
      />
      <Cropper.Dial
        aria-label="Straighten"
        style={{ maxWidth: 361 }}
      >
        <Cropper.DialTicks />
      </Cropper.Dial>
      <Cropper.Zoom aria-label="Zoom" style={track}>
        <Cropper.ZoomThumb style={thumb} />
      </Cropper.Zoom>
      <Cropper.AspectGroup aria-label="Aspect ratio">
        {aspects.map((aspect) => (
          <Cropper.AspectItem
            key={aspect.label}
            value={aspect.value}
          >
            {aspect.label}
          </Cropper.AspectItem>
        ))}
      </Cropper.AspectGroup>
      <Cropper.Orientation>Orientation</Cropper.Orientation>
      <Cropper.Rotate>Rotate</Cropper.Rotate>
      <Cropper.Flip>Flip</Cropper.Flip>
      <Cropper.Reset>Reset</Cropper.Reset>
      <Cropper.Instructions>
        {instructions}
      </Cropper.Instructions>
      <Cropper.Announcer announce={announce} />
      <button
        type="button"
        disabled={!ready}
        aria-disabled={saving}
        onClick={save}
      >
        Export
      </button>
      {failed && <p role="alert">This photo does not open.</p>}
      {exportFailed && (
        <p role="alert">This crop did not save. Try again.</p>
      )}
    </Cropper.Root>
  );
};
```

The demo above runs this file with the icons and styles from the zriz site. It shows the angle to one decimal and calls square 1:1. Its aspect labels turn with the orientation, as on [aspect ratio](https://zriz.dev/docs/aspect-ratio.md). It leaves out the zoom slider, and wheel and pinch still zoom.
