diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 6018e3e2f..6136ba256 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -19,7 +19,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( const { t } = useTranslation(); const saveFile = useSaveFile(); 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 fileContent = await downloadMedia(src); @@ -100,6 +100,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( src={src} alt={alt} onMouseDown={onMouseDown} + onTouchStart={onTouchStart} /> diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 486bb9d9f..7d95b520d 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -17,6 +17,7 @@ import { UserAvatar } from '../user-avatar'; import colorMXID from '../../../util/colorMXID'; import { getMxIdLocalPart } from '../../utils/matrix'; import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css'; +import { ModalMobileFull } from '../../styles/Modal.css'; import { UserPresence } from '../../hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '../presence'; import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration'; @@ -83,7 +84,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) { escapeDeactivates: stopPropagation, }} > - evt.stopPropagation()}> + evt.stopPropagation()} + > ; + onTouchStart: React.TouchEventHandler; onImageDoubleClick: () => void; }) { const mx = useMatrixClient(); @@ -253,6 +255,7 @@ function LightboxMedia({ alt={item.body} draggable={false} onMouseDown={onMouseDown} + onTouchStart={onTouchStart} onDoubleClick={onImageDoubleClick} style={{ maxWidth: '100%', @@ -297,7 +300,7 @@ function Lightbox({ 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 { pan, cursor, onMouseDown, onTouchStart } = 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). @@ -512,6 +515,7 @@ function Lightbox({ pan={pan} cursor={cursor} onMouseDown={onMouseDown} + onTouchStart={onTouchStart} onImageDoubleClick={toggleZoom} /> diff --git a/src/app/hooks/usePan.ts b/src/app/hooks/usePan.ts index 243f5239a..590079789 100644 --- a/src/app/hooks/usePan.ts +++ b/src/app/hooks/usePan.ts @@ -1,4 +1,4 @@ -import { MouseEventHandler, useEffect, useRef, useState } from 'react'; +import { MouseEventHandler, TouchEventHandler, useEffect, useRef, useState } from 'react'; export type Pan = { translateX: number; @@ -21,6 +21,10 @@ export const usePan = (active: boolean) => { const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>( null, ); + const touchAttachedRef = useRef<{ + move: (e: TouchEvent) => void; + end: (e: TouchEvent) => void; + } | null>(null); useEffect(() => { setCursor(active ? 'grab' : 'initial'); @@ -53,6 +57,40 @@ export const usePan = (active: boolean) => { 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 = (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(() => { if (!active) setPan(INITIAL_PAN); }, [active]); @@ -65,6 +103,12 @@ export const usePan = (active: boolean) => { document.removeEventListener('mouseup', attachedRef.current.up); 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, cursor, onMouseDown: handleMouseDown, + onTouchStart: handleTouchStart, }; }; diff --git a/src/app/styles/Modal.css.ts b/src/app/styles/Modal.css.ts index 83e84c2df..df80eeb5f 100644 --- a/src/app/styles/Modal.css.ts +++ b/src/app/styles/Modal.css.ts @@ -1,6 +1,27 @@ import { style } from '@vanilla-extract/css'; +const mobileFullscreen = { + minWidth: '100vw', + minHeight: '100vh', + width: '100vw', + height: '100vh', + borderRadius: 0, +} as const; + export const ModalWide = style({ minWidth: '85vw', 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, + }, });