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:
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextAreaComponent as TextArea, Input } from 'folds';
|
||||
import { Box, Chip, IconButton, Text, config, Button, Spinner, color, TextArea, Input } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { Page, PageHeader } from '../../../components/page';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
@@ -171,7 +171,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
||||
<Box shrink="No">
|
||||
<Text size="L400">JSON Content</Text>
|
||||
</Box>
|
||||
<TextAreaComponent
|
||||
<TextArea
|
||||
ref={textAreaRef}
|
||||
name="contentTextArea"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { FormEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Text, IconButton, Chip, Scroll, config, TextAreaComponent as TextArea, color, Spinner, Button } from 'folds';
|
||||
import { Box, Text, IconButton, Chip, Scroll, config, TextArea, color, Spinner, Button } from 'folds';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { Page, PageHeader } from '../../../components/page';
|
||||
@@ -153,7 +153,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
|
||||
<Box shrink="No">
|
||||
<Text size="L400">JSON Content</Text>
|
||||
</Box>
|
||||
<TextAreaComponent
|
||||
<TextArea
|
||||
ref={textAreaRef}
|
||||
name="contentTextArea"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
|
||||
@@ -137,6 +137,8 @@ import {
|
||||
PAARROT_CAROUSEL_TOTAL_PROPERTY_NAME,
|
||||
PAARROT_CAROUSEL_UUID_PROPERTY_NAME,
|
||||
} from '../../../types/matrix/common';
|
||||
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
|
||||
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
|
||||
|
||||
/** Information about a call member event for grouping */
|
||||
interface CallEventInfo {
|
||||
@@ -582,6 +584,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
);
|
||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
|
||||
useJumboEmojiConfetti(room);
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
@@ -1597,6 +1601,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
disabledEmbedPatterns={combinedEmbedFilters}
|
||||
targetEventId={mEventId}
|
||||
onJumboEmojiClick={handleJumboEmojiClick}
|
||||
/>
|
||||
)}
|
||||
</Message>
|
||||
@@ -1705,6 +1711,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
disabledEmbedPatterns={combinedEmbedFilters}
|
||||
targetEventId={mEventId}
|
||||
onJumboEmojiClick={handleJumboEmojiClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2002,6 +2010,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// Return special marker for call event (will be handled by eventRenderer)
|
||||
return 'CALL_EVENT_PENDING' as unknown as React.ReactNode;
|
||||
},
|
||||
[MessageEvent.RelayEmojiConfetti]: () => null,
|
||||
},
|
||||
(mEventId, mEvent, item) => {
|
||||
if (!showHiddenEvents) return null;
|
||||
@@ -2704,6 +2713,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
</Box>
|
||||
</TimelineFloat>
|
||||
)}
|
||||
<EmojiConfettiOverlay bursts={emojiConfettiBursts} onBurstComplete={removeEmojiConfettiBurst} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ import * as roomViewCss from './RoomViewFollowing.css';
|
||||
import { Message, Reactions, EncryptedContent } from './message';
|
||||
import { Reply } from '../../components/message';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { EmojiConfettiOverlay } from './emoji-confetti/EmojiConfettiOverlay';
|
||||
import { useJumboEmojiConfetti } from './emoji-confetti/useJumboEmojiConfetti';
|
||||
import {
|
||||
LINKIFY_OPTS,
|
||||
factoryRenderLinkifyWithMention,
|
||||
@@ -156,6 +158,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const { bursts: emojiConfettiBursts, removeBurst: removeEmojiConfettiBurst, handleJumboEmojiClick } =
|
||||
useJumboEmojiConfetti(room);
|
||||
|
||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||
const direct = useIsDirectRoom();
|
||||
@@ -638,6 +642,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
disabledEmbedPatterns={combinedEmbedFilters}
|
||||
targetEventId={mEventId}
|
||||
onJumboEmojiClick={handleJumboEmojiClick}
|
||||
/>
|
||||
)}
|
||||
</EncryptedContent>
|
||||
@@ -654,6 +660,8 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
disabledEmbedPatterns={combinedEmbedFilters}
|
||||
targetEventId={mEventId}
|
||||
onJumboEmojiClick={handleJumboEmojiClick}
|
||||
/>
|
||||
)}
|
||||
</Message>
|
||||
@@ -759,6 +767,10 @@ export function ThreadView({ room, threadRootId }: ThreadViewProps) {
|
||||
{events.map((evt, idx, arr) => renderEvent(evt, idx, arr))}
|
||||
</Box>
|
||||
</Scroll>
|
||||
<EmojiConfettiOverlay
|
||||
bursts={emojiConfettiBursts}
|
||||
onBurstComplete={removeEmojiConfettiBurst}
|
||||
/>
|
||||
</Box>
|
||||
<div className={roomViewCss.RoomViewBottomFloat}>
|
||||
<RoomViewTyping room={room} />
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { EmojiConfettiBurst } from './types';
|
||||
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
|
||||
import {
|
||||
BurstParticle,
|
||||
drawBurstParticles,
|
||||
spawnEmojiBurst,
|
||||
stepBurstParticles,
|
||||
} from './emojiBurstEngine';
|
||||
|
||||
const MAX_DPR = 2;
|
||||
|
||||
type EmojiConfettiBurstCanvasProps = {
|
||||
burst: EmojiConfettiBurst;
|
||||
onComplete: (burstId: string) => void;
|
||||
};
|
||||
|
||||
export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBurstCanvasProps) {
|
||||
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,
|
||||
burst.emojis[0] ?? '🎉',
|
||||
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.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]);
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { EmojiConfettiBurst } from './types';
|
||||
import { EmojiConfettiBurstCanvas } from './EmojiConfettiBurstCanvas';
|
||||
|
||||
type EmojiConfettiOverlayProps = {
|
||||
bursts: EmojiConfettiBurst[];
|
||||
onBurstComplete: (burstId: string) => void;
|
||||
};
|
||||
|
||||
export function EmojiConfettiOverlay({ bursts, onBurstComplete }: EmojiConfettiOverlayProps) {
|
||||
if (bursts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{bursts.map((burst) => (
|
||||
<EmojiConfettiBurstCanvas
|
||||
key={burst.id}
|
||||
burst={burst}
|
||||
onComplete={onBurstComplete}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
src/app/features/room/emoji-confetti/burstOrigin.ts
Normal file
74
src/app/features/room/emoji-confetti/burstOrigin.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type BurstPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
maskRadius?: number;
|
||||
};
|
||||
|
||||
export function getBurstPointFromElement(element: Element): BurstPoint {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
maskRadius: Math.max(rect.width, rect.height) * 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
export function getElementCenter(element: Element): BurstPoint {
|
||||
return getBurstPointFromElement(element);
|
||||
}
|
||||
|
||||
export function findEmojiOriginInMessage(
|
||||
targetEventId: string,
|
||||
emoji: string
|
||||
): BurstPoint | undefined {
|
||||
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||
if (!message) return undefined;
|
||||
|
||||
const matches = message.querySelectorAll('[data-emoticon]');
|
||||
for (const element of matches) {
|
||||
if (element.getAttribute('data-emoticon') === emoji) {
|
||||
return getBurstPointFromElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getMessageFallbackOrigin(targetEventId: string): BurstPoint | undefined {
|
||||
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||
if (!message) return undefined;
|
||||
|
||||
const jumboBody = message.querySelector('[data-jumbo-emoji]');
|
||||
if (jumboBody) {
|
||||
return getBurstPointFromElement(jumboBody);
|
||||
}
|
||||
|
||||
const body = message.querySelector('[data-message-body]');
|
||||
if (body) {
|
||||
return getBurstPointFromElement(body);
|
||||
}
|
||||
|
||||
return getBurstPointFromElement(message);
|
||||
}
|
||||
|
||||
export function resolveBurstOrigin(
|
||||
targetEventId: string | undefined,
|
||||
emojis: string[],
|
||||
explicitOrigin?: BurstPoint
|
||||
): BurstPoint {
|
||||
if (explicitOrigin) return explicitOrigin;
|
||||
|
||||
const primaryEmoji = emojis[0];
|
||||
if (targetEventId && primaryEmoji) {
|
||||
const emojiOrigin = findEmojiOriginInMessage(targetEventId, primaryEmoji);
|
||||
if (emojiOrigin) return emojiOrigin;
|
||||
|
||||
const messageOrigin = getMessageFallbackOrigin(targetEventId);
|
||||
if (messageOrigin) return messageOrigin;
|
||||
}
|
||||
|
||||
return {
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight * 0.35,
|
||||
};
|
||||
}
|
||||
357
src/app/features/room/emoji-confetti/emojiBurstEngine.ts
Normal file
357
src/app/features/room/emoji-confetti/emojiBurstEngine.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { BurstPoint } from './burstOrigin';
|
||||
import {
|
||||
BurstMotionStyle,
|
||||
EmojiBurstProfile,
|
||||
getEmojiBurstProfile,
|
||||
pickParticleEmoji,
|
||||
sampleBurstAngle,
|
||||
} from './emojiParticleProfiles';
|
||||
|
||||
/** Particles spawn over this window. */
|
||||
export const BURST_SPAWN_MS = 500;
|
||||
/** Global fade runs from 0.5s → 2s after burst start. */
|
||||
export const BURST_FADE_START_MS = 500;
|
||||
export const BURST_FADE_END_MS = 2000;
|
||||
export const BURST_TOTAL_MS = BURST_FADE_END_MS;
|
||||
|
||||
export type BurstParticle = {
|
||||
burstId: string;
|
||||
burstStartMs: number;
|
||||
emoji: string;
|
||||
fontSize: number;
|
||||
spawnX: number;
|
||||
spawnY: number;
|
||||
maskRadius: number;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
bornAt: number;
|
||||
rotation: number;
|
||||
spin: number;
|
||||
peakScale: number;
|
||||
style: BurstMotionStyle;
|
||||
gravity: number;
|
||||
drag: number;
|
||||
phase: number;
|
||||
twinkle?: boolean;
|
||||
wobble?: boolean;
|
||||
orbitRadius: number;
|
||||
orbitSpeed: number;
|
||||
orbitAngle: number;
|
||||
bounceDamping: number;
|
||||
bouncesLeft: number;
|
||||
};
|
||||
|
||||
const EMOJI_CACHE_PX = 64;
|
||||
const EMOJI_CACHE_SCALE = 2;
|
||||
const MAX_DPR = 2;
|
||||
const POP_IN_MS = 70;
|
||||
|
||||
const emojiCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||
|
||||
function easeOutBack(t: number): number {
|
||||
const c1 = 1.70158;
|
||||
const c3 = c1 + 1;
|
||||
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - (1 - t) ** 3;
|
||||
}
|
||||
|
||||
function lerp(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||
const cacheKey = `${emoji}:${dpr}`;
|
||||
const cached = emojiCanvasCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const fontSize = Math.ceil(EMOJI_CACHE_PX * dpr);
|
||||
const size = Math.ceil(fontSize * EMOJI_CACHE_SCALE);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.font = `${fontSize}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||
context.fillText(emoji, size / 2, size / 2);
|
||||
}
|
||||
|
||||
emojiCanvasCache.set(cacheKey, canvas);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function emergeOpacity(
|
||||
x: number,
|
||||
y: number,
|
||||
spawnX: number,
|
||||
spawnY: number,
|
||||
maskRadius: number
|
||||
): number {
|
||||
const dist = Math.hypot(x - spawnX, y - spawnY);
|
||||
const inner = maskRadius * 0.55;
|
||||
const outer = maskRadius * 1.05;
|
||||
if (dist <= inner) return 0;
|
||||
if (dist >= outer) return 1;
|
||||
return (dist - inner) / (outer - inner);
|
||||
}
|
||||
|
||||
function spawnParticle(
|
||||
particles: BurstParticle[],
|
||||
burstId: string,
|
||||
burstStartMs: number,
|
||||
point: BurstPoint,
|
||||
primaryEmoji: string,
|
||||
profile: EmojiBurstProfile,
|
||||
bornAt: number,
|
||||
options?: { hero?: boolean }
|
||||
) {
|
||||
const hero = options?.hero ?? false;
|
||||
const angle = sampleBurstAngle(profile);
|
||||
const speed = hero
|
||||
? lerp(profile.speedMin * 0.65, profile.speedMax * 0.55)
|
||||
: lerp(profile.speedMin, profile.speedMax);
|
||||
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
||||
const maskRadius = point.maskRadius ?? 28;
|
||||
const jitter = hero ? 3 : 12;
|
||||
const emoji = pickParticleEmoji(profile, primaryEmoji);
|
||||
|
||||
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 60;
|
||||
let vy = Math.sin(angle) * speed - launchUp;
|
||||
|
||||
if (profile.style === 'spiral') {
|
||||
vx *= 0.35;
|
||||
vy *= 0.35;
|
||||
}
|
||||
|
||||
if (profile.style === 'sparkle') {
|
||||
vx *= 0.75;
|
||||
vy *= 0.75;
|
||||
}
|
||||
|
||||
particles.push({
|
||||
burstId,
|
||||
burstStartMs,
|
||||
emoji,
|
||||
fontSize: hero
|
||||
? lerp(profile.heroFontSizeMin, profile.heroFontSizeMax)
|
||||
: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
||||
spawnX: point.x,
|
||||
spawnY: point.y,
|
||||
maskRadius,
|
||||
x: point.x + (Math.random() - 0.5) * jitter,
|
||||
y: point.y + (Math.random() - 0.5) * jitter,
|
||||
vx,
|
||||
vy,
|
||||
bornAt,
|
||||
rotation: (Math.random() - 0.5) * 40,
|
||||
spin: lerp(profile.spinMin, profile.spinMax),
|
||||
peakScale: hero ? 1.1 + Math.random() * 0.2 : 0.9 + Math.random() * 0.4,
|
||||
style: profile.style,
|
||||
gravity: profile.gravity,
|
||||
drag: profile.drag,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
twinkle: profile.twinkle,
|
||||
wobble: profile.wobble,
|
||||
orbitRadius: hero ? 8 : 14 + Math.random() * 28,
|
||||
orbitSpeed: (Math.random() < 0.5 ? -1 : 1) * (1.8 + Math.random() * 2.4),
|
||||
orbitAngle: Math.random() * Math.PI * 2,
|
||||
bounceDamping: 0.42 + Math.random() * 0.18,
|
||||
bouncesLeft: profile.style === 'bounce' ? 2 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fountain from behind the emoji for 0.5s, fade 0.5s→2s. */
|
||||
export function spawnEmojiBurst(
|
||||
particles: BurstParticle[],
|
||||
burstId: string,
|
||||
point: BurstPoint,
|
||||
emoji: string,
|
||||
now = performance.now()
|
||||
) {
|
||||
const profile = getEmojiBurstProfile(emoji);
|
||||
const burstStartMs = now;
|
||||
|
||||
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, burstStartMs, {
|
||||
hero: true,
|
||||
});
|
||||
|
||||
const particleCount = profile.particleCount;
|
||||
for (let i = 0; i < particleCount; i += 1) {
|
||||
const slot = i / particleCount;
|
||||
const bornAt =
|
||||
burstStartMs + slot * BURST_SPAWN_MS + Math.random() * (BURST_SPAWN_MS / particleCount);
|
||||
spawnParticle(particles, burstId, burstStartMs, point, emoji, profile, bornAt);
|
||||
}
|
||||
}
|
||||
|
||||
function stepSpiralParticle(particle: BurstParticle, dtSeconds: number) {
|
||||
particle.orbitRadius += 42 * dtSeconds;
|
||||
particle.orbitAngle += particle.orbitSpeed * dtSeconds;
|
||||
particle.x = particle.spawnX + Math.cos(particle.orbitAngle) * particle.orbitRadius;
|
||||
particle.y =
|
||||
particle.spawnY + Math.sin(particle.orbitAngle) * particle.orbitRadius * 0.65 + particle.vy * dtSeconds * 8;
|
||||
particle.vy += particle.gravity * dtSeconds * 0.35;
|
||||
particle.rotation += particle.spin * dtSeconds;
|
||||
}
|
||||
|
||||
function stepBounceParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
|
||||
particle.vy += particle.gravity * dtSeconds;
|
||||
particle.vx *= dragFactor;
|
||||
particle.vy *= dragFactor;
|
||||
particle.x += particle.vx * dtSeconds;
|
||||
particle.y += particle.vy * dtSeconds;
|
||||
particle.rotation += particle.spin * dtSeconds;
|
||||
|
||||
if (particle.bouncesLeft > 0 && particle.vy > 0 && particle.y >= particle.spawnY + 6) {
|
||||
particle.y = particle.spawnY + 6;
|
||||
particle.vy = -Math.abs(particle.vy) * particle.bounceDamping;
|
||||
particle.vx *= 0.82;
|
||||
particle.bouncesLeft -= 1;
|
||||
particle.spin += (Math.random() - 0.5) * 120;
|
||||
}
|
||||
}
|
||||
|
||||
function stepDefaultParticle(particle: BurstParticle, dtSeconds: number, dragFactor: number) {
|
||||
particle.vy += particle.gravity * dtSeconds;
|
||||
particle.vx *= dragFactor;
|
||||
particle.vy *= dragFactor;
|
||||
particle.x += particle.vx * dtSeconds;
|
||||
particle.y += particle.vy * dtSeconds;
|
||||
particle.rotation += particle.spin * dtSeconds;
|
||||
}
|
||||
|
||||
export function stepBurstParticles(
|
||||
particles: BurstParticle[],
|
||||
now: number,
|
||||
dtSeconds: number
|
||||
): void {
|
||||
for (let i = particles.length - 1; i >= 0; i -= 1) {
|
||||
const particle = particles[i];
|
||||
const burstElapsed = now - particle.burstStartMs;
|
||||
|
||||
if (burstElapsed > BURST_TOTAL_MS) {
|
||||
particles[i] = particles[particles.length - 1];
|
||||
particles.pop();
|
||||
continue;
|
||||
}
|
||||
|
||||
const ageMs = now - particle.bornAt;
|
||||
if (ageMs < 0) continue;
|
||||
|
||||
const dragFactor = particle.drag ** (dtSeconds * 60);
|
||||
|
||||
switch (particle.style) {
|
||||
case 'spiral':
|
||||
stepSpiralParticle(particle, dtSeconds);
|
||||
break;
|
||||
case 'bounce':
|
||||
stepBounceParticle(particle, dtSeconds, dragFactor);
|
||||
break;
|
||||
default:
|
||||
stepDefaultParticle(particle, dtSeconds, dragFactor);
|
||||
break;
|
||||
}
|
||||
|
||||
particle.phase += dtSeconds * (particle.twinkle ? 9 : 5);
|
||||
}
|
||||
}
|
||||
|
||||
type DrawState = {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
opacity: number;
|
||||
rotation: number;
|
||||
};
|
||||
|
||||
function particleDrawState(particle: BurstParticle, now: number): DrawState | null {
|
||||
const burstElapsed = now - particle.burstStartMs;
|
||||
if (burstElapsed > BURST_TOTAL_MS) return null;
|
||||
|
||||
const ageMs = now - particle.bornAt;
|
||||
if (ageMs < 0) return null;
|
||||
|
||||
let scale = particle.peakScale;
|
||||
let opacity = emergeOpacity(
|
||||
particle.x,
|
||||
particle.y,
|
||||
particle.spawnX,
|
||||
particle.spawnY,
|
||||
particle.maskRadius
|
||||
);
|
||||
|
||||
if (opacity <= 0) return null;
|
||||
|
||||
if (ageMs < POP_IN_MS) {
|
||||
const pop = easeOutBack(ageMs / POP_IN_MS);
|
||||
scale = 0.1 + pop * particle.peakScale;
|
||||
opacity *= pop;
|
||||
}
|
||||
|
||||
if (particle.twinkle) {
|
||||
opacity *= 0.55 + 0.45 * Math.sin(particle.phase * 1.6);
|
||||
}
|
||||
|
||||
if (particle.wobble) {
|
||||
scale *= 1 + 0.12 * Math.sin(particle.phase * 2.2);
|
||||
}
|
||||
|
||||
if (particle.style === 'sparkle' && burstElapsed < BURST_FADE_START_MS) {
|
||||
scale *= 0.85 + 0.3 * Math.sin(particle.phase * 3);
|
||||
}
|
||||
|
||||
if (burstElapsed >= BURST_FADE_START_MS) {
|
||||
const fadeT = (burstElapsed - BURST_FADE_START_MS) / (BURST_FADE_END_MS - BURST_FADE_START_MS);
|
||||
opacity *= 1 - easeOutCubic(Math.min(1, fadeT));
|
||||
scale *= 1 - easeOutCubic(Math.min(1, fadeT)) * 0.2;
|
||||
}
|
||||
|
||||
if (opacity <= 0.02) return null;
|
||||
|
||||
return {
|
||||
x: particle.x,
|
||||
y: particle.y,
|
||||
scale,
|
||||
opacity,
|
||||
rotation: particle.rotation,
|
||||
};
|
||||
}
|
||||
|
||||
export function drawBurstParticles(
|
||||
context: CanvasRenderingContext2D,
|
||||
particles: BurstParticle[],
|
||||
dpr: number,
|
||||
now: number
|
||||
) {
|
||||
for (const particle of particles) {
|
||||
const state = particleDrawState(particle, now);
|
||||
if (!state) continue;
|
||||
|
||||
context.globalAlpha = state.opacity;
|
||||
|
||||
const emojiCanvas = getEmojiCanvas(particle.emoji);
|
||||
const drawSize = particle.fontSize * state.scale * EMOJI_CACHE_SCALE;
|
||||
const halfSize = drawSize / 2;
|
||||
const radians = (state.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(radians) * dpr;
|
||||
const sin = Math.sin(radians) * dpr;
|
||||
|
||||
context.setTransform(cos, sin, -sin, cos, state.x * dpr, state.y * dpr);
|
||||
context.drawImage(emojiCanvas, -halfSize, -halfSize, drawSize, drawSize);
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
export function hasBurstParticles(particles: BurstParticle[], burstId: string): boolean {
|
||||
return particles.some((particle) => particle.burstId === burstId);
|
||||
}
|
||||
391
src/app/features/room/emoji-confetti/emojiParticleProfiles.ts
Normal file
391
src/app/features/room/emoji-confetti/emojiParticleProfiles.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
export type BurstMotionStyle =
|
||||
| 'explode'
|
||||
| 'bounce'
|
||||
| 'rise'
|
||||
| 'sparkle'
|
||||
| 'splash'
|
||||
| 'spiral'
|
||||
| 'punch'
|
||||
| 'scatter'
|
||||
| 'shower';
|
||||
|
||||
export type EmojiBurstProfile = {
|
||||
style: BurstMotionStyle;
|
||||
particleCount: number;
|
||||
gravity: number;
|
||||
drag: number;
|
||||
speedMin: number;
|
||||
speedMax: number;
|
||||
launchUpMin: number;
|
||||
launchUpMax: number;
|
||||
spinMin: number;
|
||||
spinMax: number;
|
||||
fontSizeMin: number;
|
||||
fontSizeMax: number;
|
||||
heroFontSizeMin: number;
|
||||
heroFontSizeMax: number;
|
||||
companions?: string[];
|
||||
companionChance?: number;
|
||||
/** Bias burst angle in radians. 0 = right, -π/2 = up. */
|
||||
angleBias?: number;
|
||||
/** Limit spawn to a cone (radians). Omit for full 360°. */
|
||||
angleSpread?: number;
|
||||
twinkle?: boolean;
|
||||
wobble?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
||||
style: 'explode',
|
||||
particleCount: 36,
|
||||
gravity: 400,
|
||||
drag: 0.935,
|
||||
speedMin: 240,
|
||||
speedMax: 560,
|
||||
launchUpMin: 40,
|
||||
launchUpMax: 130,
|
||||
spinMin: -180,
|
||||
spinMax: 180,
|
||||
fontSizeMin: 14,
|
||||
fontSizeMax: 26,
|
||||
heroFontSizeMin: 26,
|
||||
heroFontSizeMax: 34,
|
||||
};
|
||||
|
||||
const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
|
||||
'😆': {
|
||||
style: 'bounce',
|
||||
particleCount: 28,
|
||||
gravity: 520,
|
||||
speedMin: 180,
|
||||
speedMax: 360,
|
||||
launchUpMin: 120,
|
||||
launchUpMax: 220,
|
||||
spinMin: -240,
|
||||
spinMax: 240,
|
||||
wobble: true,
|
||||
},
|
||||
'😂': {
|
||||
style: 'bounce',
|
||||
particleCount: 30,
|
||||
gravity: 500,
|
||||
speedMin: 160,
|
||||
speedMax: 340,
|
||||
launchUpMin: 110,
|
||||
launchUpMax: 210,
|
||||
spinMin: -220,
|
||||
spinMax: 220,
|
||||
wobble: true,
|
||||
},
|
||||
'🤣': {
|
||||
style: 'bounce',
|
||||
particleCount: 32,
|
||||
gravity: 480,
|
||||
speedMin: 200,
|
||||
speedMax: 380,
|
||||
launchUpMin: 130,
|
||||
launchUpMax: 240,
|
||||
wobble: true,
|
||||
},
|
||||
'🔥': {
|
||||
style: 'rise',
|
||||
particleCount: 24,
|
||||
gravity: -120,
|
||||
drag: 0.96,
|
||||
speedMin: 80,
|
||||
speedMax: 200,
|
||||
launchUpMin: 60,
|
||||
launchUpMax: 160,
|
||||
spinMin: -90,
|
||||
spinMax: 90,
|
||||
fontSizeMin: 16,
|
||||
fontSizeMax: 28,
|
||||
twinkle: true,
|
||||
wobble: true,
|
||||
companions: ['✨'],
|
||||
companionChance: 0.35,
|
||||
},
|
||||
'❤️': {
|
||||
style: 'rise',
|
||||
particleCount: 22,
|
||||
gravity: -80,
|
||||
drag: 0.97,
|
||||
speedMin: 60,
|
||||
speedMax: 150,
|
||||
launchUpMin: 40,
|
||||
launchUpMax: 110,
|
||||
spinMin: -40,
|
||||
spinMax: 40,
|
||||
fontSizeMin: 14,
|
||||
fontSizeMax: 24,
|
||||
companions: ['💕', '💖'],
|
||||
companionChance: 0.4,
|
||||
},
|
||||
'💕': {
|
||||
style: 'rise',
|
||||
particleCount: 20,
|
||||
gravity: -70,
|
||||
drag: 0.97,
|
||||
speedMin: 50,
|
||||
speedMax: 140,
|
||||
launchUpMin: 35,
|
||||
launchUpMax: 100,
|
||||
spinMin: -35,
|
||||
spinMax: 35,
|
||||
},
|
||||
'💖': {
|
||||
style: 'rise',
|
||||
particleCount: 20,
|
||||
gravity: -90,
|
||||
drag: 0.968,
|
||||
speedMin: 55,
|
||||
speedMax: 150,
|
||||
launchUpMin: 45,
|
||||
launchUpMax: 115,
|
||||
twinkle: true,
|
||||
},
|
||||
'⭐': {
|
||||
style: 'sparkle',
|
||||
particleCount: 26,
|
||||
gravity: 40,
|
||||
drag: 0.94,
|
||||
speedMin: 100,
|
||||
speedMax: 260,
|
||||
launchUpMin: 20,
|
||||
launchUpMax: 80,
|
||||
spinMin: -120,
|
||||
spinMax: 120,
|
||||
twinkle: true,
|
||||
companions: ['✨'],
|
||||
companionChance: 0.5,
|
||||
},
|
||||
'✨': {
|
||||
style: 'sparkle',
|
||||
particleCount: 30,
|
||||
gravity: 30,
|
||||
drag: 0.945,
|
||||
speedMin: 90,
|
||||
speedMax: 240,
|
||||
launchUpMin: 15,
|
||||
launchUpMax: 70,
|
||||
spinMin: -200,
|
||||
spinMax: 200,
|
||||
twinkle: true,
|
||||
},
|
||||
'💀': {
|
||||
style: 'scatter',
|
||||
particleCount: 20,
|
||||
gravity: 620,
|
||||
drag: 0.92,
|
||||
speedMin: 200,
|
||||
speedMax: 420,
|
||||
launchUpMin: 20,
|
||||
launchUpMax: 90,
|
||||
spinMin: -360,
|
||||
spinMax: 360,
|
||||
fontSizeMin: 16,
|
||||
fontSizeMax: 28,
|
||||
},
|
||||
'🎉': {
|
||||
style: 'shower',
|
||||
particleCount: 40,
|
||||
gravity: 280,
|
||||
drag: 0.93,
|
||||
speedMin: 200,
|
||||
speedMax: 480,
|
||||
launchUpMin: 80,
|
||||
launchUpMax: 200,
|
||||
companions: ['🎊', '✨', '🎈'],
|
||||
companionChance: 0.45,
|
||||
},
|
||||
'🎊': {
|
||||
style: 'shower',
|
||||
particleCount: 38,
|
||||
gravity: 260,
|
||||
drag: 0.932,
|
||||
speedMin: 190,
|
||||
speedMax: 460,
|
||||
launchUpMin: 70,
|
||||
launchUpMax: 190,
|
||||
companions: ['🎉', '✨'],
|
||||
companionChance: 0.4,
|
||||
},
|
||||
'👏': {
|
||||
style: 'punch',
|
||||
particleCount: 18,
|
||||
gravity: 320,
|
||||
speedMin: 220,
|
||||
speedMax: 400,
|
||||
launchUpMin: 30,
|
||||
launchUpMax: 100,
|
||||
angleBias: -Math.PI / 2,
|
||||
angleSpread: Math.PI * 0.85,
|
||||
spinMin: -100,
|
||||
spinMax: 100,
|
||||
},
|
||||
'👍': {
|
||||
style: 'punch',
|
||||
particleCount: 14,
|
||||
gravity: 380,
|
||||
speedMin: 260,
|
||||
speedMax: 440,
|
||||
launchUpMin: 100,
|
||||
launchUpMax: 200,
|
||||
angleBias: -Math.PI / 2,
|
||||
angleSpread: Math.PI * 0.55,
|
||||
spinMin: -60,
|
||||
spinMax: 60,
|
||||
},
|
||||
'💯': {
|
||||
style: 'punch',
|
||||
particleCount: 16,
|
||||
gravity: 300,
|
||||
speedMin: 200,
|
||||
speedMax: 380,
|
||||
launchUpMin: 140,
|
||||
launchUpMax: 240,
|
||||
angleBias: -Math.PI / 2,
|
||||
angleSpread: Math.PI * 0.45,
|
||||
fontSizeMin: 16,
|
||||
fontSizeMax: 30,
|
||||
wobble: true,
|
||||
},
|
||||
'💦': {
|
||||
style: 'splash',
|
||||
particleCount: 32,
|
||||
gravity: 540,
|
||||
drag: 0.925,
|
||||
speedMin: 280,
|
||||
speedMax: 520,
|
||||
launchUpMin: 160,
|
||||
launchUpMax: 300,
|
||||
spinMin: -140,
|
||||
spinMax: 140,
|
||||
angleBias: -Math.PI / 2,
|
||||
angleSpread: Math.PI * 1.1,
|
||||
},
|
||||
'🌊': {
|
||||
style: 'splash',
|
||||
particleCount: 28,
|
||||
gravity: 420,
|
||||
drag: 0.93,
|
||||
speedMin: 220,
|
||||
speedMax: 460,
|
||||
launchUpMin: 100,
|
||||
launchUpMax: 220,
|
||||
angleBias: -Math.PI / 2,
|
||||
angleSpread: Math.PI,
|
||||
companions: ['💧'],
|
||||
companionChance: 0.3,
|
||||
},
|
||||
'⚡': {
|
||||
style: 'scatter',
|
||||
particleCount: 14,
|
||||
gravity: 180,
|
||||
drag: 0.88,
|
||||
speedMin: 380,
|
||||
speedMax: 680,
|
||||
launchUpMin: 10,
|
||||
launchUpMax: 60,
|
||||
spinMin: -30,
|
||||
spinMax: 30,
|
||||
fontSizeMin: 18,
|
||||
fontSizeMax: 32,
|
||||
angleSpread: Math.PI * 1.2,
|
||||
},
|
||||
'🌀': {
|
||||
style: 'spiral',
|
||||
particleCount: 24,
|
||||
gravity: 60,
|
||||
drag: 0.96,
|
||||
speedMin: 120,
|
||||
speedMax: 260,
|
||||
launchUpMin: 0,
|
||||
launchUpMax: 40,
|
||||
spinMin: -420,
|
||||
spinMax: 420,
|
||||
},
|
||||
'🤯': {
|
||||
style: 'explode',
|
||||
particleCount: 34,
|
||||
gravity: 360,
|
||||
speedMin: 280,
|
||||
speedMax: 580,
|
||||
launchUpMin: 60,
|
||||
launchUpMax: 160,
|
||||
companions: ['💥', '✨', '⭐'],
|
||||
companionChance: 0.5,
|
||||
},
|
||||
'😭': {
|
||||
style: 'splash',
|
||||
particleCount: 26,
|
||||
gravity: 480,
|
||||
drag: 0.94,
|
||||
speedMin: 140,
|
||||
speedMax: 300,
|
||||
launchUpMin: 40,
|
||||
launchUpMax: 120,
|
||||
angleBias: Math.PI / 2,
|
||||
angleSpread: Math.PI * 0.7,
|
||||
companions: ['💧'],
|
||||
companionChance: 0.55,
|
||||
},
|
||||
'🥳': {
|
||||
style: 'shower',
|
||||
particleCount: 36,
|
||||
gravity: 250,
|
||||
speedMin: 180,
|
||||
speedMax: 440,
|
||||
launchUpMin: 90,
|
||||
launchUpMax: 210,
|
||||
companions: ['🎉', '🎊', '🎈'],
|
||||
companionChance: 0.4,
|
||||
},
|
||||
'🐱': {
|
||||
style: 'bounce',
|
||||
particleCount: 20,
|
||||
gravity: 440,
|
||||
speedMin: 150,
|
||||
speedMax: 320,
|
||||
launchUpMin: 90,
|
||||
launchUpMax: 180,
|
||||
spinMin: -160,
|
||||
spinMax: 160,
|
||||
},
|
||||
'🐶': {
|
||||
style: 'bounce',
|
||||
particleCount: 20,
|
||||
gravity: 460,
|
||||
speedMin: 160,
|
||||
speedMax: 330,
|
||||
launchUpMin: 85,
|
||||
launchUpMax: 175,
|
||||
wobble: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
|
||||
const override = PROFILE_OVERRIDES[emoji];
|
||||
if (!override) return DEFAULT_PROFILE;
|
||||
return { ...DEFAULT_PROFILE, ...override };
|
||||
}
|
||||
|
||||
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
||||
if (!profile.companions?.length || !profile.companionChance) {
|
||||
return primaryEmoji;
|
||||
}
|
||||
if (Math.random() >= profile.companionChance) {
|
||||
return primaryEmoji;
|
||||
}
|
||||
const companion = profile.companions[Math.floor(Math.random() * profile.companions.length)];
|
||||
return companion ?? primaryEmoji;
|
||||
}
|
||||
|
||||
export function sampleBurstAngle(profile: EmojiBurstProfile): number {
|
||||
if (profile.angleSpread === undefined) {
|
||||
return Math.random() * Math.PI * 2;
|
||||
}
|
||||
|
||||
const bias = profile.angleBias ?? -Math.PI / 2;
|
||||
const halfSpread = profile.angleSpread / 2;
|
||||
return bias + (Math.random() - 0.5) * profile.angleSpread * (0.6 + Math.random() * 0.4);
|
||||
}
|
||||
11
src/app/features/room/emoji-confetti/findJumboMount.ts
Normal file
11
src/app/features/room/emoji-confetti/findJumboMount.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function findJumboEmojiElement(targetEventId: string): HTMLElement | null {
|
||||
const message = document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||
if (!message) return null;
|
||||
|
||||
const jumbo = message.querySelector('[data-jumbo-emoji]');
|
||||
return jumbo instanceof HTMLElement ? jumbo : null;
|
||||
}
|
||||
|
||||
export function getLocalBurstCanvasSize(maskRadius: number): number {
|
||||
return Math.max(300, Math.round(maskRadius * 6.5));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { KeyboardEvent, MouseEvent } from 'react';
|
||||
|
||||
export type JumboEmojiClickHandler = (body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
|
||||
|
||||
export function getJumboEmojiInteractionProps({
|
||||
body,
|
||||
isJumboEmoji,
|
||||
onJumboEmojiClick,
|
||||
}: {
|
||||
body: string;
|
||||
isJumboEmoji: boolean;
|
||||
onJumboEmojiClick?: JumboEmojiClickHandler;
|
||||
}) {
|
||||
if (!isJumboEmoji || !onJumboEmojiClick) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const activate = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
|
||||
if ('key' in event) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
}
|
||||
event.stopPropagation();
|
||||
onJumboEmojiClick(body, event);
|
||||
};
|
||||
|
||||
return {
|
||||
role: 'button' as const,
|
||||
tabIndex: 0,
|
||||
title: 'Throw emoji confetti',
|
||||
onClick: activate,
|
||||
onKeyDown: activate,
|
||||
};
|
||||
}
|
||||
54
src/app/features/room/emoji-confetti/resolveClickedEmoji.ts
Normal file
54
src/app/features/room/emoji-confetti/resolveClickedEmoji.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { KeyboardEvent, MouseEvent } from 'react';
|
||||
import { extractJumboEmojis } from './sendEmojiConfetti';
|
||||
import { BurstPoint, getBurstPointFromElement } from './burstOrigin';
|
||||
|
||||
export function resolveClickedEmoji(
|
||||
event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>,
|
||||
fallbackBody: string
|
||||
): { emoji: string; origin: BurstPoint } | null {
|
||||
const currentTarget = event.currentTarget as HTMLElement;
|
||||
const target = event.target as HTMLElement;
|
||||
const emoticonEl = target.closest('[data-emoticon]') ?? currentTarget.querySelector('[data-emoticon]');
|
||||
if (emoticonEl instanceof HTMLElement) {
|
||||
const emoji = emoticonEl.getAttribute('data-emoticon');
|
||||
if (emoji) {
|
||||
return {
|
||||
emoji,
|
||||
origin: getBurstPointFromElement(emoticonEl),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentTarget.closest('[data-jumbo-emoji]') && !currentTarget.hasAttribute('data-jumbo-emoji')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emojis = extractJumboEmojis(fallbackBody);
|
||||
if (emojis.length === 0) return null;
|
||||
|
||||
if ('clientX' in event) {
|
||||
return {
|
||||
emoji: emojis[0],
|
||||
origin: {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const firstEmoticon = currentTarget.querySelector('[data-emoticon]');
|
||||
if (firstEmoticon instanceof HTMLElement) {
|
||||
const emoji = firstEmoticon.getAttribute('data-emoticon');
|
||||
if (emoji) {
|
||||
return {
|
||||
emoji,
|
||||
origin: getBurstPointFromElement(firstEmoticon),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
emoji: emojis[0],
|
||||
origin: getBurstPointFromElement(currentTarget),
|
||||
};
|
||||
}
|
||||
42
src/app/features/room/emoji-confetti/sendEmojiConfetti.ts
Normal file
42
src/app/features/room/emoji-confetti/sendEmojiConfetti.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { EMOJI_REG_G, JUMBO_EMOJI_REG } from '../../../utils/regex';
|
||||
import { trimReplyFromBody } from '../../../utils/room';
|
||||
import { emojis } from '../../../plugins/emoji';
|
||||
import { EMOJI_CONFETTI_EVENT_TYPE, EmojiConfettiContent } from './types';
|
||||
|
||||
const SHORTCODE_PATTERN = /:([^\s:]+):/g;
|
||||
|
||||
export function extractJumboEmojis(body: string): string[] {
|
||||
const trimmedBody = trimReplyFromBody(body).trim();
|
||||
if (!JUMBO_EMOJI_REG.test(trimmedBody)) return [];
|
||||
|
||||
const found: string[] = [];
|
||||
|
||||
for (const match of trimmedBody.matchAll(EMOJI_REG_G)) {
|
||||
const emoji = match[1];
|
||||
if (emoji) found.push(emoji);
|
||||
}
|
||||
|
||||
for (const match of trimmedBody.matchAll(SHORTCODE_PATTERN)) {
|
||||
const shortcode = match[1];
|
||||
const resolved = emojis.find((emoji) => emoji.shortcode === shortcode);
|
||||
if (resolved) found.push(resolved.unicode);
|
||||
}
|
||||
|
||||
return found.length > 0 ? found : ['🎉'];
|
||||
}
|
||||
|
||||
export function sendEmojiConfettiEvent(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
targetEventId: string,
|
||||
emojis: string[]
|
||||
) {
|
||||
const content: EmojiConfettiContent = {
|
||||
emojis,
|
||||
msgtype: EMOJI_CONFETTI_EVENT_TYPE,
|
||||
target_event_id: targetEventId,
|
||||
};
|
||||
|
||||
return mx.sendEvent(roomId, EMOJI_CONFETTI_EVENT_TYPE as never, content);
|
||||
}
|
||||
16
src/app/features/room/emoji-confetti/types.ts
Normal file
16
src/app/features/room/emoji-confetti/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { BurstPoint } from './burstOrigin';
|
||||
|
||||
export const EMOJI_CONFETTI_EVENT_TYPE = 'app.relay.emoji_confetti';
|
||||
|
||||
export type EmojiConfettiContent = {
|
||||
emojis?: string[];
|
||||
msgtype?: string;
|
||||
target_event_id?: string;
|
||||
};
|
||||
|
||||
export type EmojiConfettiBurst = {
|
||||
id: string;
|
||||
targetEventId: string;
|
||||
emojis: string[];
|
||||
origin: BurstPoint;
|
||||
};
|
||||
137
src/app/features/room/emoji-confetti/useEmojiConfetti.ts
Normal file
137
src/app/features/room/emoji-confetti/useEmojiConfetti.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { BurstPoint, resolveBurstOrigin } from './burstOrigin';
|
||||
import {
|
||||
EMOJI_CONFETTI_EVENT_TYPE,
|
||||
EmojiConfettiBurst,
|
||||
EmojiConfettiContent,
|
||||
} from './types';
|
||||
|
||||
function scheduleBurstFromValues(
|
||||
burstId: string,
|
||||
emojis: string[],
|
||||
targetEventId: string | undefined,
|
||||
onBurst: (burst: EmojiConfettiBurst) => void,
|
||||
attempt = 0,
|
||||
explicitOrigin?: BurstPoint
|
||||
) {
|
||||
const targetElement =
|
||||
targetEventId &&
|
||||
document.querySelector(`[data-message-id="${CSS.escape(targetEventId)}"]`);
|
||||
|
||||
if (targetEventId && !targetElement && attempt < 8) {
|
||||
window.setTimeout(
|
||||
() =>
|
||||
scheduleBurstFromValues(
|
||||
burstId,
|
||||
emojis,
|
||||
targetEventId,
|
||||
onBurst,
|
||||
attempt + 1,
|
||||
explicitOrigin
|
||||
),
|
||||
50
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onBurst({
|
||||
id: burstId,
|
||||
targetEventId: targetEventId ?? '',
|
||||
emojis: emojis.length > 0 ? emojis : ['🎉'],
|
||||
origin: resolveBurstOrigin(targetEventId, emojis, explicitOrigin),
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleBurst(
|
||||
event: MatrixEvent,
|
||||
onBurst: (burst: EmojiConfettiBurst) => void,
|
||||
attempt = 0
|
||||
) {
|
||||
const eventId = event.getId();
|
||||
if (!eventId) return;
|
||||
|
||||
const { emojis, targetEventId } = parseEmojiConfettiContent(event.getContent());
|
||||
scheduleBurstFromValues(eventId, emojis, targetEventId, onBurst, attempt);
|
||||
}
|
||||
|
||||
function parseEmojiConfettiContent(content: unknown): { emojis: string[]; targetEventId?: string } {
|
||||
const confettiContent = content as EmojiConfettiContent;
|
||||
const emojis = Array.isArray(confettiContent.emojis)
|
||||
? confettiContent.emojis.filter((emoji): emoji is string => typeof emoji === 'string' && emoji.length > 0)
|
||||
: [];
|
||||
|
||||
return {
|
||||
emojis: emojis.length > 0 ? emojis : ['🎉'],
|
||||
targetEventId:
|
||||
typeof confettiContent.target_event_id === 'string'
|
||||
? confettiContent.target_event_id
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useEmojiConfetti(room: Room) {
|
||||
const [bursts, setBursts] = useState<EmojiConfettiBurst[]>([]);
|
||||
const seenEventIdsRef = useRef(new Set<string>());
|
||||
const ownEventIdsRef = useRef(new Set<string>());
|
||||
|
||||
const addBurst = useCallback((burst: EmojiConfettiBurst) => {
|
||||
setBursts((current) => [...current, burst]);
|
||||
}, []);
|
||||
|
||||
const registerOwnEventId = useCallback((eventId: string) => {
|
||||
ownEventIdsRef.current.add(eventId);
|
||||
}, []);
|
||||
|
||||
const queueBurst = useCallback((event: MatrixEvent) => {
|
||||
const eventId = event.getId();
|
||||
if (!eventId || seenEventIdsRef.current.has(eventId)) return;
|
||||
|
||||
seenEventIdsRef.current.add(eventId);
|
||||
|
||||
if (ownEventIdsRef.current.has(eventId)) {
|
||||
ownEventIdsRef.current.delete(eventId);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleBurst(event, addBurst);
|
||||
}, [addBurst]);
|
||||
|
||||
const triggerLocalBurst = useCallback(
|
||||
(targetEventId: string, emojis: string[], origin?: BurstPoint) => {
|
||||
const burstId = `local-${targetEventId}-${Date.now()}`;
|
||||
scheduleBurstFromValues(burstId, emojis, targetEventId, addBurst, 0, origin);
|
||||
},
|
||||
[addBurst]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTimeline: (
|
||||
event: MatrixEvent,
|
||||
eventRoom: Room | undefined,
|
||||
toStartOfTimeline?: boolean,
|
||||
removed?: boolean
|
||||
) => void = (event, eventRoom, toStartOfTimeline, removed) => {
|
||||
if (removed || toStartOfTimeline || eventRoom?.roomId !== room.roomId) return;
|
||||
if (event.getType() !== EMOJI_CONFETTI_EVENT_TYPE) return;
|
||||
|
||||
queueBurst(event);
|
||||
};
|
||||
|
||||
room.on(RoomEvent.Timeline, handleTimeline);
|
||||
return () => {
|
||||
room.off(RoomEvent.Timeline, handleTimeline);
|
||||
};
|
||||
}, [queueBurst, room]);
|
||||
|
||||
const removeBurst = useCallback((burstId: string) => {
|
||||
setBursts((current) => current.filter((burst) => burst.id !== burstId));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bursts,
|
||||
removeBurst,
|
||||
triggerLocalBurst,
|
||||
registerOwnEventId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { KeyboardEvent, MouseEvent, useCallback } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { resolveClickedEmoji } from './resolveClickedEmoji';
|
||||
import { sendEmojiConfettiEvent } from './sendEmojiConfetti';
|
||||
import { useEmojiConfetti } from './useEmojiConfetti';
|
||||
|
||||
export function useJumboEmojiConfetti(room: Room) {
|
||||
const mx = useMatrixClient();
|
||||
const { bursts, removeBurst, triggerLocalBurst, registerOwnEventId } = useEmojiConfetti(room);
|
||||
|
||||
const handleJumboEmojiClick = useCallback(
|
||||
(targetEventId: string, body: string, event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
|
||||
const clicked = resolveClickedEmoji(event, body);
|
||||
if (!clicked) return;
|
||||
|
||||
const emojis = [clicked.emoji];
|
||||
|
||||
triggerLocalBurst(targetEventId, emojis, clicked.origin);
|
||||
sendEmojiConfettiEvent(mx, room.roomId, targetEventId, emojis)
|
||||
.then((response) => {
|
||||
const eventId = response?.event_id;
|
||||
if (eventId) registerOwnEventId(eventId);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[emoji-confetti] Failed to send confetti event:', error);
|
||||
});
|
||||
},
|
||||
[mx, registerOwnEventId, room.roomId, triggerLocalBurst]
|
||||
);
|
||||
|
||||
return {
|
||||
bursts,
|
||||
removeBurst,
|
||||
handleJumboEmojiClick,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
} from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { as, Box, Button, Chip, color, config, Header, IconButton, Input, Menu, MenuItem, PopOut, RectCords, Scroll, Switch, Text, toRem } from 'folds';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Icon, Icons } from '../../../components/icons';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
LightTheme,
|
||||
Theme,
|
||||
ThemeKind,
|
||||
themePreviewIdAtom,
|
||||
useSystemThemeKind,
|
||||
useThemeNames,
|
||||
useThemes,
|
||||
@@ -41,28 +43,49 @@ type ThemeSelectorProps = {
|
||||
onSelect: (theme: Theme) => void;
|
||||
};
|
||||
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||||
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
|
||||
<Menu {...props} ref={ref}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{themes.map((theme) => (
|
||||
<MenuItem
|
||||
key={theme.id}
|
||||
size="300"
|
||||
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => onSelect(theme)}
|
||||
>
|
||||
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
)
|
||||
({ themeNames, themes, selected, onSelect, ...props }, ref) => {
|
||||
const setPreviewId = useSetAtom(themePreviewIdAtom);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
setPreviewId(undefined);
|
||||
},
|
||||
[setPreviewId]
|
||||
);
|
||||
|
||||
const clearPreview = () => setPreviewId(undefined);
|
||||
const previewTheme = (theme: Theme) => setPreviewId(theme.id);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
{...props}
|
||||
ref={ref}
|
||||
onMouseLeave={clearPreview}
|
||||
>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{themes.map((theme) => (
|
||||
<MenuItem
|
||||
key={theme.id}
|
||||
size="300"
|
||||
variant={theme.id === selected.id ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onMouseEnter={() => previewTheme(theme)}
|
||||
onFocus={() => previewTheme(theme)}
|
||||
onClick={() => onSelect(theme)}
|
||||
>
|
||||
<Text size="T300">{themeNames[theme.id] ?? theme.id}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
const themes = useThemes();
|
||||
const themeNames = useThemeNames();
|
||||
const setPreviewId = useSetAtom(themePreviewIdAtom);
|
||||
const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme;
|
||||
@@ -72,10 +95,16 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
};
|
||||
|
||||
const handleThemeSelect = (theme: Theme) => {
|
||||
setPreviewId(undefined);
|
||||
setThemeId(theme.id);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
setPreviewId(undefined);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
@@ -99,7 +128,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
onDeactivate: closeMenu,
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
@@ -125,6 +154,7 @@ function SystemThemePreferences() {
|
||||
const themeKind = useSystemThemeKind();
|
||||
const themeNames = useThemeNames();
|
||||
const themes = useThemes();
|
||||
const setPreviewId = useSetAtom(themePreviewIdAtom);
|
||||
const [lightThemeId, setLightThemeId] = useSetting(settingsAtom, 'lightThemeId');
|
||||
const [darkThemeId, setDarkThemeId] = useSetting(settingsAtom, 'darkThemeId');
|
||||
|
||||
@@ -145,15 +175,27 @@ function SystemThemePreferences() {
|
||||
};
|
||||
|
||||
const handleLightThemeSelect = (theme: Theme) => {
|
||||
setPreviewId(undefined);
|
||||
setLightThemeId(theme.id);
|
||||
setLTCords(undefined);
|
||||
};
|
||||
|
||||
const handleDarkThemeSelect = (theme: Theme) => {
|
||||
setPreviewId(undefined);
|
||||
setDarkThemeId(theme.id);
|
||||
setDTCords(undefined);
|
||||
};
|
||||
|
||||
const closeLightMenu = () => {
|
||||
setPreviewId(undefined);
|
||||
setLTCords(undefined);
|
||||
};
|
||||
|
||||
const closeDarkMenu = () => {
|
||||
setPreviewId(undefined);
|
||||
setDTCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box wrap="Wrap" gap="400">
|
||||
<SettingTile
|
||||
@@ -179,7 +221,7 @@ function SystemThemePreferences() {
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setLTCords(undefined),
|
||||
onDeactivate: closeLightMenu,
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
@@ -220,7 +262,7 @@ function SystemThemePreferences() {
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setDTCords(undefined),
|
||||
onDeactivate: closeDarkMenu,
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
|
||||
Reference in New Issue
Block a user