([]);
@@ -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);