Support app.relay.emoji_confetti on jumbo emoji clicks with canvas physics, per-emoji particle profiles, and a fixed overlay that does not affect scroll. Also add a Vite playground for live component previews and theme hover preview.
181 lines
5.0 KiB
TypeScript
181 lines
5.0 KiB
TypeScript
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<HTMLCanvasElement>(null);
|
|
const layerRef = useRef<HTMLDivElement>(null);
|
|
const particlesRef = useRef<BurstParticle[]>([]);
|
|
const frameRef = useRef<number | null>(null);
|
|
const lastFrameTimeRef = useRef<number | null>(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(
|
|
<div
|
|
ref={layerRef}
|
|
aria-hidden
|
|
style={{
|
|
position: 'fixed',
|
|
width: canvasSize,
|
|
height: canvasSize,
|
|
pointerEvents: 'none',
|
|
zIndex: 4,
|
|
}}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|