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 { 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',
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
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 () => {
|
||||
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 (
|
||||
<Box
|
||||
className={classNames(css.ImageViewer, className)}
|
||||
direction="Column"
|
||||
<div
|
||||
className={classNames(css.Root, className)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt || 'Image viewer'}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Header className={css.ImageViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
<div className={css.Backdrop} onClick={requestClose} aria-hidden />
|
||||
<div className={css.Stage}>
|
||||
<div className={css.Chrome}>
|
||||
<div className={css.Title} title={alt}>
|
||||
{alt}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<IconButton
|
||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||
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"
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ChromeButton}
|
||||
aria-label="Download"
|
||||
onClick={handleDownload}
|
||||
radii="300"
|
||||
before={<Icon size="50" src={Icons.Download} />}
|
||||
>
|
||||
<Text size="B300">Download</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box
|
||||
grow="Yes"
|
||||
className={css.ImageViewerContent}
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
>
|
||||
<img
|
||||
className={css.ImageViewerImg}
|
||||
style={{
|
||||
cursor,
|
||||
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
||||
}}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Icon size="50" src={Icons.Download} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ChromeButton}
|
||||
aria-label="Close"
|
||||
onClick={requestClose}
|
||||
>
|
||||
<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
|
||||
ref={imgRef}
|
||||
className={css.Image}
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
onLoad={fitImage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>(
|
||||
<div className={css.MediaSkeleton} />
|
||||
))}
|
||||
{srcState.status === AsyncStatus.Success && (
|
||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
alt: body,
|
||||
requestClose: () => setViewer(false),
|
||||
})}
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
<Overlay open={viewer} backdrop={null}>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewer(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
alt: body,
|
||||
requestClose: () => setViewer(false),
|
||||
})}
|
||||
</FocusTrap>
|
||||
</Overlay>
|
||||
)}
|
||||
{!autoPlay && !markedAsSpoiler && srcState.status === AsyncStatus.Idle && (
|
||||
|
||||
@@ -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
|
||||
</Avatar>
|
||||
</AvatarPresence>
|
||||
{viewAvatar && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewAvatar(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="500" onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}>
|
||||
<ImageViewer
|
||||
src={viewAvatar}
|
||||
alt={userId}
|
||||
requestClose={() => setViewAvatar(undefined)}
|
||||
/>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
<Overlay open backdrop={null}>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setViewAvatar(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<ImageViewer
|
||||
src={viewAvatar}
|
||||
alt={userId}
|
||||
requestClose={() => setViewAvatar(undefined)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user