ImagePreviewModal.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import { useCallback, useEffect, useRef, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Image as ImageIcon, Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
  4. import { api, getAuthToken } from '../api/client';
  5. import { formatFileSize } from '../utils/file';
  6. import { PreviewModalShell, previewIconButtonClass } from './PreviewModalShell';
  7. import { usePreviewFullscreen } from '../hooks/usePreviewFullscreen';
  8. // The whole file is fetched into memory before it is shown, so a multi-hundred
  9. // megabyte scan gets the same notice the other previews give instead of a tab
  10. // that stops responding (#2976).
  11. export const IMAGE_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
  12. const MIN_ZOOM = 0.4;
  13. // Higher than the PDF's ceiling: a photo or a scan is worth inspecting at the
  14. // pixel, and unlike a PDF page there is no sharper raster to fall back on.
  15. const MAX_ZOOM = 8;
  16. // One button press, one key press, one mouse-wheel notch.
  17. const ZOOM_STEP = 1.25;
  18. // A pinch arrives as many small ctrlKey wheel events, a mouse notch as one
  19. // ±100; clamping the delta before the exponent gives the notch one ZOOM_STEP
  20. // and the pinch a smooth ramp.
  21. const WHEEL_DELTA_CLAMP = 30;
  22. const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / WHEEL_DELTA_CLAMP;
  23. const clampZoom = (zoom: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
  24. interface Point {
  25. x: number;
  26. y: number;
  27. }
  28. const ORIGIN: Point = { x: 0, y: 0 };
  29. interface ImagePreviewModalProps {
  30. libraryFileId: number;
  31. filename: string;
  32. fileSize: number;
  33. onClose: () => void;
  34. }
  35. // deltaMode 1 is lines (Firefox mouse wheel), 2 is pages; both scaled to pixels.
  36. function wheelDeltaPixels(e: WheelEvent): number {
  37. if (e.deltaMode === 1) return e.deltaY * 16;
  38. if (e.deltaMode === 2) return e.deltaY * 100;
  39. return e.deltaY;
  40. }
  41. export function ImagePreviewModal({ libraryFileId, filename, fileSize, onClose }: ImagePreviewModalProps) {
  42. const { t } = useTranslation();
  43. const fullscreen = usePreviewFullscreen();
  44. const { isFullscreen, toggleFullscreen } = fullscreen;
  45. const containerRef = useRef<HTMLDivElement>(null);
  46. const imageRef = useRef<HTMLImageElement>(null);
  47. const [src, setSrc] = useState<string | null>(null);
  48. const [loaded, setLoaded] = useState(false);
  49. const [error, setError] = useState<string | null>(null);
  50. // The image is laid out fitted to the panel; zoom and pan are a transform on
  51. // top of that, so zoom 1 is always "fits the window".
  52. const [zoom, setZoom] = useState(1);
  53. const [offset, setOffset] = useState<Point>(ORIGIN);
  54. const [dragging, setDragging] = useState(false);
  55. const zoomRef = useRef(1);
  56. const offsetRef = useRef<Point>(ORIGIN);
  57. const pointersRef = useRef<Map<number, Point>>(new Map());
  58. const pinchDistanceRef = useRef<number | null>(null);
  59. // Panning past the edges would strand the image off-screen, so the offset is
  60. // bounded by however much of it the zoom pushes outside the viewport.
  61. const clampOffset = useCallback((next: Point, atZoom: number): Point => {
  62. const container = containerRef.current;
  63. const image = imageRef.current;
  64. if (!container || !image) return next;
  65. const maxX = Math.max(0, (image.offsetWidth * atZoom - container.clientWidth) / 2);
  66. const maxY = Math.max(0, (image.offsetHeight * atZoom - container.clientHeight) / 2);
  67. return {
  68. x: Math.min(maxX, Math.max(-maxX, next.x)),
  69. y: Math.min(maxY, Math.max(-maxY, next.y)),
  70. };
  71. }, []);
  72. const setView = useCallback((nextZoom: number, nextOffset: Point) => {
  73. zoomRef.current = nextZoom;
  74. offsetRef.current = nextOffset;
  75. setZoom(nextZoom);
  76. setOffset(nextOffset);
  77. }, []);
  78. /** Zoom by `factor`, keeping the point under the pointer where it is. */
  79. const zoomAt = useCallback(
  80. (factor: number, clientX?: number, clientY?: number) => {
  81. const current = zoomRef.current;
  82. const next = clampZoom(current * factor);
  83. if (next === current) return;
  84. const container = containerRef.current;
  85. const previous = offsetRef.current;
  86. let nextOffset = { x: previous.x * (next / current), y: previous.y * (next / current) };
  87. if (container) {
  88. const rect = container.getBoundingClientRect();
  89. // The transform grows the image around the container's centre, so an
  90. // anchored point is expressed relative to that centre.
  91. const ax = (clientX == null ? rect.left + rect.width / 2 : clientX) - (rect.left + rect.width / 2);
  92. const ay = (clientY == null ? rect.top + rect.height / 2 : clientY) - (rect.top + rect.height / 2);
  93. const k = next / current;
  94. nextOffset = { x: ax - (ax - previous.x) * k, y: ay - (ay - previous.y) * k };
  95. }
  96. setView(next, clampOffset(nextOffset, next));
  97. },
  98. [clampOffset, setView]
  99. );
  100. const resetView = useCallback(() => setView(1, ORIGIN), [setView]);
  101. useEffect(() => {
  102. let cancelled = false;
  103. let objectUrl: string | null = null;
  104. setSrc(null);
  105. setLoaded(false);
  106. setError(null);
  107. setView(1, ORIGIN);
  108. if (fileSize > IMAGE_PREVIEW_MAX_BYTES) {
  109. setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
  110. return;
  111. }
  112. const headers: HeadersInit = {};
  113. const token = getAuthToken();
  114. if (token) headers['Authorization'] = `Bearer ${token}`;
  115. (async () => {
  116. // Fetched rather than pointed at with <img src>: the download endpoint
  117. // wants the bearer token, which an <img> cannot send.
  118. const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
  119. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  120. const blob = await res.blob();
  121. if (cancelled) return;
  122. objectUrl = URL.createObjectURL(blob);
  123. setSrc(objectUrl);
  124. })().catch((err: unknown) => {
  125. console.error('[image-preview] load failed', err);
  126. if (!cancelled) setError(t('fileManager.preview.error'));
  127. });
  128. return () => {
  129. cancelled = true;
  130. if (objectUrl) URL.revokeObjectURL(objectUrl);
  131. };
  132. }, [libraryFileId, fileSize, t, setView]);
  133. useEffect(() => {
  134. const handleKeyDown = (e: KeyboardEvent) => {
  135. // Ctrl/⌘ combinations are the browser's own zoom; leave them alone.
  136. if (e.ctrlKey || e.metaKey || e.altKey) return;
  137. if (e.key === '+' || e.key === '=') {
  138. e.preventDefault();
  139. zoomAt(ZOOM_STEP);
  140. } else if (e.key === '-') {
  141. e.preventDefault();
  142. zoomAt(1 / ZOOM_STEP);
  143. } else if (e.key === '0') {
  144. e.preventDefault();
  145. resetView();
  146. }
  147. };
  148. window.addEventListener('keydown', handleKeyDown);
  149. return () => window.removeEventListener('keydown', handleKeyDown);
  150. }, [resetView, zoomAt]);
  151. // Native listener: React registers wheel as passive, so preventDefault —
  152. // which keeps Ctrl+wheel from zooming the whole page — would be ignored.
  153. // Nothing scrolls here, so a plain wheel zooms as well.
  154. useEffect(() => {
  155. const container = containerRef.current;
  156. if (!container) return;
  157. const handleWheel = (e: WheelEvent) => {
  158. e.preventDefault();
  159. const delta = Math.max(-WHEEL_DELTA_CLAMP, Math.min(WHEEL_DELTA_CLAMP, wheelDeltaPixels(e)));
  160. zoomAt(Math.exp(-delta * WHEEL_ZOOM_RATE), e.clientX, e.clientY);
  161. };
  162. container.addEventListener('wheel', handleWheel, { passive: false });
  163. return () => container.removeEventListener('wheel', handleWheel);
  164. }, [zoomAt, src]);
  165. // One pointer drags the image once it is bigger than the window, two fingers
  166. // pinch-zoom around their midpoint. `touch-action: none` on the container
  167. // hands both gestures here instead of to the browser's scroll / page zoom.
  168. const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
  169. pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
  170. pinchDistanceRef.current = null;
  171. if (pointersRef.current.size === 1 && zoomRef.current > 1) {
  172. e.currentTarget.setPointerCapture(e.pointerId);
  173. setDragging(true);
  174. }
  175. };
  176. const handlePointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {
  177. pointersRef.current.delete(e.pointerId);
  178. pinchDistanceRef.current = null;
  179. if (pointersRef.current.size === 0) setDragging(false);
  180. };
  181. const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
  182. const pointers = pointersRef.current;
  183. const previous = pointers.get(e.pointerId);
  184. if (!previous) return;
  185. // A button released outside this container never reaches onPointerUp (no
  186. // capture is taken below zoom 1), and the stale entry would then pan the
  187. // image under a bare cursor — or count as a second finger on a hybrid
  188. // device, turning the next one-finger drag into a pinch (#2976).
  189. if (e.pointerType === 'mouse' && e.buttons === 0) {
  190. handlePointerEnd(e);
  191. return;
  192. }
  193. pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
  194. if (pointers.size === 1) {
  195. if (zoomRef.current <= 1) return;
  196. const moved = { x: offsetRef.current.x + (e.clientX - previous.x), y: offsetRef.current.y + (e.clientY - previous.y) };
  197. setView(zoomRef.current, clampOffset(moved, zoomRef.current));
  198. return;
  199. }
  200. if (pointers.size !== 2) return;
  201. const [a, b] = Array.from(pointers.values());
  202. const distance = Math.hypot(a.x - b.x, a.y - b.y);
  203. const last = pinchDistanceRef.current;
  204. pinchDistanceRef.current = distance;
  205. if (last != null && last > 0 && distance > 0) {
  206. zoomAt(distance / last, (a.x + b.x) / 2, (a.y + b.y) / 2);
  207. }
  208. };
  209. const canPan = zoom > 1;
  210. return (
  211. <PreviewModalShell
  212. title={filename}
  213. fullscreen={fullscreen}
  214. onClose={onClose}
  215. icon={<ImageIcon className="w-5 h-5 text-bambu-green flex-shrink-0" />}
  216. actions={
  217. src ? (
  218. <>
  219. <button
  220. onClick={() => zoomAt(1 / ZOOM_STEP)}
  221. className={previewIconButtonClass}
  222. aria-label={t('fileManager.preview.zoomOut')}
  223. >
  224. <ZoomOut className="w-4 h-4" />
  225. </button>
  226. <button
  227. onClick={() => zoomAt(ZOOM_STEP)}
  228. className={previewIconButtonClass}
  229. aria-label={t('fileManager.preview.zoomIn')}
  230. >
  231. <ZoomIn className="w-4 h-4" />
  232. </button>
  233. <button onClick={resetView} className={previewIconButtonClass} aria-label={t('fileManager.preview.resetZoom')}>
  234. <RotateCcw className="w-4 h-4" />
  235. </button>
  236. </>
  237. ) : undefined
  238. }
  239. >
  240. <div
  241. ref={containerRef}
  242. data-testid="image-preview-content"
  243. className={`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${
  244. isFullscreen ? '' : 'rounded-b-lg'
  245. } ${canPan ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : ''}`}
  246. onDoubleClick={toggleFullscreen}
  247. onPointerDown={handlePointerDown}
  248. onPointerMove={handlePointerMove}
  249. onPointerUp={handlePointerEnd}
  250. onPointerCancel={handlePointerEnd}
  251. onPointerLeave={handlePointerEnd}
  252. >
  253. {error ? (
  254. <p className="text-bambu-gray text-center p-6">{error}</p>
  255. ) : (
  256. <>
  257. {src && (
  258. <img
  259. ref={imageRef}
  260. src={src}
  261. alt={filename}
  262. draggable={false}
  263. onLoad={() => setLoaded(true)}
  264. onError={() => setError(t('fileManager.preview.error'))}
  265. style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})` }}
  266. className="max-w-full max-h-full object-contain select-none"
  267. />
  268. )}
  269. {!loaded && (
  270. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  271. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  272. </div>
  273. )}
  274. </>
  275. )}
  276. </div>
  277. </PreviewModalShell>
  278. );
  279. }