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.
|
`MediaGallery.tsx` — a right-side drawer for browsing room media.
|
||||||
|
|
||||||
- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count)
|
- 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)
|
- **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
|
- **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
|
- **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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
Header,
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -20,6 +21,8 @@ import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-s
|
|||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useNearViewport } from '../../hooks/useNearViewport';
|
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 { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
@@ -176,9 +179,19 @@ type LightboxItem = {
|
|||||||
function LightboxMedia({
|
function LightboxMedia({
|
||||||
item,
|
item,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
zoom,
|
||||||
|
pan,
|
||||||
|
cursor,
|
||||||
|
onMouseDown,
|
||||||
|
onImageDoubleClick,
|
||||||
}: {
|
}: {
|
||||||
item: LightboxItem;
|
item: LightboxItem;
|
||||||
useAuthentication: boolean;
|
useAuthentication: boolean;
|
||||||
|
zoom: number;
|
||||||
|
pan: Pan;
|
||||||
|
cursor: string;
|
||||||
|
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
||||||
|
onImageDoubleClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const media = useDecryptedMediaUrl(
|
const media = useDecryptedMediaUrl(
|
||||||
@@ -231,12 +244,19 @@ function LightboxMedia({
|
|||||||
<img
|
<img
|
||||||
src={media.url}
|
src={media.url}
|
||||||
alt={item.body}
|
alt={item.body}
|
||||||
|
draggable={false}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onDoubleClick={onImageDoubleClick}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: 'calc(100vh - 120px)',
|
maxHeight: 'calc(100vh - 120px)',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
display: 'block',
|
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 [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 prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
|
||||||
const next = useCallback(
|
const next = useCallback(
|
||||||
() => setIndex((i) => Math.min(items.length - 1, i + 1)),
|
() => setIndex((i) => Math.min(items.length - 1, i + 1)),
|
||||||
@@ -269,11 +303,21 @@ function Lightbox({
|
|||||||
if (e.key === 'ArrowLeft') prev();
|
if (e.key === 'ArrowLeft') prev();
|
||||||
else if (e.key === 'ArrowRight') next();
|
else if (e.key === 'ArrowRight') next();
|
||||||
else if (e.key === 'Escape') onClose();
|
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;
|
if (!item) return null;
|
||||||
|
|
||||||
const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
|
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 }}>
|
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
||||||
{index + 1} / {items.length}
|
{index + 1} / {items.length}
|
||||||
</Text>
|
</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 && (
|
{item.mxcUrl && (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
@@ -396,6 +467,7 @@ function Lightbox({
|
|||||||
grow="Yes"
|
grow="Yes"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
|
onWheel={handleWheel}
|
||||||
style={{ overflow: 'hidden', padding: config.space.S400 }}
|
style={{ overflow: 'hidden', padding: config.space.S400 }}
|
||||||
>
|
>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
@@ -418,6 +490,11 @@ function Lightbox({
|
|||||||
key={`${item.mxcUrl}-${item.ts}`}
|
key={`${item.mxcUrl}-${item.ts}`}
|
||||||
item={item}
|
item={item}
|
||||||
useAuthentication={useAuthentication}
|
useAuthentication={useAuthentication}
|
||||||
|
zoom={zoom}
|
||||||
|
pan={pan}
|
||||||
|
cursor={cursor}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onImageDoubleClick={toggleZoom}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{index < items.length - 1 && (
|
{index < items.length - 1 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user