From 87915f8bf197eab4a31e99acb4a60826cd90fa97 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:32:51 +0200 Subject: [PATCH] feat(files): zoom and pan images in the preview, close by clicking beside them The image viewer only zoomed with its buttons, scaled the image inside a fixed box so a zoomed image could not be moved, and the area around the image swallowed clicks, so clicking beside it did nothing. Both the Files image viewer and the attachment preview now use one viewer that: - zooms with the wheel, trackpad or two-finger pinch, centred on the pointer, and with a zoom bar at the bottom (-, percentage, +, fit, rotate) - toggles between fit and full size on double-click or double-tap - pans by dragging, or with the arrow keys, while zoomed - closes on a click or tap beside the image, but not after a drag - keeps + / - / 0 / r as shortcuts and Escape to close --- .../__tests__/image-preview-modal.test.tsx | 58 +++ components/files/file-preview-modal.tsx | 13 +- components/files/image-preview-modal.tsx | 99 ++--- components/files/zoomable-image.tsx | 348 ++++++++++++++++++ locales/en/common.json | 7 +- 5 files changed, 468 insertions(+), 57 deletions(-) create mode 100644 components/files/__tests__/image-preview-modal.test.tsx create mode 100644 components/files/zoomable-image.tsx diff --git a/components/files/__tests__/image-preview-modal.test.tsx b/components/files/__tests__/image-preview-modal.test.tsx new file mode 100644 index 000000000..27f3d499c --- /dev/null +++ b/components/files/__tests__/image-preview-modal.test.tsx @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { ImagePreviewModal } from '../image-preview-modal'; + +async function renderViewer() { + const onClose = vi.fn(); + render( + {}} + getImageUrl={async () => 'data:image/png;base64,AAAA'} + />, + ); + const img = await screen.findByAltText('photo.jpg'); + return { onClose, img, box: img.parentElement! }; +} + +function tap(el: Element, x = 10, y = 10) { + fireEvent.pointerDown(el, { pointerId: 1, button: 0, clientX: x, clientY: y, pointerType: 'mouse' }); + fireEvent.pointerUp(el, { pointerId: 1, button: 0, clientX: x, clientY: y, pointerType: 'mouse' }); +} + +describe('ImagePreviewModal', () => { + it('closes when the area around the image is clicked', async () => { + const { onClose, box } = await renderViewer(); + tap(box); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('stays open when the image itself is clicked', async () => { + const { onClose, img } = await renderViewer(); + tap(img); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('does not close after a drag that starts beside the image', async () => { + const { onClose, box } = await renderViewer(); + fireEvent.pointerDown(box, { pointerId: 1, button: 0, clientX: 10, clientY: 10, pointerType: 'mouse' }); + fireEvent.pointerMove(box, { pointerId: 1, clientX: 80, clientY: 60, pointerType: 'mouse' }); + fireEvent.pointerUp(box, { pointerId: 1, button: 0, clientX: 80, clientY: 60, pointerType: 'mouse' }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('zooms with the buttons and resets to fit', async () => { + const { img } = await renderViewer(); + fireEvent.click(screen.getByRole('button', { name: 'zoom_in' })); + expect(img.style.transform).toContain('scale(1.5)'); + act(() => { fireEvent.keyDown(window, { key: '0' }); }); + expect(img.style.transform).toContain('scale(1)'); + }); + + it('closes on Escape', async () => { + const { onClose } = await renderViewer(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index 9d89c45f4..212d63a10 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; import { X, Download, Loader2, ExternalLink } from "@/components/icons"; import { Button } from "@/components/ui/button"; +import { ZoomableImage } from "@/components/files/zoomable-image"; import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview"; import dynamic from "next/dynamic"; import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview"; @@ -290,7 +291,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: -
+
{loading && (
@@ -324,13 +325,9 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }: )} {!loading && !error && fileType === "image" && objectUrl && ( - {name} e.stopPropagation()} - /> +
+ +
)} {!loading && !error && fileType === "html" && objectUrl && ( diff --git a/components/files/image-preview-modal.tsx b/components/files/image-preview-modal.tsx index 8cf009bcb..fff8793af 100644 --- a/components/files/image-preview-modal.tsx +++ b/components/files/image-preview-modal.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; -import { X, Download, ZoomIn, ZoomOut, RotateCw } from "@/components/icons"; +import { useState, useEffect } from "react"; +import { X, Download } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useTranslations } from "next-intl"; +import { ZoomableImage } from "@/components/files/zoomable-image"; interface ImagePreviewModalProps { name: string; @@ -17,95 +18,97 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im const [imageUrl, setImageUrl] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); - const [zoom, setZoom] = useState(1); - const [rotation, setRotation] = useState(0); useEffect(() => { let revoke: string | null = null; + let cancelled = false; setLoading(true); setError(false); + setImageUrl(null); getImageUrl(name) .then((url) => { revoke = url; + if (cancelled) return; setImageUrl(url); setLoading(false); }) .catch(() => { + if (cancelled) return; setError(true); setLoading(false); }); return () => { - if (revoke) URL.revokeObjectURL(revoke); + cancelled = true; + if (revoke?.startsWith("blob:")) URL.revokeObjectURL(revoke); }; }, [name, getImageUrl]); - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === "Escape") { onClose(); return; } - const target = e.target as HTMLElement; - const tag = target?.tagName?.toLowerCase(); - if (tag === "input" || tag === "textarea" || tag === "select") return; - if (target?.getAttribute("contenteditable") === "true") return; - if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 5)); - if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25)); - if (e.key === "r") setRotation((r) => r + 90); - }, [onClose]); - useEffect(() => { - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleKeyDown]); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener("keydown", onKey, { capture: true }); + return () => window.removeEventListener("keydown", onKey, { capture: true }); + }, [onClose]); return (
- {/* Header */} {/* Same safe-area handling as FilePreviewModal: full-screen overlay + `viewport-fit=cover` puts this bar under the iOS status bar in an installed PWA (#936). */} -
- {name} -
- - - - -
- {/* Image */} -
e.stopPropagation()}> +
{loading && ( -
+
+
+
)} {error && ( -

{t("preview_error")}

+
+

{t("preview_error")}

+
)} {imageUrl && !error && ( - {name} setLoading(false)} - draggable={false} - /> + )}
diff --git a/components/files/zoomable-image.tsx b/components/files/zoomable-image.tsx new file mode 100644 index 000000000..79e351dd3 --- /dev/null +++ b/components/files/zoomable-image.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { ZoomIn, ZoomOut, RotateCw, Maximize2 } from "@/components/icons"; +import { cn } from "@/lib/utils"; + +const MAX_SCALE = 10; +const BUTTON_STEP = 1.5; +const DOUBLE_TAP_MS = 300; +const TAP_SLOP = 6; + +interface View { + /** Zoom relative to the fitted size; 1 = the whole image fits. */ + scale: number; + x: number; + y: number; +} + +const FIT: View = { scale: 1, x: 0, y: 0 }; + +interface ZoomableImageProps { + src: string; + alt: string; + /** A click or tap that lands outside the image, e.g. to close the viewer. */ + onBackgroundClick?: () => void; + className?: string; + imageClassName?: string; +} + +/** + * An image that fits its box and can be zoomed (wheel, pinch, double-click, + * buttons, + / - / 0), panned by dragging while zoomed, and rotated (r). + */ +export function ZoomableImage({ src, alt, onBackgroundClick, className, imageClassName }: ZoomableImageProps) { + const t = useTranslations("files"); + const boxRef = useRef(null); + const imgRef = useRef(null); + const [box, setBox] = useState({ w: 0, h: 0 }); + const [natural, setNatural] = useState({ w: 0, h: 0 }); + const [view, setView] = useState(FIT); + const [rotation, setRotation] = useState(0); + const [animate, setAnimate] = useState(false); + const viewRef = useRef(view); + viewRef.current = view; + + // Reset when another image is shown. + useEffect(() => { + setView(FIT); + setRotation(0); + setNatural({ w: 0, h: 0 }); + }, [src]); + + useLayoutEffect(() => { + const el = boxRef.current; + if (!el) return; + const measure = () => setBox({ w: el.clientWidth, h: el.clientHeight }); + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const sideways = rotation % 180 !== 0; + // An SVG without a size reports 0; give it a square that fits. + const fallback = Math.min(box.w, box.h) * 0.8 || 300; + const natW = natural.w || fallback; + const natH = natural.h || fallback; + const visualW = sideways ? natH : natW; + const visualH = sideways ? natW : natH; + // Fit the whole image, but never blow a small one up past its real size. + const fit = box.w && box.h ? Math.min(box.w / visualW, box.h / visualH, 1) : 1; + const fitRef = useRef(fit); + fitRef.current = fit; + // Zoom that shows the image at its real pixel size (at least 2x the fit). + const actualScale = Math.max(2, 1 / fit); + + const clamp = useCallback((v: View): View => { + const scale = Math.min(MAX_SCALE, Math.max(1, v.scale)); + const w = visualW * fit * scale; + const h = visualH * fit * scale; + const maxX = Math.max(0, (w - box.w) / 2); + const maxY = Math.max(0, (h - box.h) / 2); + return { scale, x: Math.min(maxX, Math.max(-maxX, v.x)), y: Math.min(maxY, Math.max(-maxY, v.y)) }; + }, [visualW, visualH, fit, box.w, box.h]); + const clampRef = useRef(clamp); + clampRef.current = clamp; + + /** Zoom to `scale`, keeping the point (px, py) - relative to the box centre - in place. */ + const zoomAt = useCallback((scale: number, px = 0, py = 0, smooth = false) => { + const cur = viewRef.current; + const next = Math.min(MAX_SCALE, Math.max(1, scale)); + const ratio = next / cur.scale; + setAnimate(smooth); + setView(clampRef.current({ scale: next, x: px - (px - cur.x) * ratio, y: py - (py - cur.y) * ratio })); + }, []); + + const pointFromEvent = useCallback((clientX: number, clientY: number) => { + const rect = boxRef.current!.getBoundingClientRect(); + return { x: clientX - rect.left - rect.width / 2, y: clientY - rect.top - rect.height / 2 }; + }, []); + + // Wheel zoom needs a non-passive listener to stop the page from scrolling. + useEffect(() => { + const el = boxRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + // Trackpad pinch arrives as ctrl+wheel with small deltas. + const speed = e.ctrlKey ? 0.01 : 0.0015; + const delta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + const p = pointFromEvent(e.clientX, e.clientY); + zoomAt(viewRef.current.scale * Math.exp(-delta * speed), p.x, p.y); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, [zoomAt, pointFromEvent]); + + // ---- Pointers: drag to pan, two fingers to pinch, taps ------------------- + const pointers = useRef(new Map()); + const gesture = useRef<{ + startX: number; startY: number; moved: boolean; + view: View; pinchDist?: number; pinchMid?: { x: number; y: number }; + } | null>(null); + const lastTap = useRef<{ time: number; x: number; y: number } | null>(null); + // Pointer capture retargets later events at the box, so remember where the press began. + const downOnImage = useRef(false); + const [dragging, setDragging] = useState(false); + + const startGesture = () => { + const pts = [...pointers.current.values()]; + const g: NonNullable = { + startX: pts[0].x, startY: pts[0].y, moved: gesture.current?.moved ?? false, view: viewRef.current, + }; + if (pts.length >= 2) { + g.pinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); + g.pinchMid = pointFromEvent((pts[0].x + pts[1].x) / 2, (pts[0].y + pts[1].y) / 2); + } + gesture.current = g; + }; + + const onPointerDown = (e: React.PointerEvent) => { + if ((e.target as HTMLElement).closest("[data-viewer-controls]")) return; + if (e.pointerType === "mouse" && e.button !== 0) return; + if (pointers.current.size === 0) downOnImage.current = e.target === imgRef.current; + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + startGesture(); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!pointers.current.has(e.pointerId)) return; + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + const g = gesture.current; + if (!g) return; + const pts = [...pointers.current.values()]; + if (pts.length >= 2 && g.pinchDist && g.pinchMid) { + g.moved = true; + const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); + const mid = pointFromEvent((pts[0].x + pts[1].x) / 2, (pts[0].y + pts[1].y) / 2); + const scale = Math.min(MAX_SCALE, Math.max(1, g.view.scale * (dist / g.pinchDist))); + const ratio = scale / g.view.scale; + setAnimate(false); + setView(clampRef.current({ + scale, + x: mid.x - (g.pinchMid.x - g.view.x) * ratio, + y: mid.y - (g.pinchMid.y - g.view.y) * ratio, + })); + return; + } + const dx = e.clientX - g.startX; + const dy = e.clientY - g.startY; + if (!g.moved && Math.hypot(dx, dy) < TAP_SLOP) return; + g.moved = true; + if (g.view.scale > 1) { + setDragging(true); + setAnimate(false); + setView(clampRef.current({ ...g.view, x: g.view.x + dx, y: g.view.y + dy })); + } + }; + + const onPointerUp = (e: React.PointerEvent) => { + if (!pointers.current.has(e.pointerId)) return; + pointers.current.delete(e.pointerId); + const g = gesture.current; + if (pointers.current.size > 0) { + // One finger of a pinch lifted: carry on panning with the other. + startGesture(); + return; + } + gesture.current = null; + setDragging(false); + if (!g || g.moved || e.type === "pointercancel") return; + + if (!downOnImage.current) { + lastTap.current = null; + onBackgroundClick?.(); + return; + } + // Double-tap or double-click on the image: zoom in there, or back to fit. + const now = Date.now(); + const prev = lastTap.current; + if (prev && now - prev.time < DOUBLE_TAP_MS && Math.hypot(e.clientX - prev.x, e.clientY - prev.y) < 30) { + lastTap.current = null; + if (viewRef.current.scale > 1.01) { + setAnimate(true); + setView(FIT); + } else { + const p = pointFromEvent(e.clientX, e.clientY); + zoomAt(actualScale, p.x, p.y, true); + } + } else { + lastTap.current = { time: now, x: e.clientX, y: e.clientY }; + } + }; + + // ---- Keyboard --------------------------------------------------------------- + const zoomBy = useCallback((factor: number) => zoomAt(viewRef.current.scale * factor, 0, 0, true), [zoomAt]); + const resetView = useCallback(() => { setAnimate(true); setView(FIT); }, []); + const rotate = useCallback(() => { + setAnimate(true); + setRotation((r) => r + 90); + setView(FIT); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const el = e.target as HTMLElement | null; + if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key === "+" || e.key === "=") { e.preventDefault(); zoomBy(BUTTON_STEP); } + else if (e.key === "-" || e.key === "_") { e.preventDefault(); zoomBy(1 / BUTTON_STEP); } + else if (e.key === "0") { e.preventDefault(); resetView(); } + else if (e.key === "r" || e.key === "R") { e.preventDefault(); rotate(); } + else if (viewRef.current.scale > 1 && e.key.startsWith("Arrow")) { + e.preventDefault(); + const step = 80; + const cur = viewRef.current; + setAnimate(true); + setView(clampRef.current({ + ...cur, + x: cur.x + (e.key === "ArrowLeft" ? step : e.key === "ArrowRight" ? -step : 0), + y: cur.y + (e.key === "ArrowUp" ? step : e.key === "ArrowDown" ? -step : 0), + })); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [zoomBy, resetView, rotate]); + + // Keep the view inside the box when the window or rotation changes. + useEffect(() => { + setView((v) => clamp(v)); + }, [clamp]); + + const zoomed = view.scale > 1.01; + // The percentage is of the image's real pixels, like other image viewers. + const percent = Math.round(view.scale * (natural.w ? fit : 1) * 100); + const ready = natural.w > 0 || box.w > 0; + + return ( +
e.stopPropagation()} + > + {alt} setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })} + className={cn( + "absolute left-1/2 top-1/2 max-w-none", + zoomed ? "" : "cursor-zoom-in", + animate && "transition-transform duration-200 ease-out", + !ready && "invisible", + imageClassName, + )} + style={{ + width: natW * fit, + height: natH * fit, + transform: `translate(-50%, -50%) translate(${view.x}px, ${view.y}px) scale(${view.scale}) rotate(${rotation}deg)`, + }} + onTransitionEnd={() => setAnimate(false)} + /> + +
e.stopPropagation()} + > + zoomBy(1 / BUTTON_STEP)} disabled={!zoomed}> + + + + zoomBy(BUTTON_STEP)} disabled={view.scale >= MAX_SCALE}> + + + + + + + + + +
+
+ ); +} + +function ControlButton({ label, onClick, disabled, children }: { + label: string; + onClick: () => void; + disabled?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/locales/en/common.json b/locales/en/common.json index 32d6e011b..250fc3d55 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3721,7 +3721,12 @@ "drop_to_upload": "Drop to upload to {folder}", "uploading_count": "Uploading {current} of {total}", "cancel_upload": "Cancel upload", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "zoom_in": "Zoom in", + "zoom_out": "Zoom out", + "zoom_reset": "Fit to screen", + "rotate": "Rotate", + "close_preview": "Close" }, "smime": { "your_certificates": "Your Certificates",