All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Sparkle 🎆/🎇 from the jumbo emoji with a lightweight particle fountain that piles on the floor. Prefer text/plain over sticky clipboard images on Linux so address-bar URLs do not also attach a leftover bitmap.
324 lines
9.4 KiB
TypeScript
324 lines
9.4 KiB
TypeScript
import React, { useEffect, useMemo, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { EmojiConfettiBurst } from './types';
|
|
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
|
|
import {
|
|
BurstParticle,
|
|
drawBurstParticles,
|
|
spawnEmojiBurst,
|
|
stepBurstParticles,
|
|
} from './emojiBurstEngine';
|
|
import { getEmojiBurstProfile, isFullscreenBurstEmoji } from './emojiParticleProfiles';
|
|
import {
|
|
createFireworkSim,
|
|
drawFireworkSim,
|
|
FireworkSim,
|
|
getFireworkDpr,
|
|
stepFireworkSim,
|
|
} from './fireworkParticleEngine';
|
|
|
|
const MAX_DPR = 2;
|
|
|
|
type EmojiConfettiBurstCanvasProps = {
|
|
burst: EmojiConfettiBurst;
|
|
onComplete: (burstId: string) => void;
|
|
};
|
|
|
|
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
|
|
const primaryEmoji = burst.emojis[0] ?? '🎉';
|
|
const fullscreen = isFullscreenBurstEmoji(primaryEmoji);
|
|
|
|
if (fullscreen) {
|
|
return <FireworkBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
|
|
}
|
|
|
|
return <LocalBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
|
|
}
|
|
|
|
type BurstCanvasProps = {
|
|
burst: EmojiConfettiBurst;
|
|
primaryEmoji: string;
|
|
onComplete: (burstId: string) => void;
|
|
};
|
|
|
|
function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const simRef = useRef<FireworkSim | null>(null);
|
|
const frameRef = useRef<number | null>(null);
|
|
const lastFrameTimeRef = useRef<number | null>(null);
|
|
const spawnedRef = useRef(false);
|
|
const onCompleteRef = useRef(onComplete);
|
|
onCompleteRef.current = onComplete;
|
|
|
|
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;
|
|
}
|
|
|
|
const canvas = canvasRef.current;
|
|
const context = canvas.getContext('2d', { alpha: true });
|
|
if (!context) {
|
|
onCompleteRef.current(burst.id);
|
|
return undefined;
|
|
}
|
|
context.imageSmoothingEnabled = false;
|
|
|
|
spawnedRef.current = true;
|
|
|
|
const resize = () => {
|
|
const dpr = getFireworkDpr();
|
|
const width = window.innerWidth;
|
|
const height = window.innerHeight;
|
|
canvas.width = Math.round(width * dpr);
|
|
canvas.height = Math.round(height * dpr);
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
return { width, height, dpr };
|
|
};
|
|
|
|
const { width, height } = resize();
|
|
const jumbo = findJumboEmojiElement(burst.targetEventId);
|
|
const jumboRect = jumbo?.getBoundingClientRect();
|
|
const origin = {
|
|
x: jumboRect ? jumboRect.left + jumboRect.width / 2 : burst.origin.x,
|
|
y: jumboRect ? jumboRect.top + jumboRect.height / 2 : burst.origin.y,
|
|
maskRadius: burst.origin.maskRadius ?? 36,
|
|
};
|
|
|
|
// Clamp origin into the viewport so off-screen messages still blast on-screen.
|
|
origin.x = Math.min(width - 24, Math.max(24, origin.x));
|
|
origin.y = Math.min(height - 24, Math.max(24, origin.y));
|
|
|
|
const profile = getEmojiBurstProfile(primaryEmoji);
|
|
simRef.current = createFireworkSim(width, height, origin, primaryEmoji, profile);
|
|
|
|
const onResize = () => {
|
|
// Extra Things: match CSS size; sim keeps its launch-time dimensions.
|
|
resize();
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
|
|
const tick = (now: number) => {
|
|
const sim = simRef.current;
|
|
if (!sim) return;
|
|
|
|
const last = lastFrameTimeRef.current ?? now;
|
|
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
|
lastFrameTimeRef.current = now;
|
|
|
|
const alive = stepFireworkSim(sim, now, dtSeconds);
|
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
if (alive) {
|
|
drawFireworkSim(context, sim, now);
|
|
frameRef.current = requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
|
|
onCompleteRef.current(burst.id);
|
|
frameRef.current = null;
|
|
lastFrameTimeRef.current = null;
|
|
};
|
|
|
|
lastFrameTimeRef.current = performance.now();
|
|
frameRef.current = requestAnimationFrame(tick);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', onResize);
|
|
if (frameRef.current !== null) {
|
|
cancelAnimationFrame(frameRef.current);
|
|
frameRef.current = null;
|
|
}
|
|
simRef.current = null;
|
|
};
|
|
}, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);
|
|
|
|
return createPortal(
|
|
<div
|
|
aria-hidden
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
width: '100vw',
|
|
height: '100vh',
|
|
pointerEvents: 'none',
|
|
zIndex: 40,
|
|
}}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
function LocalBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
|
|
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, primaryEmoji, 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.id, burst.targetEventId, canvasSize, localOrigin, primaryEmoji]);
|
|
|
|
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
|
|
);
|
|
}
|