| @@ -185,8 +185,12 @@ function FullscreenViewer({ | |||
| }) { | |||
| const [current, setCurrent] = useState(startIndex); | |||
| const [playing, setPlaying] = useState<Set<number>>(new Set()); | |||
| const [zoom, setZoom] = useState(1); | |||
| const [pan, setPan] = useState({ x: 0, y: 0 }); | |||
| const touchStartX = useRef<number | null>(null); | |||
| const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({}); | |||
| const containerRef = useRef<HTMLDivElement | null>(null); | |||
| const dragRef = useRef<{ sx: number; sy: number; px: number; py: number; moved: boolean } | null>(null); | |||
| const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; }); | |||
| const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; }); | |||
| @@ -207,6 +211,38 @@ function FullscreenViewer({ | |||
| } | |||
| }; | |||
| const onImgClick = (e: React.MouseEvent<HTMLImageElement>) => { | |||
| if (dragRef.current?.moved) { | |||
| dragRef.current = null; | |||
| return; | |||
| } | |||
| dragRef.current = null; | |||
| if (zoom > 1) { | |||
| setZoom(1); | |||
| setPan({ x: 0, y: 0 }); | |||
| } else { | |||
| const r = e.currentTarget.getBoundingClientRect(); | |||
| const ox = e.clientX - r.left - r.width / 2; | |||
| const oy = e.clientY - r.top - r.height / 2; | |||
| setZoom(2); | |||
| setPan({ x: -2 * ox, y: -2 * oy }); | |||
| } | |||
| }; | |||
| const onImgPointerDown = (e: React.PointerEvent<HTMLImageElement>) => { | |||
| if (zoom <= 1) return; | |||
| dragRef.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y, moved: false }; | |||
| e.currentTarget.setPointerCapture?.(e.pointerId); | |||
| }; | |||
| const onImgPointerMove = (e: React.PointerEvent<HTMLImageElement>) => { | |||
| if (!dragRef.current) return; | |||
| const dx = e.clientX - dragRef.current.sx; | |||
| const dy = e.clientY - dragRef.current.sy; | |||
| if (Math.hypot(dx, dy) > 4) dragRef.current.moved = true; | |||
| setPan({ x: dragRef.current.px + dx, y: dragRef.current.py + dy }); | |||
| }; | |||
| const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]); | |||
| const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]); | |||
| @@ -237,8 +273,37 @@ function FullscreenViewer({ | |||
| }); | |||
| }, [current, items]); | |||
| const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; }; | |||
| // Reset zoom whenever the active slide changes | |||
| useEffect(() => { | |||
| setZoom(1); | |||
| setPan({ x: 0, y: 0 }); | |||
| }, [current]); | |||
| // Wheel zoom (only on images). preventDefault requires passive: false → manual listener. | |||
| useEffect(() => { | |||
| const el = containerRef.current; | |||
| if (!el) return; | |||
| const onWheel = (e: WheelEvent) => { | |||
| const item = items[current]; | |||
| if (!item || isVideoUrl(item.url)) return; | |||
| e.preventDefault(); | |||
| const factor = 1 - e.deltaY * 0.001; | |||
| setZoom(prev => { | |||
| const next = Math.max(1, Math.min(4, prev * factor)); | |||
| if (next === 1) setPan({ x: 0, y: 0 }); | |||
| return next; | |||
| }); | |||
| }; | |||
| el.addEventListener('wheel', onWheel, { passive: false }); | |||
| return () => el.removeEventListener('wheel', onWheel); | |||
| }, [current, items]); | |||
| const onTouchStart = (e: React.TouchEvent) => { | |||
| if (zoom > 1) return; // pan via pointer events instead | |||
| touchStartX.current = e.touches[0].clientX; | |||
| }; | |||
| const onTouchEnd = (e: React.TouchEvent) => { | |||
| if (zoom > 1) return; | |||
| if (touchStartX.current === null) return; | |||
| const delta = e.changedTouches[0].clientX - touchStartX.current; | |||
| if (Math.abs(delta) > 50) delta < 0 ? next() : prev(); | |||
| @@ -247,6 +312,7 @@ function FullscreenViewer({ | |||
| return ( | |||
| <div | |||
| ref={containerRef} | |||
| className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200" | |||
| onTouchStart={onTouchStart} | |||
| onTouchEnd={onTouchEnd} | |||
| @@ -287,6 +353,17 @@ function FullscreenViewer({ | |||
| src={item.url} | |||
| alt="" | |||
| className="max-w-full max-h-full object-contain" | |||
| draggable={false} | |||
| style={isActive ? { | |||
| transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, | |||
| cursor: zoom > 1 ? 'grab' : 'zoom-in', | |||
| transition: dragRef.current ? 'none' : 'transform 0.2s', | |||
| touchAction: zoom > 1 ? 'none' : 'auto', | |||
| willChange: 'transform', | |||
| } : undefined} | |||
| onClick={isActive ? onImgClick : undefined} | |||
| onPointerDown={isActive ? onImgPointerDown : undefined} | |||
| onPointerMove={isActive ? onImgPointerMove : undefined} | |||
| /> | |||
| )} | |||
| </div> | |||