Compare commits
3 Commits
13523fea2b
...
9887903f49
| Author | SHA1 | Date | |
|---|---|---|---|
| 9887903f49 | |||
| 898d217451 | |||
| e08b4ec22e |
@@ -5,7 +5,7 @@ import classNames from 'classnames';
|
|||||||
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
|
import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import FileSaver from 'file-saver';
|
import { saveMediaBlob } from '../../utils/saveMedia';
|
||||||
import * as css from './PdfViewer.css';
|
import * as css from './PdfViewer.css';
|
||||||
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
@@ -61,8 +61,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||||||
}
|
}
|
||||||
}, [docState, pageNo, zoom]);
|
}, [docState, pageNo, zoom]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = async () => {
|
||||||
FileSaver.saveAs(src, name);
|
try {
|
||||||
|
const res = await fetch(src);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch PDF');
|
||||||
|
await saveMediaBlob(await res.blob(), name);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[PdfViewer] Failed to download:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||||
import React from 'react';
|
import React, {
|
||||||
import FileSaver from 'file-saver';
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
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 { downloadAndSaveMedia } from '../../utils/saveMedia';
|
||||||
import { usePan } from '../../hooks/usePan';
|
|
||||||
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,100 +64,382 @@ 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
|
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
||||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
|
||||||
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);
|
|
||||||
if (response.ok) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
FileSaver.saveAs(blob, alt);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If all else fails, open in new tab to let browser handle it
|
|
||||||
window.open(src, '_blank');
|
window.open(src, '_blank');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
<img
|
</div>
|
||||||
className={css.ImageViewerImg}
|
<div
|
||||||
style={{
|
ref={frameRef}
|
||||||
cursor,
|
className={css.Frame}
|
||||||
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
data-zoomed={zoomed || undefined}
|
||||||
}}
|
data-pannable={zoomed || undefined}
|
||||||
src={src}
|
data-dragging={dragging || undefined}
|
||||||
alt={alt}
|
onClick={handleClick}
|
||||||
draggable={false}
|
onPointerDown={handlePointerDown}
|
||||||
onMouseDown={onMouseDown}
|
onPointerMove={handlePointerMove}
|
||||||
/>
|
onPointerUp={handlePointerUp}
|
||||||
</Box>
|
onPointerCancel={handlePointerUp}
|
||||||
</Box>
|
>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
className={css.Image}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
draggable={false}
|
||||||
|
onLoad={fitImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
|
|||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import React, { ReactNode, useCallback } from 'react';
|
import React, { ReactNode, useCallback } from 'react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
mxcUrlToHttp,
|
mxcUrlToHttp,
|
||||||
} from '../../utils/matrix';
|
} from '../../utils/matrix';
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
import { getCurrentAccessToken } from '../../utils/auth';
|
||||||
|
import { saveMediaBlob } from '../../utils/saveMedia';
|
||||||
|
|
||||||
const badgeStyles = { maxWidth: toRem(100) };
|
const badgeStyles = { maxWidth: toRem(100) };
|
||||||
|
|
||||||
@@ -36,9 +36,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
: await downloadMedia(mediaUrl, accessToken);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
await saveMediaBlob(fileContent, filename);
|
||||||
FileSaver.saveAs(fileURL, filename);
|
return true;
|
||||||
return fileURL;
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { ReactNode, useCallback, useState } from 'react';
|
import React, { ReactNode, useCallback, useState } from 'react';
|
||||||
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
|
||||||
import { Icon, Icons } from '../../icons';
|
import { Icon, Icons } from '../../icons';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { saveMediaBlob } from '../../../utils/saveMedia';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { IFileInfo } from '../../../../types/matrix/common';
|
import { IFileInfo } from '../../../../types/matrix/common';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
@@ -252,9 +252,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
|
||||||
: await downloadMedia(mediaUrl, accessToken);
|
: await downloadMedia(mediaUrl, accessToken);
|
||||||
|
|
||||||
const fileURL = URL.createObjectURL(fileContent);
|
await saveMediaBlob(fileContent, body);
|
||||||
FileSaver.saveAs(fileURL, body);
|
return fileContent;
|
||||||
return fileURL;
|
|
||||||
}, [mx, url, useAuthentication, mimeType, encInfo, body])
|
}, [mx, url, useAuthentication, mimeType, encInfo, body])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -268,7 +267,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
|
|||||||
size="400"
|
size="400"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadState.status === AsyncStatus.Success
|
downloadState.status === AsyncStatus.Success
|
||||||
? FileSaver.saveAs(downloadState.data, body)
|
? saveMediaBlob(downloadState.data, body)
|
||||||
: download()
|
: download()
|
||||||
}
|
}
|
||||||
disabled={downloadState.status === AsyncStatus.Loading}
|
disabled={downloadState.status === AsyncStatus.Loading}
|
||||||
|
|||||||
@@ -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,28 +191,21 @@ 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,
|
onDeactivate: () => setViewer(false),
|
||||||
onDeactivate: () => setViewer(false),
|
clickOutsideDeactivates: true,
|
||||||
clickOutsideDeactivates: true,
|
escapeDeactivates: stopPropagation,
|
||||||
escapeDeactivates: stopPropagation,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{renderViewer({
|
||||||
<Modal
|
src: srcState.data,
|
||||||
className={ModalWide}
|
alt: body,
|
||||||
size="500"
|
requestClose: () => setViewer(false),
|
||||||
>
|
})}
|
||||||
{renderViewer({
|
</FocusTrap>
|
||||||
src: srcState.data,
|
|
||||||
alt: body,
|
|
||||||
requestClose: () => setViewer(false),
|
|
||||||
})}
|
|
||||||
</Modal>
|
|
||||||
</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,25 +87,21 @@ 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,
|
onDeactivate: () => setViewAvatar(undefined),
|
||||||
onDeactivate: () => setViewAvatar(undefined),
|
clickOutsideDeactivates: true,
|
||||||
clickOutsideDeactivates: true,
|
escapeDeactivates: stopPropagation,
|
||||||
escapeDeactivates: stopPropagation,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<ImageViewer
|
||||||
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
|
src={viewAvatar}
|
||||||
<ImageViewer
|
alt={userId}
|
||||||
src={viewAvatar}
|
requestClose={() => setViewAvatar(undefined)}
|
||||||
alt={userId}
|
/>
|
||||||
requestClose={() => setViewAvatar(undefined)}
|
</FocusTrap>
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import FileSaver from 'file-saver';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import * as css from './VideoViewer.css';
|
import * as css from './VideoViewer.css';
|
||||||
import { downloadMedia } from '../../utils/matrix';
|
import { downloadAndSaveMedia } from '../../utils/saveMedia';
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
import { getCurrentAccessToken } from '../../utils/auth';
|
||||||
|
|
||||||
export type VideoViewerProps = {
|
export type VideoViewerProps = {
|
||||||
@@ -14,20 +13,16 @@ export type VideoViewerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const VideoViewer = as<'div', VideoViewerProps>(
|
export const VideoViewer = as<'div', VideoViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => { const handleDownload = async () => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
|
||||||
FileSaver.saveAs(fileContent, alt);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[VideoViewer] Failed to download media:', error);
|
console.warn('[VideoViewer] Failed to download media:', error);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(src);
|
|
||||||
if (response.ok) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
FileSaver.saveAs(blob, alt);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
window.open(src, '_blank');
|
window.open(src, '_blank');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef } 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,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
FireworkSim,
|
FireworkSim,
|
||||||
getFireworkDpr,
|
getFireworkDpr,
|
||||||
stepFireworkSim,
|
stepFireworkSim,
|
||||||
|
syncFireworkHero,
|
||||||
} from './fireworkParticleEngine';
|
} from './fireworkParticleEngine';
|
||||||
|
|
||||||
const MAX_DPR = 2;
|
const MAX_DPR = 2;
|
||||||
@@ -81,36 +82,85 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { width, height } = resize();
|
const { width, height } = resize();
|
||||||
const jumbo = findJumboEmojiElement(burst.targetEventId);
|
const profile = getEmojiBurstProfile(primaryEmoji);
|
||||||
const jumboRect = jumbo?.getBoundingClientRect();
|
const glyph = measureJumboGlyph(burst.targetEventId);
|
||||||
|
const pinToJumbo = Boolean(profile.morphTo && glyph);
|
||||||
|
|
||||||
const origin = {
|
const origin = {
|
||||||
x: jumboRect ? jumboRect.left + jumboRect.width / 2 : burst.origin.x,
|
x: glyph ? glyph.x : burst.origin.x,
|
||||||
y: jumboRect ? jumboRect.top + jumboRect.height / 2 : burst.origin.y,
|
y: glyph ? glyph.y : burst.origin.y,
|
||||||
maskRadius: burst.origin.maskRadius ?? 36,
|
maskRadius: glyph ? glyph.size * 0.5 : burst.origin.maskRadius ?? 36,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clamp origin into the viewport so off-screen messages still blast on-screen.
|
// Keep morph heroes glued to the message emoji; only clamp undirected fireworks.
|
||||||
origin.x = Math.min(width - 24, Math.max(24, origin.x));
|
if (!pinToJumbo) {
|
||||||
origin.y = Math.min(height - 24, Math.max(24, origin.y));
|
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(
|
||||||
simRef.current = createFireworkSim(width, height, origin, primaryEmoji, profile);
|
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 = () => {
|
const onResize = () => {
|
||||||
// Extra Things: match CSS size; sim keeps its launch-time dimensions.
|
// Extra Things: match CSS size; sim keeps its launch-time world size.
|
||||||
resize();
|
resize();
|
||||||
|
syncHero();
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', onResize);
|
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 tick = (now: number) => {
|
||||||
const sim = simRef.current;
|
const sim = simRef.current;
|
||||||
if (!sim) return;
|
if (!sim) return;
|
||||||
|
|
||||||
|
syncHero();
|
||||||
|
|
||||||
const last = lastFrameTimeRef.current ?? now;
|
const last = lastFrameTimeRef.current ?? now;
|
||||||
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
const dtSeconds = Math.min((now - last) / 1000, 0.05);
|
||||||
lastFrameTimeRef.current = now;
|
lastFrameTimeRef.current = now;
|
||||||
|
|
||||||
const alive = stepFireworkSim(sim, now, dtSeconds);
|
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.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
@@ -120,9 +170,7 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onCompleteRef.current(burst.id);
|
finish();
|
||||||
frameRef.current = null;
|
|
||||||
lastFrameTimeRef.current = null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
lastFrameTimeRef.current = performance.now();
|
lastFrameTimeRef.current = performance.now();
|
||||||
@@ -130,11 +178,15 @@ function FireworkBurstCanvas({ burst, primaryEmoji, onComplete }: BurstCanvasPro
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', onResize);
|
window.removeEventListener('resize', onResize);
|
||||||
|
window.removeEventListener('scroll', syncHero, true);
|
||||||
|
revealJumbo();
|
||||||
if (frameRef.current !== null) {
|
if (frameRef.current !== null) {
|
||||||
cancelAnimationFrame(frameRef.current);
|
cancelAnimationFrame(frameRef.current);
|
||||||
frameRef.current = null;
|
frameRef.current = null;
|
||||||
}
|
}
|
||||||
simRef.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]);
|
}, [burst.id, burst.origin.maskRadius, burst.origin.x, burst.origin.y, burst.targetEventId, primaryEmoji]);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ export type EmojiBurstProfile = {
|
|||||||
wobble?: boolean;
|
wobble?: boolean;
|
||||||
/** Full-viewport overlay (Box2D pile for fireworks). */
|
/** Full-viewport overlay (Box2D pile for fireworks). */
|
||||||
fullscreen?: boolean;
|
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 = {
|
||||||
@@ -382,6 +393,58 @@ const PROFILE_OVERRIDES: Record<string, Partial<EmojiBurstProfile>> = {
|
|||||||
companionChance: 0.5,
|
companionChance: 0.5,
|
||||||
twinkle: true,
|
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,
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
* Same vibe as late-90s/MSN page effects, just with emoji.
|
* Same vibe as late-90s/MSN page effects, just with emoji.
|
||||||
*/
|
*/
|
||||||
import { BurstPoint } from './burstOrigin';
|
import { BurstPoint } from './burstOrigin';
|
||||||
import { pickParticleEmoji, type EmojiBurstProfile } from './emojiParticleProfiles';
|
import {
|
||||||
|
pickParticleEmoji,
|
||||||
|
sampleBurstAngle,
|
||||||
|
type EmojiBurstProfile,
|
||||||
|
} from './emojiParticleProfiles';
|
||||||
|
|
||||||
const EMOJI_CACHE_PX = 40;
|
const EMOJI_CACHE_PX = 40;
|
||||||
const EMOJI_CACHE_SCALE = 2;
|
const EMOJI_CACHE_SCALE = 2;
|
||||||
@@ -43,9 +47,25 @@ export type FireworkParticle = {
|
|||||||
settled: boolean;
|
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 = {
|
export type FireworkSim = {
|
||||||
particles: FireworkParticle[];
|
particles: FireworkParticle[];
|
||||||
profile: EmojiBurstProfile;
|
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;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
origin: BurstPoint;
|
origin: BurstPoint;
|
||||||
@@ -66,11 +86,35 @@ function lerp(min: number, max: number): number {
|
|||||||
return min + Math.random() * (max - min);
|
return min + Math.random() * (max - min);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
const HERO_PAD_RATIO = 0.24;
|
||||||
const cached = emojiCanvasCache.get(emoji);
|
|
||||||
if (cached) return cached;
|
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 size = EMOJI_CACHE_PX * EMOJI_CACHE_SCALE;
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = size;
|
canvas.width = size;
|
||||||
canvas.height = size;
|
canvas.height = size;
|
||||||
@@ -79,11 +123,52 @@ function getEmojiCanvas(emoji: string): HTMLCanvasElement {
|
|||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
ctx.font = `${EMOJI_CACHE_PX}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
ctx.font = `${fontPx}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
|
||||||
ctx.fillText(emoji, size / 2, size / 2);
|
// 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(emoji, canvas);
|
emojiCanvasCache.set(cacheKey, canvas);
|
||||||
return 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(
|
function buildSpawnPlan(
|
||||||
@@ -91,13 +176,16 @@ function buildSpawnPlan(
|
|||||||
primaryEmoji: string,
|
primaryEmoji: string,
|
||||||
startMs: number
|
startMs: number
|
||||||
): SpawnItem[] {
|
): SpawnItem[] {
|
||||||
const plan: 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),
|
{
|
||||||
},
|
atMs: startMs,
|
||||||
];
|
emoji: primaryEmoji,
|
||||||
|
fontSize: lerp(profile.heroFontSizeMin, profile.heroFontSizeMax),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const count = profile.particleCount;
|
const count = profile.particleCount;
|
||||||
for (let i = 0; i < count; i += 1) {
|
for (let i = 0; i < count; i += 1) {
|
||||||
@@ -109,7 +197,7 @@ function buildSpawnPlan(
|
|||||||
80 +
|
80 +
|
||||||
eased * SPAWN_WINDOW_MS +
|
eased * SPAWN_WINDOW_MS +
|
||||||
(Math.random() - 0.5) * 70,
|
(Math.random() - 0.5) * 70,
|
||||||
emoji: pickParticleEmoji(profile, primaryEmoji),
|
emoji: pickSpewEmoji(profile, primaryEmoji),
|
||||||
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
fontSize: lerp(profile.fontSizeMin, profile.fontSizeMax),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -118,6 +206,18 @@ function buildSpawnPlan(
|
|||||||
return plan;
|
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 {
|
function ensurePile(sim: FireworkSim): CanvasRenderingContext2D {
|
||||||
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
|
if (sim.pileCanvas && sim.pileCtx) return sim.pileCtx;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -145,20 +245,36 @@ function stampSettled(sim: FireworkSim, p: FireworkParticle) {
|
|||||||
function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) {
|
function spawnOne(sim: FireworkSim, item: SpawnItem, now: number) {
|
||||||
const { profile, origin } = sim;
|
const { profile, origin } = sim;
|
||||||
const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25);
|
const jitter = Math.max(4, (origin.maskRadius ?? 28) * 0.25);
|
||||||
const angle = Math.random() * Math.PI * 2;
|
const directed = profile.angleSpread !== undefined;
|
||||||
|
const angle = directed ? sampleBurstAngle(profile) : Math.random() * Math.PI * 2;
|
||||||
const speed = lerp(profile.speedMin, profile.speedMax);
|
const speed = lerp(profile.speedMin, profile.speedMax);
|
||||||
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
const launchUp = lerp(profile.launchUpMin, profile.launchUpMax);
|
||||||
const drawSize = item.fontSize * EMOJI_CACHE_SCALE * 0.85;
|
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({
|
sim.particles.push({
|
||||||
x: origin.x + (Math.random() - 0.5) * jitter,
|
x: origin.x + (Math.random() - 0.5) * jitter,
|
||||||
y: origin.y + (Math.random() - 0.5) * jitter,
|
y: origin.y + (Math.random() - 0.5) * jitter,
|
||||||
vx: Math.cos(angle) * speed + (Math.random() - 0.5) * 40,
|
vx,
|
||||||
vy: Math.sin(angle) * speed - launchUp,
|
vy,
|
||||||
spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
|
spin: lerp(profile.spinMin, profile.spinMax) * 0.35,
|
||||||
angle: (Math.random() - 0.5) * 40,
|
angle: (Math.random() - 0.5) * 40,
|
||||||
emoji: item.emoji,
|
emoji,
|
||||||
emojiCanvas: getEmojiCanvas(item.emoji),
|
emojiCanvas: getEmojiCanvas(emoji, tint),
|
||||||
drawSize,
|
drawSize,
|
||||||
halfSize: drawSize / 2,
|
halfSize: drawSize / 2,
|
||||||
bornAt: now,
|
bornAt: now,
|
||||||
@@ -203,17 +319,49 @@ export function createFireworkSim(
|
|||||||
originPx: BurstPoint,
|
originPx: BurstPoint,
|
||||||
primaryEmoji: string,
|
primaryEmoji: string,
|
||||||
profile: EmojiBurstProfile,
|
profile: EmojiBurstProfile,
|
||||||
startMs = performance.now()
|
startMs = performance.now(),
|
||||||
|
/** Pixel size of the source jumbo emoji — hero face matches this when set. */
|
||||||
|
heroSizePx?: number
|
||||||
): FireworkSim {
|
): FireworkSim {
|
||||||
const colCount = Math.max(8, Math.ceil(widthPx / PILE_CELL));
|
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 {
|
return {
|
||||||
particles: [],
|
particles: [],
|
||||||
profile,
|
profile,
|
||||||
|
primaryEmoji,
|
||||||
|
morphAtMs: profile.morphTo ? startMs + (profile.morphAfterMs ?? 220) : null,
|
||||||
|
heroUntilMs,
|
||||||
|
hero,
|
||||||
width: widthPx,
|
width: widthPx,
|
||||||
height: heightPx,
|
height: heightPx,
|
||||||
origin: originPx,
|
origin: originPx,
|
||||||
startMs,
|
startMs,
|
||||||
spawnPlan: buildSpawnPlan(profile, primaryEmoji, startMs),
|
spawnPlan,
|
||||||
spawnCursor: 0,
|
spawnCursor: 0,
|
||||||
floorY: heightPx - FLOOR_PAD,
|
floorY: heightPx - FLOOR_PAD,
|
||||||
pileCols: new Uint16Array(colCount),
|
pileCols: new Uint16Array(colCount),
|
||||||
@@ -223,10 +371,27 @@ export function createFireworkSim(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** @returns false when the burst should be removed. */
|
||||||
export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean {
|
export function stepFireworkSim(sim: FireworkSim, now: number, dtSeconds: number): boolean {
|
||||||
if (now - sim.startMs > FIREWORK_TOTAL_MS) return false;
|
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 dt = Math.min(dtSeconds, 0.05);
|
||||||
const g = sim.profile.gravity;
|
const g = sim.profile.gravity;
|
||||||
|
|
||||||
@@ -299,6 +464,20 @@ export function drawFireworkSim(
|
|||||||
context.drawImage(sim.pileCanvas, 0, 0);
|
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) {
|
for (let i = 0; i < sim.particles.length; i += 1) {
|
||||||
const p = sim.particles[i];
|
const p = sim.particles[i];
|
||||||
if (p.settled) continue;
|
if (p.settled) continue;
|
||||||
|
|||||||
55
src/app/utils/saveMedia.ts
Normal file
55
src/app/utils/saveMedia.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import { downloadMedia } from './matrix';
|
||||||
|
import { getCurrentAccessToken } from './auth';
|
||||||
|
|
||||||
|
export type MediaSaver = (blob: Blob, filename: string) => void | Promise<void>;
|
||||||
|
|
||||||
|
let customMediaSaver: MediaSaver | null = null;
|
||||||
|
|
||||||
|
const defaultMediaSaver: MediaSaver = (blob, filename) => {
|
||||||
|
FileSaver.saveAs(blob, filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a platform-specific media saver (Android MediaStore, Electron dialog, etc.).
|
||||||
|
* Pass null to restore the default FileSaver path.
|
||||||
|
*
|
||||||
|
* Platforms should call this once at startup (mobile overlay / desktop shell entry).
|
||||||
|
*/
|
||||||
|
export const registerMediaSaver = (saver: MediaSaver | null): void => {
|
||||||
|
customMediaSaver = saver;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a Blob to disk via the registered platform saver, or FileSaver by default.
|
||||||
|
*/
|
||||||
|
export const saveMediaBlob = async (blob: Blob, filename: string): Promise<void> => {
|
||||||
|
const saver = customMediaSaver ?? defaultMediaSaver;
|
||||||
|
await saver(blob, filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch media (auth download or blob:/data:/http URL) and save it via [saveMediaBlob].
|
||||||
|
*/
|
||||||
|
export const downloadAndSaveMedia = async (
|
||||||
|
src: string,
|
||||||
|
filename: string,
|
||||||
|
accessToken?: string | null
|
||||||
|
): Promise<void> => {
|
||||||
|
let blob: Blob;
|
||||||
|
try {
|
||||||
|
if (src.startsWith('blob:') || src.startsWith('data:')) {
|
||||||
|
const res = await fetch(src);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch ${src}`);
|
||||||
|
blob = await res.blob();
|
||||||
|
} else {
|
||||||
|
blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error);
|
||||||
|
const res = await fetch(src);
|
||||||
|
if (!res.ok) throw error;
|
||||||
|
blob = await res.blob();
|
||||||
|
}
|
||||||
|
await saveMediaBlob(blob, filename);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user