Compare commits
3 Commits
8a68a1e30a
...
898d217451
| Author | SHA1 | Date | |
|---|---|---|---|
| 898d217451 | |||
| e08b4ec22e | |||
| 13523fea2b |
@@ -1,42 +1,127 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
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,
|
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,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
paddingLeft: config.space.S200,
|
position: 'absolute',
|
||||||
paddingRight: config.space.S200,
|
inset: 0,
|
||||||
borderBottomWidth: config.borderWidth.B300,
|
backgroundColor: 'rgba(0, 0, 0, 0.72)',
|
||||||
flexShrink: 0,
|
backdropFilter: 'blur(18px)',
|
||||||
gap: config.space.S200,
|
WebkitBackdropFilter: 'blur(18px)',
|
||||||
|
cursor: 'zoom-out',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ImageViewerContent = style([
|
export const Stage = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
backgroundColor: color.Background.Container,
|
position: 'relative',
|
||||||
color: color.Background.OnContainer,
|
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',
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ImageViewerImg = style([
|
export const ChromeButton = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
objectFit: 'contain',
|
display: 'inline-flex',
|
||||||
width: 'auto',
|
alignItems: 'center',
|
||||||
height: 'auto',
|
justifyContent: 'center',
|
||||||
maxWidth: '100%',
|
flexShrink: 0,
|
||||||
maxHeight: '100%',
|
padding: 6,
|
||||||
backgroundColor: color.Surface.Container,
|
border: 'none',
|
||||||
transition: 'transform 100ms linear',
|
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',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,16 +1,62 @@
|
|||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
/* 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 FileSaver from 'file-saver';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
import { as } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import * as css from './ImageViewer.css';
|
import * as css from './ImageViewer.css';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
|
||||||
import { usePan } from '../../hooks/usePan';
|
|
||||||
import { downloadMedia } from '../../utils/matrix';
|
import { downloadMedia } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
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 = {
|
export type ImageViewerProps = {
|
||||||
alt: string;
|
alt: string;
|
||||||
src: string;
|
src: string;
|
||||||
@@ -19,18 +65,215 @@ export type ImageViewerProps = {
|
|||||||
|
|
||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
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 () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
||||||
FileSaver.saveAs(fileContent, alt);
|
FileSaver.saveAs(fileContent, alt);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ImageViewer] Failed to download media:', error);
|
console.warn('[ImageViewer] Failed to download media:', error);
|
||||||
// Fallback: try to fetch via standard fetch as blob
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(src);
|
const response = await fetch(src);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -38,81 +281,171 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
FileSaver.saveAs(blob, alt);
|
FileSaver.saveAs(blob, alt);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If all else fails, open in new tab to let browser handle it
|
|
||||||
window.open(src, '_blank');
|
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 (
|
return (
|
||||||
<Box
|
<div
|
||||||
className={classNames(css.ImageViewer, className)}
|
className={classNames(css.Root, className)}
|
||||||
direction="Column"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={alt || 'Image viewer'}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<Header className={css.ImageViewerHeader} size="400">
|
<div className={css.Backdrop} onClick={requestClose} aria-hidden />
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<div className={css.Stage}>
|
||||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
<div className={css.Chrome}>
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
<div className={css.Title} title={alt}>
|
||||||
</IconButton>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{alt}
|
{alt}
|
||||||
</Text>
|
</div>
|
||||||
</Box>
|
<button
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
type="button"
|
||||||
<IconButton
|
className={css.ChromeButton}
|
||||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
aria-label="Download"
|
||||||
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"
|
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
|
||||||
>
|
>
|
||||||
<Text size="B300">Download</Text>
|
<Icon size="50" src={Icons.Download} />
|
||||||
</Chip>
|
</button>
|
||||||
</Box>
|
<button
|
||||||
</Header>
|
type="button"
|
||||||
<Box
|
className={css.ChromeButton}
|
||||||
grow="Yes"
|
aria-label="Close"
|
||||||
className={css.ImageViewerContent}
|
onClick={requestClose}
|
||||||
justifyContent="Center"
|
>
|
||||||
alignItems="Center"
|
<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
|
<img
|
||||||
className={css.ImageViewerImg}
|
ref={imgRef}
|
||||||
style={{
|
className={css.Image}
|
||||||
cursor,
|
|
||||||
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
|
||||||
}}
|
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onMouseDown={onMouseDown}
|
onLoad={fitImage}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
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 { Icon, Icons } from '../../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Blurhash } from 'react-blurhash';
|
import { Blurhash } from 'react-blurhash';
|
||||||
@@ -14,7 +14,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
|||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||||
@@ -192,8 +191,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
<div className={css.MediaSkeleton} />
|
<div className={css.MediaSkeleton} />
|
||||||
))}
|
))}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={null}>
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
@@ -201,19 +199,13 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<Modal
|
|
||||||
className={ModalWide}
|
|
||||||
size="500"
|
|
||||||
>
|
>
|
||||||
{renderViewer({
|
{renderViewer({
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
alt: body,
|
alt: body,
|
||||||
requestClose: () => setViewer(false),
|
requestClose: () => setViewer(false),
|
||||||
})}
|
})}
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { Icon, Icons } from '../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
@@ -87,8 +87,7 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
</AvatarPresence>
|
</AvatarPresence>
|
||||||
{viewAvatar && (
|
{viewAvatar && (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={null}>
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
@@ -97,15 +96,12 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
|
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={viewAvatar}
|
src={viewAvatar}
|
||||||
alt={userId}
|
alt={userId}
|
||||||
requestClose={() => setViewAvatar(undefined)}
|
requestClose={() => setViewAvatar(undefined)}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { EmojiConfettiBurst } from './types';
|
import { EmojiConfettiBurst } from './types';
|
||||||
import { findJumboEmojiElement, getLocalBurstCanvasSize } from './findJumboMount';
|
import { findJumboEmojiElement, getLocalBurstCanvasSize, measureJumboGlyph, setJumboEmojiHidden } from './findJumboMount';
|
||||||
import {
|
import {
|
||||||
BurstParticle,
|
BurstParticle,
|
||||||
drawBurstParticles,
|
drawBurstParticles,
|
||||||
spawnEmojiBurst,
|
spawnEmojiBurst,
|
||||||
stepBurstParticles,
|
stepBurstParticles,
|
||||||
} from './emojiBurstEngine';
|
} from './emojiBurstEngine';
|
||||||
|
import { getEmojiBurstProfile, isFullscreenBurstEmoji } from './emojiParticleProfiles';
|
||||||
|
import {
|
||||||
|
createFireworkSim,
|
||||||
|
drawFireworkSim,
|
||||||
|
FireworkSim,
|
||||||
|
getFireworkDpr,
|
||||||
|
stepFireworkSim,
|
||||||
|
syncFireworkHero,
|
||||||
|
} from './fireworkParticleEngine';
|
||||||
|
|
||||||
const MAX_DPR = 2;
|
const MAX_DPR = 2;
|
||||||
|
|
||||||
@@ -17,6 +26,198 @@ type EmojiConfettiBurstCanvasProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function EmojiConfettiBurstCanvas({ burst, onComplete }: 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 profile = getEmojiBurstProfile(primaryEmoji);
|
||||||
|
const glyph = measureJumboGlyph(burst.targetEventId);
|
||||||
|
const pinToJumbo = Boolean(profile.morphTo && glyph);
|
||||||
|
|
||||||
|
const origin = {
|
||||||
|
x: glyph ? glyph.x : burst.origin.x,
|
||||||
|
y: glyph ? glyph.y : burst.origin.y,
|
||||||
|
maskRadius: glyph ? glyph.size * 0.5 : burst.origin.maskRadius ?? 36,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep morph heroes glued to the message emoji; only clamp undirected fireworks.
|
||||||
|
if (!pinToJumbo) {
|
||||||
|
origin.x = Math.min(width - 24, Math.max(24, origin.x));
|
||||||
|
origin.y = Math.min(height - 24, Math.max(24, origin.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
simRef.current = createFireworkSim(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
origin,
|
||||||
|
primaryEmoji,
|
||||||
|
profile,
|
||||||
|
performance.now(),
|
||||||
|
glyph?.size
|
||||||
|
);
|
||||||
|
|
||||||
|
const revealJumbo = () => {
|
||||||
|
setJumboEmojiHidden(burst.targetEventId, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncHero = () => {
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
if (!sim.hero) {
|
||||||
|
revealJumbo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const metrics = measureJumboGlyph(burst.targetEventId);
|
||||||
|
syncFireworkHero(sim, metrics);
|
||||||
|
// Re-query every frame so React remounts still stay hidden.
|
||||||
|
setJumboEmojiHidden(burst.targetEventId, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
// Extra Things: match CSS size; sim keeps its launch-time world size.
|
||||||
|
resize();
|
||||||
|
syncHero();
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
window.addEventListener('scroll', syncHero, true);
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
revealJumbo();
|
||||||
|
onCompleteRef.current(burst.id);
|
||||||
|
frameRef.current = null;
|
||||||
|
lastFrameTimeRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hide immediately before first paint of the stand-in.
|
||||||
|
if (pinToJumbo) {
|
||||||
|
setJumboEmojiHidden(burst.targetEventId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const sim = simRef.current;
|
||||||
|
if (!sim) return;
|
||||||
|
|
||||||
|
syncHero();
|
||||||
|
|
||||||
|
const last = lastFrameTimeRef.current ?? now;
|
||||||
|
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
||||||
|
lastFrameTimeRef.current = now;
|
||||||
|
|
||||||
|
const alive = stepFireworkSim(sim, now, dtSeconds);
|
||||||
|
// Hero may have just been dismissed — reveal real jumbo immediately.
|
||||||
|
if (!sim.hero) {
|
||||||
|
revealJumbo();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
lastFrameTimeRef.current = performance.now();
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
window.removeEventListener('scroll', syncHero, true);
|
||||||
|
revealJumbo();
|
||||||
|
if (frameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
frameRef.current = null;
|
||||||
|
}
|
||||||
|
simRef.current = null;
|
||||||
|
// Extra Things: allow Strict Mode remount to start a fresh burst.
|
||||||
|
spawnedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const layerRef = useRef<HTMLDivElement>(null);
|
const layerRef = useRef<HTMLDivElement>(null);
|
||||||
const particlesRef = useRef<BurstParticle[]>([]);
|
const particlesRef = useRef<BurstParticle[]>([]);
|
||||||
@@ -89,13 +290,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur
|
|||||||
canvas.style.width = `${canvasSize}px`;
|
canvas.style.width = `${canvasSize}px`;
|
||||||
canvas.style.height = `${canvasSize}px`;
|
canvas.style.height = `${canvasSize}px`;
|
||||||
|
|
||||||
spawnEmojiBurst(
|
spawnEmojiBurst(particlesRef.current, burst.id, localOrigin, primaryEmoji, performance.now());
|
||||||
particlesRef.current,
|
|
||||||
burst.id,
|
|
||||||
localOrigin,
|
|
||||||
burst.emojis[0] ?? '🎉',
|
|
||||||
performance.now()
|
|
||||||
);
|
|
||||||
|
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
updateLayerPosition();
|
updateLayerPosition();
|
||||||
@@ -152,7 +347,7 @@ export function EmojiConfettiBurstCanvas({ burst, onComplete }: EmojiConfettiBur
|
|||||||
window.clearInterval(retry);
|
window.clearInterval(retry);
|
||||||
frameCleanup?.();
|
frameCleanup?.();
|
||||||
};
|
};
|
||||||
}, [burst.emojis, burst.id, burst.targetEventId, canvasSize, localOrigin]);
|
}, [burst.id, burst.targetEventId, canvasSize, localOrigin, primaryEmoji]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export type BurstMotionStyle =
|
|||||||
| 'spiral'
|
| 'spiral'
|
||||||
| 'punch'
|
| 'punch'
|
||||||
| 'scatter'
|
| 'scatter'
|
||||||
| 'shower';
|
| 'shower'
|
||||||
|
| 'firework';
|
||||||
|
|
||||||
export type EmojiBurstProfile = {
|
export type EmojiBurstProfile = {
|
||||||
style: BurstMotionStyle;
|
style: BurstMotionStyle;
|
||||||
@@ -32,6 +33,19 @@ export type EmojiBurstProfile = {
|
|||||||
angleSpread?: number;
|
angleSpread?: number;
|
||||||
twinkle?: boolean;
|
twinkle?: boolean;
|
||||||
wobble?: boolean;
|
wobble?: boolean;
|
||||||
|
/** Full-viewport overlay (Box2D pile for fireworks). */
|
||||||
|
fullscreen?: boolean;
|
||||||
|
/** Soft color wash applied to matching particle glyphs (e.g. green spit). */
|
||||||
|
tintColor?: string;
|
||||||
|
/** Emojis that receive `tintColor` when drawn. */
|
||||||
|
tintEmojis?: string[];
|
||||||
|
/** Swap the primary face to this emoji mid-burst (e.g. 🤢 → 🤮). */
|
||||||
|
morphTo?: string;
|
||||||
|
/** Delay before `morphTo` kicks in. */
|
||||||
|
morphAfterMs?: number;
|
||||||
|
/** How long the stand-in hero face stays before the real jumbo returns.
|
||||||
|
* Omit to keep it up until the last particle spawns. */
|
||||||
|
heroDurationMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
const DEFAULT_PROFILE: EmojiBurstProfile = {
|
||||||
@@ -340,6 +354,97 @@ const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
|
|||||||
companions: ['🎉', '🎊', '🎈'],
|
companions: ['🎉', '🎊', '🎈'],
|
||||||
companionChance: 0.4,
|
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,
|
||||||
|
},
|
||||||
|
// Full-viewport spew — downward cone, green-tinted droplets, piles on the floor.
|
||||||
|
'🤢': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 420,
|
||||||
|
gravity: 1200,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 380,
|
||||||
|
speedMax: 980,
|
||||||
|
launchUpMin: 0,
|
||||||
|
launchUpMax: 40,
|
||||||
|
spinMin: -560,
|
||||||
|
spinMax: 560,
|
||||||
|
fontSizeMin: 18,
|
||||||
|
fontSizeMax: 34,
|
||||||
|
heroFontSizeMin: 44,
|
||||||
|
heroFontSizeMax: 58,
|
||||||
|
// Downward throw-up cone (π/2 = down in canvas space).
|
||||||
|
angleBias: Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 1.05,
|
||||||
|
companions: ['💦', '💧', '💚'],
|
||||||
|
companionChance: 1,
|
||||||
|
tintColor: 'rgba(72, 190, 48, 0.72)',
|
||||||
|
tintEmojis: ['💦', '💧'],
|
||||||
|
morphTo: '🤮',
|
||||||
|
morphAfterMs: 220,
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
|
'🤮': {
|
||||||
|
style: 'firework',
|
||||||
|
fullscreen: true,
|
||||||
|
particleCount: 460,
|
||||||
|
gravity: 1250,
|
||||||
|
drag: 1,
|
||||||
|
speedMin: 420,
|
||||||
|
speedMax: 1050,
|
||||||
|
launchUpMin: 0,
|
||||||
|
launchUpMax: 30,
|
||||||
|
spinMin: -600,
|
||||||
|
spinMax: 600,
|
||||||
|
fontSizeMin: 18,
|
||||||
|
fontSizeMax: 36,
|
||||||
|
heroFontSizeMin: 46,
|
||||||
|
heroFontSizeMax: 60,
|
||||||
|
angleBias: Math.PI / 2,
|
||||||
|
angleSpread: Math.PI * 1.15,
|
||||||
|
companions: ['💦', '💧', '💚'],
|
||||||
|
companionChance: 1,
|
||||||
|
tintColor: 'rgba(72, 190, 48, 0.72)',
|
||||||
|
tintEmojis: ['💦', '💧'],
|
||||||
|
wobble: true,
|
||||||
|
},
|
||||||
'🐱': {
|
'🐱': {
|
||||||
style: 'bounce',
|
style: 'bounce',
|
||||||
particleCount: 20,
|
particleCount: 20,
|
||||||
@@ -369,6 +474,10 @@ export function getEmojiBurstProfile(emoji: string): EmojiBurstProfile {
|
|||||||
return { ...DEFAULT_PROFILE, ...override };
|
return { ...DEFAULT_PROFILE, ...override };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFullscreenBurstEmoji(emoji: string): boolean {
|
||||||
|
return getEmojiBurstProfile(emoji).fullscreen === true;
|
||||||
|
}
|
||||||
|
|
||||||
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
export function pickParticleEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
||||||
if (!profile.companions?.length || !profile.companionChance) {
|
if (!profile.companions?.length || !profile.companionChance) {
|
||||||
return primaryEmoji;
|
return primaryEmoji;
|
||||||
|
|||||||
@@ -6,6 +6,60 @@ export function findJumboEmojiElement(targetEventId: string): HTMLElement | null
|
|||||||
return jumbo instanceof HTMLElement ? jumbo : null;
|
return jumbo instanceof HTMLElement ? jumbo : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JumboGlyphMetrics = {
|
||||||
|
/** Outer jumbo mount (message body) — hide/show this. */
|
||||||
|
mount: HTMLElement;
|
||||||
|
/** Visual glyph center in viewport coords. */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** CSS px size matching the rendered emoji (usually computed font-size). */
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the on-screen jumbo glyph. Prefer computed font-size so the canvas
|
||||||
|
* stand-in matches unicode emoji; fall back to the glyph box for images.
|
||||||
|
*/
|
||||||
|
export function measureJumboGlyph(targetEventId: string): JumboGlyphMetrics | null {
|
||||||
|
const mount = findJumboEmojiElement(targetEventId);
|
||||||
|
if (!mount) return null;
|
||||||
|
|
||||||
|
const glyph =
|
||||||
|
(mount.querySelector('[data-emoticon]') as HTMLElement | null) ||
|
||||||
|
(mount.querySelector('img') as HTMLElement | null) ||
|
||||||
|
mount;
|
||||||
|
|
||||||
|
const rect = glyph.getBoundingClientRect();
|
||||||
|
const fontSize =
|
||||||
|
parseFloat(getComputedStyle(glyph).fontSize) ||
|
||||||
|
parseFloat(getComputedStyle(mount).fontSize) ||
|
||||||
|
0;
|
||||||
|
|
||||||
|
const isImg = glyph instanceof HTMLImageElement || glyph.tagName === 'IMG';
|
||||||
|
const size = isImg
|
||||||
|
? Math.max(rect.width, rect.height)
|
||||||
|
: fontSize > 0
|
||||||
|
? fontSize
|
||||||
|
: Math.max(rect.width, rect.height);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mount,
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setJumboEmojiHidden(targetEventId: string, hidden: boolean) {
|
||||||
|
const mount = findJumboEmojiElement(targetEventId);
|
||||||
|
if (!mount) return;
|
||||||
|
if (hidden) {
|
||||||
|
mount.style.visibility = 'hidden';
|
||||||
|
} else {
|
||||||
|
mount.style.removeProperty('visibility');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getLocalBurstCanvasSize(maskRadius: number): number {
|
export function getLocalBurstCanvasSize(maskRadius: number): number {
|
||||||
return Math.max(300, Math.round(maskRadius * 6.5));
|
return Math.max(300, Math.round(maskRadius * 6.5));
|
||||||
}
|
}
|
||||||
|
|||||||
515
src/app/features/room/emoji-confetti/fireworkParticleEngine.ts
Normal file
515
src/app/features/room/emoji-confetti/fireworkParticleEngine.ts
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
sampleBurstAngle,
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeroFace = {
|
||||||
|
emoji: string;
|
||||||
|
emojiCanvas: HTMLCanvasElement;
|
||||||
|
/** Target CSS pixel size of the glyph (matches jumbo font-size). */
|
||||||
|
drawSize: number;
|
||||||
|
halfSize: number;
|
||||||
|
/** canvasPx / fontPx — draw box is drawSize * canvasScale so the glyph isn't cropped. */
|
||||||
|
canvasScale: number;
|
||||||
|
morphed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FireworkSim = {
|
||||||
|
particles: FireworkParticle[];
|
||||||
|
profile: EmojiBurstProfile;
|
||||||
|
primaryEmoji: string;
|
||||||
|
morphAtMs: number | null;
|
||||||
|
/** When to drop the stand-in hero and reveal the real jumbo again. */
|
||||||
|
heroUntilMs: number | null;
|
||||||
|
hero: HeroFace | null;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HERO_PAD_RATIO = 0.24;
|
||||||
|
|
||||||
|
type EmojiBitmap = {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
/** Multiply CSS glyph size by this when drawImage'ing to keep padding uncropped. */
|
||||||
|
canvasScale: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEmojiCanvas(emoji: string, tintColor?: string, cssPx?: number): HTMLCanvasElement {
|
||||||
|
return getEmojiBitmap(emoji, tintColor, cssPx, 0).canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmojiBitmap(
|
||||||
|
emoji: string,
|
||||||
|
tintColor: string | undefined,
|
||||||
|
cssPx: number | undefined,
|
||||||
|
padRatio: number
|
||||||
|
): EmojiBitmap {
|
||||||
|
const targetCss = Math.max(16, Math.round(cssPx ?? EMOJI_CACHE_PX));
|
||||||
|
const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1;
|
||||||
|
const fontPx = Math.round(targetCss * dpr);
|
||||||
|
const pad = Math.ceil(fontPx * padRatio);
|
||||||
|
const size = fontPx + pad * 2;
|
||||||
|
const cacheKey = `${emoji}|px:${fontPx}|pad:${pad}|tint:${tintColor ?? ''}`;
|
||||||
|
const cached = emojiCanvasCache.get(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return { canvas: cached, canvasScale: size / fontPx };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `${fontPx}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||||
|
// Slight optical nudge — color emoji fonts sit high in the em box.
|
||||||
|
ctx.fillText(emoji, size / 2, size / 2 + fontPx * 0.06);
|
||||||
|
|
||||||
|
if (tintColor) {
|
||||||
|
ctx.globalCompositeOperation = 'source-atop';
|
||||||
|
ctx.fillStyle = tintColor;
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emojiCanvasCache.set(cacheKey, canvas);
|
||||||
|
return { canvas, canvasScale: size / Math.max(fontPx, 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeroEmoji(hero: HeroFace, emoji: string) {
|
||||||
|
const bitmap = getEmojiBitmap(emoji, undefined, hero.drawSize, HERO_PAD_RATIO);
|
||||||
|
hero.emoji = emoji;
|
||||||
|
hero.emojiCanvas = bitmap.canvas;
|
||||||
|
hero.canvasScale = bitmap.canvasScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeroSize(hero: HeroFace, cssPx: number) {
|
||||||
|
if (!(cssPx > 0)) return;
|
||||||
|
if (Math.abs(cssPx - hero.drawSize) < 0.5) return;
|
||||||
|
hero.drawSize = cssPx;
|
||||||
|
hero.halfSize = cssPx / 2;
|
||||||
|
const bitmap = getEmojiBitmap(hero.emoji, undefined, cssPx, HERO_PAD_RATIO);
|
||||||
|
hero.emojiCanvas = bitmap.canvas;
|
||||||
|
hero.canvasScale = bitmap.canvasScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function particleTint(profile: EmojiBurstProfile, emoji: string): string | undefined {
|
||||||
|
if (!profile.tintColor || !profile.tintEmojis?.length) return undefined;
|
||||||
|
return profile.tintEmojis.includes(emoji) ? profile.tintColor : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spew particles for morph bursts — never the morphTo face (that stays on the hero). */
|
||||||
|
function pickSpewEmoji(profile: EmojiBurstProfile, primaryEmoji: string): string {
|
||||||
|
const morphTo = profile.morphTo;
|
||||||
|
const companions = (profile.companions ?? []).filter((e) => e !== morphTo);
|
||||||
|
if (morphTo && companions.length) {
|
||||||
|
return companions[Math.floor(Math.random() * companions.length)] ?? primaryEmoji;
|
||||||
|
}
|
||||||
|
const picked = pickParticleEmoji(profile, primaryEmoji);
|
||||||
|
return picked === morphTo ? primaryEmoji : picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpawnPlan(
|
||||||
|
profile: EmojiBurstProfile,
|
||||||
|
primaryEmoji: string,
|
||||||
|
startMs: number
|
||||||
|
): SpawnItem[] {
|
||||||
|
// Anchored morphing hero is drawn separately — skip a flying hero twin.
|
||||||
|
const plan: SpawnItem[] = profile.morphTo
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
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: pickSpewEmoji(profile, primaryEmoji),
|
||||||
|
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.sort((a, b) => a.atMs - b.atMs);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMorph(sim: FireworkSim, now: number) {
|
||||||
|
const to = sim.profile.morphTo;
|
||||||
|
if (!to || sim.morphAtMs === null || now < sim.morphAtMs) return;
|
||||||
|
if (sim.hero?.morphed) return;
|
||||||
|
|
||||||
|
// Only the stand-in face morphs — spray stays droplets / companions.
|
||||||
|
if (sim.hero) {
|
||||||
|
setHeroEmoji(sim.hero, to);
|
||||||
|
sim.hero.morphed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 directed = profile.angleSpread !== undefined;
|
||||||
|
const angle = directed ? sampleBurstAngle(profile) : 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;
|
||||||
|
const emoji = item.emoji;
|
||||||
|
|
||||||
|
// Directed cones (e.g. throw-up) push along the sampled angle.
|
||||||
|
// Undirected fireworks keep the classic upward launch kick.
|
||||||
|
let vx = Math.cos(angle) * speed + (Math.random() - 0.5) * 40;
|
||||||
|
let vy = Math.sin(angle) * speed;
|
||||||
|
if (directed) {
|
||||||
|
const bias = profile.angleBias ?? angle;
|
||||||
|
vx += Math.cos(bias) * launchUp * 0.25;
|
||||||
|
vy += Math.sin(bias) * launchUp;
|
||||||
|
} else {
|
||||||
|
vy -= launchUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tint = particleTint(profile, emoji);
|
||||||
|
|
||||||
|
sim.particles.push({
|
||||||
|
x: origin.x + (Math.random() - 0.5) * jitter,
|
||||||
|
y: origin.y + (Math.random() - 0.5) * jitter,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
|
||||||
|
angle: (Math.random() - 0.5) * 40,
|
||||||
|
emoji,
|
||||||
|
emojiCanvas: getEmojiCanvas(emoji, tint),
|
||||||
|
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(),
|
||||||
|
/** Pixel size of the source jumbo emoji — hero face matches this when set. */
|
||||||
|
heroSizePx?: number
|
||||||
|
): FireworkSim {
|
||||||
|
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL));
|
||||||
|
const fallbackHero =
|
||||||
|
lerp(profile.heroFontSizeMin, profile.heroFontSizeMax) * EMOJI_CACHE_SCALE * 0.95;
|
||||||
|
const heroSize = heroSizePx && heroSizePx > 0 ? heroSizePx : fallbackHero;
|
||||||
|
const hero: HeroFace | null = profile.morphTo
|
||||||
|
? (() => {
|
||||||
|
const bitmap = getEmojiBitmap(primaryEmoji, undefined, heroSize, HERO_PAD_RATIO);
|
||||||
|
return {
|
||||||
|
emoji: primaryEmoji,
|
||||||
|
emojiCanvas: bitmap.canvas,
|
||||||
|
drawSize: heroSize,
|
||||||
|
halfSize: heroSize / 2,
|
||||||
|
canvasScale: bitmap.canvasScale,
|
||||||
|
morphed: false,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const spawnPlan = buildSpawnPlan(profile, primaryEmoji, startMs);
|
||||||
|
// Keep the spewing stand-in up for the whole fountain — not just a short flash.
|
||||||
|
const lastSpawnAt = spawnPlan.length > 0 ? spawnPlan[spawnPlan.length - 1].atMs : startMs;
|
||||||
|
const heroUntilMs = profile.morphTo
|
||||||
|
? profile.heroDurationMs != null
|
||||||
|
? startMs + profile.heroDurationMs
|
||||||
|
: lastSpawnAt + 120
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
particles: [],
|
||||||
|
profile,
|
||||||
|
primaryEmoji,
|
||||||
|
morphAtMs: profile.morphTo ? startMs + (profile.morphAfterMs ?? 220) : null,
|
||||||
|
heroUntilMs,
|
||||||
|
hero,
|
||||||
|
width: widthPx,
|
||||||
|
height: heightPx,
|
||||||
|
origin: originPx,
|
||||||
|
startMs,
|
||||||
|
spawnPlan,
|
||||||
|
spawnCursor: 0,
|
||||||
|
floorY: heightPx - FLOOR_PAD,
|
||||||
|
pileCols: new Uint16Array(colCount),
|
||||||
|
pileCanvas: null,
|
||||||
|
pileCtx: null,
|
||||||
|
flyingCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the stand-in face glued to the live jumbo metrics. */
|
||||||
|
export function syncFireworkHero(
|
||||||
|
sim: FireworkSim,
|
||||||
|
metrics: { x: number; y: number; size: number } | null
|
||||||
|
) {
|
||||||
|
if (!sim.hero || !metrics) return;
|
||||||
|
sim.origin.x = metrics.x;
|
||||||
|
sim.origin.y = metrics.y;
|
||||||
|
setHeroSize(sim.hero, metrics.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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;
|
||||||
|
|
||||||
|
applyMorph(sim, now);
|
||||||
|
|
||||||
|
if (sim.hero && sim.heroUntilMs !== null && now >= sim.heroUntilMs) {
|
||||||
|
sim.hero = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchored face at the spew origin — morphs 🤢 → 🤮 mid-throw, then drops away.
|
||||||
|
if (sim.hero && fade > 0.02) {
|
||||||
|
const hero = sim.hero;
|
||||||
|
const heaveAmp = Math.max(1.5, hero.drawSize * 0.02);
|
||||||
|
const heave = Math.sin(elapsed / 70) * (hero.morphed ? heaveAmp * 1.25 : heaveAmp);
|
||||||
|
// Scale includes bitmap padding so the glyph matches jumbo size without clipping.
|
||||||
|
const box = hero.drawSize * hero.canvasScale;
|
||||||
|
const half = box / 2;
|
||||||
|
context.globalAlpha = fade;
|
||||||
|
context.setTransform(1, 0, 0, 1, sim.origin.x, sim.origin.y + heave);
|
||||||
|
context.drawImage(hero.emojiCanvas, -half, -half, box, box);
|
||||||
|
context.setTransform(1, 0, 0, 1, 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;
|
||||||
|
}
|
||||||
@@ -4,21 +4,30 @@ import { readClipboardImage, isTauri, isLinux } from '../utils/tauri';
|
|||||||
|
|
||||||
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
||||||
useCallback(
|
useCallback(
|
||||||
async (evt) => {
|
(evt) => {
|
||||||
const files = getDataTransferFiles(evt.clipboardData);
|
const files = getDataTransferFiles(evt.clipboardData);
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
|
evt.preventDefault();
|
||||||
onPaste(files);
|
onPaste(files);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On Linux with Tauri, browser clipboard API doesn't work for images
|
// Address-bar / plain-text copies still win. On Linux the native clipboard
|
||||||
// Use our custom Tauri command with arboard/Wayland support
|
// often still has a previous image (or a junk bitmap) alongside text/plain;
|
||||||
if (isTauri() && isLinux()) {
|
// don't treat those as an image paste.
|
||||||
const clipboardImage = await readClipboardImage();
|
const text = evt.clipboardData?.getData('text/plain')?.trim() ?? '';
|
||||||
if (clipboardImage) {
|
if (text.length > 0) {
|
||||||
onPaste([clipboardImage]);
|
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]
|
[onPaste]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -534,12 +534,20 @@ export const readClipboardImage = async (): Promise<File | null> => {
|
|||||||
try {
|
try {
|
||||||
const electron = (window as any).electron;
|
const electron = (window as any).electron;
|
||||||
if (electron?.clipboard?.readImage) {
|
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;
|
if (!dataUrl) return null;
|
||||||
|
|
||||||
// Convert data URL to File
|
// Convert data URL to File
|
||||||
const response = await fetch(dataUrl);
|
const response = await fetch(dataUrl);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
if (blob.size < 32) return null;
|
||||||
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -559,6 +567,7 @@ export const readClipboardImage = async (): Promise<File | null> => {
|
|||||||
// Convert data URL to File
|
// Convert data URL to File
|
||||||
const response = await fetch(dataUrl);
|
const response = await fetch(dataUrl);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
if (blob.size < 32) return null;
|
||||||
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
return new File([blob], 'clipboard-image.png', { type: 'image/png' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to read Tauri clipboard image:', err);
|
console.warn('Failed to read Tauri clipboard image:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user