Add emoji confetti bursts and component playground.
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.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user