import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { EmojiConfettiBurst } from './types'; import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount'; import { BurstParticle, drawBurstParticles, spawnEmojiBurst, stepBurstParticles, } from './emojiBurstEngine'; const MAX_DPR = 2; type EmojiConfettiBurstCanvasProps = { burst: EmojiConfettiBurst; onComplete: (burstId: string) => void; }; export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) { const canvasRef = useRef(null); const layerRef = useRef(null); const particlesRef = useRef([]); const frameRef = useRef(null); const lastFrameTimeRef = useRef(null); const spawnedRef = useRef(false); const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; const canvasSize = getLocalBurstCanvasSize(burst.origin.maskRadius ?? 36); const localOrigin = useMemo( () => ({ x: canvasSize / 2, y: canvasSize / 2, maskRadius: burst.origin.maskRadius ?? 36, }), [burst.origin.maskRadius, canvasSize] ); const updateLayerPosition = () => { const layer = layerRef.current; if (!layer) return false; const jumbo = findJumboEmojiElement(burst.targetEventId); if (!jumbo) return false; const rect = jumbo.getBoundingClientRect(); layer.style.left = `${rect.left + rect.width / 2 - canvasSize / 2}px`; layer.style.top = `${rect.top + rect.height / 2 - canvasSize / 2}px`; return true; }; useEffect(() => { const onScrollOrResize = () => { updateLayerPosition(); }; window.addEventListener('scroll', onScrollOrResize, true); window.addEventListener('resize', onScrollOrResize); return () => { window.removeEventListener('scroll', onScrollOrResize, true); window.removeEventListener('resize', onScrollOrResize); }; }, [burst.targetEventId, canvasSize]); useEffect(() => { if (!canvasRef.current || spawnedRef.current) return undefined; const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (reducedMotion) { onCompleteRef.current(burst.id); return undefined; } let frameCleanup: (() => void) | undefined; const start = (): boolean => { if (spawnedRef.current || !canvasRef.current) return false; if (!updateLayerPosition()) return false; spawnedRef.current = true; const canvas = canvasRef.current; const context = canvas.getContext('2d'); if (!context) return false; const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); canvas.width = Math.round(canvasSize * dpr); canvas.height = Math.round(canvasSize * dpr); canvas.style.width = `${canvasSize}px`; canvas.style.height = `${canvasSize}px`; spawnEmojiBurst( particlesRef.current, burst.id, localOrigin, burst.emojis[0] ?? '🎉', performance.now() ); const tick = (now: number) => { updateLayerPosition(); const last = lastFrameTimeRef.current ?? now; const dtSeconds = Math.min((now - last) / 1000, 0.05); lastFrameTimeRef.current = now; stepBurstParticles(particlesRef.current, now, dtSeconds); context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, canvas.width, canvas.height); if (particlesRef.current.length > 0) { drawBurstParticles(context, particlesRef.current, dpr, now); frameRef.current = requestAnimationFrame(tick); return; } onCompleteRef.current(burst.id); frameRef.current = null; lastFrameTimeRef.current = null; }; lastFrameTimeRef.current = performance.now(); frameRef.current = requestAnimationFrame(tick); frameCleanup = () => { if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); frameRef.current = null; } }; return true; }; if (start()) { return frameCleanup; } let attempt = 0; const retry = window.setInterval(() => { attempt += 1; if (start() || attempt >= 10) { window.clearInterval(retry); if (attempt >= 10 && !spawnedRef.current) { onCompleteRef.current(burst.id); } } }, 50); return () => { window.clearInterval(retry); frameCleanup?.(); }; }, [burst.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]); return createPortal(
, document.body ); }