From 0d31e3c2fb089a73b4c91c7b906fdabec57aa47b Mon Sep 17 00:00:00 2001 From: boojack Date: Tue, 2 Jun 2026 22:46:07 +0800 Subject: [PATCH] chore(media): add zoom controls to preview dialog --- web/src/components/PreviewImageDialog.tsx | 127 +++++++++++++++++++--- web/tests/preview-image-dialog.test.tsx | 118 ++++++++++++++++++++ 2 files changed, 231 insertions(+), 14 deletions(-) create mode 100644 web/tests/preview-image-dialog.test.tsx diff --git a/web/src/components/PreviewImageDialog.tsx b/web/src/components/PreviewImageDialog.tsx index 303eb4c0..684fcee3 100644 --- a/web/src/components/PreviewImageDialog.tsx +++ b/web/src/components/PreviewImageDialog.tsx @@ -1,8 +1,8 @@ -import { ChevronLeft, ChevronRight, X } from "lucide-react"; +import { ChevronLeft, ChevronRight, RotateCcw, X, ZoomIn, ZoomOut } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import MotionPhotoPreview from "@/components/MotionPhotoPreview"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { VisuallyHidden } from "@/components/ui/visually-hidden"; import useMediaQuery from "@/hooks/useMediaQuery"; import { cn } from "@/lib/utils"; @@ -16,9 +16,17 @@ interface Props { initialIndex?: number; } +const MIN_ZOOM = 1; +const MAX_ZOOM = 4; +const ZOOM_STEP = 0.2; +const DOUBLE_TAP_ZOOM = 2; + +const clampZoom = (scale: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, scale)); + function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) { const sm = useMediaQuery("sm"); const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [zoomScale, setZoomScale] = useState(MIN_ZOOM); const previewItems = useMemo( () => items ?? imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image" })), [imgUrls, items], @@ -34,8 +42,11 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn const safeIndex = Math.max(0, Math.min(currentIndex, itemCount - 1)); const currentItem = previewItems[safeIndex]; const hasMultiple = itemCount > 1; + const isImagePreview = currentItem?.kind === "image"; const canGoPrevious = safeIndex > 0; const canGoNext = safeIndex < itemCount - 1; + const zoomPercent = Math.round(zoomScale * 100); + const isZoomed = zoomScale > MIN_ZOOM; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -62,23 +73,48 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn return () => document.removeEventListener("keydown", handleKeyDown); }, [itemCount, onOpenChange, open]); + useEffect(() => { + setZoomScale(MIN_ZOOM); + }, [currentItem?.id, open]); + + const handleClose = () => onOpenChange(false); + const handlePrevious = () => { + setCurrentIndex((prev) => Math.max(prev - 1, 0)); + }; + const handleNext = () => { + setCurrentIndex((prev) => Math.min(prev + 1, itemCount - 1)); + }; + + const updateZoom = (nextScale: number) => { + setZoomScale(clampZoom(nextScale)); + }; + const resetZoom = () => setZoomScale(MIN_ZOOM); + const handleZoomIn = () => updateZoom(zoomScale + ZOOM_STEP); + const handleZoomOut = () => updateZoom(zoomScale - ZOOM_STEP); + const handleWheel = (event: React.WheelEvent) => { + if (isImagePreview) { + event.preventDefault(); + updateZoom(zoomScale + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)); + } + }; + const handleDoubleClick = () => setZoomScale((scale) => (scale === MIN_ZOOM ? DOUBLE_TAP_ZOOM : MIN_ZOOM)); + if (!itemCount || !currentItem) { return null; } - const handleClose = () => onOpenChange(false); - const handlePrevious = () => setCurrentIndex((prev) => Math.max(prev - 1, 0)); - const handleNext = () => setCurrentIndex((prev) => Math.min(prev + 1, itemCount - 1)); - return ( {currentItem.filename || "Attachment preview"} + + Attachment preview dialog. Press Escape to close, use left or right arrow keys to switch items, and zoom images with the + controls, mouse wheel, or double tap. +
@@ -106,9 +142,14 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
{ - if (event.target === event.currentTarget) { + if (event.target === event.currentTarget && !isZoomed) { handleClose(); } }} @@ -139,6 +180,12 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn src={currentItem.sourceUrl} alt={`Preview image ${safeIndex + 1} of ${itemCount}`} className="max-h-[calc(100vh-8rem)] max-w-[calc(100vw-1.5rem)] rounded-md object-contain select-none sm:max-h-[calc(100vh-7rem)] sm:max-w-[calc(100vw-8rem)]" + style={{ + transform: `translate3d(0px, 0px, 0) scale(${zoomScale})`, + transition: "transform 120ms ease-out", + transformOrigin: "center center", + }} + onDoubleClick={handleDoubleClick} draggable={false} loading="eager" decoding="async" @@ -147,6 +194,38 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
+ {isImagePreview && ( +
+
+ {hasMultiple && !sm && ( + <> + + + +
+ {safeIndex + 1}/{itemCount} +
+ + + +
+ + )} + + + +
{zoomPercent}%
+ + + +
+ + + +
+
+ )} + {hasMultiple && sm && ( <> )} - {hasMultiple && !sm && ( + {hasMultiple && !sm && !isImagePreview && (
)} - -
- Attachment preview dialog. Press Escape to close and use left or right arrow keys to switch items. -
); @@ -229,4 +304,28 @@ const NavButton = ({ side, disabled, label, onClick, icon }: NavButtonProps) => ); +const ZoomButton = ({ + disabled, + label, + onClick, + children, +}: { + disabled?: boolean; + label: string; + onClick: () => void; + children: React.ReactNode; +}) => ( + +); + export default PreviewImageDialog; diff --git a/web/tests/preview-image-dialog.test.tsx b/web/tests/preview-image-dialog.test.tsx new file mode 100644 index 00000000..7fa13041 --- /dev/null +++ b/web/tests/preview-image-dialog.test.tsx @@ -0,0 +1,118 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import PreviewImageDialog from "@/components/PreviewImageDialog"; + +vi.mock("@/hooks/useMediaQuery", () => ({ + __esModule: true, + default: () => false, +})); + +describe("", () => { + it("provides a dialog description without Radix accessibility warnings", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + render( + , + ); + + await waitFor(() => { + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Missing `Description`")); + }); + }); + + it("keeps hook order stable when preview items appear after an empty render", () => { + const { rerender } = render(); + + expect(() => { + rerender( + , + ); + }).not.toThrow(); + + expect(screen.getByAltText("Preview image 1 of 1")).toBeInTheDocument(); + }); + + it("shows zoom controls for image previews", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: /zoom in/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /zoom out/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /reset zoom/i })).toBeInTheDocument(); + expect(screen.getByText("100%")).toBeInTheDocument(); + }); + + it("toggles image zoom on double click", () => { + render( + , + ); + + const image = screen.getByAltText("Preview image 1 of 1"); + + fireEvent.doubleClick(image); + + expect(image).toHaveStyle({ transform: "translate3d(0px, 0px, 0) scale(2)" }); + expect(screen.getByText("200%")).toBeInTheDocument(); + }); + + it("zooms image previews with the wheel", () => { + render( + , + ); + + fireEvent.wheel(screen.getByTestId("preview-zoom-surface"), { deltaY: -100 }); + + expect(screen.getByText("120%")).toBeInTheDocument(); + }); + + it("does not show zoom controls for video previews", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: /zoom in/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /zoom out/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /reset zoom/i })).not.toBeInTheDocument(); + }); + + it("keeps previous and next controls available for mobile image galleries", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: /previous item/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /next item/i })).toBeInTheDocument(); + }); +});