Repository navigation
feat(web): pinch to zoom and scroll expanded images #10718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
ff71f0f
475c467
7ca2362
3684b21
6181b7e
0c3dc5e
0497878
ff68b2c
d836a8a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,4 +1,12 @@ | ||||||
| import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; | ||||||
| import { | ||||||
| memo, | ||||||
| useCallback, | ||||||
| useEffect, | ||||||
| useRef, | ||||||
| useState, | ||||||
| type ReactNode, | ||||||
| type RefObject, | ||||||
| } from "react"; | ||||||
| import { createPortal } from "react-dom"; | ||||||
| import { ChevronLeftIcon, ChevronRightIcon, ImageIcon, TextIcon, XIcon } from "lucide-react"; | ||||||
| import { Button } from "../ui/button"; | ||||||
|
|
@@ -16,6 +24,16 @@ import { | |||||
| } from "./SnapShotAttachmentDetails"; | ||||||
| import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||||||
| import { composerFloatingLayerProps } from "./composerEventScope"; | ||||||
| import { | ||||||
| clampImagePan, | ||||||
| clickZoomScale, | ||||||
| IMAGE_ZOOM_IDENTITY, | ||||||
| type ImageZoomFrame, | ||||||
| type ImageZoomState, | ||||||
| panImage, | ||||||
| wheelZoomFactor, | ||||||
| zoomImageAt, | ||||||
| } from "./imageZoom"; | ||||||
|
|
||||||
| interface ExpandedImageDialogProps { | ||||||
| preview: ExpandedImagePreview; | ||||||
|
|
@@ -58,10 +76,209 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { | |||||
| ); | ||||||
| } | ||||||
|
|
||||||
| function zoomFrame(image: HTMLImageElement, zoom: ImageZoomState): ImageZoomFrame { | ||||||
| // The rect is already transformed. Scaling around the center leaves the | ||||||
| // center put, so subtracting the translate gives the untransformed center. | ||||||
| const rect = image.getBoundingClientRect(); | ||||||
| return { | ||||||
| width: image.offsetWidth, | ||||||
| height: image.offsetHeight, | ||||||
| viewportWidth: window.innerWidth, | ||||||
| viewportHeight: window.innerHeight, | ||||||
| centerX: rect.left + rect.width / 2 - zoom.x - window.innerWidth / 2, | ||||||
| centerY: rect.top + rect.height / 2 - zoom.y - window.innerHeight / 2, | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| /** Pointer position relative to the untransformed center of the image box. */ | ||||||
| function zoomAnchor(frame: ImageZoomFrame, event: { clientX: number; clientY: number }) { | ||||||
| return { | ||||||
| x: event.clientX - frame.viewportWidth / 2 - frame.centerX, | ||||||
| y: event.clientY - frame.viewportHeight / 2 - frame.centerY, | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Safari reports trackpad pinch as gesture events instead of ctrl+wheel. | ||||||
| * Apple documents `clientX` and `clientY` on them, but they are not in any | ||||||
| * standard, so callers must be ready for them to be missing. | ||||||
| */ | ||||||
| interface SafariGestureEvent extends UIEvent { | ||||||
| readonly scale: number; | ||||||
| readonly clientX?: number; | ||||||
| readonly clientY?: number; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * The screenshot with the zoom model of a native image viewer: pinch or | ||||||
| * ctrl+wheel zooms around the pointer, two-finger scroll or drag pans, and a | ||||||
| * click toggles between fitted and actual size. Gestures are read from the | ||||||
| * whole dialog so the pointer does not have to sit on the image. Only the | ||||||
| * transform changes while zooming, so Chromium keeps the work on the | ||||||
| * compositor and never repaints the bitmap. | ||||||
| */ | ||||||
| function ZoomableImage({ | ||||||
| src, | ||||||
| alt, | ||||||
| surfaceRef, | ||||||
| onError, | ||||||
| }: { | ||||||
| readonly src: string; | ||||||
| readonly alt: string; | ||||||
| /** The dialog element that receives pinch and wheel gestures. */ | ||||||
| readonly surfaceRef: RefObject<HTMLElement | null>; | ||||||
| readonly onError: () => void; | ||||||
| }) { | ||||||
| const imageRef = useRef<HTMLImageElement | null>(null); | ||||||
| const [zoom, setZoom] = useState<ImageZoomState>(IMAGE_ZOOM_IDENTITY); | ||||||
| const dragRef = useRef<{ | ||||||
| pointerId: number; | ||||||
| x: number; | ||||||
| y: number; | ||||||
| startX: number; | ||||||
| startY: number; | ||||||
| moved: boolean; | ||||||
| } | null>(null); | ||||||
|
|
||||||
| // Wheel and gesture listeners must be non-passive to stop the page from | ||||||
| // scrolling and the browser from zooming the whole window. React registers | ||||||
| // wheel as passive, so attach them by hand. | ||||||
| useEffect(() => { | ||||||
| const surface = surfaceRef.current; | ||||||
| const image = imageRef.current; | ||||||
| if (!surface || !image) return; | ||||||
| const onWheel = (event: WheelEvent) => { | ||||||
| event.preventDefault(); | ||||||
| const lineScale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : 1; | ||||||
| if (event.ctrlKey) { | ||||||
| // Chromium and Firefox report a trackpad pinch as ctrl+wheel. | ||||||
| const factor = wheelZoomFactor(event.deltaY * lineScale); | ||||||
| setZoom((current) => { | ||||||
| const frame = zoomFrame(image, current); | ||||||
| return zoomImageAt(current, factor, zoomAnchor(frame, event), frame); | ||||||
| }); | ||||||
| return; | ||||||
| } | ||||||
| // A plain two-finger scroll pans the zoomed image, like a native viewer. | ||||||
| const dx = -event.deltaX * lineScale; | ||||||
| const dy = -event.deltaY * lineScale; | ||||||
| setZoom((current) => panImage(current, dx, dy, zoomFrame(image, current))); | ||||||
| }; | ||||||
| // The last pointer position over the dialog, for gesture events that | ||||||
| // arrive without coordinates of their own. | ||||||
| let pointer = { clientX: window.innerWidth / 2, clientY: window.innerHeight / 2 }; | ||||||
| const onPointerMove = (event: PointerEvent) => { | ||||||
| pointer = { clientX: event.clientX, clientY: event.clientY }; | ||||||
| }; | ||||||
| // Safari's `scale` is cumulative from the start of the gesture. | ||||||
| let gestureStartScale = 1; | ||||||
| const onGestureStart = (event: Event) => { | ||||||
| event.preventDefault(); | ||||||
| setZoom((current) => { | ||||||
| gestureStartScale = current.scale; | ||||||
| return current; | ||||||
| }); | ||||||
| }; | ||||||
|
macroscopeapp[bot] marked this conversation as resolved.
Outdated
|
||||||
| const onGestureChange = (event: Event) => { | ||||||
| event.preventDefault(); | ||||||
| const gesture = event as SafariGestureEvent; | ||||||
| const at = | ||||||
| Number.isFinite(gesture.clientX) && Number.isFinite(gesture.clientY) | ||||||
| ? { clientX: gesture.clientX as number, clientY: gesture.clientY as number } | ||||||
| : pointer; | ||||||
| setZoom((current) => { | ||||||
| const frame = zoomFrame(image, current); | ||||||
| const factor = (gestureStartScale * gesture.scale) / current.scale; | ||||||
| return zoomImageAt(current, factor, zoomAnchor(frame, at), frame); | ||||||
| }); | ||||||
| }; | ||||||
| surface.addEventListener("wheel", onWheel, { passive: false }); | ||||||
| surface.addEventListener("pointermove", onPointerMove, { passive: true }); | ||||||
| surface.addEventListener("gesturestart", onGestureStart, { passive: false }); | ||||||
| surface.addEventListener("gesturechange", onGestureChange, { passive: false }); | ||||||
| return () => { | ||||||
| surface.removeEventListener("wheel", onWheel); | ||||||
| surface.removeEventListener("pointermove", onPointerMove); | ||||||
| surface.removeEventListener("gesturestart", onGestureStart); | ||||||
| surface.removeEventListener("gesturechange", onGestureChange); | ||||||
| }; | ||||||
| }, [surfaceRef]); | ||||||
|
|
||||||
| // A resize changes the pan bounds. Reclamp so a grown window does not leave | ||||||
| // the image offset with backdrop showing at one edge. | ||||||
| useEffect(() => { | ||||||
| const image = imageRef.current; | ||||||
| if (!image) return; | ||||||
| const onResize = () => setZoom((current) => clampImagePan(current, zoomFrame(image, current))); | ||||||
| window.addEventListener("resize", onResize); | ||||||
| return () => window.removeEventListener("resize", onResize); | ||||||
| }, []); | ||||||
|
macroscopeapp[bot] marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
| const zoomed = zoom.scale > 1; | ||||||
| return ( | ||||||
| <img | ||||||
| ref={imageRef} | ||||||
| src={src} | ||||||
| alt={alt} | ||||||
| className={`max-h-[86vh] max-w-[92vw] animate-[snap-shot-contents-enter_140ms_ease-out] touch-none select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl motion-reduce:animate-none ${ | ||||||
| zoomed ? "cursor-grab active:cursor-grabbing" : "cursor-zoom-in" | ||||||
| }`} | ||||||
| style={{ transform: `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.scale})` }} | ||||||
| draggable={false} | ||||||
| onError={onError} | ||||||
| onPointerDown={(event) => { | ||||||
| if (event.button !== 0) return; | ||||||
| dragRef.current = { | ||||||
| pointerId: event.pointerId, | ||||||
| x: event.clientX, | ||||||
| y: event.clientY, | ||||||
| startX: event.clientX, | ||||||
| startY: event.clientY, | ||||||
| moved: false, | ||||||
| }; | ||||||
| if (zoomed) event.currentTarget.setPointerCapture(event.pointerId); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Capture every primary pointer press. At 1×, this condition lets a pointer leave the image before release. The image then misses Capture the pointer for every primary press, or clear Proposed fix- if (zoomed) event.currentTarget.setPointerCapture(event.pointerId);
+ event.currentTarget.setPointerCapture(event.pointerId);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| }} | ||||||
| onPointerMove={(event) => { | ||||||
| const drag = dragRef.current; | ||||||
| if (!drag || drag.pointerId !== event.pointerId) return; | ||||||
| const dx = event.clientX - drag.x; | ||||||
| const dy = event.clientY - drag.y; | ||||||
| drag.x = event.clientX; | ||||||
| drag.y = event.clientY; | ||||||
| // A few pixels of jitter during a click still count as a click. | ||||||
| if (Math.abs(event.clientX - drag.startX) + Math.abs(event.clientY - drag.startY) > 4) { | ||||||
|
macroscopeapp[bot] marked this conversation as resolved.
Outdated
|
||||||
| drag.moved = true; | ||||||
| } | ||||||
| if (!zoomed) return; | ||||||
| const image = event.currentTarget; | ||||||
| setZoom((current) => panImage(current, dx, dy, zoomFrame(image, current))); | ||||||
| }} | ||||||
| onPointerUp={(event) => { | ||||||
| const drag = dragRef.current; | ||||||
| if (drag?.pointerId !== event.pointerId) return; | ||||||
| dragRef.current = null; | ||||||
| // A drag that moved is a pan, not a click. | ||||||
| if (drag.moved) return; | ||||||
| const image = event.currentTarget; | ||||||
| setZoom((current) => { | ||||||
| if (current.scale > 1) return IMAGE_ZOOM_IDENTITY; | ||||||
| const frame = zoomFrame(image, current); | ||||||
| const scale = clickZoomScale(image.naturalWidth, image.offsetWidth); | ||||||
| return zoomImageAt(current, scale, zoomAnchor(frame, event), frame); | ||||||
| }); | ||||||
| }} | ||||||
| onPointerCancel={() => { | ||||||
| dragRef.current = null; | ||||||
| }} | ||||||
| /> | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | ||||||
| preview, | ||||||
| onClose, | ||||||
| }: ExpandedImageDialogProps) { | ||||||
| const surfaceRef = useRef<HTMLDivElement | null>(null); | ||||||
| const [imageOffset, setImageOffset] = useState(0); | ||||||
| const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null); | ||||||
| const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState<string | null>(null); | ||||||
|
|
@@ -152,6 +369,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | |||||
| return createPortal( | ||||||
| <div | ||||||
| {...composerFloatingLayerProps} | ||||||
| ref={surfaceRef} | ||||||
| className="fixed inset-0 z-[60] flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]" | ||||||
| role="dialog" | ||||||
| aria-modal="true" | ||||||
|
|
@@ -206,11 +424,12 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ | |||||
| {openOriginalLink} | ||||||
| </ExpandedMediaFailure> | ||||||
| ) : ( | ||||||
| <img | ||||||
| <ZoomableImage | ||||||
| // A new image starts unzoomed with its own wheel listener. | ||||||
| key={item.src} | ||||||
| src={item.src} | ||||||
| alt={item.name} | ||||||
| className="max-h-[86vh] max-w-[92vw] animate-[snap-shot-contents-enter_140ms_ease-out] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl motion-reduce:animate-none" | ||||||
| draggable={false} | ||||||
| surfaceRef={surfaceRef} | ||||||
| onError={() => setFailedImageSrc(item.src)} | ||||||
| /> | ||||||
| )} | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { | ||
| clampImagePan, | ||
| clickZoomScale, | ||
| IMAGE_ZOOM_IDENTITY, | ||
| MAX_IMAGE_ZOOM, | ||
| panImage, | ||
| wheelZoomFactor, | ||
| zoomImageAt, | ||
| } from "./imageZoom"; | ||
|
|
||
| const frame = { | ||
| width: 400, | ||
| height: 300, | ||
| viewportWidth: 1000, | ||
| viewportHeight: 800, | ||
| centerX: 0, | ||
| centerY: 0, | ||
| }; | ||
|
|
||
| describe("zoomImageAt", () => { | ||
| it("keeps the anchored point fixed on screen", () => { | ||
| const anchor = { x: 100, y: -50 }; | ||
| // 4x overflows the viewport on both axes, so the clamp leaves room to | ||
| // keep the anchor fixed. The image point under the anchor before the zoom | ||
| // is `anchor` itself, and after the zoom it renders at x + scale * anchor. | ||
| const zoomed = zoomImageAt(IMAGE_ZOOM_IDENTITY, 4, anchor, frame); | ||
| expect(zoomed.scale).toBe(4); | ||
| expect(zoomed.x + zoomed.scale * anchor.x).toBeCloseTo(anchor.x); | ||
| expect(zoomed.y + zoomed.scale * anchor.y).toBeCloseTo(anchor.y); | ||
| }); | ||
|
|
||
| it("recenters when the zoomed image still fits the viewport", () => { | ||
| expect(zoomImageAt(IMAGE_ZOOM_IDENTITY, 2, { x: 100, y: 100 }, frame)).toEqual({ | ||
| scale: 2, | ||
| x: 0, | ||
| y: 0, | ||
| }); | ||
| }); | ||
|
|
||
| it("clamps the scale and returns the same state when nothing changes", () => { | ||
| const max = zoomImageAt(IMAGE_ZOOM_IDENTITY, 100, { x: 0, y: 0 }, frame); | ||
| expect(max.scale).toBe(MAX_IMAGE_ZOOM); | ||
| expect(zoomImageAt(max, 2, { x: 0, y: 0 }, frame)).toBe(max); | ||
| expect(zoomImageAt(IMAGE_ZOOM_IDENTITY, 0.5, { x: 0, y: 0 }, frame)).toBe(IMAGE_ZOOM_IDENTITY); | ||
| }); | ||
|
|
||
| it("recenters when zooming back out to 1x", () => { | ||
| const zoomed = zoomImageAt(IMAGE_ZOOM_IDENTITY, 4, { x: 150, y: 100 }, frame); | ||
| expect(zoomImageAt(zoomed, 0.25, { x: 0, y: 0 }, frame)).toEqual(IMAGE_ZOOM_IDENTITY); | ||
| }); | ||
| }); | ||
|
|
||
| describe("clampImagePan", () => { | ||
| it("centers an axis where the image still fits the viewport", () => { | ||
| expect(clampImagePan({ scale: 2, x: 300, y: 200 }, frame)).toEqual({ scale: 2, x: 0, y: 0 }); | ||
| }); | ||
|
|
||
| it("stops the far edge at the viewport edge once the image overflows", () => { | ||
| // 400 * 4 = 1600 wide in a 1000 viewport leaves 300 of slack per side. | ||
| expect(clampImagePan({ scale: 4, x: 900, y: -900 }, frame)).toEqual({ | ||
| scale: 4, | ||
| x: 300, | ||
| y: -200, | ||
| }); | ||
| }); | ||
|
|
||
| it("shifts the bounds by the image center offset so the viewport stays covered", () => { | ||
| // The caption sits the image 12px above the viewport center. Dragging down | ||
| // must stop 12px earlier and dragging up may go 12px further. | ||
| const offset = { ...frame, centerY: -12 }; | ||
| expect(clampImagePan({ scale: 4, x: 0, y: 900 }, offset).y).toBe(212); | ||
| expect(clampImagePan({ scale: 4, x: 0, y: -900 }, offset).y).toBe(-188); | ||
| }); | ||
| }); | ||
|
|
||
| describe("panImage", () => { | ||
| it("ignores pans at 1x", () => { | ||
| expect(panImage(IMAGE_ZOOM_IDENTITY, 40, 40, frame)).toBe(IMAGE_ZOOM_IDENTITY); | ||
| }); | ||
|
|
||
| it("moves a zoomed image within the clamp", () => { | ||
| expect(panImage({ scale: 4, x: 0, y: 0 }, -100, 50, frame)).toEqual({ | ||
| scale: 4, | ||
| x: -100, | ||
| y: 50, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("wheelZoomFactor", () => { | ||
| it("matches the native pinch rate for small deltas", () => { | ||
| // Chromium encodes a 5% pinch as deltaY -5. | ||
| expect(wheelZoomFactor(-5)).toBeCloseTo(1.05, 2); | ||
| expect(wheelZoomFactor(5)).toBeCloseTo(0.95, 2); | ||
| }); | ||
|
|
||
| it("caps a mouse wheel notch", () => { | ||
| expect(wheelZoomFactor(-100)).toBe(wheelZoomFactor(-25)); | ||
| }); | ||
| }); | ||
|
|
||
| describe("clickZoomScale", () => { | ||
| it("zooms to the actual pixel size of a large image", () => { | ||
| expect(clickZoomScale(3000, 1000)).toBe(3); | ||
| }); | ||
|
|
||
| it("zooms to 2x when the image already shows near its actual size", () => { | ||
| expect(clickZoomScale(1000, 1000)).toBe(2); | ||
| expect(clickZoomScale(1500, 1000)).toBe(2); | ||
| expect(clickZoomScale(0, 1000)).toBe(2); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.