import { 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; } /** * cropper surface. * * @param props div props. * @returns the viewport element. * @throws when rendered outside a ``. */ 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(null); const rect = useRef(null); const pointersDown = useRef(new Set()); 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): 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) => { 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) => { 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) => { 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) => { onPointerCancel?.(e); engine.pointerUp(e.pointerId); pointersDown.current.delete(e.pointerId); }, [engine, onPointerCancel], ); const handleContextMenu = useCallback( (e: ReactMouseEvent) => { onContextMenu?.(e); endStuckGesture(); }, [endStuckGesture, onContextMenu], ); return ( // oxlint-disable-next-line jsx-a11y/no-static-element-interactions
{children}
); };