'use client' import React, { useEffect, useRef, useState } from 'react' import { getGlobalStyle } from '../../../helpers' interface HorizontalScrollWrapperProps { targetId: string columns: number className?: string minimapWidth?: number // px, por defecto 120 minViewportWidth?: number // px, por defecto 24 style?: React.CSSProperties } export const HorizontalScrollWrapper: React.FC = ({ targetId, columns, className = '', minimapWidth = 120, minViewportWidth = 24, style = {} }) => { const containerRef = useRef(null) const viewportRef = useRef(null) // estados visibles (UI) const [viewportWidthPx, setViewportWidthPx] = useState(minViewportWidth) const [viewportLeftPx, setViewportLeftPx] = useState(0) // refs para lectura sin esperar rerender const ratioRef = useRef(1) const viewportLeftRef = useRef(0) const viewportWidthRef = useRef(minViewportWidth) const mountedRef = useRef(false) const rafRef = useRef(null) useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false if (rafRef.current) cancelAnimationFrame(rafRef.current) } }, []) const getTarget = () => document.getElementById(targetId) as HTMLDivElement | null // recalc: calcula ratio y viewport (se puede llamar desde observers/resizes/scroll) const recalc = () => { const t = getTarget() if (!t) return const fullWidth = Math.max(1, t.scrollWidth) // ancho total del contenido const visibleWidth = Math.max(1, t.clientWidth) // area visible // ratio map miniatura/real const r = minimapWidth / fullWidth ratioRef.current = r const computedViewportWidth = Math.max(minViewportWidth, visibleWidth * r) const computedLeft = t.scrollLeft * r // actualizar refs y estado de forma consistente viewportWidthRef.current = computedViewportWidth viewportLeftRef.current = computedLeft if (mountedRef.current) { setViewportWidthPx(computedViewportWidth) setViewportLeftPx(computedLeft) } } // sincroniza scroll real -> minimap (ejecuta en rAF) useEffect(() => { const t = getTarget() if (!t) return // recalc inicial recalc() let locked = false const onScroll = () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) rafRef.current = requestAnimationFrame(() => { const r = ratioRef.current const left = t.scrollLeft * r viewportLeftRef.current = left if (mountedRef.current) setViewportLeftPx(left) }) } // también observamos cambios de tamaño en el target (cuando cambian columnas/children) const ro = new ResizeObserver(() => { // recalc en rAF para estabilidad visual if (rafRef.current) cancelAnimationFrame(rafRef.current) rafRef.current = requestAnimationFrame(recalc) }) ro.observe(t) window.addEventListener('resize', recalc) t.addEventListener('scroll', onScroll, { passive: true }) return () => { t.removeEventListener('scroll', onScroll) window.removeEventListener('resize', recalc) ro.disconnect() if (rafRef.current) cancelAnimationFrame(rafRef.current) } // intentionally minimal deps: recalc uses current refs and targetId (if changes, component likely remounts) // eslint-disable-next-line react-hooks/exhaustive-deps }, [targetId, minimapWidth, minViewportWidth, columns]) // mantener refs sincronizados con state useEffect(() => { viewportLeftRef.current = viewportLeftPx }, [viewportLeftPx]) useEffect(() => { viewportWidthRef.current = viewportWidthPx }, [viewportWidthPx]) // pointer dragging — manejadores nativos con refs para evitar re-creating handlers constantemente useEffect(() => { const viewportEl = viewportRef.current if (!viewportEl) return let dragging = false let startX = 0 let startLeft = 0 const onPointerDown = (ev: PointerEvent) => { // solo con botón principal if (ev.button !== 0) return ev.preventDefault() dragging = true startX = ev.clientX startLeft = viewportLeftRef.current // asegurar captura del pointer (soporte moderno) ;(viewportEl as any).setPointerCapture?.(ev.pointerId) viewportEl.style.cursor = 'grabbing' // quitar transición para seguimiento inmediato viewportEl.style.transition = 'none' } const onPointerMove = (ev: PointerEvent) => { if (!dragging) return ev.preventDefault() const dx = ev.clientX - startX const t = getTarget() if (!t) return const r = ratioRef.current const maxLeft = Math.max(0, minimapWidth - viewportWidthRef.current) let newLeft = startLeft + dx if (newLeft < 0) newLeft = 0 if (newLeft > maxLeft) newLeft = maxLeft // actualizar ref y estado (state para rerender del viewport) viewportLeftRef.current = newLeft if (mountedRef.current) setViewportLeftPx(newLeft) // actualizar scroll real inversamente proporcional // escribir directamente t.scrollLeft evita round-trip de setState t.scrollLeft = newLeft / r } const onPointerUp = (ev: PointerEvent) => { if (!dragging) return dragging = false try { ;(viewportEl as any).releasePointerCapture?.(ev.pointerId) } catch (e) { // algunos navegadores pueden lanzar si no capturó; ignorar } viewportEl.style.cursor = 'grab' // restaurar transición breve para suavizar pequeños saltos posteriores viewportEl.style.transition = 'left 0.03s linear' } viewportEl.addEventListener('pointerdown', onPointerDown) window.addEventListener('pointermove', onPointerMove) window.addEventListener('pointerup', onPointerUp) return () => { viewportEl.removeEventListener('pointerdown', onPointerDown) window.removeEventListener('pointermove', onPointerMove) window.removeEventListener('pointerup', onPointerUp) } // deps intencionalmente mínimas: usamos refs internos para estado dinámico }, [minimapWidth, targetId]) // click en minimapa para "teletransportar" el viewport (centra donde clickeas) const handleMinimapClick = (ev: React.MouseEvent) => { // evitar conflicto si el click viene por arrastre del viewport (mouse down en viewport ya captura) if ((ev.target as HTMLElement) === viewportRef.current) return const rect = containerRef.current?.getBoundingClientRect() if (!rect) return const x = ev.clientX - rect.left const newLeft = Math.max(0, Math.min(minimapWidth - viewportWidthRef.current, x - viewportWidthRef.current / 2)) const t = getTarget() if (!t) return const r = ratioRef.current viewportLeftRef.current = newLeft if (mountedRef.current) setViewportLeftPx(newLeft) t.scrollLeft = newLeft / r } // UI guard if (columns <= 0 && viewportWidthRef.current <= 0 && viewportLeftPx <= 0) return null // estilos inline (puedes externalizar) return (
{Array.from({ length: Math.max(1, columns) }).map((_, i) => (
))}
) }