/* 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(null); const imgRef = useRef(null); const zoomRef = useRef({ ...INITIAL_ZOOM }); const pointersRef = useRef>(new Map()); const dragRef = useRef<{ id: number; x: number; y: number; tx: number; ty: number; moved: boolean; } | null>(null); const pinchRef = useRef(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(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 (
{alt}
{alt}
); } );