208 lines
5.8 KiB
TypeScript
208 lines
5.8 KiB
TypeScript
import React, { memo, useCallback, useRef } from '../../lib/teact/teact';
|
|
import { withGlobal } from '../../global';
|
|
|
|
import type { TabState } from '../../global/types';
|
|
import type { FC } from '../../lib/teact/teact';
|
|
|
|
import { pick } from '../../util/iteratees';
|
|
import buildStyle from '../../util/buildStyle';
|
|
import { selectTabState } from '../../global/selectors';
|
|
|
|
import useWindowSize from '../../hooks/useWindowSize';
|
|
import useSyncEffect from '../../hooks/useSyncEffect';
|
|
import useForceUpdate from '../../hooks/useForceUpdate';
|
|
import useAppLayout from '../../hooks/useAppLayout';
|
|
|
|
import styles from './ConfettiContainer.module.scss';
|
|
|
|
type StateProps = {
|
|
confetti?: TabState['confetti'];
|
|
};
|
|
|
|
interface Confetti {
|
|
pos: {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
velocity: {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
size: number;
|
|
color: string;
|
|
flicker: number;
|
|
flickerFrequency: number;
|
|
rotation: number;
|
|
lastDrawnAt: number;
|
|
frameCount: number;
|
|
}
|
|
|
|
const CONFETTI_FADEOUT_TIMEOUT = 10000;
|
|
const DEFAULT_CONFETTI_SIZE = 10;
|
|
const CONFETTI_COLORS = ['#E8BC2C', '#D0049E', '#02CBFE', '#5723FD', '#FE8C27', '#6CB859'];
|
|
|
|
const ConfettiContainer: FC<StateProps> = ({ confetti }) => {
|
|
// eslint-disable-next-line no-null/no-null
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const confettiRef = useRef<Confetti[]>([]);
|
|
const isRafStartedRef = useRef(false);
|
|
const windowSize = useWindowSize();
|
|
const forceUpdate = useForceUpdate();
|
|
const { isMobile } = useAppLayout();
|
|
|
|
const defaultConfettiAmount = isMobile ? 50 : 100;
|
|
const {
|
|
lastConfettiTime, top, width, left, height,
|
|
} = confetti || {};
|
|
|
|
const generateConfetti = useCallback((w: number, h: number, amount = defaultConfettiAmount) => {
|
|
for (let i = 0; i < amount; i++) {
|
|
const leftSide = i % 2;
|
|
const pos = {
|
|
x: w * (leftSide ? -0.1 : 1.1),
|
|
y: h * 0.75,
|
|
};
|
|
const randomX = Math.random() * w * 1.5;
|
|
const randomY = -h / 2 - Math.random() * h;
|
|
const velocity = {
|
|
x: leftSide ? randomX : randomX * -1,
|
|
y: randomY,
|
|
};
|
|
|
|
const randomColor = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
|
|
const size = DEFAULT_CONFETTI_SIZE;
|
|
confettiRef.current.push({
|
|
pos,
|
|
size,
|
|
color: randomColor,
|
|
velocity,
|
|
flicker: size,
|
|
flickerFrequency: Math.random() * 0.2,
|
|
rotation: 0,
|
|
lastDrawnAt: Date.now(),
|
|
frameCount: 0,
|
|
});
|
|
}
|
|
}, [defaultConfettiAmount]);
|
|
|
|
const updateCanvas = useCallback(() => {
|
|
if (!canvasRef.current || !isRafStartedRef.current) {
|
|
return;
|
|
}
|
|
const canvas = canvasRef.current;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
|
|
const { width: canvasWidth, height: canvasHeight } = canvas;
|
|
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
|
|
const confettiToRemove: Confetti[] = [];
|
|
confettiRef.current.forEach((c, i) => {
|
|
const {
|
|
pos,
|
|
velocity,
|
|
size,
|
|
color,
|
|
flicker,
|
|
flickerFrequency,
|
|
rotation,
|
|
lastDrawnAt,
|
|
frameCount,
|
|
} = c;
|
|
const diff = (Date.now() - lastDrawnAt) / 1000;
|
|
|
|
const newPos = {
|
|
x: pos.x + velocity.x * diff,
|
|
y: pos.y + velocity.y * diff,
|
|
};
|
|
|
|
const newVelocity = {
|
|
x: velocity.x * 0.98, // Air Resistance
|
|
y: velocity.y += diff * 1000, // Gravity
|
|
};
|
|
|
|
const newFlicker = size * Math.abs(Math.sin(frameCount * flickerFrequency));
|
|
const newRotation = 5 * frameCount * flickerFrequency * (Math.PI / 180);
|
|
|
|
const newFrameCount = frameCount + 1;
|
|
const newLastDrawnAt = Date.now();
|
|
|
|
const shouldRemove = newPos.y > canvasHeight + c.size;
|
|
if (shouldRemove) {
|
|
confettiToRemove.push(c);
|
|
return;
|
|
}
|
|
|
|
const newConfetti = {
|
|
...c,
|
|
pos: newPos,
|
|
velocity: newVelocity,
|
|
flicker: newFlicker,
|
|
rotation: newRotation,
|
|
lastDrawnAt: newLastDrawnAt,
|
|
frameCount: newFrameCount,
|
|
};
|
|
|
|
confettiRef.current[i] = newConfetti;
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.ellipse(
|
|
pos.x,
|
|
pos.y,
|
|
size,
|
|
flicker,
|
|
rotation,
|
|
0,
|
|
2 * Math.PI,
|
|
);
|
|
ctx.fill();
|
|
});
|
|
confettiRef.current = confettiRef.current.filter((c) => !confettiToRemove.includes(c));
|
|
if (confettiRef.current.length) {
|
|
requestAnimationFrame(updateCanvas);
|
|
} else {
|
|
isRafStartedRef.current = false;
|
|
}
|
|
}, []);
|
|
|
|
useSyncEffect(([prevConfettiTime]) => {
|
|
let hideTimeout: ReturnType<typeof setTimeout>;
|
|
if (prevConfettiTime !== lastConfettiTime) {
|
|
generateConfetti(width || windowSize.width, height || windowSize.height);
|
|
hideTimeout = setTimeout(forceUpdate, CONFETTI_FADEOUT_TIMEOUT);
|
|
if (!isRafStartedRef.current) {
|
|
isRafStartedRef.current = true;
|
|
requestAnimationFrame(updateCanvas);
|
|
}
|
|
}
|
|
return () => {
|
|
clearTimeout(hideTimeout);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Old timeout should be cleared only if new confetti is generated
|
|
}, [lastConfettiTime, forceUpdate, updateCanvas]);
|
|
|
|
if (!lastConfettiTime || Date.now() - lastConfettiTime > CONFETTI_FADEOUT_TIMEOUT) {
|
|
return undefined;
|
|
}
|
|
|
|
const style = buildStyle(
|
|
Boolean(top) && `top: ${top}px`,
|
|
Boolean(left) && `left: ${left}px`,
|
|
Boolean(width) && `width: ${width}px`,
|
|
Boolean(height) && `height: ${height}px`,
|
|
);
|
|
|
|
return (
|
|
<div id="Confetti" className={styles.root} style={style}>
|
|
<canvas ref={canvasRef} className={styles.canvas} width={windowSize.width} height={windowSize.height} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default memo(withGlobal(
|
|
(global): StateProps => pick(selectTabState(global), ['confetti']),
|
|
)(ConfettiContainer));
|