From e76322329c5053208e5abaa92529b4a4b9615b50 Mon Sep 17 00:00:00 2001 From: sanic Date: Mon, 24 Aug 2026 10:44:59 -0700 Subject: [PATCH 1/2] fix(desktop): make lightbox zoom controls interactive Replace decorative lightbox zoom icons with accessible buttons wired to the existing clamped zoom state. Keep controls from closing the dialog, including SVG icon targets, and cover the regression in the gallery E2E spec. Signed-off-by: sanic Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- desktop/src/shared/ui/markdown.tsx | 118 +++++++++--------- .../ui/markdown/ImageLightboxZoomControls.tsx | 91 ++++++++++++++ .../src/shared/ui/markdown/imageLightbox.ts | 83 +++++++++++- .../e2e/image-attachment-gallery.spec.ts | 62 +++++++++ 4 files changed, 292 insertions(+), 62 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index af3997f8155..445a7c2abad 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1,13 +1,7 @@ import * as React from "react"; import { createPortal } from "react-dom"; import type { Components } from "react-markdown"; -import { - ChevronLeft, - ChevronRight, - Download, - ZoomIn, - ZoomOut, -} from "lucide-react"; +import { ChevronLeft, ChevronRight, Download } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; @@ -49,6 +43,7 @@ import { markdownPropsAreEqual, } from "./markdownUtils"; import { ImageMosaic } from "./markdown/ImageMosaic"; +import { ImageLightboxZoomControls } from "./markdown/ImageLightboxZoomControls"; import { CODE_BLOCK_CLASS, extractLanguage, @@ -74,7 +69,6 @@ import { } from "./markdown/MediaContextMenu"; import { isVideoMedia } from "./markdown/mediaEntry"; import { - clampImageLightboxZoom, type ImageGalleryDirection, type ImageGalleryItem, type ImageLightboxBox, @@ -90,13 +84,11 @@ import { IMAGE_LIGHTBOX_GALLERY_EASE, IMAGE_LIGHTBOX_GALLERY_SLIDE_DISTANCE_PX, IMAGE_LIGHTBOX_GALLERY_SLIDE_MS, - IMAGE_LIGHTBOX_MAX_ZOOM, IMAGE_LIGHTBOX_MIN_ZOOM, IMAGE_LIGHTBOX_REDUCED_MOTION_MS, IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS, IMAGE_LIGHTBOX_WHEEL_ZOOM_MAX_DELTA, IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED, - IMAGE_LIGHTBOX_ZOOM_STEP, IMAGE_LIGHTBOX_ZOOM_TRANSITION_MS, imageLightboxBasisBoxForItem, imageLightboxBoxFromRect, @@ -110,6 +102,8 @@ import { imageLightboxTargetBox, imageLightboxTransform, imageLightboxZoomBox, + imageLightboxZoomStateAtPoint, + imageLightboxZoomStateAtZoom, normalizedWheelDeltaY, visibleImageGalleryForTrigger, } from "./markdown/imageLightbox"; @@ -218,7 +212,10 @@ function ImageZoomOverlay({ const [returnBox, setReturnBox] = React.useState(sourceBox); const [returnCornerRadii, setReturnCornerRadii] = React.useState(sourceCornerRadii); - const [zoom, setZoom] = React.useState(IMAGE_LIGHTBOX_MIN_ZOOM); + const [{ zoom, zoomOffset }, setZoomState] = React.useState(() => ({ + zoom: IMAGE_LIGHTBOX_MIN_ZOOM, + zoomOffset: { x: 0, y: 0 }, + })); const controlPointerDownRef = React.useRef(false); const fadeTimerRef = React.useRef(null); const galleryTransitionTimerRef = React.useRef(null); @@ -269,7 +266,6 @@ function ImageZoomOverlay({ Date.now() + IMAGE_LIGHTBOX_CONTROL_SUPPRESS_CLOSE_MS; }, []); const closeMenu = React.useCallback(() => setMenu(null), []); - const finishZoomGestureSoon = React.useCallback(() => { if (zoomIdleTimerRef.current != null) { window.clearTimeout(zoomIdleTimerRef.current); @@ -280,13 +276,21 @@ function ImageZoomOverlay({ }, IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS); }, []); - const setClampedZoom = React.useCallback((nextZoom: number) => { - setZoom(clampImageLightboxZoom(nextZoom)); - }, []); + const setClampedZoom = React.useCallback( + (nextZoom: number) => + setZoomState((current) => + imageLightboxZoomStateAtZoom(current, nextZoom), + ), + [], + ); - const updateZoom = React.useCallback((updater: (zoom: number) => number) => { - setZoom((currentZoom) => clampImageLightboxZoom(updater(currentZoom))); - }, []); + const updateZoom = React.useCallback( + (updater: (zoom: number) => number) => + setZoomState((current) => + imageLightboxZoomStateAtZoom(current, updater(current.zoom)), + ), + [], + ); const close = React.useCallback(() => { if (closeTimerRef.current != null) return; @@ -351,7 +355,10 @@ function ImageZoomOverlay({ galleryTransitionTimerRef.current = null; }, IMAGE_LIGHTBOX_GALLERY_SLIDE_MS); setIsAdjustingZoom(false); - setZoom(IMAGE_LIGHTBOX_MIN_ZOOM); + setZoomState({ + zoom: IMAGE_LIGHTBOX_MIN_ZOOM, + zoomOffset: { x: 0, y: 0 }, + }); setCurrentIndex(nextIndex); }, [currentIndex, items.length, markControlGesture, prefersReducedMotion], @@ -638,7 +645,7 @@ function ImageZoomOverlay({ const isClosing = phase === "closing"; const isOpen = phase === "open"; const isFading = phase === "fading"; - const displayBox = imageLightboxZoomBox(targetBox, zoom); + const displayBox = imageLightboxZoomBox(targetBox, zoom, zoomOffset); const frameBox = isReturning ? returnBox : targetBox; const frameCornerRadii = isReturning ? returnCornerRadii @@ -677,11 +684,25 @@ function ImageZoomOverlay({ : isFading ? IMAGE_LIGHTBOX_FADE_EXIT_MS : IMAGE_LIGHTBOX_FADE_ENTER_MS; - const zoomFillPercent = - ((zoom - IMAGE_LIGHTBOX_MIN_ZOOM) / - (IMAGE_LIGHTBOX_MAX_ZOOM - IMAGE_LIGHTBOX_MIN_ZOOM)) * - 100; const label = currentItem.alt?.trim() || "Image preview"; + const handleImageClick = React.useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!isOpen || isReturning) { + return; + } + + setIsAdjustingZoom(false); + setZoomState((current) => + imageLightboxZoomStateAtPoint(targetBox, current, { + x: event.clientX, + y: event.clientY, + }), + ); + }, + [isOpen, isReturning, targetBox], + ); const handleImageContextMenu = React.useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -716,7 +737,7 @@ function ImageZoomOverlay({ return; } if ( - event.target instanceof HTMLElement && + event.target instanceof Element && event.target.closest("[data-image-lightbox-controls]") ) { markControlGesture(); @@ -738,7 +759,7 @@ function ImageZoomOverlay({ }} onPointerDownCapture={(event) => { if ( - event.target instanceof HTMLElement && + event.target instanceof Element && event.target.closest("[data-image-lightbox-controls]") ) { controlPointerDownRef.current = true; @@ -756,7 +777,8 @@ function ImageZoomOverlay({ tabIndex={-1} >

- Full-size image preview. Press Escape or click to close. + Full-size image preview. Press Escape or click outside the image to + close. Click the image to zoom.

IMAGE_LIGHTBOX_MIN_ZOOM + ? "cursor-zoom-out" + : "cursor-zoom-in", )} custom={galleryDirection} exit="exit" @@ -843,6 +868,7 @@ function ImageZoomOverlay({ ease: IMAGE_LIGHTBOX_GALLERY_EASE, }} variants={galleryImageVariants} + onClick={handleImageClick} onContextMenuCapture={handleImageContextMenu} /> @@ -919,39 +945,13 @@ function ImageZoomOverlay({ aria-hidden="true" className="h-5 w-px shrink-0 bg-muted-foreground/15" /> -
{menu && canActOnCurrentImage ? ( diff --git a/desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx b/desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx new file mode 100644 index 00000000000..f92f4afc89a --- /dev/null +++ b/desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx @@ -0,0 +1,91 @@ +import type { CSSProperties } from "react"; +import { ZoomIn, ZoomOut } from "lucide-react"; + +import { + IMAGE_LIGHTBOX_MAX_ZOOM, + IMAGE_LIGHTBOX_MIN_ZOOM, + IMAGE_LIGHTBOX_ZOOM_STEP, +} from "./imageLightbox"; + +type ImageLightboxZoomControlsProps = { + markControlGesture: () => void; + setClampedZoom: (nextZoom: number) => void; + setIsAdjustingZoom: (isAdjusting: boolean) => void; + updateZoom: (updater: (currentZoom: number) => number) => void; + zoom: number; +}; + +const ZOOM_BUTTON_CLASS_NAME = + "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-muted-foreground/10 hover:text-foreground outline-hidden focus-visible:ring-2 focus-visible:ring-ring/70 disabled:pointer-events-none disabled:opacity-45"; + +/** Interactive zoom controls for the image lightbox toolbar. */ +export function ImageLightboxZoomControls({ + markControlGesture, + setClampedZoom, + setIsAdjustingZoom, + updateZoom, + zoom, +}: ImageLightboxZoomControlsProps) { + const zoomFillPercent = + ((zoom - IMAGE_LIGHTBOX_MIN_ZOOM) / + (IMAGE_LIGHTBOX_MAX_ZOOM - IMAGE_LIGHTBOX_MIN_ZOOM)) * + 100; + + return ( + <> + + setIsAdjustingZoom(false)} + onChange={(event) => { + markControlGesture(); + setClampedZoom(Number(event.target.value)); + }} + onPointerCancel={() => setIsAdjustingZoom(false)} + onPointerDown={() => { + markControlGesture(); + setIsAdjustingZoom(true); + }} + onPointerUp={() => { + markControlGesture(); + setIsAdjustingZoom(false); + }} + /> + + + {Math.round(zoom * 100)}% + + + ); +} diff --git a/desktop/src/shared/ui/markdown/imageLightbox.ts b/desktop/src/shared/ui/markdown/imageLightbox.ts index 4c0c01f93e5..24295fed101 100644 --- a/desktop/src/shared/ui/markdown/imageLightbox.ts +++ b/desktop/src/shared/ui/markdown/imageLightbox.ts @@ -38,6 +38,16 @@ export type ImageGalleryItem = { thumbnailCornerRadii?: ImageLightboxCornerRadii; }; +export type ImageLightboxZoomAnchor = { + x: number; + y: number; +}; + +export type ImageLightboxZoomState = { + zoom: number; + zoomOffset: ImageLightboxZoomAnchor; +}; + export const IMAGE_LIGHTBOX_ENTER_MS = 260; export const IMAGE_LIGHTBOX_EXIT_MS = 170; export const IMAGE_LIGHTBOX_FADE_ENTER_MS = 180; @@ -54,7 +64,8 @@ export const IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED = 0.002; export const IMAGE_LIGHTBOX_WHEEL_ZOOM_MAX_DELTA = 0.2; export const IMAGE_LIGHTBOX_MIN_ZOOM = 1; export const IMAGE_LIGHTBOX_MAX_ZOOM = 3; -export const IMAGE_LIGHTBOX_ZOOM_STEP = 0.05; +export const IMAGE_LIGHTBOX_ZOOM_STEP = 0.15; +export const IMAGE_LIGHTBOX_CLICK_ZOOM = 1.75; export const IMAGE_LIGHTBOX_EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)"; export const IMAGE_LIGHTBOX_EASE_IN_OUT = "cubic-bezier(0.77, 0, 0.175, 1)"; export const IMAGE_LIGHTBOX_EXPANDED_CORNER_RADIUS = "1rem"; @@ -151,18 +162,84 @@ export function imageLightboxTransform( export function imageLightboxZoomBox( targetBox: ImageLightboxBox, zoom: number, + offset: ImageLightboxZoomAnchor = { x: 0, y: 0 }, ): ImageLightboxBox { const width = targetBox.width * zoom; const height = targetBox.height * zoom; return { height, - left: targetBox.left + (targetBox.width - width) / 2, - top: targetBox.top + (targetBox.height - height) / 2, + left: targetBox.left + (targetBox.width - width) / 2 + offset.x, + top: targetBox.top + (targetBox.height - height) / 2 + offset.y, width, }; } +export function imageLightboxZoomBoxAtPoint( + targetBox: ImageLightboxBox, + currentBox: ImageLightboxBox, + nextZoom: number, + point: ImageLightboxZoomAnchor, +): ImageLightboxBox { + const relativeX = (point.x - currentBox.left) / Math.max(1, currentBox.width); + const relativeY = (point.y - currentBox.top) / Math.max(1, currentBox.height); + const nextBox = imageLightboxZoomBox(targetBox, nextZoom); + + return { + ...nextBox, + left: point.x - relativeX * nextBox.width, + top: point.y - relativeY * nextBox.height, + }; +} + +export function imageLightboxZoomStateAtZoom( + currentState: ImageLightboxZoomState, + nextZoom: number, +): ImageLightboxZoomState { + const zoom = clampImageLightboxZoom(nextZoom); + return { + zoom, + zoomOffset: + zoom === IMAGE_LIGHTBOX_MIN_ZOOM + ? { x: 0, y: 0 } + : currentState.zoomOffset, + }; +} + +export function imageLightboxZoomStateAtPoint( + targetBox: ImageLightboxBox, + currentState: ImageLightboxZoomState, + point: ImageLightboxZoomAnchor, +): ImageLightboxZoomState { + const nextZoom = + currentState.zoom === IMAGE_LIGHTBOX_MIN_ZOOM + ? IMAGE_LIGHTBOX_CLICK_ZOOM + : IMAGE_LIGHTBOX_MIN_ZOOM; + if (nextZoom === IMAGE_LIGHTBOX_MIN_ZOOM) { + return imageLightboxZoomStateAtZoom(currentState, nextZoom); + } + + const currentBox = imageLightboxZoomBox( + targetBox, + currentState.zoom, + currentState.zoomOffset, + ); + const nextBox = imageLightboxZoomBoxAtPoint( + targetBox, + currentBox, + nextZoom, + point, + ); + const centeredNextBox = imageLightboxZoomBox(targetBox, nextZoom); + return { + zoom: nextZoom, + zoomOffset: { + x: nextBox.left - centeredNextBox.left, + y: nextBox.top - centeredNextBox.top, + }, + }; +} + export function imageLightboxBasisBoxForItem( item: ImageGalleryItem, fallbackBox: ImageLightboxBox, diff --git a/desktop/tests/e2e/image-attachment-gallery.spec.ts b/desktop/tests/e2e/image-attachment-gallery.spec.ts index 8c360236550..38975b4a3ab 100644 --- a/desktop/tests/e2e/image-attachment-gallery.spec.ts +++ b/desktop/tests/e2e/image-attachment-gallery.spec.ts @@ -175,6 +175,68 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => { .first(); await expectCornerRadiusPx(lightboxSurface, 16); await expectSmoothCorners(lightboxSurface); + await expect(page.getByRole("button", { name: "Zoom out" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Zoom in" })).toBeEnabled(); + + // Clicking the image zooms to the secondary level instead of dismissing the + // dialog, and the clicked image point remains under the cursor. + await waitForAnimations(page); + const lightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`); + const initialImageBox = await lightboxImage.boundingBox(); + if (!initialImageBox) { + throw new Error("Expected lightbox image to have a layout box"); + } + const clickPoint = { + x: initialImageBox.x + initialImageBox.width * 0.25, + y: initialImageBox.y + initialImageBox.height * 0.35, + }; + await page.mouse.click(clickPoint.x, clickPoint.y); + await expect(dialog).toBeVisible(); + await expect(page.getByText("175%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1.75", + ); + await waitForAnimations(page); + const zoomedImageBox = await lightboxImage.boundingBox(); + if (!zoomedImageBox) { + throw new Error("Expected zoomed lightbox image to have a layout box"); + } + expect(zoomedImageBox.width).toBeCloseTo(initialImageBox.width * 1.75, 0); + expect(zoomedImageBox.height).toBeCloseTo(initialImageBox.height * 1.75, 0); + expect( + Math.abs( + zoomedImageBox.x + initialImageBox.width * 0.25 * 1.75 - clickPoint.x, + ), + ).toBeLessThan(2); + expect( + Math.abs( + zoomedImageBox.y + initialImageBox.height * 0.35 * 1.75 - clickPoint.y, + ), + ).toBeLessThan(2); + + // A second image click toggles back to the centered 1× view. + await page.mouse.click(clickPoint.x, clickPoint.y); + await expect(dialog).toBeVisible(); + await expect(page.getByText("100%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1", + ); + + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect(dialog).toBeVisible(); + await expect(page.getByText("115%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1.15", + ); + await expect( + page.getByRole("slider", { name: "Image zoom" }), + ).toHaveAttribute("step", "0.15"); + await page.getByRole("button", { name: "Zoom out" }).click(); + await expect(dialog).toBeVisible(); + await expect(page.getByText("100%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1", + ); await expect( page.getByRole("button", { name: "Previous image" }), ).toHaveCount(0); From eca34048dc8c15550c23576f7208060da73d50e4 Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 16:10:33 -0700 Subject: [PATCH 2/2] fix(desktop): anchor lightbox zoom offset across zoom changes Scale the stored zoom offset by the zoom ratio in imageLightboxZoomStateAtZoom so the image point at the frame center stays anchored when the slider or wheel changes zoom, instead of leaving a fixed pixel offset that drifted and exposed empty frame gutter. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- desktop/src/shared/ui/markdown/imageLightbox.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/desktop/src/shared/ui/markdown/imageLightbox.ts b/desktop/src/shared/ui/markdown/imageLightbox.ts index 24295fed101..da29fc4971b 100644 --- a/desktop/src/shared/ui/markdown/imageLightbox.ts +++ b/desktop/src/shared/ui/markdown/imageLightbox.ts @@ -197,12 +197,19 @@ export function imageLightboxZoomStateAtZoom( nextZoom: number, ): ImageLightboxZoomState { const zoom = clampImageLightboxZoom(nextZoom); + if (zoom === IMAGE_LIGHTBOX_MIN_ZOOM) { + return { zoom, zoomOffset: { x: 0, y: 0 } }; + } + + // Scale the stored offset by the zoom ratio so the image point currently + // at the frame center stays anchored there as the slider/wheel changes zoom. + const offsetScale = zoom / currentState.zoom; return { zoom, - zoomOffset: - zoom === IMAGE_LIGHTBOX_MIN_ZOOM - ? { x: 0, y: 0 } - : currentState.zoomOffset, + zoomOffset: { + x: currentState.zoomOffset.x * offsetScale, + y: currentState.zoomOffset.y * offsetScale, + }, }; }