All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Full-viewport blur backdrop, floating chrome, wheel/click/pinch zoom and pan; drop the ModalWide card shell around timeline and profile image viewers.
452 lines
13 KiB
TypeScript
452 lines
13 KiB
TypeScript
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import FileSaver from 'file-saver';
|
|
import classNames from 'classnames';
|
|
import { as } from 'folds';
|
|
import { Icon, Icons } from '../icons';
|
|
import * as css from './ImageViewer.css';
|
|
import { downloadMedia } from '../../utils/matrix';
|
|
import { getCurrentAccessToken } from '../../utils/auth';
|
|
|
|
const ZOOM_MIN = 1;
|
|
const ZOOM_MAX = 5;
|
|
const ZOOM_CLICK = 2.5;
|
|
const DRAG_CLICK_SLOP = 6;
|
|
|
|
type ZoomState = {
|
|
scale: number;
|
|
tx: number;
|
|
ty: number;
|
|
};
|
|
|
|
type PinchSession = {
|
|
startDist: number;
|
|
startScale: number;
|
|
startTx: number;
|
|
startTy: number;
|
|
startMidX: number;
|
|
startMidY: number;
|
|
};
|
|
|
|
const INITIAL_ZOOM: ZoomState = { scale: 1, tx: 0, ty: 0 };
|
|
|
|
function imageViewportMax() {
|
|
return {
|
|
w: Math.min(window.innerWidth * 0.96, 1100),
|
|
h: Math.min(window.innerHeight * 0.8, 820),
|
|
};
|
|
}
|
|
|
|
function pointerDistance(
|
|
a: { x: number; y: number },
|
|
b: { x: number; y: number }
|
|
): number {
|
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
}
|
|
|
|
function pointerMidpoint(
|
|
a: { x: number; y: number },
|
|
b: { x: number; y: number }
|
|
): { x: number; y: number } {
|
|
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
}
|
|
|
|
export type ImageViewerProps = {
|
|
alt: string;
|
|
src: string;
|
|
requestClose: () => void;
|
|
};
|
|
|
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
|
const frameRef = useRef<HTMLDivElement>(null);
|
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
const zoomRef = useRef<ZoomState>({ ...INITIAL_ZOOM });
|
|
const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
|
const dragRef = useRef<{
|
|
id: number;
|
|
x: number;
|
|
y: number;
|
|
tx: number;
|
|
ty: number;
|
|
moved: boolean;
|
|
} | null>(null);
|
|
const pinchRef = useRef<PinchSession | null>(null);
|
|
/** Skip the next click after a pinch so we don't toggle zoom. */
|
|
const suppressClickRef = useRef(false);
|
|
const baseSizeRef = useRef({ w: 0, h: 0 });
|
|
|
|
const [zoom, setZoom] = useState<ZoomState>(INITIAL_ZOOM);
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
const applyZoomCss = useCallback((next: ZoomState) => {
|
|
const img = imgRef.current;
|
|
if (!img) return;
|
|
img.style.setProperty('--img-zoom', String(next.scale));
|
|
img.style.setProperty('--img-tx', `${next.tx}px`);
|
|
img.style.setProperty('--img-ty', `${next.ty}px`);
|
|
}, []);
|
|
|
|
const clampPan = useCallback((state: ZoomState): ZoomState => {
|
|
const img = imgRef.current;
|
|
const frame = frameRef.current;
|
|
if (!img || !frame) return state;
|
|
|
|
const baseW = img.offsetWidth || baseSizeRef.current.w;
|
|
const baseH = img.offsetHeight || baseSizeRef.current.h;
|
|
const frameW = frame.clientWidth;
|
|
const frameH = frame.clientHeight;
|
|
if (!baseW || !baseH || !frameW || !frameH) return state;
|
|
|
|
const scaledW = baseW * state.scale;
|
|
const scaledH = baseH * state.scale;
|
|
let { tx, ty } = state;
|
|
|
|
if (scaledW <= frameW) tx = (frameW - scaledW) / 2;
|
|
else tx = Math.min(0, Math.max(frameW - scaledW, tx));
|
|
|
|
if (scaledH <= frameH) ty = (frameH - scaledH) / 2;
|
|
else ty = Math.min(0, Math.max(frameH - scaledH, ty));
|
|
|
|
return { ...state, tx, ty };
|
|
}, []);
|
|
|
|
const commitZoom = useCallback(
|
|
(next: ZoomState) => {
|
|
const clamped = clampPan(next);
|
|
zoomRef.current = clamped;
|
|
applyZoomCss(clamped);
|
|
setZoom(clamped);
|
|
},
|
|
[applyZoomCss, clampPan]
|
|
);
|
|
|
|
const resetZoom = useCallback(() => {
|
|
dragRef.current = null;
|
|
pinchRef.current = null;
|
|
setDragging(false);
|
|
commitZoom({ ...INITIAL_ZOOM });
|
|
}, [commitZoom]);
|
|
|
|
const fitImage = useCallback(() => {
|
|
const img = imgRef.current;
|
|
if (!img) return;
|
|
const iw = img.naturalWidth || 0;
|
|
const ih = img.naturalHeight || 0;
|
|
if (iw <= 0 || ih <= 0) return;
|
|
|
|
const { w: maxW, h: maxH } = imageViewportMax();
|
|
const s = Math.min(1, maxW / iw, maxH / ih);
|
|
const w = Math.max(1, Math.round(iw * s));
|
|
const h = Math.max(1, Math.round(ih * s));
|
|
img.style.width = `${w}px`;
|
|
img.style.height = `${h}px`;
|
|
baseSizeRef.current = { w, h };
|
|
commitZoom({ ...INITIAL_ZOOM });
|
|
}, [commitZoom]);
|
|
|
|
const zoomAround = useCallback(
|
|
(px: number, py: number, newScale: number) => {
|
|
const z = zoomRef.current;
|
|
const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newScale));
|
|
const ix = (px - z.tx) / z.scale;
|
|
const iy = (py - z.ty) / z.scale;
|
|
commitZoom({
|
|
scale,
|
|
tx: px - ix * scale,
|
|
ty: py - iy * scale,
|
|
});
|
|
},
|
|
[commitZoom]
|
|
);
|
|
|
|
const framePoint = (clientX: number, clientY: number) => {
|
|
const frame = frameRef.current;
|
|
if (!frame) return { x: 0, y: 0 };
|
|
const r = frame.getBoundingClientRect();
|
|
return { x: clientX - r.left, y: clientY - r.top };
|
|
};
|
|
|
|
const beginPinch = useCallback(() => {
|
|
const pts = [...pointersRef.current.values()];
|
|
if (pts.length < 2) return;
|
|
const [a, b] = pts;
|
|
const mid = pointerMidpoint(a, b);
|
|
const local = framePoint(mid.x, mid.y);
|
|
const z = zoomRef.current;
|
|
pinchRef.current = {
|
|
startDist: Math.max(1, pointerDistance(a, b)),
|
|
startScale: z.scale,
|
|
startTx: z.tx,
|
|
startTy: z.ty,
|
|
startMidX: local.x,
|
|
startMidY: local.y,
|
|
};
|
|
dragRef.current = null;
|
|
setDragging(true);
|
|
}, []);
|
|
|
|
const updatePinch = useCallback(() => {
|
|
const pinch = pinchRef.current;
|
|
const pts = [...pointersRef.current.values()];
|
|
if (!pinch || pts.length < 2) return;
|
|
|
|
const [a, b] = pts;
|
|
const dist = Math.max(1, pointerDistance(a, b));
|
|
const mid = pointerMidpoint(a, b);
|
|
const local = framePoint(mid.x, mid.y);
|
|
const scale = Math.min(
|
|
ZOOM_MAX,
|
|
Math.max(ZOOM_MIN, pinch.startScale * (dist / pinch.startDist))
|
|
);
|
|
|
|
// Keep the original content under the pinch midpoint while scaling + panning.
|
|
const ix = (pinch.startMidX - pinch.startTx) / pinch.startScale;
|
|
const iy = (pinch.startMidY - pinch.startTy) / pinch.startScale;
|
|
commitZoom({
|
|
scale,
|
|
tx: local.x - ix * scale,
|
|
ty: local.y - iy * scale,
|
|
});
|
|
suppressClickRef.current = true;
|
|
}, [commitZoom]);
|
|
|
|
useLayoutEffect(() => {
|
|
resetZoom();
|
|
}, [src, resetZoom]);
|
|
|
|
useEffect(() => {
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
requestClose();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => document.removeEventListener('keydown', onKey);
|
|
}, [requestClose]);
|
|
|
|
useEffect(() => {
|
|
const frame = frameRef.current;
|
|
if (!frame) return undefined;
|
|
|
|
const onWheel = (event: WheelEvent) => {
|
|
event.preventDefault();
|
|
const img = imgRef.current;
|
|
if (!img?.src) return;
|
|
const factor = Math.exp(-event.deltaY * 0.002);
|
|
const r = frame.getBoundingClientRect();
|
|
const x = event.clientX - r.left;
|
|
const y = event.clientY - r.top;
|
|
zoomAround(x, y, zoomRef.current.scale * factor);
|
|
};
|
|
|
|
// Block browser gesture zoom / scroll while pinching on the frame.
|
|
const blockGesture = (event: Event) => {
|
|
if (pointersRef.current.size >= 2 || pinchRef.current) {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
|
|
frame.addEventListener('wheel', onWheel, { passive: false });
|
|
frame.addEventListener('touchmove', blockGesture, { passive: false });
|
|
return () => {
|
|
frame.removeEventListener('wheel', onWheel);
|
|
frame.removeEventListener('touchmove', blockGesture);
|
|
};
|
|
}, [zoomAround]);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => fitImage();
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, [fitImage]);
|
|
|
|
const handleDownload = async () => {
|
|
try {
|
|
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
|
FileSaver.saveAs(fileContent, alt);
|
|
} catch (error) {
|
|
console.warn('[ImageViewer] Failed to download media:', error);
|
|
try {
|
|
const response = await fetch(src);
|
|
if (response.ok) {
|
|
const blob = await response.blob();
|
|
FileSaver.saveAs(blob, alt);
|
|
}
|
|
} catch {
|
|
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 (
|
|
<div
|
|
className={classNames(css.Root, className)}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={alt || 'Image viewer'}
|
|
{...props}
|
|
ref={ref}
|
|
>
|
|
<div className={css.Backdrop} onClick={requestClose} aria-hidden />
|
|
<div className={css.Stage}>
|
|
<div className={css.Chrome}>
|
|
<div className={css.Title} title={alt}>
|
|
{alt}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={css.ChromeButton}
|
|
aria-label="Download"
|
|
onClick={handleDownload}
|
|
>
|
|
<Icon size="50" src={Icons.Download} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={css.ChromeButton}
|
|
aria-label="Close"
|
|
onClick={requestClose}
|
|
>
|
|
<Icon size="50" src={Icons.Cross} />
|
|
</button>
|
|
</div>
|
|
<div
|
|
ref={frameRef}
|
|
className={css.Frame}
|
|
data-zoomed={zoomed || undefined}
|
|
data-pannable={zoomed || undefined}
|
|
data-dragging={dragging || undefined}
|
|
onClick={handleClick}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerCancel={handlePointerUp}
|
|
>
|
|
<img
|
|
ref={imgRef}
|
|
className={css.Image}
|
|
src={src}
|
|
alt={alt}
|
|
draggable={false}
|
|
onLoad={fitImage}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|