Repository Public
1// Images (and the JPEG rendition of HEIC, and SVG) in an <img>: an SVG shown this way cannot run scripts, and the2// markup is never inlined into the application document. Zoom with the wheel, a pinch or a double click; drag to pan.3import { useCallback, useContext, useEffect, useRef, useState } from 'react';4import { useT } from '../i18n.ts';5import type { PreviewSource } from '../types.ts';6import { cx, IconButton } from '../ui.tsx';7import { Busy, explain, Failure, Panel, DownloadLink, Tools, ToolText, ViewerCtx } from './shared.tsx';89interface View { s: number; x: number; y: number }10const FIT: View = { s: 1, x: 0, y: 0 };11const MAX = 12;1213export function ImageView({ src, source, vector }: { src: string; source: PreviewSource; vector?: boolean }) {14 const t = useT();15 const { setSwipeLocked } = useContext(ViewerCtx);16 const box = useRef<HTMLDivElement>(null);17 const img = useRef<HTMLImageElement>(null);18 const [view, setView] = useState<View>(FIT);19 const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');20 const [error, setError] = useState<unknown>(null);21 const [attempt, setAttempt] = useState(0);22 const [natural, setNatural] = useState<{ w: number; fit: number } | null>(null);23 const [dragging, setDragging] = useState(false);24 const viewRef = useRef(view); viewRef.current = view;2526 useEffect(() => { setSwipeLocked(view.s > 1.01); return () => setSwipeLocked(false); }, [view.s > 1.01, setSwipeLocked]);2728 /** Keep the picture over the frame: no panning into the void. */29 const clamp = useCallback((v: View): View => {30 const b = box.current, i = img.current;31 if (!b || !i || v.s <= 1) return FIT;32 const mx = Math.max(0, (i.offsetWidth * v.s - b.clientWidth) / 2), my = Math.max(0, (i.offsetHeight * v.s - b.clientHeight) / 2);33 return { s: v.s, x: Math.max(-mx, Math.min(mx, v.x)), y: Math.max(-my, Math.min(my, v.y)) };34 }, []);35 /** Zoom by `factor` keeping the point (px, py) — relative to the centre of the frame — under the finger. */36 const zoomAt = useCallback((factor: number, px = 0, py = 0) => {37 setView((v) => { const s = Math.max(1, Math.min(MAX, v.s * factor)); const k = s / v.s; return clamp({ s, x: px - (px - v.x) * k, y: py - (py - v.y) * k }); });38 }, [clamp]);39 const rel = (cx_: number, cy_: number) => { const r = box.current!.getBoundingClientRect(); return { x: cx_ - r.left - r.width / 2, y: cy_ - r.top - r.height / 2 }; };4041 useEffect(() => {42 const el = box.current; if (!el) return;43 const onWheel = (e: WheelEvent) => { e.preventDefault(); const p = rel(e.clientX, e.clientY); zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0018)), p.x, p.y); };44 el.addEventListener('wheel', onWheel, { passive: false });45 return () => el.removeEventListener('wheel', onWheel);46 }, [zoomAt, status]);4748 // pointers: one drags (when zoomed), two pinch; two quick taps toggle the zoom49 const pts = useRef(new Map<number, { x: number; y: number }>());50 const lastTap = useRef({ at: 0, x: 0, y: 0 });51 const moved = useRef(0);52 const onPointerDown = (e: React.PointerEvent) => {53 if (e.pointerType === 'mouse' && e.button !== 0) return;54 pts.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); moved.current = 0;55 if (viewRef.current.s > 1 || pts.current.size > 1) { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); setDragging(true); }56 };57 const onPointerMove = (e: React.PointerEvent) => {58 const prev = pts.current.get(e.pointerId); if (!prev) return;59 const all = pts.current;60 if (all.size === 2) {61 const [a, b] = [...all.entries()].map(([id, p]) => (id === e.pointerId ? { x: e.clientX, y: e.clientY } : p)) as [{ x: number; y: number }, { x: number; y: number }];62 const [pa, pb] = [...all.values()] as [{ x: number; y: number }, { x: number; y: number }];63 const d0 = Math.hypot(pa.x - pb.x, pa.y - pb.y), d1 = Math.hypot(a.x - b.x, a.y - b.y);64 const mid = rel((a.x + b.x) / 2, (a.y + b.y) / 2);65 if (d0 > 0) zoomAt(d1 / d0, mid.x, mid.y);66 } else if (all.size === 1 && viewRef.current.s > 1) {67 const dx = e.clientX - prev.x, dy = e.clientY - prev.y;68 setView((v) => clamp({ s: v.s, x: v.x + dx, y: v.y + dy }));69 }70 moved.current += Math.abs(e.clientX - prev.x) + Math.abs(e.clientY - prev.y);71 all.set(e.pointerId, { x: e.clientX, y: e.clientY });72 };73 const onPointerUp = (e: React.PointerEvent) => {74 if (!pts.current.delete(e.pointerId)) return;75 if (pts.current.size === 0) setDragging(false);76 if (moved.current > 12 || pts.current.size) return;77 const now = Date.now(), l = lastTap.current;78 if (now - l.at < 320 && Math.hypot(e.clientX - l.x, e.clientY - l.y) < 40) {79 lastTap.current = { at: 0, x: 0, y: 0 };80 const p = rel(e.clientX, e.clientY);81 if (viewRef.current.s > 1.01) setView(FIT); else zoomAt(2.5, p.x, p.y);82 } else lastTap.current = { at: now, x: e.clientX, y: e.clientY };83 };8485 if (status === 'error') {86 return error87 ? <Failure error={error} source={source} onRetry={() => { setStatus('loading'); setError(null); setAttempt((n) => n + 1); }} />88 : <Panel icon="image" tone="warn" title={t('preview.imageUnreadable')} text={t('preview.imageUnreadableText')} testId="preview-error"><DownloadLink source={source} primary /></Panel>;89 }90 const pct = natural ? Math.round(natural.fit * view.s * 100) : null;91 return (92 <>93 <Tools>94 <IconButton icon="zoomOut" label={t('preview.zoomOut')} disabled={view.s <= 1} onClick={() => zoomAt(1 / 1.4)} />95 <ToolText data-testid="image-zoom">{pct !== null ? `${pct} %` : '—'}</ToolText>96 <IconButton icon="zoomIn" label={t('preview.zoomIn')} disabled={view.s >= MAX} onClick={() => zoomAt(1.4)} />97 <IconButton icon="fitPage" label={t('preview.fit')} disabled={view.s === 1} onClick={() => setView(FIT)} />98 </Tools>99 <div ref={box} data-testid="image-stage" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}100 className={cx('relative flex min-h-0 flex-1 touch-none select-none items-center justify-center overflow-hidden p-3 sm:p-6', view.s > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in')}>101 {status === 'loading' ? <div className="absolute inset-0"><Busy label={t('common.loading')} /></div> : null}102 <img ref={img} key={attempt} src={src} alt={source.name} draggable={false} decoding="async" data-testid="image"103 onLoad={(e) => { const i = e.currentTarget; setNatural(i.naturalWidth ? { w: i.naturalWidth, fit: i.offsetWidth / i.naturalWidth } : null); setStatus('ready'); }}104 onError={() => { void explain(src).then((err) => { setError(err); setStatus('error'); }); }}105 style={{ transform: `translate3d(${view.x}px, ${view.y}px, 0) scale(${view.s})`, transition: dragging ? 'none' : 'transform 90ms ease-out' }}106 className={cx('max-h-full max-w-full rounded-sm object-contain', vector && 'h-full w-full', status !== 'ready' && 'opacity-0')} />107 </div>108 </>109 );110}111