e6a46a9d60
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [ ] Pas de régression - [ ] TU/TI/TF rédigée - [ ] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #55 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
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<HTMLElement | null>,
|
|
options: UseInfiniteWheelOptions,
|
|
) {
|
|
const centeredIndex = ref(options.initialIndex())
|
|
let scrollEndTimer: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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}
|
|
}
|