chore(media): add zoom controls to preview dialog
This commit is contained in:
parent
fadc974364
commit
0d31e3c2fb
2 changed files with 231 additions and 14 deletions
|
|
@ -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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="!h-[100vh] !w-[100vw] !max-h-[100vh] !max-w-[100vw] overflow-hidden border-0 bg-black/92 p-0 shadow-none"
|
||||
aria-describedby="image-preview-description"
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{currentItem.filename || "Attachment preview"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
|
||||
<div className="absolute inset-x-0 top-0 z-20 bg-linear-to-b from-black/70 via-black/35 to-transparent px-3 pb-6 pt-3 sm:px-5 sm:pt-4">
|
||||
|
|
@ -106,9 +142,14 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center px-3 pb-20 pt-16 sm:px-16 sm:pb-8 sm:pt-20"
|
||||
data-testid={isImagePreview ? "preview-zoom-surface" : undefined}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center px-3 pb-20 pt-16 sm:px-16 sm:pb-8 sm:pt-20",
|
||||
isImagePreview && "cursor-zoom-in",
|
||||
)}
|
||||
onWheel={handleWheel}
|
||||
onClick={(event) => {
|
||||
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
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{isImagePreview && (
|
||||
<div className="absolute inset-x-0 bottom-0 z-30 px-3 pb-3 pt-6">
|
||||
<div className="mx-auto flex w-fit items-center gap-1 rounded-full bg-black/60 px-2 py-2 text-white shadow-lg backdrop-blur-sm">
|
||||
{hasMultiple && !sm && (
|
||||
<>
|
||||
<ZoomButton label="Previous item" onClick={handlePrevious} disabled={!canGoPrevious}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</ZoomButton>
|
||||
<div className="min-w-9 px-1 text-center text-xs font-medium tabular-nums text-white/75">
|
||||
{safeIndex + 1}/{itemCount}
|
||||
</div>
|
||||
<ZoomButton label="Next item" onClick={handleNext} disabled={!canGoNext}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</ZoomButton>
|
||||
<div className="mx-1 h-5 w-px bg-white/18" />
|
||||
</>
|
||||
)}
|
||||
<ZoomButton label="Zoom out" onClick={handleZoomOut} disabled={zoomScale === MIN_ZOOM}>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</ZoomButton>
|
||||
<div className="min-w-12 px-2 text-center text-xs font-medium tabular-nums text-white/80">{zoomPercent}%</div>
|
||||
<ZoomButton label="Zoom in" onClick={handleZoomIn} disabled={zoomScale === MAX_ZOOM}>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</ZoomButton>
|
||||
<div className="mx-1 h-5 w-px bg-white/18" />
|
||||
<ZoomButton label="Reset zoom" onClick={resetZoom} disabled={!isZoomed}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</ZoomButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMultiple && sm && (
|
||||
<>
|
||||
<NavButton
|
||||
|
|
@ -166,7 +245,7 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
|
|||
</>
|
||||
)}
|
||||
|
||||
{hasMultiple && !sm && (
|
||||
{hasMultiple && !sm && !isImagePreview && (
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 px-3 pb-3 pt-6">
|
||||
<div className="mx-auto flex max-w-xs items-center justify-between rounded-full bg-black/55 px-2 py-2 backdrop-blur-sm">
|
||||
<Button
|
||||
|
|
@ -195,10 +274,6 @@ function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIn
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div id="image-preview-description" className="sr-only">
|
||||
Attachment preview dialog. Press Escape to close and use left or right arrow keys to switch items.
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
@ -229,4 +304,28 @@ const NavButton = ({ side, disabled, label, onClick, icon }: NavButtonProps) =>
|
|||
</Button>
|
||||
);
|
||||
|
||||
const ZoomButton = ({
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className="h-9 w-9 rounded-full text-white hover:bg-white/12 hover:text-white disabled:text-white/35"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
export default PreviewImageDialog;
|
||||
|
|
|
|||
118
web/tests/preview-image-dialog.test.tsx
Normal file
118
web/tests/preview-image-dialog.test.tsx
Normal file
|
|
@ -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("<PreviewImageDialog>", () => {
|
||||
it("provides a dialog description without Radix accessibility warnings", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
render(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<PreviewImageDialog open onOpenChange={vi.fn()} items={[]} />);
|
||||
|
||||
expect(() => {
|
||||
rerender(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByAltText("Preview image 1 of 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows zoom controls for image previews", () => {
|
||||
render(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "image-1", kind: "image", sourceUrl: "/image.jpg", posterUrl: "/image.jpg", filename: "image.jpg" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.wheel(screen.getByTestId("preview-zoom-surface"), { deltaY: -100 });
|
||||
|
||||
expect(screen.getByText("120%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show zoom controls for video previews", () => {
|
||||
render(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[{ id: "video-1", kind: "video", sourceUrl: "/video.mp4", posterUrl: "/poster.jpg", filename: "video.mp4" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PreviewImageDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
items={[
|
||||
{ id: "image-1", kind: "image", sourceUrl: "/image-1.jpg", posterUrl: "/image-1.jpg", filename: "image-1.jpg" },
|
||||
{ id: "image-2", kind: "image", sourceUrl: "/image-2.jpg", posterUrl: "/image-2.jpg", filename: "image-2.jpg" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /previous item/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /next item/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue