Messenger/packages/web/src/features/messaging/ui/MediaViewer.tsx
Заид Омар Медхат | Zaid Omar Medhat f5b620111b redesign
2026-07-11 04:49:07 +05:00

182 lines
4.9 KiB
TypeScript

import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { CloseIcon, DownloadIcon } from '../../../shared/ui';
import { claimPlayback } from '../playback';
export interface ViewerItem {
type: 'image' | 'video';
url: string;
name: string;
}
interface ViewerContextValue {
open: (item: ViewerItem) => void;
}
const ViewerContext = createContext<ViewerContextValue | null>(null);
export const useMediaViewer = (): ViewerContextValue => {
const context = useContext(ViewerContext);
if (context === null) {
throw new Error('useMediaViewer must be used within a MediaViewerProvider');
}
return context;
};
interface Point {
x: number;
y: number;
}
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const Overlay = ({ item, onClose }: { item: ViewerItem; onClose: () => void }): ReactElement => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
const dragging = useRef<{ startX: number; startY: number; baseX: number; baseY: number } | null>(
null,
);
const reset = useCallback((): void => {
setScale(1);
setOffset({ x: 0, y: 0 });
}, []);
useEffect(() => {
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
};
}, [onClose]);
const onWheel = (event: React.WheelEvent): void => {
if (item.type !== 'image') {
return;
}
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale - event.deltaY * 0.0015));
setScale(nextScale);
if (nextScale === 1) {
setOffset({ x: 0, y: 0 });
}
};
const onPointerDown = (event: React.PointerEvent): void => {
if (scale <= 1) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
dragging.current = {
startX: event.clientX,
startY: event.clientY,
baseX: offset.x,
baseY: offset.y,
};
};
const onPointerMove = (event: React.PointerEvent): void => {
const state = dragging.current;
if (state === null) {
return;
}
setOffset({
x: state.baseX + (event.clientX - state.startX),
y: state.baseY + (event.clientY - state.startY),
});
};
const onPointerUp = (): void => {
dragging.current = null;
};
return (
<div className="viewer" role="dialog" aria-modal="true" onClick={onClose}>
<div className="viewer-toolbar" onClick={(event) => {
event.stopPropagation();
}}>
<span className="viewer-name">{item.name}</span>
<div className="viewer-actions">
<a
className="icon-btn viewer-btn"
href={item.url}
download={item.name}
aria-label="Download"
onClick={(event) => {
event.stopPropagation();
}}
>
<DownloadIcon />
</a>
<button type="button" className="icon-btn viewer-btn" aria-label="Close" onClick={onClose}>
<CloseIcon />
</button>
</div>
</div>
<div className="viewer-stage" onClick={(event) => {
event.stopPropagation();
}}>
{item.type === 'image' ? (
<img
src={item.url}
alt={item.name}
className="viewer-img"
draggable={false}
style={{
transform: `translate(${String(offset.x)}px, ${String(offset.y)}px) scale(${String(scale)})`,
cursor: scale > 1 ? 'grab' : 'zoom-in',
}}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={reset}
onClick={() => {
if (scale === 1) {
setScale(2.2);
} else {
reset();
}
}}
/>
) : (
<video
src={item.url}
className="viewer-video"
controls
autoPlay
onPlay={(event) => {
claimPlayback(event.currentTarget);
}}
/>
)}
</div>
</div>
);
};
export const MediaViewerProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [item, setItem] = useState<ViewerItem | null>(null);
const open = useCallback((next: ViewerItem): void => {
setItem(next);
}, []);
const close = useCallback((): void => {
setItem(null);
}, []);
const value = useMemo<ViewerContextValue>(() => ({ open }), [open]);
return (
<ViewerContext.Provider value={value}>
{children}
{item !== null ? createPortal(<Overlay item={item} onClose={close} />, document.body) : null}
</ViewerContext.Provider>
);
};