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'; export interface ViewerItem { type: 'image' | 'video'; url: string; name: string; } interface ViewerContextValue { open: (item: ViewerItem) => void; } const ViewerContext = createContext(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({ 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 (
{ event.stopPropagation(); }}> {item.name}
{ event.stopPropagation(); }} >
{ event.stopPropagation(); }}> {item.type === 'image' ? ( {item.name} 1 ? 'grab' : 'zoom-in', }} onWheel={onWheel} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onDoubleClick={reset} onClick={() => { if (scale === 1) { setScale(2.2); } else { reset(); } }} /> ) : (
); }; export const MediaViewerProvider = ({ children }: { children: ReactNode }): ReactElement => { const [item, setItem] = useState(null); const open = useCallback((next: ViewerItem): void => { setItem(next); }, []); const close = useCallback((): void => { setItem(null); }, []); const value = useMemo(() => ({ open }), [open]); return ( {children} {item !== null ? createPortal(, document.body) : null} ); };