mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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
This commit is contained in:
@@ -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(
|
||||
<ImagePreviewModal
|
||||
name="photo.jpg"
|
||||
onClose={onClose}
|
||||
onDownload={async () => {}}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center overflow-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))]">
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center overflow-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))]">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
@@ -324,13 +325,9 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "image" && objectUrl && (
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={name}
|
||||
className="max-w-full max-h-full object-contain rounded-lg bg-background"
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="absolute inset-0">
|
||||
<ZoomableImage src={objectUrl} alt={name} onBackgroundClick={onClose} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && fileType === "html" && objectUrl && (
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/90"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* 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). */}
|
||||
<div className="absolute top-0 left-0 right-0 flex items-center justify-between pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-gradient-to-b from-black/60 to-transparent z-10">
|
||||
<span className="text-white text-sm font-medium truncate max-w-[50%]">{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.min(z + 0.25, 5)); }}>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.max(z - 0.25, 0.25)); }}>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setRotation((r) => r + 90); }}>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); onDownload(name); }}>
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(0.5rem+env(safe-area-inset-right))] pt-[calc(0.5rem+env(safe-area-inset-top))] pb-2 text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-sm font-medium truncate min-w-0">{name}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-white hover:bg-white/15 hover:text-white"
|
||||
onClick={() => void onDownload(name)}
|
||||
aria-label={t("download")}
|
||||
title={t("download")}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-white hover:bg-white/15 hover:text-white"
|
||||
onClick={onClose}
|
||||
aria-label={t("close_preview")}
|
||||
title={t("close_preview")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="flex items-center justify-center w-full h-full p-16 pt-[calc(4rem+env(safe-area-inset-top))] pb-[calc(4rem+env(safe-area-inset-bottom))]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{loading && (
|
||||
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-white/70 text-sm">{t("preview_error")}</p>
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<p className="text-white/70 text-sm">{t("preview_error")}</p>
|
||||
</div>
|
||||
)}
|
||||
{imageUrl && !error && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={name}
|
||||
className="max-w-full max-h-full object-contain transition-transform duration-200"
|
||||
style={{ transform: `scale(${zoom}) rotate(${rotation}deg)` }}
|
||||
onLoad={() => setLoading(false)}
|
||||
draggable={false}
|
||||
/>
|
||||
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [box, setBox] = useState({ w: 0, h: 0 });
|
||||
const [natural, setNatural] = useState({ w: 0, h: 0 });
|
||||
const [view, setView] = useState<View>(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<number, { x: number; y: number }>());
|
||||
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<typeof gesture.current> = {
|
||||
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 (
|
||||
<div
|
||||
ref={boxRef}
|
||||
className={cn(
|
||||
"relative w-full h-full overflow-hidden select-none touch-none",
|
||||
zoomed ? (dragging ? "cursor-grabbing" : "cursor-grab") : "cursor-default",
|
||||
className,
|
||||
)}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
// Clicks are handled as taps above; keep them from reaching the dialog.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
onLoad={(e) => 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)}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-viewer-controls
|
||||
className="absolute bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 -translate-x-1/2 flex items-center gap-0.5 rounded-full bg-black/70 text-white px-1.5 py-1 shadow-lg backdrop-blur"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ControlButton label={t("zoom_out")} onClick={() => zoomBy(1 / BUTTON_STEP)} disabled={!zoomed}>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</ControlButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
className="min-w-[3.5rem] h-8 px-2 rounded-full text-xs tabular-nums hover:bg-white/15 transition-colors"
|
||||
title={t("zoom_reset")}
|
||||
aria-label={t("zoom_reset")}
|
||||
>
|
||||
{percent}%
|
||||
</button>
|
||||
<ControlButton label={t("zoom_in")} onClick={() => zoomBy(BUTTON_STEP)} disabled={view.scale >= MAX_SCALE}>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</ControlButton>
|
||||
<span className="w-px h-5 bg-white/20 mx-1" aria-hidden />
|
||||
<ControlButton label={t("zoom_reset")} onClick={resetView} disabled={!zoomed}>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</ControlButton>
|
||||
<ControlButton label={t("rotate")} onClick={rotate}>
|
||||
<RotateCw className="w-4 h-4" />
|
||||
</ControlButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlButton({ label, onClick, disabled, children }: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="h-8 w-8 flex items-center justify-center rounded-full hover:bg-white/15 disabled:opacity-40 disabled:hover:bg-transparent transition-colors"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user