# Vanilla JS

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

Run the same cropper in plain JavaScript or any framework, with `createCropper` and the DOM binders.

## Install the core package

Add `@zriz/core` as in [exporting](https://zriz.dev/docs/exporting.md). It has no dependencies, not even React.

## Write the markup

Give each element its `data-zriz-item`, so the stylesheet finds it, and give the viewport a width and a height. The binders set no role or name on the crop area, so write them in the markup:

```html
<div id="cropper" data-zriz-item="root">
  <div
    id="viewport"
    data-zriz-item="viewport"
    style="max-width: 480px; height: 360px; padding: 24px"
  >
    <div id="photo" data-zriz-item="image"></div>
    <div id="mask" data-zriz-item="mask"></div>
    <div
      id="area"
      data-zriz-item="area"
      role="application"
      tabindex="0"
      aria-label="Crop area"
      style="position: absolute"
    ></div>
  </div>
  <div
    id="dial"
    data-zriz-item="dial"
    aria-label="Straighten"
    style="position: relative"
  >
    <div id="ticks" data-zriz-item="dial-ticks"></div>
  </div>
</div>
<input id="file" type="file" aria-label="Choose a photo" accept="image/*" />
<p id="status" role="alert"></p>
```

## Bind the parts

Create a cropper, bind each element, add the four corner handles, and attach the photo when the user picks a file. Save this as `cropper.ts`. The snippets below import `cropper` and `cleanups` from it:

```ts
import { createCropper } from "@zriz/core";
import {
  attachImage,
  bindArea,
  bindHandle,
  bindMask,
  bindPhoto,
  bindState,
  bindViewport,
  loadImage,
} from "@zriz/core/dom";
import "@zriz/core/styles.css";

const byId = (id: string) =>
  document.getElementById(id) as HTMLElement;

export const cropper = createCropper({
  defaultValue: { aspect: 4 / 3 },
});

export const cleanups = [
  bindState(cropper, byId("cropper")),
  bindViewport(cropper, byId("viewport")),
  bindPhoto(cropper, byId("photo")),
  bindMask(cropper, byId("mask")),
  bindArea(cropper, byId("area")),
];

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

for (const corner of corners) {
  const handle = document.createElement("div");

  handle.dataset.zrizItem = "handle";
  handle.setAttribute("aria-hidden", "true");
  byId("area").append(handle);
  cleanups.push(bindHandle(cropper, handle, corner));
}

const input = byId("file") as HTMLInputElement;
let last: AbortController | undefined;

const onFile = async () => {
  const file = input.files?.[0];

  if (!file) return;
  last?.abort();
  const controller = new AbortController();
  const { signal } = controller;

  last = controller;
  byId("status").textContent = "";

  try {
    attachImage(cropper, await loadImage(file, { signal }));
  } catch {
    if (signal.aborted) return;
    byId("status").textContent = "This photo does not open.";
  }
};

input.addEventListener("change", onFile);

cleanups.push(() => {
  last?.abort();
  input.removeEventListener("change", onFile);
});
```

Each binder writes geometry inline and returns its cleanup. `bindHandle` also takes `"top"`, `"right"`, `"bottom"` and `"left"` for the edge bars. After you pick a file, the photo shows under a 4:3 crop. The handles have no layout until the next section places them.

Each pick aborts the load before it, so a slow file never replaces a newer one, and clears the old message. A cleanup aborts the last load and removes the listener. When a file does not open, `loadImage` rejects with a `ZrizError`, and [errors](https://zriz.dev/docs/errors.md) lists its codes.

## Place the handles

`bindHandle` wires the drag but leaves layout to you. This CSS centres a 44 px hit box on each corner and on the middle of each edge, and `--zriz-handle-size` on the area resizes it as on [crop area](https://zriz.dev/docs/crop-area.md):

```css
[data-zriz-item="handle"] {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--zriz-handle-size, 44px);
  height: var(--zriz-handle-size, 44px);
  translate: -50% -50%;
}
[data-edge$="right"] {
  left: 100%;
}
[data-edge^="bottom"] {
  top: 100%;
}
[data-edge="top"],
[data-edge="bottom"] {
  left: 50%;
}
[data-edge="left"],
[data-edge="right"] {
  top: 50%;
}
```

## Move the crop with keys

`bindArea` moves the crop but listens to no keys. Pass the area's events to `areaKeyHandlers`:

```ts
import { areaKeyHandlers } from "@zriz/core/dom";
import { cropper } from "./cropper";

const area = document.getElementById("area") as HTMLElement;
const keys = areaKeyHandlers(cropper);

area.onkeydown = keys.onKeyDown;
area.onkeyup = keys.onKeyUp;
area.onblur = keys.onBlur;

export const unbind = () => {
  keys.release();
};
```

The keys are the ones on [crop area](https://zriz.dev/docs/crop-area.md). Call `keys.release()` when you unbind, so a key held at that moment does not lock the cropper. `zoomHandlers` and `handleHandlers` have the same `release`.

## Add the dial

`bindDial` handles drags, keys and the slider's ARIA values. `bindDialTicks` fills the strip with 41 ticks and moves it:

```ts
import { bindDial, bindDialTicks } from "@zriz/core/dom";
import { cleanups, cropper } from "./cropper";

const dial = document.getElementById("dial") as HTMLElement;
const ticks = document.getElementById("ticks") as HTMLElement;

cleanups.push(
  bindDial(cropper, dial),
  bindDialTicks(cropper, ticks)
);
```

On a right-to-left page, the arrows and the drag swap. Pass `{ dir: "rtl" }` or `{ dir: "ltr" }` as the third argument of `bindDial` to set it yourself.

The same argument takes `locale` and `format` for the spoken angle, and `ownText: true` to keep an `aria-valuetext` of yours, which `bindDial` overwrites otherwise. It also takes `gain`, the pixels of drag per degree, 4 by default, `slop`, the pixels a drag travels before the dial moves, 10 by default, and `tau`, the time constant of a flick in ms, 95 by default.

After a flick, the speed falls to about a third every `tau` ms, and the glide stops once it drops under 0.5° a second. It ends about the flick's speed times `tau` past where you let go, so a flick at 100° a second glides about 9.5°.

`locale`, `format` and `ownText` belong to that element. `dir`, `gain`, `slop` and `tau` belong to the cropper: a second `bindDial` merges its values over the first, and both dials follow them. `bindDialTicks` spaces the ticks `gain` times 2.25 px apart, so it redraws when `gain` changes. When the last dial unbinds, the defaults come back.

`bindDialTicks(cropper, strip, count)` makes 41 ticks by default, and uses the children when the strip already has some.

## Add a zoom slider

Zoom has no single binder. Give the element `role="slider"`, `tabindex="0"`, an `aria-label`, a size, `position: relative`, `touch-action: none` and `user-select: none`, and draw the thumb yourself. The stylesheet paints nothing on zoom:

```html
<div
  id="zoom"
  data-zriz-item="zoom"
  role="slider"
  tabindex="0"
  aria-label="Zoom"
  style="
    position: relative;
    width: 200px;
    height: 24px;
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
  "
>
  <div
    id="thumb"
    data-zriz-item="zoom-thumb"
    style="
      top: 4px;
      width: 16px;
      height: 16px;
      border-radius: 8px;
      background: currentColor;
      translate: -50%;
    "
  ></div>
</div>
```

Forced colours mode drops the colour on the thumb. Add this rule to your stylesheet to paint it in `CanvasText`. Keep it in a CSS file, because a strict `style-src` refuses a `<style>` element. The thumb's colour is inline, so the rule needs `!important`:

```css
@media (forced-colors: active) {
  [data-zriz-item="zoom-thumb"] {
    background: CanvasText !important;
  }
}
```

Then hand its events to `zoomHandlers`:

```ts
import {
  bindZoomThumb,
  zoomAria,
  zoomHandlers,
} from "@zriz/core/dom";
import { cleanups, cropper } from "./cropper";

const zoom = document.getElementById("zoom") as HTMLElement;
const thumb = document.getElementById("thumb") as HTMLElement;
const handlers = zoomHandlers(cropper);

zoom.onpointerdown = handlers.onPointerDown;
zoom.onpointermove = handlers.onPointerMove;
zoom.onpointerup = handlers.onPointerUp;
zoom.onpointercancel = handlers.onPointerCancel;
zoom.onlostpointercapture = handlers.onLostPointerCapture;
zoom.onkeydown = handlers.onKeyDown;
zoom.onkeyup = handlers.onKeyUp;
zoom.onblur = handlers.onBlur;

cleanups.push(
  zoomAria(cropper, zoom),
  bindZoomThumb(cropper, thumb)
);
```

`zoomAria` keeps the slider's values current, and `bindZoomThumb` moves the thumb from 0% at 1× to 100% at the largest zoom. To end the track early, pass the same `max` to all three: `zoomHandlers(cropper, { max, dir })`, `zoomAria(cropper, zoom, { max, locale, format, ownText })` and `bindZoomThumb(cropper, thumb, { max })`. Without `ownText: true`, `zoomAria` overwrites your `aria-valuetext`.

## Read the crop

The `commit` event fires once per gesture with the crop value and the export plan, which is `null` until a photo loads:

```ts
import { cropper } from "./cropper";

cropper.on("commit", (value, details) => {
  console.log(value.size, details.pixelCrop);
});
```

To turn the crop into a file, pass the cropper to `exportCrop` as in [exporting](https://zriz.dev/docs/exporting.md). The first photo sends no `commit` until someone touches it, and a later photo sends one only when it has to refit the crop, see [events](https://zriz.dev/docs/events.md).

## Connect the rest

| Part | How to wire it |
| - | - |
| Dial value | `bindDialValue(cropper, element, format)` writes the angle as text. Without `format`, it writes whole degrees |
| Grid | No binder. `bindState` writes `data-grid` on the root as `"none"`, `"coarse"` or `"fine"`. Draw the lines in CSS. The stylesheet keeps `data-zriz-item="grid"` at opacity 0 without `data-visible`, and no binder sets it, so set `opacity` from `[data-grid]` on the root or leave that attribute off the element |
| State attributes | `bindState` writes every attribute [root](https://zriz.dev/docs/root.md) lists on your element, except `data-shape`. Set that one in your markup. Only the React parts write `data-interacting` on the area, `data-active` on a handle, `data-ready` on the image, `data-dragging` on zoom and `data-shape` on the area and mask. Leave out the fade rule on [viewport](https://zriz.dev/docs/viewport.md), which would keep the photo hidden here |
| Buttons | No binder. Call `rotate90()`, `flip()`, `reset()` or `setAspect(1)` on the cropper from a click |
| Your own img | `bindImage(cropper, element)` sizes and moves an element of yours with the photo. You set its `src` |
| Round crop | Pass `"circle"` as the third argument of `bindMask` and `{ shape: "circle" }` as the third argument of `bindArea`. Set `aspect: 1` in `defaultValue`. The stylesheet hides the corner handles when the area has `data-shape="circle"`, which `bindArea` leaves to your markup |
| Wheel and twist | Pass `{ wheel: "zoom", rotateGesture: false }` as the third argument of `bindViewport`. `wheel` also takes `"pan"` and `"none"` |
| Motion | Call `viewOf(cropper).setOptions({ motion: "reduce", recenterDelay: 800 })`. `motion` also takes `"auto"` and `"always"`. Each call replaces both options |
| Live variables | Pass `{ liveVariables: true }` as the third argument of `bindArea`, see [crop area](https://zriz.dev/docs/crop-area.md) |
| Announcements | No binder. On `commit`, write your own text into an element with `role="status"`, and point the area's `aria-describedby` at your instructions |

## Tear it down

Call every cleanup, detach the photo so its memory is freed, then destroy the cropper:

```ts
import { attachImage } from "@zriz/core/dom";
import { cleanups, cropper } from "./cropper";

for (const cleanup of cleanups) cleanup();
attachImage(cropper, null);
cropper.destroy();
```

`destroy()` also runs each plugin's `destroy`.
