diff --git a/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx b/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx index c533837..5f0ed5d 100644 --- a/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx +++ b/src/app/features/room/emoji-confetti/EmojiConfettiBurstCanvas.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { EmojiConfettiBurst } from './types'; import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount'; @@ -8,6 +8,14 @@ import { spawnEmojiBurst, stepBurstParticles, } from './emojiBurstEngine'; +import { getEmojiBurstProfile, isFullscreenBurstEmoji } from './emojiParticleProfiles'; +import { + createFireworkSim, + drawFireworkSim, + FireworkSim, + getFireworkDpr, + stepFireworkSim, +} from './fireworkParticleEngine'; const MAX_DPR = 2; @@ -17,6 +25,147 @@ type EmojiConfettiBurstCanvasProps = { }; export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) { + const primaryEmoji = burst.emojis[0] ?? '🎉'; + const fullscreen = isFullscreenBurstEmoji(primaryEmoji); + + if (fullscreen) { + return ; + } + + return ; +} + +type BurstCanvasProps = { + burst: EmojiConfettiBurst; + primaryEmoji: string; + onComplete: (burstId: string) => void; +}; + +function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) { + const canvasRef = useRef(null); + const simRef = useRef(null); + const frameRef = useRef(null); + const lastFrameTimeRef = useRef(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( +
+ +
, + document.body + ); +} + +function LocalBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) { const canvasRef = useRef(null); const layerRef = useRef(null); const particlesRef = useRef([]); @@ -89,13 +238,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur canvas.style.width = `${canvasSize}px`; canvas.style.height = `${canvasSize}px`; - spawnEmojiBurst( - particlesRef.current, - burst.id, - localOrigin, - burst.emojis[0] ?? '🎉', - performance.now() - ); + spawnEmojiBurst(particlesRef.current, burst.id, localOrigin, primaryEmoji, performance.now()); const tick = (now: number) => { updateLayerPosition(); @@ -152,7 +295,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur window.clearInterval(retry); frameCleanup?.(); }; - }, [burst.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]); + }, [burst.id, burst.targetEventId, canvasSize, localOrigin, primaryEmoji]); return createPortal(
> = { companions: ['🎉', '🎊', '🎈'], companionChance: 0.4, }, + '🎆': { + style: 'firework', + fullscreen: true, + particleCount: 320, + gravity: 980, + drag: 1, + speedMin: 420, + speedMax: 980, + launchUpMin: 180, + launchUpMax: 420, + spinMin: -520, + spinMax: 520, + fontSizeMin: 16, + fontSizeMax: 30, + heroFontSizeMin: 40, + heroFontSizeMax: 52, + companions: ['🎇', '✨', '💥', '⭐', '🎉'], + companionChance: 0.55, + }, + '🎇': { + style: 'firework', + fullscreen: true, + particleCount: 280, + gravity: 960, + drag: 1, + speedMin: 380, + speedMax: 920, + launchUpMin: 160, + launchUpMax: 400, + spinMin: -480, + spinMax: 480, + fontSizeMin: 14, + fontSizeMax: 28, + heroFontSizeMin: 36, + heroFontSizeMax: 48, + companions: ['🎆', '✨', '⭐', '💥'], + companionChance: 0.5, + twinkle: true, + }, '🐱': { style: 'bounce', particleCount: 20, @@ -369,6 +411,10 @@ export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile { return { ...DEFAULT_PROFILE, ...override }; } +export function isFullscreenBurstEmoji(emoji: string): boolean { + return getEmojiBurstProfile(emoji).fullscreen === true; +} + export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string { if (!profile.companions?.length || !profile.companionChance) { return primaryEmoji; diff --git a/src/app/features/room/emoji-confetti/fireworkParticleEngine.ts b/src/app/features/room/emoji-confetti/fireworkParticleEngine.ts new file mode 100644 index 0000000..44a0805 --- /dev/null +++ b/src/app/features/room/emoji-confetti/fireworkParticleEngine.ts @@ -0,0 +1,336 @@ +/** + * Old-school fullscreen firework fountain — no physics engine. + * Position/velocity arrays, gravity, floor bounce, settle into a pile. + * Same vibe as late-90s/MSN page effects, just with emoji. + */ +import { BurstPoint } from './burstOrigin'; +import { pickParticleEmoji, type EmojiBurstProfile } from './emojiParticleProfiles'; + +const EMOJI_CACHE_PX = 40; +const EMOJI_CACHE_SCALE = 2; + +const SPAWN_WINDOW_MS = 2200; +const SETTLE_HOLD_MS = 7800; +const FADE_MS = 800; +export const FIREWORK_TOTAL_MS = SETTLE_HOLD_MS + FADE_MS; + +const SPARKLE_IN_MS = 120; +const MAX_SPAWNS_PER_FRAME = 6; +const FLOOR_PAD = 8; +const BOUNCE = 0.38; +const DRAG = 0.992; +const SETTLE_SPEED = 55; +const PILE_CELL = 22; + +type SpawnItem = { + atMs: number; + emoji: string; + fontSize: number; +}; + +export type FireworkParticle = { + x: number; + y: number; + vx: number; + vy: number; + spin: number; + angle: number; + emoji: string; + emojiCanvas: HTMLCanvasElement; + drawSize: number; + halfSize: number; + bornAt: number; + settled: boolean; +}; + +export type FireworkSim = { + particles: FireworkParticle[]; + profile: EmojiBurstProfile; + width: number; + height: number; + origin: BurstPoint; + startMs: number; + spawnPlan: SpawnItem[]; + spawnCursor: number; + floorY: number; + /** Column stack heights for the settled pile (in cells). */ + pileCols: Uint16Array; + pileCanvas: HTMLCanvasElement | null; + pileCtx: CanvasRenderingContext2D | null; + flyingCount: number; +}; + +const emojiCanvasCache = new Map(); + +function lerp(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function getEmojiCanvas(emoji: string): HTMLCanvasElement { + const cached = emojiCanvasCache.get(emoji); + if (cached) return cached; + + const size = EMOJI_CACHE_PX * EMOJI_CACHE_SCALE; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.imageSmoothingEnabled = false; + ctx.font = `${EMOJI_CACHE_PX}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; + ctx.fillText(emoji, size / 2, size / 2); + } + emojiCanvasCache.set(emoji, canvas); + return canvas; +} + +function buildSpawnPlan( + profile: EmojiBurstProfile, + primaryEmoji: string, + startMs: number +): SpawnItem[] { + const plan: SpawnItem[] = [ + { + atMs: startMs, + emoji: primaryEmoji, + fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax), + }, + ]; + + const count = profile.particleCount; + for (let i = 0; i < count; i += 1) { + const slot = (i + 0.5) / count; + const eased = slot * slot; + plan.push({ + atMs: + startMs + + 80 + + eased * SPAWN_WINDOW_MS + + (Math.random() - 0.5) * 70, + emoji: pickParticleEmoji(profile, primaryEmoji), + fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax), + }); + } + + plan.sort((a, b) => a.atMs - b.atMs); + return plan; +} + +function ensurePile(sim: FireworkSim): CanvasRenderingContext2D { + if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx; + const canvas = document.createElement('canvas'); + canvas.width = sim.width; + canvas.height = sim.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('firework pile unsupported'); + ctx.imageSmoothingEnabled = false; + sim.pileCanvas = canvas; + sim.pileCtx = ctx; + return ctx; +} + +function stampSettled(sim: FireworkSim, p: FireworkParticle) { + const ctx = ensurePile(sim); + const rad = (p.angle * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + ctx.setTransform(cos, sin, -sin, cos, p.x, p.y); + ctx.globalAlpha = 1; + ctx.drawImage(p.emojiCanvas, -p.halfSize, -p.halfSize, p.drawSize, p.drawSize); + ctx.setTransform(1, 0, 0, 1, 0, 0); +} + +function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) { + const { profile, origin } = sim; + const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25); + const angle = Math.random() * Math.PI * 2; + const speed = lerp(profile.speedMin, profile.speedMax); + const launchUp = lerp(profile.launchUpMin, profile.launchUpMax); + const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85; + + sim.particles.push({ + x: origin.x + (Math.random() - 0.5) * jitter, + y: origin.y + (Math.random() - 0.5) * jitter, + vx: Math.cos(angle) * speed + (Math.random() - 0.5) * 40, + vy: Math.sin(angle) * speed - launchUp, + spin: lerp(profile.spinMin, profile.spinMax) * 0.35, + angle: (Math.random() - 0.5) * 40, + emoji: item.emoji, + emojiCanvas: getEmojiCanvas(item.emoji), + drawSize, + halfSize: drawSize / 2, + bornAt: now, + settled: false, + }); + sim.flyingCount += 1; +} + +function settleParticle(sim: FireworkSim, p: FireworkParticle) { + const cols = sim.pileCols.length; + let col = Math.floor(p.x / PILE_CELL); + if (col < 0) col = 0; + if (col >= cols) col = cols - 1; + + // Prefer the intended column; spill to neighbors if that stack is already tall. + let bestCol = col; + let bestH = sim.pileCols[col]; + for (const delta of [0, -1, 1, -2, 2]) { + const c = col + delta; + if (c < 0 || c >= cols) continue; + if (sim.pileCols[c] < bestH) { + bestH = sim.pileCols[c]; + bestCol = c; + } + } + + const stack = sim.pileCols[bestCol]; + sim.pileCols[bestCol] = stack + 1; + p.x = bestCol * PILE_CELL + PILE_CELL * 0.5 + (Math.random() - 0.5) * 6; + p.y = sim.floorY - stack * (PILE_CELL * 0.72) - p.halfSize; + p.vx = 0; + p.vy = 0; + p.spin = 0; + p.settled = true; + sim.flyingCount = Math.max(0, sim.flyingCount - 1); + stampSettled(sim, p); +} + +export function createFireworkSim( + widthPx: number, + heightPx: number, + originPx: BurstPoint, + primaryEmoji: string, + profile: EmojiBurstProfile, + startMs = performance.now() +): FireworkSim { + const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL)); + return { + particles: [], + profile, + width: widthPx, + height: heightPx, + origin: originPx, + startMs, + spawnPlan: buildSpawnPlan(profile, primaryEmoji, startMs), + spawnCursor: 0, + floorY: heightPx - FLOOR_PAD, + pileCols: new Uint16Array(colCount), + pileCanvas: null, + pileCtx: null, + flyingCount: 0, + }; +} + +/** @returns false when the burst should be removed. */ +export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean { + if (now - sim.startMs > FIREWORK_TOTAL_MS) return false; + + const dt = Math.min(dtSeconds, 0.05); + const g = sim.profile.gravity; + + let spawned = 0; + while ( + spawned < MAX_SPAWNS_PER_FRAME && + sim.spawnCursor < sim.spawnPlan.length && + sim.spawnPlan[sim.spawnCursor].atMs <= now + ) { + spawnOne(sim, sim.spawnPlan[sim.spawnCursor], now); + sim.spawnCursor += 1; + spawned += 1; + } + + const floor = sim.floorY; + const left = 4; + const right = sim.width - 4; + + for (let i = 0; i < sim.particles.length; i += 1) { + const p = sim.particles[i]; + if (p.settled) continue; + + p.vy += g * dt; + p.vx *= DRAG; + p.vy *= DRAG; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.angle += p.spin * dt; + + if (p.x < left) { + p.x = left; + p.vx = Math.abs(p.vx) * BOUNCE; + } else if (p.x > right) { + p.x = right; + p.vx = -Math.abs(p.vx) * BOUNCE; + } + + if (p.y >= floor - p.halfSize) { + p.y = floor - p.halfSize; + const speed = Math.hypot(p.vx, p.vy); + if (speed < SETTLE_SPEED || Math.abs(p.vy) < SETTLE_SPEED * 0.55) { + settleParticle(sim, p); + } else { + p.vy = -Math.abs(p.vy) * BOUNCE; + p.vx *= 0.85; + p.spin *= 0.7; + } + } + } + + return true; +} + +export function drawFireworkSim( + context: CanvasRenderingContext2D, + sim: FireworkSim, + now: number +) { + const elapsed = now - sim.startMs; + let fade = 1; + if (elapsed >= SETTLE_HOLD_MS) { + fade = 1 - Math.min(1, (elapsed - SETTLE_HOLD_MS) / FADE_MS); + } + + context.setTransform(1, 0, 0, 1, 0, 0); + context.imageSmoothingEnabled = false; + + if (sim.pileCanvas) { + context.globalAlpha = fade; + context.drawImage(sim.pileCanvas, 0, 0); + } + + for (let i = 0; i < sim.particles.length; i += 1) { + const p = sim.particles[i]; + if (p.settled) continue; + + const ageMs = now - p.bornAt; + if (ageMs < 0) continue; + + let opacity = fade; + let scale = 1; + if (ageMs < SPARKLE_IN_MS) { + const t = ageMs / SPARKLE_IN_MS; + const appear = t * t * (3 - 2 * t); + scale = 0.5 + 0.65 * appear; + opacity *= appear; + } + if (opacity <= 0.02) continue; + + const rad = (p.angle * Math.PI) / 180; + const size = p.drawSize * scale; + const half = size / 2; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + context.globalAlpha = opacity; + context.setTransform(cos, sin, -sin, cos, p.x, p.y); + context.drawImage(p.emojiCanvas, -half, -half, size, size); + } + + context.globalAlpha = 1; + context.setTransform(1, 0, 0, 1, 0, 0); +} + +export function getFireworkDpr(): number { + return 1; +} diff --git a/src/app/hooks/useFilePasteHandler.ts b/src/app/hooks/useFilePasteHandler.ts index fabe40a..d071551 100644 --- a/src/app/hooks/useFilePasteHandler.ts +++ b/src/app/hooks/useFilePasteHandler.ts @@ -4,21 +4,30 @@ import { readClipboardImage, isTauri, isLinux } from '../utils/tauri'; export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler => useCallback( - async (evt) => { + (evt) => { const files = getDataTransferFiles(evt.clipboardData); if (files && files.length > 0) { + evt.preventDefault(); onPaste(files); return; } - // On Linux with Tauri, browser clipboard API doesn't work for images - // Use our custom Tauri command with arboard/Wayland support - if (isTauri() && isLinux()) { - const clipboardImage = await readClipboardImage(); - if (clipboardImage) { - onPaste([clipboardImage]); - } + // Address-bar / plain-text copies still win. On Linux the native clipboard + // often still has a previous image (or a junk bitmap) alongside text/plain; + // don't treat those as an image paste. + const text = evt.clipboardData?.getData('text/plain')?.trim() ?? ''; + if (text.length > 0) { + return; } + + // Browser clipboardData.files is empty for raw image copies on Linux Tauri/Electron. + if (!(isTauri() && isLinux())) return; + + // preventDefault before the async read, or the editor also inserts text. + evt.preventDefault(); + void readClipboardImage().then((clipboardImage) => { + if (clipboardImage) onPaste([clipboardImage]); + }); }, [onPaste] ); diff --git a/src/app/utils/tauri.ts b/src/app/utils/tauri.ts index 14c936f..6bf2c08 100644 --- a/src/app/utils/tauri.ts +++ b/src/app/utils/tauri.ts @@ -534,12 +534,20 @@ export const readClipboardImage = async (): Promise => { try { const electron = (window as any).electron; if (electron?.clipboard?.readImage) { - const dataUrl = await electron.clipboard.readImage(); + const result = await electron.clipboard.readImage(); + // IPC may return a data URL string or { success, data } depending on path. + const dataUrl = + typeof result === 'string' + ? result + : result && typeof result === 'object' && typeof result.data === 'string' + ? result.data + : null; if (!dataUrl) return null; // Convert data URL to File const response = await fetch(dataUrl); const blob = await response.blob(); + if (blob.size < 32) return null; return new File([blob], 'clipboard-image.png', { type: 'image/png' }); } } catch (err) { @@ -559,6 +567,7 @@ export const readClipboardImage = async (): Promise => { // Convert data URL to File const response = await fetch(dataUrl); const blob = await response.blob(); + if (blob.size < 32) return null; return new File([blob], 'clipboard-image.png', { type: 'image/png' }); } catch (err) { console.warn('Failed to read Tauri clipboard image:', err);