fix(mobile): full-screen media viewers + touch-pan for zoomed images (M3)
Mobile-audit batch 3. All changes mobile-gated (@media <=750px) so desktop is unchanged. - ModalWide: fill the phone screen (100vw/100vh, no radius) at <=750px instead of floating as an 85vw card. This also full-screens the file/PDF viewer and the avatar-crop editor on mobile (they share ModalWide) — intended. - UserHero avatar viewer: new mobile-only ModalMobileFull class (no desktop effect) so it goes edge-to-edge on phones like the timeline lightbox. - usePan: add touch support (single-finger drag, cleaned up on touchend/cancel/unmount) alongside the unchanged mouse path, so a zoomed image can be panned on a phone. Wired into ImageViewer and the MediaGallery lightbox. Two review passes: mouse path byte-for-byte unchanged; desktop provably unaffected; touch is gated to zoom!=1 so a non-zoomed image never hijacks swipe/scroll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const saveFile = useSaveFile();
|
const saveFile = useSaveFile();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoom !== 1);
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
const fileContent = await downloadMedia(src);
|
const fileContent = await downloadMedia(src);
|
||||||
@@ -100,6 +100,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { UserAvatar } from '../user-avatar';
|
|||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
||||||
|
import { ModalMobileFull } from '../../styles/Modal.css';
|
||||||
import { UserPresence } from '../../hooks/useUserPresence';
|
import { UserPresence } from '../../hooks/useUserPresence';
|
||||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||||
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
||||||
@@ -83,7 +84,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
|
<Modal
|
||||||
|
size="500"
|
||||||
|
className={ModalMobileFull}
|
||||||
|
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||||
|
>
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={viewAvatar}
|
src={viewAvatar}
|
||||||
alt={userId}
|
alt={userId}
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ function LightboxMedia({
|
|||||||
pan,
|
pan,
|
||||||
cursor,
|
cursor,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
|
onTouchStart,
|
||||||
onImageDoubleClick,
|
onImageDoubleClick,
|
||||||
}: {
|
}: {
|
||||||
item: LightboxItem;
|
item: LightboxItem;
|
||||||
@@ -198,6 +199,7 @@ function LightboxMedia({
|
|||||||
pan: Pan;
|
pan: Pan;
|
||||||
cursor: string;
|
cursor: string;
|
||||||
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
||||||
|
onTouchStart: React.TouchEventHandler<HTMLElement>;
|
||||||
onImageDoubleClick: () => void;
|
onImageDoubleClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -253,6 +255,7 @@ function LightboxMedia({
|
|||||||
alt={item.body}
|
alt={item.body}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
onDoubleClick={onImageDoubleClick}
|
onDoubleClick={onImageDoubleClick}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -297,7 +300,7 @@ function Lightbox({
|
|||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
// Pan is only active for a zoomed-in image; usePan resets its offset when this
|
// 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).
|
// flips false (i.e. back to 1x, on navigation, or on a video).
|
||||||
const { pan, cursor, onMouseDown } = usePan(isImage && zoom !== 1);
|
const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
|
||||||
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
||||||
|
|
||||||
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
||||||
@@ -512,6 +515,7 @@ function Lightbox({
|
|||||||
pan={pan}
|
pan={pan}
|
||||||
cursor={cursor}
|
cursor={cursor}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
onImageDoubleClick={toggleZoom}
|
onImageDoubleClick={toggleZoom}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
+46
-1
@@ -1,4 +1,4 @@
|
|||||||
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
|
import { MouseEventHandler, TouchEventHandler, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
export type Pan = {
|
export type Pan = {
|
||||||
translateX: number;
|
translateX: number;
|
||||||
@@ -21,6 +21,10 @@ export const usePan = (active: boolean) => {
|
|||||||
const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const touchAttachedRef = useRef<{
|
||||||
|
move: (e: TouchEvent) => void;
|
||||||
|
end: (e: TouchEvent) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCursor(active ? 'grab' : 'initial');
|
setCursor(active ? 'grab' : 'initial');
|
||||||
@@ -53,6 +57,40 @@ export const usePan = (active: boolean) => {
|
|||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Touch equivalent so a zoomed image can be dragged on a phone. Single-finger
|
||||||
|
// only (ignore multi-touch / pinch); touch events carry no movementX/Y, so we
|
||||||
|
// derive the delta from the previous touch position.
|
||||||
|
const handleTouchStart: TouchEventHandler<HTMLElement> = (evt) => {
|
||||||
|
if (!active || evt.touches.length !== 1) return;
|
||||||
|
setCursor('grabbing');
|
||||||
|
let lastX = evt.touches[0].clientX;
|
||||||
|
let lastY = evt.touches[0].clientY;
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const t = e.touches[0];
|
||||||
|
const dx = t.clientX - lastX;
|
||||||
|
const dy = t.clientY - lastY;
|
||||||
|
lastX = t.clientX;
|
||||||
|
lastY = t.clientY;
|
||||||
|
setPan((p) => ({ translateX: p.translateX + dx, translateY: p.translateY + dy }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
setCursor('grab');
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
document.removeEventListener('touchcancel', handleTouchEnd);
|
||||||
|
touchAttachedRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
touchAttachedRef.current = { move: handleTouchMove, end: handleTouchEnd };
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', handleTouchEnd);
|
||||||
|
document.addEventListener('touchcancel', handleTouchEnd);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) setPan(INITIAL_PAN);
|
if (!active) setPan(INITIAL_PAN);
|
||||||
}, [active]);
|
}, [active]);
|
||||||
@@ -65,6 +103,12 @@ export const usePan = (active: boolean) => {
|
|||||||
document.removeEventListener('mouseup', attachedRef.current.up);
|
document.removeEventListener('mouseup', attachedRef.current.up);
|
||||||
attachedRef.current = null;
|
attachedRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (touchAttachedRef.current) {
|
||||||
|
document.removeEventListener('touchmove', touchAttachedRef.current.move);
|
||||||
|
document.removeEventListener('touchend', touchAttachedRef.current.end);
|
||||||
|
document.removeEventListener('touchcancel', touchAttachedRef.current.end);
|
||||||
|
touchAttachedRef.current = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -73,5 +117,6 @@ export const usePan = (active: boolean) => {
|
|||||||
pan,
|
pan,
|
||||||
cursor,
|
cursor,
|
||||||
onMouseDown: handleMouseDown,
|
onMouseDown: handleMouseDown,
|
||||||
|
onTouchStart: handleTouchStart,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
const mobileFullscreen = {
|
||||||
|
minWidth: '100vw',
|
||||||
|
minHeight: '100vh',
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
borderRadius: 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const ModalWide = style({
|
export const ModalWide = style({
|
||||||
minWidth: '85vw',
|
minWidth: '85vw',
|
||||||
minHeight: '90vh',
|
minHeight: '90vh',
|
||||||
|
'@media': {
|
||||||
|
// Fill the phone screen instead of floating as an 85vw card with margins.
|
||||||
|
'(max-width: 750px)': mobileFullscreen,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile-only full-screen: no desktop effect (keeps the modal's normal size),
|
||||||
|
// but fills the viewport on phones. For dialogs that should stay a small card on
|
||||||
|
// desktop but go edge-to-edge on mobile (e.g. the avatar viewer).
|
||||||
|
export const ModalMobileFull = style({
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': mobileFullscreen,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user