diff --git a/src/app/components/image-viewer/ImageViewer.css.ts b/src/app/components/image-viewer/ImageViewer.css.ts index d688afc..0873601 100644 --- a/src/app/components/image-viewer/ImageViewer.css.ts +++ b/src/app/components/image-viewer/ImageViewer.css.ts @@ -1,42 +1,127 @@ import { style } from '@vanilla-extract/css'; -import { DefaultReset, color, config } from 'folds'; +import { DefaultReset } from 'folds'; -export const ImageViewer = style([ +export const Root = style([ DefaultReset, { - height: '100%', + position: 'fixed', + inset: 0, + zIndex: 200, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 16, + boxSizing: 'border-box', }, ]); -export const ImageViewerHeader = style([ +export const Backdrop = style([ DefaultReset, { - paddingLeft: config.space.S200, - paddingRight: config.space.S200, - borderBottomWidth: config.borderWidth.B300, - flexShrink: 0, - gap: config.space.S200, + position: 'absolute', + inset: 0, + backgroundColor: 'rgba(0, 0, 0, 0.72)', + backdropFilter: 'blur(18px)', + WebkitBackdropFilter: 'blur(18px)', + cursor: 'zoom-out', }, ]); -export const ImageViewerContent = style([ +export const Stage = style([ DefaultReset, { - backgroundColor: color.Background.Container, - color: color.Background.OnContainer, + position: 'relative', + zIndex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + gap: 10, + maxWidth: 'min(96vw, 1100px)', + maxHeight: 'min(92vh, 900px)', + width: '100%', + minWidth: 0, + pointerEvents: 'none', + }, +]); + +export const Chrome = style([ + DefaultReset, + { + display: 'flex', + alignItems: 'center', + gap: 8, + width: '100%', + minWidth: 0, + color: '#fff', + pointerEvents: 'auto', + }, +]); + +export const Title = style([ + DefaultReset, + { + flex: 1, + minWidth: 0, + fontSize: 13, + fontWeight: 600, + lineHeight: '18px', + color: 'rgba(255, 255, 255, 0.9)', overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', }, ]); -export const ImageViewerImg = style([ +export const ChromeButton = style([ DefaultReset, { - objectFit: 'contain', - width: 'auto', - height: 'auto', - maxWidth: '100%', - maxHeight: '100%', - backgroundColor: color.Surface.Container, - transition: 'transform 100ms linear', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + padding: 6, + border: 'none', + borderRadius: 4, + background: 'rgba(0, 0, 0, 0.4)', + color: '#fff', + cursor: 'pointer', + }, +]); + +export const Frame = style([ + DefaultReset, + { + position: 'relative', + alignSelf: 'center', + maxWidth: 'min(96vw, 1100px)', + maxHeight: 'min(80vh, 820px)', + overflow: 'hidden', + cursor: 'zoom-in', + touchAction: 'none', + pointerEvents: 'auto', + selectors: { + '&[data-zoomed]': { + cursor: 'zoom-out', + overflow: 'visible', + }, + '&[data-pannable]': { + cursor: 'grab', + }, + '&[data-dragging]': { + cursor: 'grabbing', + }, + }, + }, +]); + +export const Image = style([ + DefaultReset, + { + display: 'block', + borderRadius: 8, + background: 'transparent', + transform: 'translate(var(--img-tx, 0px), var(--img-ty, 0px)) scale(var(--img-zoom, 1))', + transformOrigin: '0 0', + userSelect: 'none', }, ]); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index ee78d7b..6fec0c0 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,16 +1,62 @@ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -import React from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import FileSaver from 'file-saver'; import classNames from 'classnames'; -import { Box, Chip, Header, IconButton, Text, as } from 'folds'; +import { as } from 'folds'; import { Icon, Icons } from '../icons'; import * as css from './ImageViewer.css'; -import { useZoom } from '../../hooks/useZoom'; -import { usePan } from '../../hooks/usePan'; import { downloadMedia } from '../../utils/matrix'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getCurrentAccessToken } from '../../utils/auth'; +const ZOOM_MIN = 1; +const ZOOM_MAX = 5; +const ZOOM_CLICK = 2.5; +const DRAG_CLICK_SLOP = 6; + +type ZoomState = { + scale: number; + tx: number; + ty: number; +}; + +type PinchSession = { + startDist: number; + startScale: number; + startTx: number; + startTy: number; + startMidX: number; + startMidY: number; +}; + +const INITIAL_ZOOM: ZoomState = { scale: 1, tx: 0, ty: 0 }; + +function imageViewportMax() { + return { + w: Math.min(window.innerWidth * 0.96, 1100), + h: Math.min(window.innerHeight * 0.8, 820), + }; +} + +function pointerDistance( + a: { x: number; y: number }, + b: { x: number; y: number } +): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} + +function pointerMidpoint( + a: { x: number; y: number }, + b: { x: number; y: number } +): { x: number; y: number } { + return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; +} + export type ImageViewerProps = { alt: string; src: string; @@ -19,18 +65,215 @@ export type ImageViewerProps = { export const ImageViewer = as<'div', ImageViewerProps>( ({ className, alt, src, requestClose, ...props }, ref) => { - const mx = useMatrixClient(); - const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); - const { pan, cursor, onMouseDown } = usePan(zoom !== 1); + const frameRef = useRef(null); + const imgRef = useRef(null); + const zoomRef = useRef({ ...INITIAL_ZOOM }); + const pointersRef = useRef>(new Map()); + const dragRef = useRef<{ + id: number; + x: number; + y: number; + tx: number; + ty: number; + moved: boolean; + } | null>(null); + const pinchRef = useRef(null); + /** Skip the next click after a pinch so we don't toggle zoom. */ + const suppressClickRef = useRef(false); + const baseSizeRef = useRef({ w: 0, h: 0 }); + + const [zoom, setZoom] = useState(INITIAL_ZOOM); + const [dragging, setDragging] = useState(false); + + const applyZoomCss = useCallback((next: ZoomState) => { + const img = imgRef.current; + if (!img) return; + img.style.setProperty('--img-zoom', String(next.scale)); + img.style.setProperty('--img-tx', `${next.tx}px`); + img.style.setProperty('--img-ty', `${next.ty}px`); + }, []); + + const clampPan = useCallback((state: ZoomState): ZoomState => { + const img = imgRef.current; + const frame = frameRef.current; + if (!img || !frame) return state; + + const baseW = img.offsetWidth || baseSizeRef.current.w; + const baseH = img.offsetHeight || baseSizeRef.current.h; + const frameW = frame.clientWidth; + const frameH = frame.clientHeight; + if (!baseW || !baseH || !frameW || !frameH) return state; + + const scaledW = baseW * state.scale; + const scaledH = baseH * state.scale; + let { tx, ty } = state; + + if (scaledW <= frameW) tx = (frameW - scaledW) / 2; + else tx = Math.min(0, Math.max(frameW - scaledW, tx)); + + if (scaledH <= frameH) ty = (frameH - scaledH) / 2; + else ty = Math.min(0, Math.max(frameH - scaledH, ty)); + + return { ...state, tx, ty }; + }, []); + + const commitZoom = useCallback( + (next: ZoomState) => { + const clamped = clampPan(next); + zoomRef.current = clamped; + applyZoomCss(clamped); + setZoom(clamped); + }, + [applyZoomCss, clampPan] + ); + + const resetZoom = useCallback(() => { + dragRef.current = null; + pinchRef.current = null; + setDragging(false); + commitZoom({ ...INITIAL_ZOOM }); + }, [commitZoom]); + + const fitImage = useCallback(() => { + const img = imgRef.current; + if (!img) return; + const iw = img.naturalWidth || 0; + const ih = img.naturalHeight || 0; + if (iw <= 0 || ih <= 0) return; + + const { w: maxW, h: maxH } = imageViewportMax(); + const s = Math.min(1, maxW / iw, maxH / ih); + const w = Math.max(1, Math.round(iw * s)); + const h = Math.max(1, Math.round(ih * s)); + img.style.width = `${w}px`; + img.style.height = `${h}px`; + baseSizeRef.current = { w, h }; + commitZoom({ ...INITIAL_ZOOM }); + }, [commitZoom]); + + const zoomAround = useCallback( + (px: number, py: number, newScale: number) => { + const z = zoomRef.current; + const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, newScale)); + const ix = (px - z.tx) / z.scale; + const iy = (py - z.ty) / z.scale; + commitZoom({ + scale, + tx: px - ix * scale, + ty: py - iy * scale, + }); + }, + [commitZoom] + ); + + const framePoint = (clientX: number, clientY: number) => { + const frame = frameRef.current; + if (!frame) return { x: 0, y: 0 }; + const r = frame.getBoundingClientRect(); + return { x: clientX - r.left, y: clientY - r.top }; + }; + + const beginPinch = useCallback(() => { + const pts = [...pointersRef.current.values()]; + if (pts.length < 2) return; + const [a, b] = pts; + const mid = pointerMidpoint(a, b); + const local = framePoint(mid.x, mid.y); + const z = zoomRef.current; + pinchRef.current = { + startDist: Math.max(1, pointerDistance(a, b)), + startScale: z.scale, + startTx: z.tx, + startTy: z.ty, + startMidX: local.x, + startMidY: local.y, + }; + dragRef.current = null; + setDragging(true); + }, []); + + const updatePinch = useCallback(() => { + const pinch = pinchRef.current; + const pts = [...pointersRef.current.values()]; + if (!pinch || pts.length < 2) return; + + const [a, b] = pts; + const dist = Math.max(1, pointerDistance(a, b)); + const mid = pointerMidpoint(a, b); + const local = framePoint(mid.x, mid.y); + const scale = Math.min( + ZOOM_MAX, + Math.max(ZOOM_MIN, pinch.startScale * (dist / pinch.startDist)) + ); + + // Keep the original content under the pinch midpoint while scaling + panning. + const ix = (pinch.startMidX - pinch.startTx) / pinch.startScale; + const iy = (pinch.startMidY - pinch.startTy) / pinch.startScale; + commitZoom({ + scale, + tx: local.x - ix * scale, + ty: local.y - iy * scale, + }); + suppressClickRef.current = true; + }, [commitZoom]); + + useLayoutEffect(() => { + resetZoom(); + }, [src, resetZoom]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + requestClose(); + } + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [requestClose]); + + useEffect(() => { + const frame = frameRef.current; + if (!frame) return undefined; + + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + const img = imgRef.current; + if (!img?.src) return; + const factor = Math.exp(-event.deltaY * 0.002); + const r = frame.getBoundingClientRect(); + const x = event.clientX - r.left; + const y = event.clientY - r.top; + zoomAround(x, y, zoomRef.current.scale * factor); + }; + + // Block browser gesture zoom / scroll while pinching on the frame. + const blockGesture = (event: Event) => { + if (pointersRef.current.size >= 2 || pinchRef.current) { + event.preventDefault(); + } + }; + + frame.addEventListener('wheel', onWheel, { passive: false }); + frame.addEventListener('touchmove', blockGesture, { passive: false }); + return () => { + frame.removeEventListener('wheel', onWheel); + frame.removeEventListener('touchmove', blockGesture); + }; + }, [zoomAround]); + + useEffect(() => { + const onResize = () => fitImage(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [fitImage]); const handleDownload = async () => { try { - // Always use current session's token to avoid stale tokens during account switches const fileContent = await downloadMedia(src, getCurrentAccessToken()); FileSaver.saveAs(fileContent, alt); } catch (error) { console.warn('[ImageViewer] Failed to download media:', error); - // Fallback: try to fetch via standard fetch as blob try { const response = await fetch(src); if (response.ok) { @@ -38,81 +281,171 @@ export const ImageViewer = as<'div', ImageViewerProps>( FileSaver.saveAs(blob, alt); } } catch { - // If all else fails, open in new tab to let browser handle it window.open(src, '_blank'); } } }; + const handleClick = (event: React.MouseEvent) => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + return; + } + if (dragRef.current?.moved) { + dragRef.current = null; + return; + } + const img = imgRef.current; + if (!img?.src) return; + event.preventDefault(); + const { x, y } = framePoint(event.clientX, event.clientY); + if (zoomRef.current.scale <= 1.01) { + zoomAround(x, y, ZOOM_CLICK); + } else { + resetZoom(); + } + }; + + const handlePointerDown = (event: React.PointerEvent) => { + if (event.button !== 0 && event.pointerType === 'mouse') return; + + pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); + frameRef.current?.setPointerCapture(event.pointerId); + + if (pointersRef.current.size >= 2) { + beginPinch(); + return; + } + + if (zoomRef.current.scale <= 1.01) return; + const z = zoomRef.current; + dragRef.current = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + tx: z.tx, + ty: z.ty, + moved: false, + }; + setDragging(true); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + if (!pointersRef.current.has(event.pointerId)) return; + pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); + + if (pointersRef.current.size >= 2 && pinchRef.current) { + event.preventDefault(); + updatePinch(); + return; + } + + const drag = dragRef.current; + if (!drag || drag.id !== event.pointerId) return; + const dx = event.clientX - drag.x; + const dy = event.clientY - drag.y; + if (!drag.moved && Math.hypot(dx, dy) > DRAG_CLICK_SLOP) drag.moved = true; + commitZoom({ + scale: zoomRef.current.scale, + tx: drag.tx + dx, + ty: drag.ty + dy, + }); + }; + + const handlePointerUp = (event: React.PointerEvent) => { + pointersRef.current.delete(event.pointerId); + frameRef.current?.releasePointerCapture(event.pointerId); + + if (pinchRef.current) { + if (pointersRef.current.size >= 2) { + beginPinch(); + return; + } + pinchRef.current = null; + setDragging(false); + // Extra Things: one finger left after pinch — hand off to pan if still zoomed. + if (pointersRef.current.size === 1 && zoomRef.current.scale > 1.01) { + const [id, pt] = [...pointersRef.current.entries()][0]; + const z = zoomRef.current; + dragRef.current = { + id, + x: pt.x, + y: pt.y, + tx: z.tx, + ty: z.ty, + moved: true, + }; + setDragging(true); + } else { + dragRef.current = null; + } + return; + } + + const drag = dragRef.current; + if (!drag || drag.id !== event.pointerId) return; + setDragging(false); + if (!drag.moved) dragRef.current = null; + else dragRef.current = { ...drag, moved: true }; + }; + + const zoomed = zoom.scale > 1.01; + return ( - -
- - - - - +
+
+
+
{alt} - - - - - - - setZoom(zoom === 1 ? 2 : 1)}> - {Math.round(zoom * 100)}% - - 1 ? 'Success' : 'SurfaceVariant'} - outlined={zoom > 1} - size="300" - radii="Pill" - onClick={zoomIn} - aria-label="Zoom In" - > - - - +
- - {alt} - -
+ + + + +
+ {alt} +
+ + ); } ); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 0f3520f..6b4c95b 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useEffect, useState } from 'react'; -import { Badge, Box, Button, Chip, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; +import { Badge, Box, Button, Chip, Overlay, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Icon, Icons } from '../../icons'; import classNames from 'classnames'; import { Blurhash } from 'react-blurhash'; @@ -14,7 +14,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes'; import { stopPropagation } from '../../../utils/keyboard'; import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { ModalWide } from '../../../styles/Modal.css'; import { validBlurHash } from '../../../utils/blurHash'; import { getCurrentAccessToken } from '../../../utils/auth'; import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache'; @@ -192,28 +191,21 @@ export const ImageContent = as<'div', ImageContentProps>(
))} {srcState.status === AsyncStatus.Success && ( - }> - - setViewer(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - - {renderViewer({ - src: srcState.data, - alt: body, - requestClose: () => setViewer(false), - })} - - - + + setViewer(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + {renderViewer({ + src: srcState.data, + alt: body, + requestClose: () => setViewer(false), + })} + )} {!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && ( diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 3148c98..5cd496b 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Avatar, Box, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, toRem } from 'folds'; +import { Avatar, Box, Overlay, Text, toRem } from 'folds'; import { Icon, Icons } from '../icons'; import classNames from 'classnames'; import FocusTrap from 'focus-trap-react'; @@ -87,25 +87,21 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro {viewAvatar && ( - }> - - setViewAvatar(undefined), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - evt.stopPropagation()}> - setViewAvatar(undefined)} - /> - - - + + setViewAvatar(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + setViewAvatar(undefined)} + /> + )}