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:
2026-07-29 20:40:36 +10:00
parent ea7642f0bb
commit 0da4f0d6cb
48 changed files with 3689 additions and 46 deletions

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react';
type Props = {
children: React.ReactNode;
};
const MIN = 160;
const MAX = 1200;
export function ResizableStage({ children }: Props) {
const [size, setSize] = useState({ width: 520, height: 480 });
const dragRef = useRef<{
edge: 'e' | 's' | 'se';
startX: number;
startY: number;
startW: number;
startH: number;
} | null>(null);
const onPointerMove = useCallback((e: PointerEvent) => {
const drag = dragRef.current;
if (!drag) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
setSize((prev) => {
let width = prev.width;
let height = prev.height;
if (drag.edge === 'e' || drag.edge === 'se') {
width = clamp(drag.startW + dx, MIN, MAX);
}
if (drag.edge === 's' || drag.edge === 'se') {
height = clamp(drag.startH + dy, MIN, MAX);
}
return { width, height };
});
}, []);
const onPointerUp = useCallback(() => {
dragRef.current = null;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
}, [onPointerMove]);
const startDrag = (edge: 'e' | 's' | 'se') => (e: React.PointerEvent) => {
e.preventDefault();
dragRef.current = {
edge,
startX: e.clientX,
startY: e.clientY,
startW: size.width,
startH: size.height,
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
};
useEffect(() => () => onPointerUp(), [onPointerUp]);
const stageStyle: CSSProperties = {
width: size.width,
height: size.height,
};
return (
<div className="stage-wrap">
<div className="stage-meta">
{size.width} × {size.height}
</div>
<div className="stage" style={stageStyle}>
<div className="stage-canvas">{children}</div>
<button type="button" className="handle handle-e" aria-label="Resize width" onPointerDown={startDrag('e')} />
<button type="button" className="handle handle-s" aria-label="Resize height" onPointerDown={startDrag('s')} />
<button
type="button"
className="handle handle-se"
aria-label="Resize both"
onPointerDown={startDrag('se')}
/>
</div>
</div>
);
}
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}