"use client";
import * as React from "react";
export type CropAspect = "free" | "1:1" | "4:3" | "16:9";
export type CropRotation = 0 | 90 | 180 | 270;
/** The crop, in source pixels of the image after rotation. */
export interface CropRect {
x: number;
y: number;
width: number;
height: number;
/** Clockwise rotation applied to the source before cropping. */
rotation: CropRotation;
}
export interface CroppedCanvasOptions {
/** Downscale the output so it is at most this wide (source pixels otherwise). */
maxWidth?: number;
/** Downscale the output so it is at most this tall. */
maxHeight?: number;
}
export interface ImageCropperHandle {
/** A canvas with the cropped (and rotated) pixels, or null before the image has loaded. */
getCroppedCanvas: (options?: CroppedCanvasOptions) => HTMLCanvasElement | null;
/** Data URL of the crop; null before load or when the image is cross-origin without CORS. */
toDataURL: (type?: string, quality?: number, options?: CroppedCanvasOptions) => string | null;
getCropRect: () => CropRect | null;
/** Rotate by 90° (1 = clockwise, -1 = counter-clockwise); resets the crop. */
rotate: (direction?: 1 | -1) => void;
/** Back to the initial crop at 1× zoom. */
reset: () => void;
setZoom: (zoom: number) => void;
}
export interface ImageCropperProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
/** Image URL (remote images need CORS headers for the canvas output). */
src: string;
alt?: string;
/** Aspect ratio of the crop box; the toolbar can change it. */
aspect?: CropAspect;
/** "box": drag and resize the crop box over the image. "pan": fixed frame, drag the image under it. */
mode?: "box" | "pan";
/** Highest zoom level (1 = image fitted). */
maxZoom?: number;
/** Rule-of-thirds guides: while dragging, always, or never. */
grid?: "drag" | "always" | "never";
/** Aspect presets, zoom slider, rotate and reset below the image. */
showToolbar?: boolean;
/** Darkness of the area outside the crop (0–1). */
overlayOpacity?: number;
/** Height of the cropping stage in px. */
height?: number;
/** Accent for handles and focus rings. */
accent?: string;
/** Accessible name of the cropper. */
label?: string;
/** Passed to the image; use "anonymous" for CORS-enabled remote images. */
crossOrigin?: "anonymous" | "use-credentials";
/** Called when a crop gesture, key press or toolbar action settles. */
onChange?: (crop: CropRect) => void;
}
/* -------------------------------- geometry -------------------------------- */
type Handle = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
interface Rect {
x: number;
y: number;
w: number;
h: number;
}
interface Bounds {
l: number;
t: number;
r: number;
b: number;
}
/** Everything needed to draw the cropper, in stage pixels. Lives in a ref, never in state. */
interface Geo {
sw: number;
sh: number;
/** Natural size of the source. */
nw: number;
nh: number;
/** Size of the source after rotation. */
rw: number;
rh: number;
rot: CropRotation;
ratio: number | null;
mode: "box" | "pan";
maxZoom: number;
/** Scale at zoom 1 (contain-fit in box mode, cover the frame in pan mode). */
base: number;
zoom: number;
/** Top-left of the rotated image in the stage. */
ox: number;
oy: number;
box: Rect;
}
type GeoEnv = Pick<Geo, "sw" | "sh" | "nw" | "nh" | "rot" | "ratio" | "mode" | "maxZoom">;
const PAD = 20;
const MIN_BOX = 32;
const ASPECTS: Record<CropAspect, number | null> = { free: null, "1:1": 1, "4:3": 4 / 3, "16:9": 16 / 9 };
const ASPECT_OPTIONS: CropAspect[] = ["free", "1:1", "4:3", "16:9"];
const HANDLES: Handle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Largest w×h with the given ratio inside w×h. */
function fitRatio(w: number, h: number, ratio: number) {
return w / h > ratio ? { w: h * ratio, h } : { w, h: w / ratio };
}
const scaleOf = (g: Geo) => g.base * g.zoom;
/** Where the crop box may go: the image, intersected with the padded stage. */
function boundsOf(g: Geo): Bounds {
const s = scaleOf(g);
return {
l: Math.max(PAD, g.ox),
t: Math.max(PAD, g.oy),
r: Math.min(g.sw - PAD, g.ox + g.rw * s),
b: Math.min(g.sh - PAD, g.oy + g.rh * s),
};
}
/** Keeps the image and the box valid after any change. Mutates `g`. */
function constrain(g: Geo) {
const s = scaleOf(g);
const iw = g.rw * s;
const ih = g.rh * s;
if (g.mode === "pan") {
// The image always covers the fixed frame.
g.ox = clamp(g.ox, g.box.x + g.box.w - iw, g.box.x);
g.oy = clamp(g.oy, g.box.y + g.box.h - ih, g.box.y);
return;
}
// Small images stay inside the stage; large ones always cover it.
const innerW = g.sw - 2 * PAD;
const innerH = g.sh - 2 * PAD;
g.ox = iw <= innerW ? clamp(g.ox, PAD, g.sw - PAD - iw) : clamp(g.ox, g.sw - PAD - iw, PAD);
g.oy = ih <= innerH ? clamp(g.oy, PAD, g.sh - PAD - ih) : clamp(g.oy, g.sh - PAD - ih, PAD);
const b = boundsOf(g);
let { x, y, w, h } = g.box;
const k = Math.min(1, (b.r - b.l) / w, (b.b - b.t) / h);
if (k < 1) {
const cx = x + w / 2;
const cy = y + h / 2;
w *= k;
h *= k;
x = cx - w / 2;
y = cy - h / 2;
}
g.box = { x: clamp(x, b.l, b.r - w), y: clamp(y, b.t, b.b - h), w, h };
}
/** The crop in rotated-source pixels (unrounded). */
function cropOf(g: Geo): Rect {
const s = scaleOf(g);
const x = clamp((g.box.x - g.ox) / s, 0, g.rw);
const y = clamp((g.box.y - g.oy) / s, 0, g.rh);
return { x, y, w: Math.min(g.box.w / s, g.rw - x), h: Math.min(g.box.h / s, g.rh - y) };
}
function toCropRect(g: Geo): CropRect {
const c = cropOf(g);
return { x: Math.round(c.x), y: Math.round(c.y), width: Math.round(c.w), height: Math.round(c.h), rotation: g.rot };
}
/** Builds the whole view from a crop (source px) and a zoom, centred in the stage. */
function layout(env: GeoEnv, crop: Rect | null, zoom: number): Geo {
const turned = env.rot % 180 !== 0;
const rw = turned ? env.nh : env.nw;
const rh = turned ? env.nw : env.nh;
const innerW = Math.max(1, env.sw - 2 * PAD);
const innerH = Math.max(1, env.sh - 2 * PAD);
const g: Geo = { ...env, rw, rh, base: 1, zoom: clamp(zoom, 1, env.maxZoom), ox: 0, oy: 0, box: { x: 0, y: 0, w: 0, h: 0 } };
if (env.mode === "pan") {
const frame = fitRatio(innerW, innerH, env.ratio ?? rw / rh);
g.box = { x: (env.sw - frame.w) / 2, y: (env.sh - frame.h) / 2, w: frame.w, h: frame.h };
g.base = Math.max(frame.w / rw, frame.h / rh);
const s = scaleOf(g);
const cx = crop ? crop.x + crop.w / 2 : rw / 2;
const cy = crop ? crop.y + crop.h / 2 : rh / 2;
g.ox = env.sw / 2 - cx * s;
g.oy = env.sh / 2 - cy * s;
} else {
g.base = Math.min(innerW / rw, innerH / rh);
let c = crop;
if (!c) {
// Default crop: 80% of the image, shaped to the aspect, centred.
let w = rw * 0.8;
let h = rh * 0.8;
if (env.ratio) ({ w, h } = fitRatio(w, h, env.ratio));
c = { x: (rw - w) / 2, y: (rh - h) / 2, w, h };
}
// Zoom out if the crop would not fit the stage at this zoom.
const k = Math.min(1, innerW / (c.w * scaleOf(g)), innerH / (c.h * scaleOf(g)));
if (k < 1) g.zoom = Math.max(1, g.zoom * k);
const s = scaleOf(g);
const bw = c.w * s;
const bh = c.h * s;
// Centre the box, then let the image clamp and carry the box with it.
g.ox = (env.sw - bw) / 2 - c.x * s;
g.oy = (env.sh - bh) / 2 - c.y * s;
g.box = { x: 0, y: 0, w: bw, h: bh };
const iw = rw * s;
const ih = rh * s;
g.ox = iw <= innerW ? clamp(g.ox, PAD, env.sw - PAD - iw) : clamp(g.ox, env.sw - PAD - iw, PAD);
g.oy = ih <= innerH ? clamp(g.oy, PAD, env.sh - PAD - ih) : clamp(g.oy, env.sh - PAD - ih, PAD);
g.box.x = g.ox + c.x * s;
g.box.y = g.oy + c.y * s;
}
constrain(g);
return g;
}
/** Re-shapes a crop to a new ratio, keeping its centre and roughly its area. */
function reshape(c: Rect, ratio: number, rw: number, rh: number): Rect {
let w = Math.sqrt(c.w * c.h * ratio);
let h = w / ratio;
const k = Math.min(1, rw / w, rh / h);
w *= k;
h *= k;
return { x: clamp(c.x + c.w / 2 - w / 2, 0, rw - w), y: clamp(c.y + c.h / 2 - h / 2, 0, rh - h), w, h };
}
/** Resizes from a handle, honouring the ratio, the bounds and a minimum size. */
function resizeBox(start: Rect, handle: Handle, dx: number, dy: number, ratio: number | null, b: Bounds): Rect {
const hasW = handle.includes("w");
const hasE = handle.includes("e");
const hasN = handle.includes("n");
const hasS = handle.includes("s");
const right = start.x + start.w;
const bottom = start.y + start.h;
if (!ratio) {
let l = start.x;
let t = start.y;
let r = right;
let btm = bottom;
if (hasW) l = clamp(l + dx, b.l, r - MIN_BOX);
if (hasE) r = clamp(r + dx, l + MIN_BOX, b.r);
if (hasN) t = clamp(t + dy, b.t, btm - MIN_BOX);
if (hasS) btm = clamp(btm + dy, t + MIN_BOX, b.b);
return { x: l, y: t, w: r - l, h: btm - t };
}
const minW = Math.max(MIN_BOX, MIN_BOX * ratio);
if ((hasW || hasE) && (hasN || hasS)) {
// Corner: anchor the opposite corner, follow whichever axis moved further.
const ax = hasW ? right : start.x;
const ay = hasN ? bottom : start.y;
const w0 = start.w + (hasW ? -dx : dx);
const h0 = start.h + (hasN ? -dy : dy);
const maxW = Math.min(hasW ? ax - b.l : b.r - ax, (hasN ? ay - b.t : b.b - ay) * ratio);
const w = clamp(Math.abs(dx) >= Math.abs(dy) * ratio ? w0 : h0 * ratio, Math.min(minW, maxW), maxW);
const h = w / ratio;
return { x: hasW ? ax - w : ax, y: hasN ? ay - h : ay, w, h };
}
if (hasW || hasE) {
// Side edge: the height follows, centred on the box.
const ax = hasW ? right : start.x;
const cy = start.y + start.h / 2;
const maxW = Math.min(hasW ? ax - b.l : b.r - ax, 2 * Math.min(cy - b.t, b.b - cy) * ratio);
const w = clamp(start.w + (hasW ? -dx : dx), Math.min(minW, maxW), maxW);
const h = w / ratio;
return { x: hasW ? ax - w : ax, y: cy - h / 2, w, h };
}
const ay = hasN ? bottom : start.y;
const cx = start.x + start.w / 2;
const maxH = Math.min(hasN ? ay - b.t : b.b - ay, (2 * Math.min(cx - b.l, b.r - cx)) / ratio);
const h = clamp(start.h + (hasN ? -dy : dy), Math.min(minW / ratio, maxH), maxH);
const w = h * ratio;
return { x: cx - w / 2, y: hasN ? ay - h : ay, w, h };
}
/** Zoom around a stage point. Mutates `g`. */
function zoomAt(g: Geo, px: number, py: number, zoom: number) {
const next = clamp(zoom, 1, g.maxZoom);
const k = next / g.zoom;
g.ox = px - (px - g.ox) * k;
g.oy = py - (py - g.oy) * k;
g.zoom = next;
constrain(g);
}
const CURSORS: Record<Handle, string> = {
n: "cursor-ns-resize",
s: "cursor-ns-resize",
e: "cursor-ew-resize",
w: "cursor-ew-resize",
ne: "cursor-nesw-resize",
sw: "cursor-nesw-resize",
nw: "cursor-nwse-resize",
se: "cursor-nwse-resize",
};
/** Handle position: corners sit on the corners, edges on the midpoints. */
function handlePosition(h: Handle) {
const x = h.includes("w") ? "left-0" : h.includes("e") ? "left-full" : "left-1/2";
const y = h.includes("n") ? "top-0" : h.includes("s") ? "top-full" : "top-1/2";
return `${x} ${y}`;
}
type Drag =
| { kind: "move" | "pan"; x: number; y: number; box: Rect; ox: number; oy: number }
| { kind: "resize"; handle: Handle; x: number; y: number; box: Rect }
| { kind: "pinch"; dist: number; zoom: number };
/* -------------------------------- component -------------------------------- */
/**
* An image cropper. Drag or resize the crop box (or pan the image under a fixed frame), zoom
* with the slider, wheel or pinch, rotate in 90° steps and read the result as a canvas or data
* URL through the ref. Geometry lives in a ref and is painted imperatively, so gestures never
* re-render; state only changes when a gesture settles.
*/
export const ImageCropper = React.forwardRef<ImageCropperHandle, ImageCropperProps>(function ImageCropper(
{
src,
alt = "",
aspect = "free",
mode = "box",
maxZoom = 4,
grid = "drag",
showToolbar = true,
overlayOpacity = 0.6,
height = 360,
accent = "#FF7448",
label = "Image cropper",
crossOrigin,
onChange,
className = "",
style,
...props
},
ref,
) {
const uid = React.useId();
const hintId = `${uid}-hint`;
const rootRef = React.useRef<HTMLDivElement>(null);
const stageRef = React.useRef<HTMLDivElement>(null);
const frameRef = React.useRef<HTMLDivElement>(null);
const imgRef = React.useRef<HTMLImageElement>(null);
const boxRef = React.useRef<HTMLDivElement>(null);
const readoutRef = React.useRef<HTMLSpanElement>(null);
const zoomInputRef = React.useRef<HTMLInputElement>(null);
const zoomTextRef = React.useRef<HTMLSpanElement>(null);
const geoRef = React.useRef<Geo | null>(null);
const layoutKeyRef = React.useRef("");
const dragRef = React.useRef<Drag | null>(null);
const pointersRef = React.useRef(new Map<number, { x: number; y: number }>());
const lastEmitRef = React.useRef("");
const commitTimer = React.useRef<number | undefined>(undefined);
const [natural, setNatural] = React.useState<{ src: string; w: number; h: number } | null>(null);
const [rotation, setRotation] = React.useState<CropRotation>(0);
const [aspectChoice, setAspectChoice] = React.useState<CropAspect>(aspect);
const [prevAspect, setPrevAspect] = React.useState<CropAspect>(aspect);
const [live, setLive] = React.useState("");
const [resetCount, setResetCount] = React.useState(0);
// The aspect prop seeds the toolbar choice and wins again whenever it changes.
if (aspect !== prevAspect) {
setPrevAspect(aspect);
setAspectChoice(aspect);
}
const size = natural && natural.src === src ? natural : null;
const zoomLimit = Math.max(1, maxZoom);
/* ------------------------------ painting ------------------------------ */
const paint = (g: Geo) => {
const s = scaleOf(g);
const frame = frameRef.current;
const img = imgRef.current;
const box = boxRef.current;
if (frame) {
frame.style.transform = `translate(${g.ox.toFixed(2)}px, ${g.oy.toFixed(2)}px)`;
frame.style.width = `${(g.rw * s).toFixed(2)}px`;
frame.style.height = `${(g.rh * s).toFixed(2)}px`;
}
if (img) {
img.style.width = `${(g.nw * s).toFixed(2)}px`;
img.style.height = `${(g.nh * s).toFixed(2)}px`;
img.style.transform = `translate(-50%, -50%) rotate(${g.rot}deg)`;
}
if (box) {
box.style.left = `${g.box.x.toFixed(2)}px`;
box.style.top = `${g.box.y.toFixed(2)}px`;
box.style.width = `${g.box.w.toFixed(2)}px`;
box.style.height = `${g.box.h.toFixed(2)}px`;
}
const c = toCropRect(g);
if (readoutRef.current) readoutRef.current.textContent = `${c.width} × ${c.height}`;
if (zoomInputRef.current) zoomInputRef.current.value = String(g.zoom);
if (zoomTextRef.current) zoomTextRef.current.textContent = `${g.zoom.toFixed(2)}×`;
};
/** Settles a change: announce it and report it (only when the crop actually changed). */
const commit = () => {
window.clearTimeout(commitTimer.current);
const g = geoRef.current;
if (!g) return;
const rect = toCropRect(g);
const key = `${rect.x},${rect.y},${rect.width},${rect.height},${rect.rotation}`;
if (key === lastEmitRef.current) return;
lastEmitRef.current = key;
const ratio = aspectChoice === "free" ? "free aspect" : `aspect ${aspectChoice}`;
setLive(`Crop ${rect.width} by ${rect.height} pixels at ${rect.x}, ${rect.y}, ${ratio}, zoom ${g.zoom.toFixed(1)}×.`);
onChange?.(rect);
};
/** For continuous inputs (wheel, slider, pinch): report once they pause. */
const scheduleCommit = () => {
window.clearTimeout(commitTimer.current);
commitTimer.current = window.setTimeout(commit, 160);
};
const applyLayout = () => {
const stage = stageRef.current;
if (!stage || !size) return;
const sw = stage.clientWidth;
const sh = stage.clientHeight;
if (!sw || !sh) return;
const env: GeoEnv = { sw, sh, nw: size.w, nh: size.h, rot: rotation, ratio: ASPECTS[aspectChoice], mode, maxZoom: zoomLimit };
const prev = geoRef.current;
const key = `${src}|${size.w}x${size.h}|${rotation}|${mode}|${resetCount}`;
let next: Geo;
if (!prev || layoutKeyRef.current !== key) {
next = layout(env, null, 1);
} else {
let crop = cropOf(prev);
if (env.ratio && prev.ratio !== env.ratio && mode === "box") crop = reshape(crop, env.ratio, prev.rw, prev.rh);
next = layout(env, crop, prev.zoom);
}
layoutKeyRef.current = key;
geoRef.current = next;
paint(next);
commit();
};
const relayout = React.useEffectEvent(applyLayout);
// Lay out before paint whenever the source, rotation, aspect, mode or limits change.
React.useLayoutEffect(() => {
relayout();
}, [size, rotation, aspectChoice, mode, zoomLimit, resetCount]);
React.useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const ro = new ResizeObserver(() => relayout());
ro.observe(stage);
return () => ro.disconnect();
}, []);
// A cached image can finish loading before hydration attaches onLoad.
const markLoaded = React.useEffectEvent((img: HTMLImageElement) => {
if (img.complete && img.naturalWidth) setNatural({ src, w: img.naturalWidth, h: img.naturalHeight });
});
React.useEffect(() => {
if (imgRef.current) markLoaded(imgRef.current);
}, [src]);
React.useEffect(() => () => window.clearTimeout(commitTimer.current), []);
/* ------------------------------ wheel zoom ----------------------------- */
const onWheel = React.useEffectEvent((event: WheelEvent) => {
const g = geoRef.current;
const stage = stageRef.current;
if (!g || !stage) return;
event.preventDefault();
const rect = stage.getBoundingClientRect();
// Trackpad pinch arrives as ctrl + wheel with small deltas.
const factor = Math.exp(-event.deltaY * (event.ctrlKey ? 0.01 : 0.0015));
zoomAt(g, event.clientX - rect.left, event.clientY - rect.top, g.zoom * factor);
paint(g);
scheduleCommit();
});
React.useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const handler = (event: WheelEvent) => onWheel(event);
stage.addEventListener("wheel", handler, { passive: false });
return () => stage.removeEventListener("wheel", handler);
}, []);
/* ---------------------------- pointer gestures ---------------------------- */
const localPoint = (event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
};
const startPinch = (g: Geo) => {
const [a, b] = [...pointersRef.current.values()];
dragRef.current = { kind: "pinch", dist: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)), zoom: g.zoom };
};
const startPan = (g: Geo, p: { x: number; y: number }) => {
dragRef.current = { kind: "pan", x: p.x, y: p.y, box: { ...g.box }, ox: g.ox, oy: g.oy };
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
const g = geoRef.current;
if (!g || (event.pointerType === "mouse" && event.button !== 0)) return;
const p = localPoint(event);
event.currentTarget.setPointerCapture(event.pointerId);
pointersRef.current.set(event.pointerId, p);
rootRef.current?.setAttribute("data-dragging", "true");
if (pointersRef.current.size === 2) {
startPinch(g);
return;
}
const target = event.target as Element;
const handle = target.closest<HTMLElement>("[data-ic-handle]")?.dataset.icHandle as Handle | undefined;
const onBox = Boolean(target.closest("[data-ic-box]"));
if (onBox) boxRef.current?.focus({ preventScroll: true });
if (g.mode === "box" && handle) dragRef.current = { kind: "resize", handle, x: p.x, y: p.y, box: { ...g.box } };
else if (g.mode === "box" && onBox) dragRef.current = { kind: "move", x: p.x, y: p.y, box: { ...g.box }, ox: g.ox, oy: g.oy };
else startPan(g, p);
event.preventDefault();
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const g = geoRef.current;
const drag = dragRef.current;
if (!g || !drag || !pointersRef.current.has(event.pointerId)) return;
const p = localPoint(event);
pointersRef.current.set(event.pointerId, p);
if (drag.kind === "pinch") {
const [a, b] = [...pointersRef.current.values()];
if (!a || !b) return;
const dist = Math.hypot(a.x - b.x, a.y - b.y);
zoomAt(g, (a.x + b.x) / 2, (a.y + b.y) / 2, drag.zoom * (dist / drag.dist));
paint(g);
return;
}
const dx = p.x - drag.x;
const dy = p.y - drag.y;
if (drag.kind === "resize") {
g.box = resizeBox(drag.box, drag.handle, dx, dy, g.ratio, boundsOf(g));
} else if (drag.kind === "move") {
const b = boundsOf(g);
g.box = { ...drag.box, x: clamp(drag.box.x + dx, b.l, b.r - drag.box.w), y: clamp(drag.box.y + dy, b.t, b.b - drag.box.h) };
} else {
g.ox = drag.ox + dx;
g.oy = drag.oy + dy;
constrain(g);
}
paint(g);
};
const onPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
if (!pointersRef.current.delete(event.pointerId)) return;
const g = geoRef.current;
const rest = [...pointersRef.current.values()];
if (g && rest.length === 1) {
// Pinch ended with one finger down: continue as a pan from there.
startPan(g, rest[0]);
return;
}
if (rest.length) return;
dragRef.current = null;
rootRef.current?.removeAttribute("data-dragging");
commit();
};
/* -------------------------------- keyboard -------------------------------- */
const onBoxKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const g = geoRef.current;
if (!g) return;
const dirs: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const dir = dirs[event.key];
const cx = g.box.x + g.box.w / 2;
const cy = g.box.y + g.box.h / 2;
if (dir) {
if (g.mode === "pan") {
// The frame moves over the image, so the image moves the other way.
const step = event.shiftKey ? 24 : 4;
g.ox -= dir[0] * step;
g.oy -= dir[1] * step;
constrain(g);
} else if (event.shiftKey) {
g.box = resizeBox(g.box, dir[0] ? "e" : "s", dir[0] * 8, dir[1] * 8, g.ratio, boundsOf(g));
} else {
const b = boundsOf(g);
g.box = { ...g.box, x: clamp(g.box.x + dir[0] * 4, b.l, b.r - g.box.w), y: clamp(g.box.y + dir[1] * 4, b.t, b.b - g.box.h) };
}
} else if (event.key === "+" || event.key === "=") {
zoomAt(g, cx, cy, g.zoom * 1.15);
} else if (event.key === "-" || event.key === "_") {
zoomAt(g, cx, cy, g.zoom / 1.15);
} else {
return;
}
event.preventDefault();
paint(g);
commit();
};
/* ------------------------------ toolbar actions ----------------------------- */
const setZoomCentered = (zoom: number) => {
const g = geoRef.current;
if (!g) return;
zoomAt(g, g.box.x + g.box.w / 2, g.box.y + g.box.h / 2, zoom);
paint(g);
};
const rotate = (direction: 1 | -1 = 1) => setRotation((r) => (((r + direction * 90 + 360) % 360) as CropRotation));
const reset = () => {
setRotation(0);
setResetCount((n) => n + 1);
};
/* ------------------------------ imperative API ----------------------------- */
React.useImperativeHandle(ref, () => {
const getCroppedCanvas = (options: CroppedCanvasOptions = {}) => {
const g = geoRef.current;
const img = imgRef.current;
if (!g || !img || !img.naturalWidth) return null;
const c = cropOf(g);
const k = Math.min(1, (options.maxWidth ?? Infinity) / c.w, (options.maxHeight ?? Infinity) / c.h);
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(c.w * k));
canvas.height = Math.max(1, Math.round(c.h * k));
const ctx = canvas.getContext("2d");
if (!ctx) return null;
ctx.imageSmoothingQuality = "high";
ctx.scale(k, k);
ctx.translate(-c.x, -c.y);
// Rotate the source about the centre of the rotated frame.
ctx.translate(g.rw / 2, g.rh / 2);
ctx.rotate((g.rot * Math.PI) / 180);
ctx.drawImage(img, -g.nw / 2, -g.nh / 2, g.nw, g.nh);
return canvas;
};
return {
getCroppedCanvas,
toDataURL: (type = "image/png", quality?: number, options?: CroppedCanvasOptions) => {
const canvas = getCroppedCanvas(options);
if (!canvas) return null;
try {
return canvas.toDataURL(type, quality);
} catch {
// Cross-origin image without CORS: the canvas is tainted.
return null;
}
},
getCropRect: () => (geoRef.current ? toCropRect(geoRef.current) : null),
rotate,
reset,
setZoom: (zoom: number) => {
setZoomCentered(zoom);
commit();
},
};
});
/* --------------------------------- render --------------------------------- */
const ready = Boolean(size);
const showGrid = grid !== "never";
const keysHint =
mode === "pan"
? "Arrow keys move the image under the frame, Shift for larger steps. Plus and minus zoom."
: "Arrow keys move the crop box. Shift plus arrow keys resize it. Plus and minus zoom.";
return (
<div
ref={rootRef}
role="group"
aria-label={label}
className={`group/ic flex w-full min-w-0 flex-col gap-3 text-zinc-950 dark:text-zinc-50 ${className}`}
style={{ ["--ic-accent" as string]: accent, ...style }}
{...props}
>
<div
ref={stageRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
className={`relative w-full touch-none select-none overflow-hidden rounded-xl border border-zinc-900/10 bg-zinc-100 bg-[radial-gradient(rgb(0_0_0/0.07)_1px,transparent_1px)] [background-size:14px_14px] dark:border-white/10 dark:bg-zinc-900 dark:bg-[radial-gradient(rgb(255_255_255/0.06)_1px,transparent_1px)] ${
mode === "pan" ? "cursor-grab active:cursor-grabbing" : ""
}`}
style={{ height }}
>
{/* Rotated-image frame: sized and moved imperatively. */}
<div
ref={frameRef}
aria-hidden="true"
className={`absolute left-0 top-0 transition-opacity duration-300 motion-reduce:transition-none ${ready ? "opacity-100" : "opacity-0"}`}
>
{/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}
<img
ref={imgRef}
src={src}
alt=""
crossOrigin={crossOrigin}
draggable={false}
onLoad={(e) => setNatural({ src, w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
className="pointer-events-none absolute left-1/2 top-1/2 max-w-none origin-center"
/>
</div>
{!ready && (
<div className="absolute inset-0 grid place-items-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
Loading image
</div>
)}
<div
ref={boxRef}
data-ic-box=""
tabIndex={ready ? 0 : -1}
role="group"
aria-roledescription="crop area"
aria-label={alt ? `Crop area for ${alt}` : "Crop area"}
aria-describedby={hintId}
onKeyDown={onBoxKeyDown}
className={`absolute outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)] ${
ready ? "" : "invisible"
} ${mode === "box" ? "cursor-move" : ""}`}
style={{ boxShadow: `0 0 0 9999px rgb(9 9 11 / ${clamp(overlayOpacity, 0, 1)})` }}
>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 border border-white/90 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.15)]" />
{showGrid && (
<div
aria-hidden="true"
className={`pointer-events-none absolute inset-0 transition-opacity duration-200 motion-reduce:transition-none ${
grid === "always" ? "opacity-100" : "opacity-0 group-data-[dragging=true]/ic:opacity-100"
}`}
>
<span className="absolute inset-y-0 left-1/3 w-px bg-white/55" />
<span className="absolute inset-y-0 left-2/3 w-px bg-white/55" />
<span className="absolute inset-x-0 top-1/3 h-px bg-white/55" />
<span className="absolute inset-x-0 top-2/3 h-px bg-white/55" />
</div>
)}
<span
ref={readoutRef}
aria-hidden="true"
className="pointer-events-none absolute left-1.5 top-1.5 rounded-[4px] bg-zinc-950/70 px-1.5 py-0.5 font-mono text-[10px] tabular-nums tracking-[0.04em] text-white"
/>
{mode === "box" &&
HANDLES.map((h) => {
const corner = h.length === 2;
return (
<span
key={h}
data-ic-handle={h}
aria-hidden="true"
className={`absolute grid size-6 -translate-x-1/2 -translate-y-1/2 place-items-center ${handlePosition(h)} ${CURSORS[h]}`}
>
<span
className={`block rounded-[2px] border border-[color:var(--ic-accent)] bg-white shadow-[0_1px_3px_rgb(0_0_0/0.35)] ${
corner ? "size-2.5" : h === "n" || h === "s" ? "h-1.5 w-4" : "h-4 w-1.5"
}`}
/>
</span>
);
})}
</div>
</div>
{showToolbar && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div role="group" aria-label="Aspect ratio" className="flex rounded-full border border-zinc-900/15 p-0.5 dark:border-white/15">
{ASPECT_OPTIONS.map((a) => (
<button
key={a}
type="button"
aria-pressed={aspectChoice === a}
disabled={!ready}
onClick={() => setAspectChoice(a)}
className={`h-7 rounded-full px-2.5 font-mono text-[11px] uppercase tracking-[0.08em] transition-colors outline-hidden focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)] disabled:opacity-50 ${
aspectChoice === a
? "bg-zinc-950 text-white dark:bg-white dark:text-zinc-950"
: "text-zinc-600 hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-white"
}`}
>
{a === "free" ? "Free" : a}
</button>
))}
</div>
<label className="flex min-w-[160px] flex-1 items-center gap-2.5">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">Zoom</span>
<input
ref={zoomInputRef}
type="range"
min={1}
max={zoomLimit}
step={0.01}
defaultValue={1}
disabled={!ready}
onChange={(e) => {
setZoomCentered(Number(e.currentTarget.value));
scheduleCommit();
}}
className="h-1 min-w-0 flex-1 cursor-pointer accent-[var(--ic-accent)] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)]"
/>
<span ref={zoomTextRef} aria-hidden="true" className="w-11 text-right font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">
1.00×
</span>
</label>
<div className="flex items-center gap-1">
<button
type="button"
aria-label="Rotate 90° clockwise"
title="Rotate 90°"
disabled={!ready}
onClick={() => rotate(1)}
className="grid size-8 place-items-center rounded-full border border-zinc-900/15 text-zinc-700 transition-colors outline-hidden hover:bg-zinc-900/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)] disabled:opacity-50 dark:border-white/15 dark:text-zinc-200 dark:hover:bg-white/10"
>
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M13 8a5 5 0 1 1-1.6-3.7" strokeLinecap="round" />
<path d="M13 2.5v2.8h-2.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
disabled={!ready}
onClick={reset}
className="h-8 rounded-full border border-zinc-900/15 px-3 font-mono text-[11px] uppercase tracking-[0.08em] text-zinc-700 transition-colors outline-hidden hover:bg-zinc-900/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)] disabled:opacity-50 dark:border-white/15 dark:text-zinc-200 dark:hover:bg-white/10"
>
Reset
</button>
</div>
</div>
)}
<p id={hintId} className="sr-only">
{keysHint}
</p>
<p className="sr-only" aria-live="polite">
{live}
</p>
</div>
);
});