import { 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; /** 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(); #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): 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): 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): 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): 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 = 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 => ({ 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 ); };