Something went wrong. Try again.
headless React image cropper
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552import { describe, expect, test } from 'bun:test';
import { type CropperGeometry, CropperEngine } from './engine.ts';import { fractionToZoom, rotatePoint, rotatedSize, zoomToFraction } from './geometry.ts';import type { CropValue, Point, Rotation } from './types.ts';
const SOURCE = { width: 1600, height: 900 };const STAGE = { width: 800, height: 600 };
const setup = ( geometry: Partial<CropperGeometry> = {}, config?: ConstructorParameters<typeof CropperEngine>[0],) => { const engine = new CropperEngine(config); engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: STAGE, ...geometry }); return engine;};
const sourceUnder = (engine: CropperEngine, focal: Point): Point => { const { cropWindow, value } = engine.getState(); const scale = cropWindow.width / value.width; return { x: value.x + (focal.x - cropWindow.x) / scale, y: value.y + (focal.y - cropWindow.y) / scale };};
const centerOf = (engine: CropperEngine): Point => { const { value } = engine.getState(); return { x: value.x + value.width / 2, y: value.y + value.height / 2 };};
const expectInsideSource = (engine: CropperEngine) => { const { value } = engine.getState(); const r = rotatedSize(SOURCE, value.rotation); expect(value.width).toBeLessThanOrEqual(r.width + 1e-9); expect(value.height).toBeLessThanOrEqual(r.height + 1e-9); expect(value.x).toBeGreaterThanOrEqual(-1e-9); expect(value.y).toBeGreaterThanOrEqual(-1e-9); expect(value.x + value.width).toBeLessThanOrEqual(r.width + 1e-9); expect(value.y + value.height).toBeLessThanOrEqual(r.height + 1e-9);};
describe('initial crop', () => { test('is the largest crop the source allows, centered', () => { const { value, zoom } = setup().getState(); expect(value).toEqual({ rotation: 0, x: 350, y: 0, width: 900, height: 900 }); expect(zoom).toBe(1); });
test('follows the source ratio when none is given', () => { const { value } = setup({ aspectRatio: undefined }).getState(); expect(value).toEqual({ rotation: 0, x: 0, y: 0, width: 1600, height: 900 }); });
test('is reported as empty until the natural size is known', () => { const engine = new CropperEngine(); engine.setGeometry({ aspectRatio: 1, natural: null, viewport: STAGE }); expect(engine.getState().value.width).toBe(0); expect(engine.getState().image.width).toBe(0); });
test('honors a supplied crop once the source lands', () => { const engine = new CropperEngine({ value: { rotation: 0, x: 10, y: 20, width: 400, height: 400 } }); engine.setGeometry({ aspectRatio: 1, natural: null, viewport: STAGE }); engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: STAGE }); expect(engine.getState().value).toEqual({ rotation: 0, x: 10, y: 20, width: 400, height: 400 }); });});
describe('the crop window', () => { test('is the largest box of the target ratio fitted into the stage', () => { expect(setup().getState().cropWindow).toEqual({ x: 100, y: 0, width: 600, height: 600 }); });
test('is inset by the configured margins', () => { const engine = setup({}, { config: { insets: { top: 50, right: 50, bottom: 50, left: 50 } } }); expect(engine.getState().cropWindow).toEqual({ x: 150, y: 50, width: 500, height: 500 }); });});
describe('stage resizing', () => { test('leaves the source-pixel value untouched', () => { const engine = setup(); engine.setZoom(2.5); const before = engine.getState().value;
engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: { width: 400, height: 1000 } }); expect(engine.getState().value).toEqual(before);
engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: { width: 1920, height: 137 } }); expect(engine.getState().value).toEqual(before); });
test('still moves the window and the image layer', () => { const engine = setup(); engine.setGeometry({ aspectRatio: 1, natural: SOURCE, viewport: { width: 300, height: 300 } }); expect(engine.getState().cropWindow).toEqual({ x: 0, y: 0, width: 300, height: 300 }); });});
describe('zoom', () => { test('holds the crop center still when anchored at the window center', () => { const engine = setup(); const before = centerOf(engine); const { cropWindow: w } = engine.getState();
engine.wheel({ x: w.x + w.width / 2, y: w.y + w.height / 2 }, -300);
expect(engine.getState().zoom).toBeGreaterThan(1); expect(centerOf(engine).x).toBeCloseTo(before.x, 9); expect(centerOf(engine).y).toBeCloseTo(before.y, 9); });
test('holds the point under the cursor still when anchored off center', () => { const engine = setup(); const focal = { x: 150, y: 100 }; const before = sourceUnder(engine, focal);
engine.wheel(focal, -300);
const after = sourceUnder(engine, focal); expect(after.x).toBeCloseTo(before.x, 9); expect(after.y).toBeCloseTo(before.y, 9); });
test('round-trips exactly when it does not hit a limit', () => { const engine = setup(); engine.setZoom(2); const before = engine.getState().value;
engine.wheel({ x: 200, y: 150 }, -120); engine.wheel({ x: 200, y: 150 }, 120);
const after = engine.getState().value; expect(after.x).toBeCloseTo(before.x, 6); expect(after.y).toBeCloseTo(before.y, 6); expect(after.width).toBeCloseTo(before.width, 6); });
test('clamps to [1, maxZoom]', () => { const engine = setup({}, { config: { maxZoom: 3 } }); engine.setZoom(99); expect(engine.getState().zoom).toBeCloseTo(3, 9); expect(engine.getState().value.width).toBeCloseTo(300, 9);
engine.setZoom(0.1); expect(engine.getState().zoom).toBeCloseTo(1, 9); expect(engine.getState().value.width).toBeCloseTo(900, 9); expectInsideSource(engine); });
test('never lets the crop escape the source, wherever it is anchored', () => { const engine = setup(); for (const focal of [ { x: 0, y: 0 }, { x: 800, y: 600 }, { x: -500, y: -500 }, { x: 5000, y: 5000 }, ]) { engine.wheel(focal, -400); expectInsideSource(engine); engine.wheel(focal, 400); expectInsideSource(engine); } });});
describe('the zoom slider mapping', () => { test('puts the cover fit at 0 and the max at 1', () => { expect(zoomToFraction(1, 6)).toBe(0); expect(zoomToFraction(6, 6)).toBe(1); expect(fractionToZoom(0, 6)).toBeCloseTo(1, 9); expect(fractionToZoom(1, 6)).toBeCloseTo(6, 9); });
test('splits the travel evenly in doublings', () => { expect(zoomToFraction(2, 4)).toBeCloseTo(0.5, 9); expect(fractionToZoom(0.5, 4)).toBeCloseTo(2, 9); });
test('round-trips', () => { for (const zoom of [1, 1.5, 2.7, 4, 6]) { expect(fractionToZoom(zoomToFraction(zoom, 6), 6)).toBeCloseTo(zoom, 9); } });
test('clamps out-of-range and non-finite input', () => { expect(zoomToFraction(99, 6)).toBe(1); expect(zoomToFraction(0, 6)).toBe(0); expect(zoomToFraction(Number.NaN, 6)).toBe(0); expect(fractionToZoom(-1, 6)).toBeCloseTo(1, 9); expect(fractionToZoom(9, 6)).toBeCloseTo(6, 9); expect(fractionToZoom(Number.NaN, 6)).toBe(1); });
test('pins to the cover fit when zoom is disabled', () => { expect(zoomToFraction(1, 1)).toBe(0); expect(fractionToZoom(0.5, 1)).toBe(1); });});
describe('pan', () => { test('reports travel only on the axis that has any', () => { const { pan, panSlack } = setup().getState(); expect(panSlack).toEqual({ x: 700, y: 0 }); expect(pan).toEqual({ x: 0.5, y: 0 }); });
test('maps a 0-1 fraction onto the available travel', () => { const engine = setup(); engine.setPan({ x: 0 }); expect(engine.getState().value.x).toBe(0); engine.setPan({ x: 1 }); expect(engine.getState().value.x).toBe(700); engine.setPan({ x: 0.25 }); expect(engine.getState().value.x).toBe(175); });
test('ignores an axis with no travel', () => { const engine = setup(); engine.setPan({ y: 1 }); expect(engine.getState().value.y).toBe(0); });
test('clamps pan at the source edges', () => { const engine = setup(); engine.pointerDown(1, { x: 400, y: 300 }); engine.pointerMove(1, { x: 4000, y: 300 }); engine.pointerUp(1); expect(engine.getState().value.x).toBe(0); expectInsideSource(engine);
engine.pointerDown(1, { x: 400, y: 300 }); engine.pointerMove(1, { x: -4000, y: 300 }); engine.pointerUp(1); expect(engine.getState().value.x).toBe(700); expectInsideSource(engine); });
test('moves the crop against the drag, so the image follows the finger', () => { const engine = setup(); const before = engine.getState().value.x; engine.pointerDown(1, { x: 400, y: 300 }); engine.pointerMove(1, { x: 460, y: 300 }); expect(engine.getState().value.x).toBeCloseTo(before - 90, 9); });});
describe('pinch', () => { test('zooms about the midpoint and pans with it', () => { const engine = setup(); engine.setZoom(2); const anchor = sourceUnder(engine, { x: 400, y: 300 });
engine.pointerDown(1, { x: 350, y: 300 }); engine.pointerDown(2, { x: 450, y: 300 }); engine.pointerMove(1, { x: 300, y: 300 }); engine.pointerMove(2, { x: 500, y: 300 });
const after = sourceUnder(engine, { x: 400, y: 300 }); expect(engine.getState().zoom).toBeGreaterThan(2); expect(after.x).toBeCloseTo(anchor.x, 6); expect(after.y).toBeCloseTo(anchor.y, 6); });
test('carries on as a pan when one finger lifts', () => { const engine = setup(); engine.pointerDown(1, { x: 350, y: 300 }); engine.pointerDown(2, { x: 450, y: 300 }); expect(engine.getState().isPinching).toBe(true);
engine.pointerUp(2); expect(engine.getState().isPinching).toBe(false); expect(engine.getState().isDragging).toBe(true);
const before = engine.getState().value.x; engine.pointerMove(1, { x: 380, y: 300 }); expect(engine.getState().value.x).toBeCloseTo(before - 45, 9); });});
describe('rotation', () => { test('re-fits to cover, so a turn never letterboxes the window', () => { const engine = setup({ aspectRatio: 640 / 225 }); for (const turns of [1, 1, 1, 1]) { engine.rotate(turns); expectInsideSource(engine); } });
test('carries the crop center into the new space', () => { const engine = setup(); engine.rotate(1); expect(engine.getState().value).toEqual({ rotation: 1, x: 0, y: 350, width: 900, height: 900 }); });
test('returns to where it started after four turns', () => { const engine = setup(); engine.setZoom(2.5); engine.setPan({ x: 0.2 }); const before = engine.getState().value;
engine.rotate(1); engine.rotate(1); engine.rotate(1); engine.rotate(1);
const after = engine.getState().value; expect(after.rotation).toBe(0); expect(after.x).toBeCloseTo(before.x, 6); expect(after.y).toBeCloseTo(before.y, 6); expect(after.width).toBeCloseTo(before.width, 6); });
test('normalizes turns past a full circle and below zero', () => { const engine = setup(); engine.rotate(-1); expect(engine.getState().value.rotation).toBe(3); engine.rotate(5); expect(engine.getState().value.rotation).toBe(0); });
test('swaps the window ratio when following the source', () => { const engine = setup({ aspectRatio: undefined }); expect(engine.getState().cropWindow).toEqual({ x: 0, y: 75, width: 800, height: 450 }); engine.rotate(1); expect(engine.getState().cropWindow).toEqual({ x: 231.25, y: 0, width: 337.5, height: 600 }); });});
describe('aspect ratio changes', () => { test('keep the crop center rather than re-clamping a screen-space pan', () => { const engine = setup(); engine.setPan({ x: 0 }); const before = centerOf(engine);
engine.setGeometry({ aspectRatio: 16 / 9, natural: SOURCE, viewport: STAGE });
expect(engine.getState().value).toEqual({ rotation: 0, x: 0, y: 0, width: 1600, height: 900 }); expect(before.x).toBe(450); });
test('keep the zoom factor', () => { const engine = setup(); engine.setZoom(2); engine.setGeometry({ aspectRatio: 16 / 9, natural: SOURCE, viewport: STAGE }); expect(engine.getState().zoom).toBeCloseTo(2, 9); expectInsideSource(engine); });});
describe('the image layer', () => { test('places the crop exactly over the window', () => { const engine = setup(); engine.setZoom(1.7); engine.setPan({ x: 0.3 });
const { cropWindow, image, value } = engine.getState(); const scale = cropWindow.width / value.width; expect(image.x + value.x * scale).toBeCloseTo(cropWindow.x, 6); expect(image.y + value.y * scale).toBeCloseTo(cropWindow.y, 6); expect(image.width).toBeCloseTo(SOURCE.width * scale, 6); expect(image.height).toBeCloseTo(SOURCE.height * scale, 6); });
test('keeps the turned layer centered on the region the crop describes', () => { const engine = setup(); engine.rotate(1);
const { cropWindow, image, value } = engine.getState(); const scale = cropWindow.width / value.width; const rotated = rotatedSize(SOURCE, image.rotation); const turnedX = image.x + image.width / 2 - (rotated.width * scale) / 2; const turnedY = image.y + image.height / 2 - (rotated.height * scale) / 2; expect(turnedX + value.x * scale).toBeCloseTo(cropWindow.x, 6); expect(turnedY + value.y * scale).toBeCloseTo(cropWindow.y, 6); });});
describe('setValue', () => { test('clamps an out-of-bounds crop to the nearest legal one', () => { const engine = setup(); engine.setValue({ rotation: 0, x: -500, y: -500, width: 5000, height: 5000 }); expect(engine.getState().value).toEqual({ rotation: 0, x: 0, y: 0, width: 900, height: 900 }); });
test('snaps a mismatched crop to the target ratio', () => { const engine = setup(); engine.setValue({ rotation: 0, x: 0, y: 0, width: 400, height: 999 }); expect(engine.getState().value.height).toBe(400); });
test('survives garbage', () => { const engine = setup(); engine.setValue({ rotation: 0, x: Number.NaN, y: Number.NaN, width: Number.NaN, height: 0 }); expectInsideSource(engine); expect(Number.isFinite(engine.getState().value.width)).toBe(true); });
test('adopts a rotation named by the incoming value', () => { const engine = setup(); engine.setValue({ rotation: 3, x: 0, y: 0, width: 900, height: 900 }); expect(engine.getState().value.rotation).toBe(3); expectInsideSource(engine); });});
describe('reset', () => { test('starts the crop over once the next source lands', () => { const engine = setup(); engine.setZoom(4); engine.setPan({ x: 0 });
engine.reset(); expect(engine.getState().value.width).toBe(0);
const next = { width: 400, height: 400 }; engine.setGeometry({ aspectRatio: 1, natural: next, viewport: STAGE }); expect(engine.getState().value).toEqual({ rotation: 0, x: 0, y: 0, width: 400, height: 400 }); });});
describe('subscription', () => { test('emits on change and stops after unsubscribing', () => { const engine = setup(); let calls = 0; const unsubscribe = engine.subscribe(() => { calls++; });
engine.setZoom(2); expect(calls).toBe(1);
unsubscribe(); engine.setZoom(3); expect(calls).toBe(1); });
test('stays quiet when a drag is already pressed against a clamp', () => { const engine = setup(); engine.pointerDown(1, { x: 400, y: 300 }); engine.pointerMove(1, { x: 4000, y: 300 });
let calls = 0; engine.subscribe(() => { calls++; }); engine.pointerMove(1, { x: 5000, y: 300 }); expect(calls).toBe(0); });
test('hands back a stable snapshot between changes', () => { const engine = setup(); expect(engine.getState()).toBe(engine.getState()); expect(engine.getState().cropWindow).toBe(engine.getState().cropWindow); });});
describe('config', () => { test('falls back on invalid values instead of propagating them', () => { const engine = setup({}, { config: { maxZoom: -5, wheelSensitivity: Number.NaN } }); expect(engine.config.maxZoom).toBe(6); expect(engine.config.wheelSensitivity).toBe(0.0015); });
test('re-legalizes the crop when maxZoom tightens', () => { const engine = setup(); engine.setZoom(6); expect(engine.getState().zoom).toBeCloseTo(6, 9);
engine.setConfig({ maxZoom: 2 }); expect(engine.getState().zoom).toBeCloseTo(2, 9); expectInsideSource(engine); });});
describe('the canvas recipe in the README', () => { const paint = ( value: CropValue, natural: { width: number; height: number }, u: number, v: number, ): Point => { const turned = rotatedSize(natural, value.rotation); const tx = turned.width / 2 - value.x; const ty = turned.height / 2 - value.y; const angle = (value.rotation * Math.PI) / 2; const [cos, sin] = [Math.cos(angle), Math.sin(angle)]; const [px, py] = [u - natural.width / 2, v - natural.height / 2]; return { x: tx + cos * px - sin * py, y: ty + sin * px + cos * py }; };
test('lands the crop region exactly on the canvas', () => { const corners = [ { u: 0, v: 0 }, { u: SOURCE.width, v: 0 }, { u: 0, v: SOURCE.height }, { u: SOURCE.width, v: SOURCE.height }, ];
for (const rotation of [0, 1, 2, 3] as Rotation[]) { const engine = setup({ aspectRatio: 16 / 9 }); engine.setRotation(rotation); engine.setZoom(2.2); engine.setPan({ x: 0.7, y: 0.3 }); const { value } = engine.getState();
for (const { u, v } of corners) { const painted = paint(value, SOURCE, u, v); const inSpace = rotatePoint({ point: { x: u, y: v }, size: SOURCE, from: 0, to: rotation }); expect(painted.x).toBeCloseTo(inSpace.x - value.x, 6); expect(painted.y).toBeCloseTo(inSpace.y - value.y, 6); } } });
test('fills the canvas edge to edge, with no gap or overhang', () => { for (const rotation of [0, 1, 2, 3] as Rotation[]) { const engine = setup({ aspectRatio: undefined }); engine.setRotation(rotation); const { value } = engine.getState();
const painted = [ paint(value, SOURCE, 0, 0), paint(value, SOURCE, SOURCE.width, 0), paint(value, SOURCE, 0, SOURCE.height), paint(value, SOURCE, SOURCE.width, SOURCE.height), ]; expect(Math.min(...painted.map((p) => p.x))).toBeCloseTo(0, 6); expect(Math.min(...painted.map((p) => p.y))).toBeCloseTo(0, 6); expect(Math.max(...painted.map((p) => p.x))).toBeCloseTo(value.width, 6); expect(Math.max(...painted.map((p) => p.y))).toBeCloseTo(value.height, 6); } });});
describe('every rotation', () => { test('keeps the crop legal at every zoom and pan extreme', () => { for (const rotation of [0, 1, 2, 3] as Rotation[]) { for (const aspectRatio of [undefined, 1, 16 / 9, 640 / 225, 0.5]) { const engine = setup({ aspectRatio }); engine.setRotation(rotation); for (const zoom of [1, 1.5, 6, 100]) { engine.setZoom(zoom); for (const pan of [0, 0.5, 1]) { engine.setPan({ x: pan, y: 1 - pan }); expectInsideSource(engine); } } } } });});