import {onBeforeUnmount, onMounted, ref, type Ref} from 'vue' export const VISIBLE_ROWS = 5 export const CENTER_OFFSET = (VISIBLE_ROWS - 1) / 2 // 2 /** Index de valeur logique (0..length-1) centré pour un scrollTop donné. */ export function valueIndexFromScroll(scrollTop: number, itemHeight: number, length: number): number { const flat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET return ((flat % length) + length) % length } /** scrollTop qui centre l'index donné dans la copie du milieu (buffer à 3 copies). */ export function scrollTopForValueIndex(valueIndex: number, itemHeight: number, length: number): number { const flat = length + valueIndex - CENTER_OFFSET return flat * itemHeight } /** Recentre le scrollTop dans la copie du milieu [length, 2*length) si on a dérivé. */ export function loopCorrection(scrollTop: number, itemHeight: number, length: number): number { const block = length * itemHeight const centeredFlat = Math.round(scrollTop / itemHeight) + CENTER_OFFSET if (centeredFlat < length) return scrollTop + block if (centeredFlat >= 2 * length) return scrollTop - block return scrollTop } export interface UseInfiniteWheelOptions { length: number itemHeight: number initialIndex: () => number onChange: (index: number) => void } export function useInfiniteWheel( containerRef: Ref, options: UseInfiniteWheelOptions, ) { const centeredIndex = ref(options.initialIndex()) let scrollEndTimer: ReturnType | null = null // Fenêtre de suppression : ignore les évènements scroll provoqués par NOS // repositionnements programmatiques (et les réajustements de scroll-snap), qui // arrivent en rafale. Un booléen one-shot n'en absorberait qu'un seul : les // suivants seraient pris pour du scroll utilisateur → settle() → onChange en // boucle (re-render ré-entrant qui corrompt le patch DOM dans le navigateur). let suppressed = false let suppressTimer: ReturnType | null = null // Scroll programmatique INSTANTANÉ : pas de 'smooth', dont l'animation multi-frames // émettrait justement la rafale d'évènements scroll problématique. function applyScroll(top: number) { const el = containerRef.value if (!el) return suppressed = true if (suppressTimer) clearTimeout(suppressTimer) suppressTimer = setTimeout(() => { suppressed = false }, 100) el.scrollTop = top } function readCentered() { const el = containerRef.value if (!el) return centeredIndex.value = valueIndexFromScroll(el.scrollTop, options.itemHeight, options.length) } function settle() { const el = containerRef.value if (!el) return readCentered() options.onChange(centeredIndex.value) const corrected = loopCorrection(el.scrollTop, options.itemHeight, options.length) if (corrected !== el.scrollTop) applyScroll(corrected) } function onScroll() { if (suppressed) return readCentered() if (scrollEndTimer) clearTimeout(scrollEndTimer) scrollEndTimer = setTimeout(settle, 120) } function scrollToIndex(index: number) { centeredIndex.value = index applyScroll(scrollTopForValueIndex(index, options.itemHeight, options.length)) options.onChange(index) } function step(delta: number) { const next = (((centeredIndex.value + delta) % options.length) + options.length) % options.length scrollToIndex(next) } function onKeydown(event: KeyboardEvent) { if (event.key === 'ArrowUp') { event.preventDefault() step(-1) } else if (event.key === 'ArrowDown') { event.preventDefault() step(1) } } onMounted(() => { const el = containerRef.value if (!el) return el.addEventListener('scroll', onScroll, {passive: true}) applyScroll(scrollTopForValueIndex(options.initialIndex(), options.itemHeight, options.length)) }) onBeforeUnmount(() => { containerRef.value?.removeEventListener('scroll', onScroll) if (scrollEndTimer) clearTimeout(scrollEndTimer) if (suppressTimer) clearTimeout(suppressTimer) }) return {centeredIndex, scrollToIndex, step, onKeydown} }