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
text/typescript
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