Replace image lightbox with slate-style shadowbox.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s
Full-viewport blur backdrop, floating chrome, wheel/click/pinch zoom and pan; drop the ModalWide card shell around timeline and profile image viewers.
This commit is contained in:
@@ -1,42 +1,127 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
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,
|
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,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
paddingLeft: config.space.S200,
|
position: 'absolute',
|
||||||
paddingRight: config.space.S200,
|
inset: 0,
|
||||||
borderBottomWidth: config.borderWidth.B300,
|
backgroundColor: 'rgba(0, 0, 0, 0.72)',
|
||||||
flexShrink: 0,
|
backdropFilter: 'blur(18px)',
|
||||||
gap: config.space.S200,
|
WebkitBackdropFilter: 'blur(18px)',
|
||||||
|
cursor: 'zoom-out',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ImageViewerContent = style([
|
export const Stage = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
backgroundColor: color.Background.Container,
|
position: 'relative',
|
||||||
color: color.Background.OnContainer,
|
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',
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ImageViewerImg = style([
|
export const ChromeButton = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
objectFit: 'contain',
|
display: 'inline-flex',
|
||||||
width: 'auto',
|
alignItems: 'center',
|
||||||
height: 'auto',
|
justifyContent: 'center',
|
||||||
maxWidth: '100%',
|
flexShrink: 0,
|
||||||
maxHeight: '100%',
|
padding: 6,
|
||||||
backgroundColor: color.Surface.Container,
|
border: 'none',
|
||||||
transition: 'transform 100ms linear',
|
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',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,16 +1,62 @@
|
|||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
/* 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 FileSaver from 'file-saver';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, IconButton, Text, as } from 'folds';
|
import { as } from 'folds';
|
||||||
import { Icon, Icons } from '../icons';
|
import { Icon, Icons } from '../icons';
|
||||||
import * as css from './ImageViewer.css';
|
import * as css from './ImageViewer.css';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
|
||||||
import { usePan } from '../../hooks/usePan';
|
|
||||||
import { downloadMedia } from '../../utils/matrix';
|
import { downloadMedia } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { getCurrentAccessToken } from '../../utils/auth';
|
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 = {
|
export type ImageViewerProps = {
|
||||||
alt: string;
|
alt: string;
|
||||||
src: string;
|
src: string;
|
||||||
@@ -19,18 +65,215 @@ export type ImageViewerProps = {
|
|||||||
|
|
||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const frameRef = useRef<HTMLDivElement>(null);
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const zoomRef = useRef<ZoomState>({ ...INITIAL_ZOOM });
|
||||||
|
const pointersRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||||
|
const dragRef = useRef<{
|
||||||
|
id: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
moved: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const pinchRef = useRef<PinchSession | null>(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<ZoomState>(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 () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
// Always use current session's token to avoid stale tokens during account switches
|
|
||||||
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
const fileContent = await downloadMedia(src, getCurrentAccessToken());
|
||||||
FileSaver.saveAs(fileContent, alt);
|
FileSaver.saveAs(fileContent, alt);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ImageViewer] Failed to download media:', error);
|
console.warn('[ImageViewer] Failed to download media:', error);
|
||||||
// Fallback: try to fetch via standard fetch as blob
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(src);
|
const response = await fetch(src);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -38,81 +281,171 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
FileSaver.saveAs(blob, alt);
|
FileSaver.saveAs(blob, alt);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If all else fails, open in new tab to let browser handle it
|
|
||||||
window.open(src, '_blank');
|
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 (
|
return (
|
||||||
<Box
|
<div
|
||||||
className={classNames(css.ImageViewer, className)}
|
className={classNames(css.Root, className)}
|
||||||
direction="Column"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={alt || 'Image viewer'}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<Header className={css.ImageViewerHeader} size="400">
|
<div className={css.Backdrop} onClick={requestClose} aria-hidden />
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<div className={css.Stage}>
|
||||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
<div className={css.Chrome}>
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
<div className={css.Title} title={alt}>
|
||||||
</IconButton>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{alt}
|
{alt}
|
||||||
</Text>
|
</div>
|
||||||
</Box>
|
<button
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
type="button"
|
||||||
<IconButton
|
className={css.ChromeButton}
|
||||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
aria-label="Download"
|
||||||
outlined={zoom < 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomOut}
|
|
||||||
aria-label="Zoom Out"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Minus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
|
||||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
|
||||||
</Chip>
|
|
||||||
<IconButton
|
|
||||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
|
||||||
outlined={zoom > 1}
|
|
||||||
size="300"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={zoomIn}
|
|
||||||
aria-label="Zoom In"
|
|
||||||
>
|
|
||||||
<Icon size="50" src={Icons.Plus} />
|
|
||||||
</IconButton>
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
radii="300"
|
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
|
||||||
>
|
>
|
||||||
<Text size="B300">Download</Text>
|
<Icon size="50" src={Icons.Download} />
|
||||||
</Chip>
|
</button>
|
||||||
</Box>
|
<button
|
||||||
</Header>
|
type="button"
|
||||||
<Box
|
className={css.ChromeButton}
|
||||||
grow="Yes"
|
aria-label="Close"
|
||||||
className={css.ImageViewerContent}
|
onClick={requestClose}
|
||||||
justifyContent="Center"
|
>
|
||||||
alignItems="Center"
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={frameRef}
|
||||||
|
className={css.Frame}
|
||||||
|
data-zoomed={zoomed || undefined}
|
||||||
|
data-pannable={zoomed || undefined}
|
||||||
|
data-dragging={dragging || undefined}
|
||||||
|
onClick={handleClick}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
className={css.ImageViewerImg}
|
ref={imgRef}
|
||||||
style={{
|
className={css.Image}
|
||||||
cursor,
|
|
||||||
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
|
||||||
}}
|
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onMouseDown={onMouseDown}
|
onLoad={fitImage}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
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 { Icon, Icons } from '../../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Blurhash } from 'react-blurhash';
|
import { Blurhash } from 'react-blurhash';
|
||||||
@@ -14,7 +14,6 @@ import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
|
|||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
import { getCurrentAccessToken } from '../../../utils/auth';
|
import { getCurrentAccessToken } from '../../../utils/auth';
|
||||||
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
import { setMediaDimensions, getMediaBlurHash, getMediaDimensions, rememberMediaBlurHash } from '../../../state/mediaDimensionCache';
|
||||||
@@ -192,8 +191,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
<div className={css.MediaSkeleton} />
|
<div className={css.MediaSkeleton} />
|
||||||
))}
|
))}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={null}>
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
@@ -201,19 +199,13 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<Modal
|
|
||||||
className={ModalWide}
|
|
||||||
size="500"
|
|
||||||
>
|
>
|
||||||
{renderViewer({
|
{renderViewer({
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
alt: body,
|
alt: body,
|
||||||
requestClose: () => setViewer(false),
|
requestClose: () => setViewer(false),
|
||||||
})}
|
})}
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { Icon, Icons } from '../icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
@@ -87,8 +87,7 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
</AvatarPresence>
|
</AvatarPresence>
|
||||||
{viewAvatar && (
|
{viewAvatar && (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={null}>
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
@@ -97,15 +96,12 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
|
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={viewAvatar}
|
src={viewAvatar}
|
||||||
alt={userId}
|
alt={userId}
|
||||||
requestClose={() => setViewAvatar(undefined)}
|
requestClose={() => setViewAvatar(undefined)}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
</Overlay>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user