feat(media-gallery): zoom & pan images in the lightbox

The timeline image viewer supports zoom/pan, but the gallery's own lightbox
rendered a plain object-fit:contain image. Add the same affordances for images
(videos keep their native controls):

- scroll wheel or header -/+ buttons to zoom; +/-/0 keys; double-click or the
  % chip toggles 1x<->2x
- drag to pan when zoomed; zoom/pan reset when navigating to another item

Reuses the shared useZoom/usePan hooks (usePan already cleans up drag listeners
on unmount and resets pan when zoom returns to 1x).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:23:14 -04:00
co-authored by Claude Opus 4.8
parent 68a88e84b8
commit 8a92821a48
2 changed files with 80 additions and 3 deletions
+1 -1
View File
@@ -1107,7 +1107,7 @@ A toggle in **Settings → Privacy** switches between sending `m.read` (public r
`MediaGallery.tsx` — a right-side drawer for browsing room media.
- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count)
- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible.
- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible. In the lightbox, **images support zoom & pan** (scroll wheel or /+ header buttons, `+`/`-`/`0` keys, double-click or the % chip to toggle 1×↔2×; drag to pan when zoomed) via the shared `useZoom`/`usePan` hooks; zoom resets when navigating to another item.
- **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, play/seek/**speed control**; decrypts on play)
- **Files** — name/size/sender rows with download
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
+79 -2
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Box,
Button,
Chip,
Header,
Icon,
IconButton,
@@ -20,6 +21,8 @@ import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-s
import FocusTrap from 'focus-trap-react';
import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { useZoom } from '../../hooks/useZoom';
import { usePan, Pan } from '../../hooks/usePan';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -176,9 +179,19 @@ type LightboxItem = {
function LightboxMedia({
item,
useAuthentication,
zoom,
pan,
cursor,
onMouseDown,
onImageDoubleClick,
}: {
item: LightboxItem;
useAuthentication: boolean;
zoom: number;
pan: Pan;
cursor: string;
onMouseDown: React.MouseEventHandler<HTMLElement>;
onImageDoubleClick: () => void;
}) {
const mx = useMatrixClient();
const media = useDecryptedMediaUrl(
@@ -231,12 +244,19 @@ function LightboxMedia({
<img
src={media.url}
alt={item.body}
draggable={false}
onMouseDown={onMouseDown}
onDoubleClick={onImageDoubleClick}
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 120px)',
objectFit: 'contain',
borderRadius: config.radii.R300,
display: 'block',
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
transition: cursor === 'grabbing' ? 'none' : 'transform 120ms ease-out',
willChange: 'transform',
}}
/>
))}
@@ -259,6 +279,20 @@ function Lightbox({
}) {
const [index, setIndex] = useState(initialIndex);
const item = items[index];
const isImage = item?.msgtype === MsgType.Image;
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
// Pan is only active for a zoomed-in image; usePan resets its offset when this
// flips false (i.e. back to 1x, on navigation, or on a video).
const { pan, cursor, onMouseDown } = usePan(isImage && zoom !== 1);
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
// Reset zoom when navigating to another item (and thus pan, via usePan).
useEffect(() => {
setZoom(1);
}, [index, setZoom]);
const prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
const next = useCallback(
() => setIndex((i) => Math.min(items.length - 1, i + 1)),
@@ -269,11 +303,21 @@ function Lightbox({
if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
else if (e.key === 'Escape') onClose();
else if (isImage && (e.key === '+' || e.key === '=')) zoomIn();
else if (isImage && e.key === '-') zoomOut();
else if (isImage && e.key === '0') setZoom(1);
},
[prev, next, onClose],
[prev, next, onClose, isImage, zoomIn, zoomOut, setZoom],
);
const handleWheel = useCallback(
(e: React.WheelEvent) => {
if (!isImage) return;
if (e.deltaY < 0) zoomIn();
else zoomOut();
},
[isImage, zoomIn, zoomOut],
);
const item = items[index];
if (!item) return null;
const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
@@ -327,6 +371,33 @@ function Lightbox({
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
{index + 1} / {items.length}
</Text>
{isImage && (
<Box shrink="No" alignItems="Center" gap="100" aria-label="Zoom controls">
<IconButton
variant="Surface"
size="300"
radii="300"
aria-label="Zoom out"
onClick={zoomOut}
disabled={zoom <= 0.1}
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="Surface" radii="Pill" onClick={toggleZoom} aria-label="Reset zoom">
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
variant="Surface"
size="300"
radii="300"
aria-label="Zoom in"
onClick={zoomIn}
disabled={zoom >= 5}
>
<Icon size="50" src={Icons.Plus} />
</IconButton>
</Box>
)}
{item.mxcUrl && (
<TooltipProvider
position="Bottom"
@@ -396,6 +467,7 @@ function Lightbox({
grow="Yes"
alignItems="Center"
justifyContent="Center"
onWheel={handleWheel}
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
{index > 0 && (
@@ -418,6 +490,11 @@ function Lightbox({
key={`${item.mxcUrl}-${item.ts}`}
item={item}
useAuthentication={useAuthentication}
zoom={zoom}
pan={pan}
cursor={cursor}
onMouseDown={onMouseDown}
onImageDoubleClick={toggleZoom}
/>
</Box>
{index < items.length - 1 && (