Compare commits

2 Commits

Author SHA1 Message Date
e08b4ec22e Replace image lightbox with slate-style shadowbox.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Full-viewport blur backdrop, floating chrome, wheel/click/pinch zoom and pan; drop the ModalWide card shell around timeline and profile image viewers.
2026-08-09 15:58:47 +10:00
13523fea2b Add fullscreen firework emoji confetti and fix Linux URL paste.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Sparkle 🎆/🎇 from the jumbo emoji with a lightweight particle fountain that piles on the floor. Prefer text/plain over sticky clipboard images on Linux so address-bar URLs do not also attach a leftover bitmap.
2026-08-09 15:50:19 +10:00
9 changed files with 1105 additions and 156 deletions

View File

@@ -1,42 +1,127 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config } from 'folds';
import { DefaultReset } from 'folds';
export const ImageViewer = style([
export const Root = style([
DefaultReset,
{
height: '100%',
position: 'fixed',
inset: 0,
zIndex: 200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
boxSizing: 'border-box',
},
]);
export const ImageViewerHeader = style([
export const Backdrop = style([
DefaultReset,
{
paddingLeft: config.space.S200,
paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.72)',
backdropFilter: 'blur(18px)',
WebkitBackdropFilter: 'blur(18px)',
cursor: 'zoom-out',
},
]);
export const ImageViewerContent = style([
export const Stage = style([
DefaultReset,
{
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
position: 'relative',
zIndex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 10,
maxWidth: 'min(96vw, 1100px)',
maxHeight: 'min(92vh, 900px)',
width: '100%',
minWidth: 0,
pointerEvents: 'none',
},
]);
export const Chrome = style([
DefaultReset,
{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
minWidth: 0,
color: '#fff',
pointerEvents: 'auto',
},
]);
export const Title = style([
DefaultReset,
{
flex: 1,
minWidth: 0,
fontSize: 13,
fontWeight: 600,
lineHeight: '18px',
color: 'rgba(255, 255, 255, 0.9)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
]);
export const ImageViewerImg = style([
export const ChromeButton = style([
DefaultReset,
{
objectFit: 'contain',
width: 'auto',
height: 'auto',
maxWidth: '100%',
maxHeight: '100%',
backgroundColor: color.Surface.Container,
transition: 'transform 100ms linear',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
padding: 6,
border: 'none',
borderRadius: 4,
background: 'rgba(0, 0, 0, 0.4)',
color: '#fff',
cursor: 'pointer',
},
]);
export const Frame = style([
DefaultReset,
{
position: 'relative',
alignSelf: 'center',
maxWidth: 'min(96vw, 1100px)',
maxHeight: 'min(80vh, 820px)',
overflow: 'hidden',
cursor: 'zoom-in',
touchAction: 'none',
pointerEvents: 'auto',
selectors: {
'&[data-zoomed]': {
cursor: 'zoom-out',
overflow: 'visible',
},
'&[data-pannable]': {
cursor: 'grab',
},
'&[data-dragging]': {
cursor: 'grabbing',
},
},
},
]);
export const Image = style([
DefaultReset,
{
display: 'block',
borderRadius: 8,
background: 'transparent',
transform: 'translate(var(--img-tx, 0px), var(--img-ty, 0px)) scale(var(--img-zoom, 1))',
transformOrigin: '0 0',
userSelect: 'none',
},
]);

View File

@@ -1,16 +1,62 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React from 'react';
import React, {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { as } from 'folds';
import { Icon, Icons } from '../icons';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getCurrentAccessToken } from '../../utils/auth';
const ZOOM_MIN = 1;
const ZOOM_MAX = 5;
const ZOOM_CLICK = 2.5;
const DRAG_CLICK_SLOP = 6;
type ZoomState = {
scale: number;
tx: number;
ty: number;
};
type PinchSession = {
startDist: number;
startScale: number;
startTx: number;
startTy: number;
startMidX: number;
startMidY: number;
};
const INITIAL_ZOOM: ZoomState = { scale: 1, tx: 0, ty: 0 };
function imageViewportMax() {
return {
w: Math.min(window.innerWidth * 0.96, 1100),
h: Math.min(window.innerHeight * 0.8, 820),
};
}
function pointerDistance(
a: { x: number; y: number },
b: { x: number; y: number }
): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function pointerMidpoint(
a: { x: number; y: number },
b: { x: number; y: number }
): { x: number; y: number } {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
export type ImageViewerProps = {
alt: string;
src: string;
@@ -19,18 +65,215 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const frameRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const zoomRef = useRef<ZoomState>({ ...INITIAL_ZOOM });
const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
const dragRef = useRef<{
id: number;
x: number;
y: number;
tx: number;
ty: number;
moved: boolean;
} | null>(null);
const pinchRef = useRef<PinchSession | null>(null);
/** Skip the next click after a pinch so we don't toggle zoom. */
const suppressClickRef = useRef(false);
const baseSizeRef = useRef({ w: 0, h: 0 });
const [zoom, setZoom] = useState<ZoomState>(INITIAL_ZOOM);
const [dragging, setDragging] = useState(false);
const applyZoomCss = useCallback((next: ZoomState) => {
const img = imgRef.current;
if (!img) return;
img.style.setProperty('--img-zoom', String(next.scale));
img.style.setProperty('--img-tx', `${next.tx}px`);
img.style.setProperty('--img-ty', `${next.ty}px`);
}, []);
const clampPan = useCallback((state: ZoomState): ZoomState => {
const img = imgRef.current;
const frame = frameRef.current;
if (!img || !frame) return state;
const baseW = img.offsetWidth || baseSizeRef.current.w;
const baseH = img.offsetHeight || baseSizeRef.current.h;
const frameW = frame.clientWidth;
const frameH = frame.clientHeight;
if (!baseW || !baseH || !frameW || !frameH) return state;
const scaledW = baseW * state.scale;
const scaledH = baseH * state.scale;
let { tx, ty } = state;
if (scaledW <= frameW) tx = (frameW - scaledW) / 2;
else tx = Math.min(0, Math.max(frameW - scaledW, tx));
if (scaledH <= frameH) ty = (frameH - scaledH) / 2;
else ty = Math.min(0, Math.max(frameH - scaledH, ty));
return { ...state, tx, ty };
}, []);
const commitZoom = useCallback(
(next: ZoomState) => {
const clamped = clampPan(next);
zoomRef.current = clamped;
applyZoomCss(clamped);
setZoom(clamped);
},
[applyZoomCss, clampPan]
);
const resetZoom = useCallback(() => {
dragRef.current = null;
pinchRef.current = null;
setDragging(false);
commitZoom({ ...INITIAL_ZOOM });
}, [commitZoom]);
const fitImage = useCallback(() => {
const img = imgRef.current;
if (!img) return;
const iw = img.naturalWidth || 0;
const ih = img.naturalHeight || 0;
if (iw <= 0 || ih <= 0) return;
const { w: maxW, h: maxH } = imageViewportMax();
const s = Math.min(1, maxW / iw, maxH / ih);
const w = Math.max(1, Math.round(iw * s));
const h = Math.max(1, Math.round(ih * s));
img.style.width = `${w}px`;
img.style.height = `${h}px`;
baseSizeRef.current = { w, h };
commitZoom({ ...INITIAL_ZOOM });
}, [commitZoom]);
const zoomAround = useCallback(
(px: number, py: number, newScale: number) => {
const z = zoomRef.current;
const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newScale));
const ix = (px - z.tx) / z.scale;
const iy = (py - z.ty) / z.scale;
commitZoom({
scale,
tx: px - ix * scale,
ty: py - iy * scale,
});
},
[commitZoom]
);
const framePoint = (clientX: number, clientY: number) => {
const frame = frameRef.current;
if (!frame) return { x: 0, y: 0 };
const r = frame.getBoundingClientRect();
return { x: clientX - r.left, y: clientY - r.top };
};
const beginPinch = useCallback(() => {
const pts = [...pointersRef.current.values()];
if (pts.length < 2) return;
const [a, b] = pts;
const mid = pointerMidpoint(a, b);
const local = framePoint(mid.x, mid.y);
const z = zoomRef.current;
pinchRef.current = {
startDist: Math.max(1, pointerDistance(a, b)),
startScale: z.scale,
startTx: z.tx,
startTy: z.ty,
startMidX: local.x,
startMidY: local.y,
};
dragRef.current = null;
setDragging(true);
}, []);
const updatePinch = useCallback(() => {
const pinch = pinchRef.current;
const pts = [...pointersRef.current.values()];
if (!pinch || pts.length < 2) return;
const [a, b] = pts;
const dist = Math.max(1, pointerDistance(a, b));
const mid = pointerMidpoint(a, b);
const local = framePoint(mid.x, mid.y);
const scale = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, pinch.startScale * (dist / pinch.startDist))
);
// Keep the original content under the pinch midpoint while scaling + panning.
const ix = (pinch.startMidX - pinch.startTx) / pinch.startScale;
const iy = (pinch.startMidY - pinch.startTy) / pinch.startScale;
commitZoom({
scale,
tx: local.x - ix * scale,
ty: local.y - iy * scale,
});
suppressClickRef.current = true;
}, [commitZoom]);
useLayoutEffect(() => {
resetZoom();
}, [src, resetZoom]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [requestClose]);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return undefined;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const img = imgRef.current;
if (!img?.src) return;
const factor = Math.exp(-event.deltaY * 0.002);
const r = frame.getBoundingClientRect();
const x = event.clientX - r.left;
const y = event.clientY - r.top;
zoomAround(x, y, zoomRef.current.scale * factor);
};
// Block browser gesture zoom / scroll while pinching on the frame.
const blockGesture = (event: Event) => {
if (pointersRef.current.size >= 2 || pinchRef.current) {
event.preventDefault();
}
};
frame.addEventListener('wheel', onWheel, { passive: false });
frame.addEventListener('touchmove', blockGesture, { passive: false });
return () => {
frame.removeEventListener('wheel', onWheel);
frame.removeEventListener('touchmove', blockGesture);
};
}, [zoomAround]);
useEffect(() => {
const onResize = () => fitImage();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [fitImage]);
const handleDownload = async () => {
try {
// Always use current session's token to avoid stale tokens during account switches
const fileContent = await downloadMedia(src, getCurrentAccessToken());
FileSaver.saveAs(fileContent, alt);
} catch (error) {
console.warn('[ImageViewer] Failed to download media:', error);
// Fallback: try to fetch via standard fetch as blob
try {
const response = await fetch(src);
if (response.ok) {
@@ -38,81 +281,171 @@ export const ImageViewer = as<'div', ImageViewerProps>(
FileSaver.saveAs(blob, alt);
}
} catch {
// If all else fails, open in new tab to let browser handle it
window.open(src, '_blank');
}
}
};
const handleClick = (event: React.MouseEvent) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
if (dragRef.current?.moved) {
dragRef.current = null;
return;
}
const img = imgRef.current;
if (!img?.src) return;
event.preventDefault();
const { x, y } = framePoint(event.clientX, event.clientY);
if (zoomRef.current.scale <= 1.01) {
zoomAround(x, y, ZOOM_CLICK);
} else {
resetZoom();
}
};
const handlePointerDown = (event: React.PointerEvent) => {
if (event.button !== 0 && event.pointerType === 'mouse') return;
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
frameRef.current?.setPointerCapture(event.pointerId);
if (pointersRef.current.size >= 2) {
beginPinch();
return;
}
if (zoomRef.current.scale <= 1.01) return;
const z = zoomRef.current;
dragRef.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
tx: z.tx,
ty: z.ty,
moved: false,
};
setDragging(true);
};
const handlePointerMove = (event: React.PointerEvent) => {
if (!pointersRef.current.has(event.pointerId)) return;
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pointersRef.current.size >= 2 && pinchRef.current) {
event.preventDefault();
updatePinch();
return;
}
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
if (!drag.moved && Math.hypot(dx, dy) > DRAG_CLICK_SLOP) drag.moved = true;
commitZoom({
scale: zoomRef.current.scale,
tx: drag.tx + dx,
ty: drag.ty + dy,
});
};
const handlePointerUp = (event: React.PointerEvent) => {
pointersRef.current.delete(event.pointerId);
frameRef.current?.releasePointerCapture(event.pointerId);
if (pinchRef.current) {
if (pointersRef.current.size >= 2) {
beginPinch();
return;
}
pinchRef.current = null;
setDragging(false);
// Extra Things: one finger left after pinch — hand off to pan if still zoomed.
if (pointersRef.current.size === 1 && zoomRef.current.scale > 1.01) {
const [id, pt] = [...pointersRef.current.entries()][0];
const z = zoomRef.current;
dragRef.current = {
id,
x: pt.x,
y: pt.y,
tx: z.tx,
ty: z.ty,
moved: true,
};
setDragging(true);
} else {
dragRef.current = null;
}
return;
}
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
setDragging(false);
if (!drag.moved) dragRef.current = null;
else dragRef.current = { ...drag, moved: true };
};
const zoomed = zoom.scale > 1.01;
return (
<Box
className={classNames(css.ImageViewer, className)}
direction="Column"
<div
className={classNames(css.Root, className)}
role="dialog"
aria-modal="true"
aria-label={alt || 'Image viewer'}
{...props}
ref={ref}
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
<div className={css.Backdrop} onClick={requestClose} aria-hidden />
<div className={css.Stage}>
<div className={css.Chrome}>
<div className={css.Title} title={alt}>
{alt}
</Text>
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
radii="Pill"
onClick={zoomOut}
aria-label="Zoom Out"
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
radii="Pill"
onClick={zoomIn}
aria-label="Zoom In"
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
<Chip
variant="Primary"
</div>
<button
type="button"
className={css.ChromeButton}
aria-label="Download"
onClick={handleDownload}
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
</Chip>
</Box>
</Header>
<Box
grow="Yes"
className={css.ImageViewerContent}
justifyContent="Center"
alignItems="Center"
>
<img
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
}}
src={src}
alt={alt}
draggable={false}
onMouseDown={onMouseDown}
/>
</Box>
</Box>
<Icon size="50" src={Icons.Download} />
</button>
<button
type="button"
className={css.ChromeButton}
aria-label="Close"
onClick={requestClose}
>
<Icon size="50" src={Icons.Cross} />
</button>
</div>
<div
ref={frameRef}
className={css.Frame}
data-zoomed={zoomed || undefined}
data-pannable={zoomed || undefined}
data-dragging={dragging || undefined}
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<img
ref={imgRef}
className={css.Image}
src={src}
alt={alt}
draggable={false}
onLoad={fitImage}
/>
</div>
</div>
</div>
);
}
);

View File

@@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Badge, Box, Button, Chip, Overlay, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
@@ -14,7 +14,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { getCurrentAccessToken } from '../../../utils/auth';
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
@@ -192,28 +191,21 @@ export const ImageContent = as<'div', ImageContentProps>(
<div className={css.MediaSkeleton} />
))}
{srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
>
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
<Overlay open={viewer} backdrop={null}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</FocusTrap>
</Overlay>
)}
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Avatar, Box, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, toRem } from 'folds';
import { Avatar, Box, Overlay, Text, toRem } from 'folds';
import { Icon, Icons } from '../icons';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
@@ -87,25 +87,21 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
</Avatar>
</AvatarPresence>
{viewAvatar && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewAvatar(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
<ImageViewer
src={viewAvatar}
alt={userId}
requestClose={() => setViewAvatar(undefined)}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
<Overlay open backdrop={null}>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewAvatar(undefined),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<ImageViewer
src={viewAvatar}
alt={userId}
requestClose={() => setViewAvatar(undefined)}
/>
</FocusTrap>
</Overlay>
)}
</div>

View File

@@ -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 <FireworkBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
}
return <LocalBurstCanvas burst={burst} primaryEmoji={primaryEmoji} onComplete={onComplete} />;
}
type BurstCanvasProps = {
burst: EmojiConfettiBurst;
primaryEmoji: string;
onComplete: (burstId: string) => void;
};
function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const simRef = useRef<FireworkSim | null>(null);
const frameRef = useRef<number | null>(null);
const lastFrameTimeRef = useRef<number | null>(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(
<div
aria-hidden
style={{
position: 'fixed',
inset: 0,
width: '100vw',
height: '100vh',
pointerEvents: 'none',
zIndex: 40,
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
}}
/>
</div>,
document.body
);
}
function LocalBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const layerRef = useRef<HTMLDivElement>(null);
const particlesRef = useRef<BurstParticle[]>([]);
@@ -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(
<div

View File

@@ -7,7 +7,8 @@ export type BurstMotionStyle =
| 'spiral'
| 'punch'
| 'scatter'
| 'shower';
| 'shower'
| 'firework';
export type EmojiBurstProfile = {
style: BurstMotionStyle;
@@ -32,6 +33,8 @@ export type EmojiBurstProfile = {
angleSpread?: number;
twinkle?: boolean;
wobble?: boolean;
/** Full-viewport overlay (Box2D pile for fireworks). */
fullscreen?: boolean;
};
const DEFAULT_PROFILE: EmojiBurstProfile = {
@@ -340,6 +343,45 @@ const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
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;

View File

@@ -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<string, HTMLCanvasElement>();
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;
}

View File

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

View File

@@ -534,12 +534,20 @@ export const readClipboardImage = async (): Promise<File | null> => {
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<File | 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) {
console.warn('Failed to read Tauri clipboard image:', err);