Something went wrong. Try again.
headless React image cropper
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206import { type ComponentPropsWithoutRef, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type Ref, useCallback, useEffect, useRef,} from 'react';
import type { Point } from '../core/types.ts';
import { useCropper, useCropperState } from './context.ts';
export interface CropperViewportProps extends ComponentPropsWithoutRef<'div'> { ref?: Ref<HTMLDivElement>;}
/** * cropper surface. * * @param props div props. * @returns the viewport element. * @throws when rendered outside a `<Cropper.Root>`. */export const Viewport = (props: CropperViewportProps) => { const { children, onContextMenu, onPointerCancel, onPointerDown, onPointerMove, onPointerUp, ref, style, ...rest } = props; const { config, engine, setViewportSize } = useCropper(); const isDragging = useCropperState((state) => state.isDragging); const isPinching = useCropperState((state) => state.isPinching); const localRef = useRef<HTMLDivElement>(null); const rect = useRef<DOMRect | null>(null); const pointersDown = useRef(new Set<number>()); const { wheelZoom } = config;
const setRefs = useCallback( (node: HTMLDivElement | null) => { localRef.current = node; if (typeof ref !== 'function') { if (ref) { ref.current = node; } return () => { localRef.current = null; if (ref) { ref.current = null; } }; } const cleanup = ref(node); return () => { localRef.current = null; if (typeof cleanup === 'function') { cleanup(); } else { ref(null); } }; }, [ref], );
const local = useCallback((event: Pick<ReactPointerEvent, 'clientX' | 'clientY'>): Point => { const r = rect.current; return r ? { x: event.clientX - r.left, y: event.clientY - r.top } : { x: event.clientX, y: event.clientY }; }, []);
// preserve fractional viewport dimensions. useEffect(() => { const el = localRef.current; if (!el) { return undefined; } const ro = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; setViewportSize({ width, height }); }); ro.observe(el); return () => ro.disconnect(); }, [setViewportSize]);
// React wheel listeners are passive and cannot prevent page zoom or scroll. useEffect(() => { const el = localRef.current; if (!el || wheelZoom === 'none') { return undefined; } const onWheel = (e: WheelEvent) => { if (wheelZoom === 'ctrl' && !e.ctrlKey) { return; } e.preventDefault(); const r = el.getBoundingClientRect(); engine.wheel({ x: e.clientX - r.left, y: e.clientY - r.top }, e.deltaY); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, [engine, wheelZoom]);
const handlePointerDown = useCallback( (e: ReactPointerEvent<HTMLDivElement>) => { onPointerDown?.(e); // let interactive descendants handle their input. const target = e.target instanceof Element ? e.target : null; if (target?.closest('button, a, input, select, textarea, [role="button"], [data-cropper-control]')) { return; } pointersDown.current.add(e.pointerId); try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} rect.current = e.currentTarget.getBoundingClientRect(); engine.pointerDown(e.pointerId, local(e)); }, [engine, local, onPointerDown], );
// end gestures when pointer capture does not deliver pointerup. const endStuckGesture = useCallback(() => { for (const id of pointersDown.current) { try { localRef.current?.releasePointerCapture(id); } catch {} engine.pointerUp(id); } pointersDown.current.clear(); }, [engine]);
const handlePointerMove = useCallback( (e: ReactPointerEvent<HTMLDivElement>) => { onPointerMove?.(e); if (e.pointerType === 'mouse' && e.buttons === 0 && pointersDown.current.has(e.pointerId)) { endStuckGesture(); return; } engine.pointerMove(e.pointerId, local(e)); }, [endStuckGesture, engine, local, onPointerMove], );
const handlePointerUp = useCallback( (e: ReactPointerEvent<HTMLDivElement>) => { onPointerUp?.(e); engine.pointerMove(e.pointerId, local(e)); engine.pointerUp(e.pointerId); pointersDown.current.delete(e.pointerId); }, [engine, local, onPointerUp], );
const handlePointerCancel = useCallback( (e: ReactPointerEvent<HTMLDivElement>) => { onPointerCancel?.(e); engine.pointerUp(e.pointerId); pointersDown.current.delete(e.pointerId); }, [engine, onPointerCancel], );
const handleContextMenu = useCallback( (e: ReactMouseEvent<HTMLDivElement>) => { onContextMenu?.(e); endStuckGesture(); }, [endStuckGesture, onContextMenu], );
return ( // oxlint-disable-next-line jsx-a11y/no-static-element-interactions <div {...rest} ref={setRefs} data-dragging={isDragging ? '' : undefined} data-pinching={isPinching ? '' : undefined} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} onContextMenu={handleContextMenu} // oxlint-disable-next-line react-perf/jsx-no-new-object-as-prop style={{ position: 'relative', overflow: 'hidden', touchAction: 'none', userSelect: 'none', WebkitUserSelect: 'none', ...style, }} > {children} </div> );};