import { 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; /** 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(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(ZERO_SIZE); const measured = useRef(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(value); // ignore the initial report; reset for later sources. const lastSrc = useRef(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( () => ({ 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 {children}; };