UNPKG

react-avatar-editor

Version:

Avatar / profile picture component. Resize and crop your uploaded image using a intuitive user interface.

472 lines (423 loc) 12 kB
import React, { type TouchEventHandler, type CSSProperties, type MouseEventHandler, useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, } from 'react' import { AvatarEditorCore, type ImageState, type Position, type AvatarEditorConfig, isPassiveSupported, } from '@react-avatar-editor/core' export interface Props extends AvatarEditorConfig { style?: CSSProperties image?: string | File position?: Position onLoadStart?: () => void onLoadFailure?: () => void onLoadSuccess?: (image: ImageState) => void onImageReady?: () => void onImageChange?: () => void onMouseUp?: () => void onMouseMove?: (e: TouchEvent | MouseEvent) => void onPositionChange?: (position: Position) => void } export type { Position, ImageState } export interface AvatarEditorRef { getImage: () => HTMLCanvasElement getImageScaledToCanvas: () => HTMLCanvasElement getCroppingRect: () => { x: number; y: number; width: number; height: number } } const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => { const { scale = 1, rotate = 0, border = 25, borderRadius = 0, width = 200, height = 200, color = [0, 0, 0, 0.5], showGrid = false, gridColor = '#666', disableBoundaryChecks = false, disableHiDPIScaling = false, disableCanvasRotation = true, image, position, backgroundColor, crossOrigin, onLoadStart, onLoadFailure, onLoadSuccess, onImageReady, onImageChange, onMouseUp, onMouseMove, onPositionChange, borderColor, style, } = props const canvas = useRef<HTMLCanvasElement>(null) const coreRef = useRef<AvatarEditorCore>( new AvatarEditorCore({ width, height, border, borderRadius, scale, rotate, color, backgroundColor, borderColor, showGrid, gridColor, disableBoundaryChecks, disableHiDPIScaling, disableCanvasRotation, crossOrigin, }), ) // Use refs for drag state to avoid stale closures in document-level listeners. // These values are read/written in handlers registered once on mount. const dragRef = useRef(false) const mxRef = useRef<number | undefined>(undefined) const myRef = useRef<number | undefined>(undefined) // Keep state for `drag` and `loading` to trigger re-renders const [drag, setDrag] = useState(false) const [loading, setLoading] = useState(false) const [imageState, setImageState] = useState<ImageState>( coreRef.current.getImageState(), ) // Store latest callback props in refs so document handlers always call current versions const onMouseUpRef = useRef(onMouseUp) onMouseUpRef.current = onMouseUp const onMouseMoveRef = useRef(onMouseMove) onMouseMoveRef.current = onMouseMove const onPositionChangeRef = useRef(onPositionChange) onPositionChangeRef.current = onPositionChange // Update core config when props change useEffect(() => { coreRef.current.updateConfig({ width, height, border, borderRadius, scale, rotate, color, backgroundColor, borderColor, showGrid, gridColor, disableBoundaryChecks, disableHiDPIScaling, disableCanvasRotation, crossOrigin, }) }, [ width, height, border, borderRadius, scale, rotate, color, backgroundColor, borderColor, showGrid, gridColor, disableBoundaryChecks, disableHiDPIScaling, disableCanvasRotation, crossOrigin, ]) const getCanvas = useCallback((): HTMLCanvasElement => { if (!canvas.current) { throw new Error( 'No canvas found, please report this to: https://github.com/mosch/react-avatar-editor/issues', ) } return canvas.current }, []) const getContext = useCallback(() => { const context = getCanvas().getContext('2d') if (!context) { throw new Error( 'No context found, please report this to: https://github.com/mosch/react-avatar-editor/issues', ) } return context }, [getCanvas]) const loadImage = useCallback( async (file: File | string) => { setLoading(true) onLoadStart?.() try { const newImageState = await coreRef.current.loadImage(file) dragRef.current = false setDrag(false) setImageState(newImageState) onImageReady?.() onLoadSuccess?.(newImageState) } catch { onLoadFailure?.() } finally { setLoading(false) } }, [onLoadStart, onImageReady, onLoadSuccess, onLoadFailure], ) const clearImage = useCallback(() => { const canvasEl = getCanvas() const context = getContext() context.clearRect(0, 0, canvasEl.width, canvasEl.height) coreRef.current.clearImage() setImageState(coreRef.current.getImageState()) }, [getCanvas, getContext]) const repaint = useCallback(() => { const context = getContext() const canvasEl = getCanvas() context.clearRect(0, 0, canvasEl.width, canvasEl.height) coreRef.current.paint(context) coreRef.current.paintImage(context, imageState, border) // eslint-disable-next-line -- all visual props must trigger repaint }, [ getContext, getCanvas, imageState, border, width, height, borderRadius, scale, rotate, color, backgroundColor, borderColor, showGrid, gridColor, disableBoundaryChecks, disableHiDPIScaling, disableCanvasRotation, crossOrigin, ]) const handleMouseDown: MouseEventHandler<HTMLCanvasElement> = useCallback( (e) => { e.preventDefault() dragRef.current = true mxRef.current = undefined myRef.current = undefined setDrag(true) }, [], ) const handleTouchStart: TouchEventHandler<HTMLCanvasElement> = useCallback(() => { dragRef.current = true mxRef.current = undefined myRef.current = undefined setDrag(true) }, []) // Expose imperative methods via ref useImperativeHandle( ref, () => ({ getImage: () => coreRef.current.getImage(), getImageScaledToCanvas: () => coreRef.current.getImageScaledToCanvas(), getCroppingRect: () => coreRef.current.getCroppingRect(), }), [], ) // Mount effect - setup document-level event listeners. // Handlers read from refs (not closures) to always have current values. useEffect(() => { const context = getContext() if (image) { loadImage(image) } coreRef.current.paint(context) const handleDocumentMouseMove = (e: MouseEvent | TouchEvent) => { if (!dragRef.current) { return } if (e.cancelable) e.preventDefault() const mousePositionX = 'targetTouches' in e ? e.targetTouches[0].pageX : e.clientX const mousePositionY = 'targetTouches' in e ? e.targetTouches[0].pageY : e.clientY const prevMx = mxRef.current const prevMy = myRef.current mxRef.current = mousePositionX myRef.current = mousePositionY if (prevMx !== undefined && prevMy !== undefined) { const currentImageState = coreRef.current.getImageState() if (currentImageState.width && currentImageState.height) { const newPosition = coreRef.current.calculateDragPosition( mousePositionX, mousePositionY, prevMx, prevMy, ) onPositionChangeRef.current?.(newPosition) const updatedImageState = { ...currentImageState, ...newPosition } coreRef.current.setImageState(updatedImageState) setImageState(updatedImageState) } } onMouseMoveRef.current?.(e) } const handleDocumentMouseUp = () => { if (dragRef.current) { dragRef.current = false setDrag(false) onMouseUpRef.current?.() } } const options = isPassiveSupported() ? { passive: false } : false document.addEventListener('mousemove', handleDocumentMouseMove, options) document.addEventListener('mouseup', handleDocumentMouseUp, options) document.addEventListener('touchmove', handleDocumentMouseMove, options) document.addEventListener('touchend', handleDocumentMouseUp, options) return () => { document.removeEventListener('mousemove', handleDocumentMouseMove, false) document.removeEventListener('mouseup', handleDocumentMouseUp, false) document.removeEventListener('touchmove', handleDocumentMouseMove, false) document.removeEventListener('touchend', handleDocumentMouseUp, false) } }, []) // Effect to handle image changes useEffect(() => { if (image) { loadImage(image) } else if (!image && imageState.x !== 0.5 && imageState.y !== 0.5) { clearImage() } }, [image, width, height, backgroundColor]) // Effect to repaint canvas whenever relevant props/state change useEffect(() => { repaint() }, [repaint]) // Pulsate the full canvas while loading useEffect(() => { if (!loading) return const canvasEl = canvas.current if (!canvasEl) return const ctx = canvasEl.getContext('2d') if (!ctx) return let frameId: number const start = performance.now() const draw = (now: number) => { const t = (now - start) / 1000 const alpha = 0.03 + Math.sin(t * 2.5) * 0.02 + 0.02 ctx.save() ctx.clearRect(0, 0, canvasEl.width, canvasEl.height) ctx.fillStyle = `rgba(255,255,255,${alpha})` ctx.fillRect(0, 0, canvasEl.width, canvasEl.height) ctx.restore() frameId = requestAnimationFrame(draw) } frameId = requestAnimationFrame(draw) return () => cancelAnimationFrame(frameId) }, [loading]) // Effect to trigger onImageChange callback const prevPropsRef = useRef({ image, width, height, position, scale, rotate, imageX: imageState.x, imageY: imageState.y, }) useEffect(() => { const prev = prevPropsRef.current if ( prev.image !== image || prev.width !== width || prev.height !== height || prev.position !== position || prev.scale !== scale || prev.rotate !== rotate || prev.imageX !== imageState.x || prev.imageY !== imageState.y ) { onImageChange?.() prevPropsRef.current = { image, width, height, position, scale, rotate, imageX: imageState.x, imageY: imageState.y, } } }, [ image, width, height, position, scale, rotate, imageState.x, imageState.y, onImageChange, ]) const dimensions = coreRef.current.getDimensions() const pixelRatio = coreRef.current.getPixelRatio() const defaultStyle: CSSProperties = { width: dimensions.canvas.width, height: dimensions.canvas.height, cursor: drag ? 'grabbing' : 'grab', touchAction: 'none', maxWidth: 'none', maxHeight: 'none', } return React.createElement('canvas', { width: dimensions.canvas.width * pixelRatio, height: dimensions.canvas.height * pixelRatio, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, style: { ...defaultStyle, ...style }, ref: canvas, }) }) AvatarEditor.displayName = 'AvatarEditor' export function useAvatarEditor() { const ref = useRef<AvatarEditorRef>(null) return { ref, getImage: (): HTMLCanvasElement | null => { try { return ref.current?.getImage() ?? null } catch { return null } }, getImageScaledToCanvas: (): HTMLCanvasElement | null => { try { return ref.current?.getImageScaledToCanvas() ?? null } catch { return null } }, getCroppingRect: (): { x: number y: number width: number height: number } | null => { return ref.current?.getCroppingRect() ?? null }, } } export default AvatarEditor