Something went wrong. Try again.
headless React image cropper
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649import { clamp, containSize, normalizeRotation, rotatePoint, rotatedSize, sameCropValue, sameSize,} from './geometry.ts';import { type CropperConfig, type CropperState, type CropValue, DEFAULT_CONFIG, EMPTY_VALUE, type ImageLayout, type Insets, type Point, type Rect, type Rotation, type Size, type WheelZoomMode,} from './types.ts';
export interface EngineOptions { /** behavior config. */ config?: Partial<CropperConfig>; /** initial crop. */ value?: CropValue;}
export interface CropperGeometry { /** crop window width divided by height. */ aspectRatio?: number; /** source size, or null before measurement. */ natural?: Size | null; /** viewport size in px. */ viewport: Size;}
type Gesture = | { kind: 'idle' } | { kind: 'pan'; startCrop: Point; startPointer: Point } | { kind: 'pinch'; anchor: Point; startDist: number; startWidth: number };
const PINCH_MIN_DIST = 1e-3;
const ZERO_SIZE: Size = { width: 0, height: 0 };
/** manages crop state without DOM or React dependencies. */export class CropperEngine { /** active config; update it through {@link setConfig}. */ config: CropperConfig;
#aspectRatio: number | undefined; #natural: Size | null = null; #viewport: Size = ZERO_SIZE;
#value: CropValue; #hasValue: boolean; #initialized = false;
#pointers = new Map<number, Point>(); #gesture: Gesture = { kind: 'idle' };
#window: Rect = { x: 0, y: 0, width: 0, height: 0 }; #snapshot: CropperState | null = null; #listeners = new Set<(state: CropperState) => void>();
/** @param options initial config and crop. */ constructor(options: EngineOptions = {}) { this.config = normalizeConfig(options.config); this.#value = options.value ? { ...options.value } : { ...EMPTY_VALUE }; this.#hasValue = options.value !== undefined; }
// #region configuration / geometry
/** * update the crop geometry while preserving the crop for viewport-only changes. * * @param geometry see {@link CropperGeometry}. */ setGeometry(geometry: CropperGeometry): void { const natural = geometry.natural ?? null; // preserve framing before changing its coordinate space. const refit = this.#initialized && (geometry.aspectRatio !== this.#aspectRatio || !sameSize(this.#natural, natural)); const zoom = refit ? this.#zoom() : 1; const center = refit ? this.#center() : { x: 0, y: 0 };
this.#aspectRatio = geometry.aspectRatio; this.#natural = natural; this.#viewport = geometry.viewport; this.#syncWindow();
if (natural) { if (!this.#initialized) { this.#initialize(); } else if (refit) { this.#setCropCentered(center, this.#maxCrop().width / zoom); } } this.#emit(); }
/** * merge behavior config and legalize the crop. * * @param config partial overrides applied over the current config. */ setConfig(config: Partial<CropperConfig>): void { this.config = normalizeConfig({ ...this.config, ...config }); this.#syncWindow(); if (this.#initialized) { this.#setCropCentered(this.#center(), this.#value.width); } this.#emit(); }
// #endregion
// #region value
/** * set and legalize the crop. * * @param value the crop to apply, in the source space named by its `rotation`. */ setValue(value: CropValue): void { this.#hasValue = true; if (!this.#initialized) { this.#value = { ...value, rotation: normalizeRotation(value.rotation) }; this.#syncWindow(); this.#emit(); return; } // rotation selects the coordinate space used by the clamps. this.#value = { ...this.#value, rotation: normalizeRotation(value.rotation) }; this.#syncWindow(); this.#setCropAt(value, value.width); this.#emit(); }
/** reset the engine for a new source image. */ reset(): void { this.#pointers.clear(); this.#gesture = { kind: 'idle' }; this.#initialized = false; this.#hasValue = false; this.#natural = null; this.#value = { ...EMPTY_VALUE }; this.#syncWindow(); this.#emit(); }
// #endregion
// #region programmatic manipulation
/** * set the zoom while holding a source point in place. * * @param zoom target zoom, clamped to `[1, maxZoom]`. * @param focal viewport point to keep fixed; defaults to the crop window center. */ setZoom(zoom: number, focal?: Point): void { if (!this.#initialized || !Number.isFinite(zoom)) { return; } const w = this.#window; this.#zoomAbout(focal ?? { x: w.x + w.width / 2, y: w.y + w.height / 2 }, zoom); this.#emit(); }
/** * set the crop position as a fraction of available travel. * * @param pan per-axis position from `0` to `1`. */ setPan(pan: Partial<Point>): void { if (!this.#initialized) { return; } const slack = this.#panSlack(); this.#setCropAt( { x: pan.x !== undefined && slack.x > 0 ? clamp(pan.x, 0, 1) * slack.x : this.#value.x, y: pan.y !== undefined && slack.y > 0 ? clamp(pan.y, 0, 1) * slack.y : this.#value.y, }, this.#value.width, ); this.#emit(); }
/** * rotate the source by quarter-turns. * * @param turns clockwise quarter-turns; negative values turn counterclockwise. */ rotate(turns: number): void { this.setRotation(normalizeRotation(this.#value.rotation + turns)); }
/** * set the source rotation and preserve the crop framing. * * @param rotation quarter-turns clockwise from the source's own orientation. */ setRotation(rotation: Rotation): void { const from = this.#value.rotation; const to = normalizeRotation(rotation); if (from === to) { return; } if (!this.#initialized) { this.#value = { ...this.#value, rotation: to }; this.#syncWindow(); this.#emit(); return; } const zoom = this.#zoom(); const center = rotatePoint({ point: this.#center(), size: this.#rotated(), from, to }); this.#value = { ...this.#value, rotation: to }; this.#syncWindow(); // derive width from the new cover fit to prevent letterboxing. this.#setCropCentered(center, this.#maxCrop().width / zoom); this.#emit(); }
// #endregion
// #region pointer input
/** * start or extend a pointer gesture. * * @param id pointer id. * @param point pointer position relative to the viewport. */ pointerDown(id: number, point: Point): void { this.#pointers.set(id, { ...point }); if (this.#pointers.size >= 2) { this.#beginPinch(); } else { this.#beginPan(point); } this.#emit(); }
/** * update a tracked pointer. * * @param id pointer id; ignored if not currently tracked. * @param point pointer position relative to the viewport. */ pointerMove(id: number, point: Point): void { const p = this.#pointers.get(id); if (!p) { return; } p.x = point.x; p.y = point.y; const before = this.#value; switch (this.#gesture.kind) { case 'pan': { this.#updatePan(this.#gesture); break; } case 'pinch': { this.#updatePinch(this.#gesture); break; } case 'idle': { break; } } // avoid updates while a pointer remains against a clamp. if (!sameCropValue(before, this.#value)) { this.#emit(); } }
/** * release a tracked pointer and continue a pinch as a pan when possible. * * @param id pointer id; ignored if not currently tracked. */ pointerUp(id: number): void { if (!this.#pointers.delete(id)) { return; } if (this.#pointers.size >= 2) { this.#beginPinch(); } else { const [survivor] = this.#pointers.values(); if (survivor) { this.#beginPan(survivor); } else { this.#gesture = { kind: 'idle' }; } } this.#emit(); }
/** * apply cursor-anchored wheel zoom. * * @param point cursor position relative to the viewport. * @param deltaY wheel delta; negative zooms in, positive zooms out. */ wheel(point: Point, deltaY: number): void { if (!this.#initialized || !Number.isFinite(deltaY)) { return; } this.#zoomAbout(point, this.#zoom() * Math.exp(-deltaY * this.config.wheelSensitivity)); this.#emit(); }
// #endregion
// #region gesture internals
#beginPan(point: Point): void { this.#gesture = { kind: 'pan', startCrop: { x: this.#value.x, y: this.#value.y }, startPointer: { ...point }, }; }
#updatePan(gesture: Extract<Gesture, { kind: 'pan' }>): void { const [p] = this.#pointers.values(); const scale = this.#scale(); if (!p || scale <= 0) { return; } this.#setCropAt( { x: gesture.startCrop.x - (p.x - gesture.startPointer.x) / scale, y: gesture.startCrop.y - (p.y - gesture.startPointer.y) / scale, }, this.#value.width, ); }
#beginPinch(): void { const [a, b] = [...this.#pointers.values()]; if (!a || !b) { return; } this.#gesture = { kind: 'pinch', anchor: this.#sourceAt(midpoint(a, b)), startDist: Math.max(dist(a, b), PINCH_MIN_DIST), startWidth: this.#value.width, }; }
#updatePinch(gesture: Extract<Gesture, { kind: 'pinch' }>): void { const [a, b] = [...this.#pointers.values()]; if (!a || !b) { return; } const factor = Math.max(dist(a, b), PINCH_MIN_DIST) / gesture.startDist; this.#anchorAt({ source: gesture.anchor, focal: midpoint(a, b), width: this.#clampWidth(gesture.startWidth / factor), }); }
#zoomAbout(focal: Point, zoom: number): void { const max = this.#maxCrop().width; if (!(max > 0)) { return; } // resolve the anchor before resizing changes the mapping. const anchor = this.#sourceAt(focal); this.#anchorAt({ source: anchor, focal, width: this.#clampWidth(max / clamp(zoom, 1, this.config.maxZoom)), }); }
#sourceAt(point: Point): Point { const w = this.#window; const scale = this.#scale(); if (scale <= 0) { return { x: this.#value.x, y: this.#value.y }; } return { x: this.#value.x + (point.x - w.x) / scale, y: this.#value.y + (point.y - w.y) / scale }; }
#anchorAt(options: { source: Point; focal: Point; width: number }): void { const { source, focal, width } = options; const w = this.#window; if (!(w.width > 0) || !(width > 0)) { return; } const scale = w.width / width; this.#setCropAt({ x: source.x - (focal.x - w.x) / scale, y: source.y - (focal.y - w.y) / scale }, width); }
// #endregion
// #region crop derivation
#initialize(): void { this.#initialized = true; if (this.#hasValue && this.#value.width > 0) { this.#setCropAt(this.#value, this.#value.width); return; } const r = this.#rotated(); this.#setCropCentered({ x: r.width / 2, y: r.height / 2 }, this.#maxCrop().width); }
#setCropAt(topLeft: Point, width: number): void { const w = this.#clampWidth(width); const h = w / this.#aspect(); const r = this.#rotated(); this.#value = { rotation: this.#value.rotation, x: clamp(finiteOrZero(topLeft.x), 0, Math.max(0, r.width - w)), y: clamp(finiteOrZero(topLeft.y), 0, Math.max(0, r.height - h)), width: w, height: h, }; }
#setCropCentered(center: Point, width: number): void { const w = this.#clampWidth(width); this.#setCropAt({ x: center.x - w / 2, y: center.y - w / this.#aspect() / 2 }, w); }
#rotated(): Size { return rotatedSize(this.#natural ?? ZERO_SIZE, this.#value.rotation); }
#aspect(): number { const a = this.#aspectRatio; if (a !== undefined && Number.isFinite(a) && a > 0) { return a; } const r = this.#rotated(); return r.height > 0 ? r.width / r.height : 1; }
#maxCrop(): Size { return containSize(this.#rotated(), this.#aspect()); }
#clampWidth(width: number): number { const max = this.#maxCrop().width; if (!(max > 0)) { return 0; } if (!Number.isFinite(width)) { return this.#value.width || max; } return clamp(width, max / this.config.maxZoom, max); }
#zoom(): number { const max = this.#maxCrop().width; return max > 0 && this.#value.width > 0 ? max / this.#value.width : 1; }
#scale(): number { return this.#value.width > 0 ? this.#window.width / this.#value.width : 0; }
#center(): Point { return { x: this.#value.x + this.#value.width / 2, y: this.#value.y + this.#value.height / 2 }; }
#panSlack(): Point { const r = this.#rotated(); return { x: Math.max(0, r.width - this.#value.width), y: Math.max(0, r.height - this.#value.height), }; }
#computeWindow(): Rect { const { top, right, bottom, left } = this.config.insets; const width = Math.max(0, this.#viewport.width - left - right); const height = Math.max(0, this.#viewport.height - top - bottom); const size = containSize({ width, height }, this.#aspect()); return { x: left + (width - size.width) / 2, y: top + (height - size.height) / 2, width: size.width, height: size.height, }; }
#syncWindow(): void { const next = this.#computeWindow(); const cur = this.#window; // preserve the reference used by selectors when geometry is unchanged. if (next.x !== cur.x || next.y !== cur.y || next.width !== cur.width || next.height !== cur.height) { this.#window = next; } }
// #endregion
// #region snapshot / subscription
/** * subscribe to state changes. * * @param fn listener invoked with each new {@link CropperState}. * @returns an unsubscribe function. */ subscribe = (fn: (state: CropperState) => void): (() => void) => { this.#listeners.add(fn); return () => { this.#listeners.delete(fn); }; };
/** * get the current render state. * * @returns state that stays stable by reference until a change. */ getState(): CropperState { return (this.#snapshot ??= this.#buildState()); }
#buildState(): CropperState { const slack = this.#panSlack(); return { cropWindow: this.#window, image: this.#imageLayout(), isDragging: this.#gesture.kind === 'pan', isPinching: this.#gesture.kind === 'pinch', pan: { x: slack.x > 0 ? clamp(this.#value.x / slack.x, 0, 1) : 0, y: slack.y > 0 ? clamp(this.#value.y / slack.y, 0, 1) : 0, }, panSlack: slack, value: this.#value, zoom: this.#zoom(), }; }
#imageLayout(): ImageLayout { const natural = this.#natural; const rotation = this.#value.rotation; const scale = this.#scale(); if (!natural || scale <= 0) { return { rotation, x: 0, y: 0, width: 0, height: 0 }; } const w = this.#window; const r = this.#rotated(); const width = natural.width * scale; const height = natural.height * scale; // center the unrotated element on the rotated image bounds. return { rotation, x: w.x - this.#value.x * scale + (r.width * scale - width) / 2, y: w.y - this.#value.y * scale + (r.height * scale - height) / 2, width, height, }; }
#emit(): void { this.#snapshot = this.#buildState(); for (const fn of this.#listeners) { fn(this.#snapshot); } }
/** release listeners and pointers. */ destroy(): void { this.#listeners.clear(); this.#pointers.clear(); this.#gesture = { kind: 'idle' }; }
// #endregion}
const dist = (a: Point, b: Point): number => { return Math.hypot(a.x - b.x, a.y - b.y);};
const midpoint = (a: Point, b: Point): Point => { return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };};
const finiteOrZero = (value: number): number => { return Number.isFinite(value) ? value : 0;};
const positiveOr = (value: number | undefined, fallback: number): number => value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback;const spanOr = (value: number | undefined, fallback: number): number => value !== undefined && Number.isFinite(value) && value >= 0 ? value : fallback;const normalizeInsets = (insets: Insets | undefined): Insets => ({ top: spanOr(insets?.top, 0), right: spanOr(insets?.right, 0), bottom: spanOr(insets?.bottom, 0), left: spanOr(insets?.left, 0),});
const WHEEL_ZOOM_MODES: ReadonlySet<WheelZoomMode> = new Set(['always', 'ctrl', 'none']);
/** * normalize behavior config. * * @param config behavior overrides. * @returns complete config with invalid values replaced by defaults. */export const normalizeConfig = (config: Partial<CropperConfig> = {}): CropperConfig => ({ insets: normalizeInsets(config.insets), maxZoom: Math.max(1, positiveOr(config.maxZoom, DEFAULT_CONFIG.maxZoom)), wheelSensitivity: spanOr(config.wheelSensitivity, DEFAULT_CONFIG.wheelSensitivity), wheelZoom: config.wheelZoom !== undefined && WHEEL_ZOOM_MODES.has(config.wheelZoom) ? config.wheelZoom : DEFAULT_CONFIG.wheelZoom,});
/** * compare normalized behavior configs. * * @param a first config. * @param b second config. * @returns whether every field matches. */export const sameConfig = (a: CropperConfig, b: CropperConfig): boolean => { return ( a.insets.top === b.insets.top && a.insets.right === b.insets.right && a.insets.bottom === b.insets.bottom && a.insets.left === b.insets.left && a.maxZoom === b.maxZoom && a.wheelSensitivity === b.wheelSensitivity && a.wheelZoom === b.wheelZoom );};