| @@ -185,8 +185,12 @@ function FullscreenViewer({ | |||||
| }) { | }) { | ||||
| const [current, setCurrent] = useState(startIndex); | const [current, setCurrent] = useState(startIndex); | ||||
| const [playing, setPlaying] = useState<Set<number>>(new Set()); | 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 touchStartX = useRef<number | null>(null); | ||||
| const videoRefs = useRef<Record<number, HTMLVideoElement | 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 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; }); | 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 prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]); | ||||
| const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]); | const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]); | ||||
| @@ -237,8 +273,37 @@ function FullscreenViewer({ | |||||
| }); | }); | ||||
| }, [current, items]); | }, [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) => { | const onTouchEnd = (e: React.TouchEvent) => { | ||||
| if (zoom > 1) return; | |||||
| if (touchStartX.current === null) return; | if (touchStartX.current === null) return; | ||||
| const delta = e.changedTouches[0].clientX - touchStartX.current; | const delta = e.changedTouches[0].clientX - touchStartX.current; | ||||
| if (Math.abs(delta) > 50) delta < 0 ? next() : prev(); | if (Math.abs(delta) > 50) delta < 0 ? next() : prev(); | ||||
| @@ -247,6 +312,7 @@ function FullscreenViewer({ | |||||
| return ( | return ( | ||||
| <div | <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" | className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200" | ||||
| onTouchStart={onTouchStart} | onTouchStart={onTouchStart} | ||||
| onTouchEnd={onTouchEnd} | onTouchEnd={onTouchEnd} | ||||
| @@ -287,6 +353,17 @@ function FullscreenViewer({ | |||||
| src={item.url} | src={item.url} | ||||
| alt="" | alt="" | ||||
| className="max-w-full max-h-full object-contain" | 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> | </div> | ||||