diff --git a/components/PublicGrid.tsx b/components/PublicGrid.tsx index d9adb67..5cb852f 100644 --- a/components/PublicGrid.tsx +++ b/components/PublicGrid.tsx @@ -185,8 +185,12 @@ function FullscreenViewer({ }) { const [current, setCurrent] = useState(startIndex); const [playing, setPlaying] = useState>(new Set()); + const [zoom, setZoom] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); const touchStartX = useRef(null); const videoRefs = useRef>({}); + const containerRef = useRef(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) => { + 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) => { + 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) => { + 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 (
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} /> )}