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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user