Something went wrong. Try again.
headless React image cropper
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore,} from 'react';
import { CropperEngine, normalizeConfig, sameConfig } from '../core/engine.ts';import { sameCropValue, sameSize } from '../core/geometry.ts';import type { CropperConfig, CropValue, Size } from '../core/types.ts';
import { CropperContext, type CropperContextValue, type CropperSource } from './context.ts';
export interface CropperRootProps { /** crop window width divided by height. */ aspectRatio?: number; children?: ReactNode; config?: Partial<CropperConfig>; /** initial uncontrolled crop. */ defaultValue?: CropValue; /** called when the crop changes. */ onValueChange?: (value: CropValue) => void; /** controlled crop. */ value?: CropValue;}
const ZERO_SIZE: Size = { width: 0, height: 0 };
/** * contains the cropper. * * @param props root props. * @returns the cropper context provider. */export const Root = (props: CropperRootProps) => { const { aspectRatio, children, config, defaultValue, onValueChange, value } = props;
const [engine] = useState(() => new CropperEngine({ config, value: value ?? defaultValue }));
useEffect(() => () => engine.destroy(), [engine]);
const [source, setSourceState] = useState<CropperSource | null>(null); const setSource = useCallback((next: CropperSource) => { setSourceState((current) => { if (current && current.src === next.src && sameSize(current.naturalSize, next.naturalSize)) { return current; } return next; }); }, []);
const viewportSize = useRef<Size>(ZERO_SIZE); const measured = useRef<Size | null>(null); const declared = source?.naturalSize ?? null; const syncGeometry = useCallback(() => { engine.setGeometry({ aspectRatio, natural: measured.current ?? declared, viewport: viewportSize.current, }); }, [aspectRatio, declared, engine]); const setViewportSize = useCallback( (size: Size) => { viewportSize.current = size; syncGeometry(); }, [syncGeometry], ); const reportNaturalSize = useCallback( (size: Size) => { measured.current = size; syncGeometry(); }, [syncGeometry], );
useEffect(() => { syncGeometry(); }, [syncGeometry]);
const [appliedConfig, setAppliedConfig] = useState(() => engine.config); useEffect(() => { const next = normalizeConfig(config); if (sameConfig(engine.config, next)) { return; } engine.setConfig(next); setAppliedConfig(next); });
const lastPushed = useRef<CropValue | undefined>(value);
// ignore the initial report; reset for later sources. const lastSrc = useRef<string | undefined | null>(null); useEffect(() => { if (!source) { return; } const previous = lastSrc.current; lastSrc.current = source.src; if (previous === null || previous === source.src) { return; } measured.current = null; lastPushed.current = value; engine.reset(); syncGeometry(); // value changes must not reset the crop. // oxlint-disable-next-line react-hooks/exhaustive-deps }, [engine, source, syncGeometry]);
useEffect(() => { if (value === undefined || (lastPushed.current && sameCropValue(lastPushed.current, value))) { return; } lastPushed.current = value; engine.setValue(value); }, [engine, value]);
const getValue = () => engine.getState().value; const engineValue = useSyncExternalStore(engine.subscribe, getValue, getValue); const lastReported = useRef(engineValue); useEffect(() => { if (sameCropValue(lastReported.current, engineValue)) { return; } lastReported.current = engineValue; if (lastPushed.current && sameCropValue(lastPushed.current, engineValue)) { return; } onValueChange?.(engineValue); }, [engineValue, onValueChange]);
const context = useMemo<CropperContextValue>( () => ({ aspectRatio, config: appliedConfig, engine, reportNaturalSize, rotate: (turns) => engine.rotate(turns), setPan: (pan) => engine.setPan(pan), setRotation: (rotation) => engine.setRotation(rotation), setSource, setValue: (next) => engine.setValue(next), setViewportSize, setZoom: (zoom, focal) => engine.setZoom(zoom, focal), }), [appliedConfig, aspectRatio, engine, reportNaturalSize, setSource, setViewportSize], );
return <CropperContext.Provider value={context}>{children}</CropperContext.Provider>;};