SPB Git

Repository Public

ImageView.tsx 6.9 KB 111 lines tsx

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
Files main
Menu

Theme