|
import type { MaybeGetter } from "$lib/types.js"; |
|
import { AnimationFrames, useDebounce, useEventListener } from "runed"; |
|
import { onMount } from "svelte"; |
|
import { extract } from "./extract.svelte.js"; |
|
import { noop } from "$lib/utils/noop.js"; |
|
|
|
export interface ScrollStateOptions { |
|
|
|
|
|
|
|
element: MaybeGetter<HTMLElement | Window | Document | null | undefined>; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
idle?: MaybeGetter<number | undefined>; |
|
|
|
|
|
|
|
|
|
|
|
offset?: MaybeGetter< |
|
| { |
|
left?: number; |
|
right?: number; |
|
top?: number; |
|
bottom?: number; |
|
} |
|
| undefined |
|
>; |
|
|
|
|
|
|
|
|
|
|
|
onScroll?: (e: Event) => void; |
|
|
|
|
|
|
|
|
|
|
|
onStop?: (e: Event) => void; |
|
|
|
|
|
|
|
|
|
|
|
|
|
eventListenerOptions?: AddEventListenerOptions; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
behavior?: MaybeGetter<ScrollBehavior | undefined>; |
|
|
|
|
|
|
|
|
|
|
|
|
|
onError?: (error: unknown) => void; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const ARRIVED_STATE_THRESHOLD_PIXELS = 1; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class ScrollState { |
|
#options!: ScrollStateOptions; |
|
element = $derived(extract(this.#options.element)); |
|
|
|
idle = $derived(extract(this.#options.idle, 200)); |
|
offset = $derived( |
|
extract(this.#options.offset, { |
|
left: 0, |
|
right: 0, |
|
top: 0, |
|
bottom: 0, |
|
}) |
|
); |
|
onScroll = $derived(this.#options.onScroll ?? noop); |
|
onStop = $derived(this.#options.onStop ?? noop); |
|
eventListenerOptions = $derived( |
|
this.#options.eventListenerOptions ?? { |
|
capture: false, |
|
passive: true, |
|
} |
|
); |
|
behavior = $derived(extract(this.#options.behavior, "auto")); |
|
onError = $derived( |
|
this.#options.onError ?? |
|
((e: unknown) => { |
|
console.error(e); |
|
}) |
|
); |
|
|
|
|
|
internalX = $state(0); |
|
internalY = $state(0); |
|
|
|
|
|
|
|
#x = $derived(this.internalX); |
|
get x() { |
|
return this.#x; |
|
} |
|
set x(v) { |
|
this.scrollTo(v, undefined); |
|
} |
|
|
|
#y = $derived(this.internalY); |
|
get y() { |
|
return this.#y; |
|
} |
|
set y(v) { |
|
this.scrollTo(undefined, v); |
|
} |
|
|
|
isScrolling = $state(false); |
|
arrived = $state({ |
|
left: true, |
|
right: false, |
|
top: true, |
|
bottom: false, |
|
}); |
|
directions = $state({ |
|
left: false, |
|
right: false, |
|
top: false, |
|
bottom: false, |
|
}); |
|
|
|
constructor(options: ScrollStateOptions) { |
|
this.#options = options; |
|
|
|
useEventListener( |
|
() => this.element, |
|
"scroll", |
|
|
|
this.onScrollHandler, |
|
this.eventListenerOptions |
|
); |
|
|
|
useEventListener( |
|
() => this.element, |
|
"scrollend", |
|
e => this.onScrollEnd(e), |
|
this.eventListenerOptions |
|
); |
|
|
|
onMount(() => { |
|
this.setArrivedState(); |
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
new AnimationFrames(() => this.setArrivedState()); |
|
} |
|
|
|
setArrivedState = () => { |
|
if (!window || !this.element) return; |
|
|
|
const el: Element = ((this.element as Window)?.document?.documentElement || |
|
(this.element as Document)?.documentElement || |
|
(this.element as HTMLElement | SVGElement)) as Element; |
|
|
|
const { display, flexDirection, direction } = getComputedStyle(el); |
|
const directionMultipler = direction === "rtl" ? -1 : 1; |
|
|
|
const scrollLeft = el.scrollLeft; |
|
this.directions.left = scrollLeft < this.internalX; |
|
this.directions.right = scrollLeft > this.internalX; |
|
|
|
const left = scrollLeft * directionMultipler <= (this.offset.left || 0); |
|
const right = |
|
scrollLeft * directionMultipler + el.clientWidth >= |
|
el.scrollWidth - (this.offset.right || 0) - ARRIVED_STATE_THRESHOLD_PIXELS; |
|
|
|
if (display === "flex" && flexDirection === "row-reverse") { |
|
this.arrived.left = right; |
|
this.arrived.right = left; |
|
} else { |
|
this.arrived.left = left; |
|
this.arrived.right = right; |
|
} |
|
|
|
this.internalX = scrollLeft; |
|
|
|
let scrollTop = el.scrollTop; |
|
|
|
|
|
if (this.element === window.document && !scrollTop) scrollTop = window.document.body.scrollTop; |
|
|
|
this.directions.top = scrollTop < this.internalY; |
|
this.directions.bottom = scrollTop > this.internalY; |
|
const top = scrollTop <= (this.offset.top || 0); |
|
const bottom = |
|
scrollTop + el.clientHeight >= el.scrollHeight - (this.offset.bottom || 0) - ARRIVED_STATE_THRESHOLD_PIXELS; |
|
|
|
|
|
|
|
|
|
|
|
if (display === "flex" && flexDirection === "column-reverse") { |
|
this.arrived.top = bottom; |
|
this.arrived.bottom = top; |
|
} else { |
|
this.arrived.top = top; |
|
this.arrived.bottom = bottom; |
|
} |
|
|
|
this.internalY = scrollTop; |
|
}; |
|
|
|
onScrollHandler = (e: Event) => { |
|
if (!window) return; |
|
|
|
this.setArrivedState(); |
|
|
|
this.isScrolling = true; |
|
this.onScrollEndDebounced(e); |
|
this.onScroll(e); |
|
}; |
|
|
|
scrollTo(x: number | undefined, y: number | undefined) { |
|
if (!window) return; |
|
|
|
(this.element instanceof Document ? window.document.body : this.element)?.scrollTo({ |
|
top: y ?? this.y, |
|
left: x ?? this.x, |
|
behavior: this.behavior, |
|
}); |
|
const scrollContainer = |
|
(this.element as Window)?.document?.documentElement || |
|
(this.element as Document)?.documentElement || |
|
(this.element as Element); |
|
if (x != null) this.internalX = scrollContainer.scrollLeft; |
|
if (y != null) this.internalY = scrollContainer.scrollTop; |
|
} |
|
|
|
scrollToTop() { |
|
this.scrollTo(undefined, 0); |
|
} |
|
|
|
scrollToBottom() { |
|
if (!window) return; |
|
|
|
const scrollContainer = |
|
(this.element as Window)?.document?.documentElement || |
|
(this.element as Document)?.documentElement || |
|
(this.element as Element); |
|
this.scrollTo(undefined, scrollContainer.scrollHeight); |
|
} |
|
|
|
onScrollEnd = (e: Event) => { |
|
|
|
if (!this.isScrolling) return; |
|
|
|
this.isScrolling = false; |
|
this.directions.left = false; |
|
this.directions.right = false; |
|
this.directions.top = false; |
|
this.directions.bottom = false; |
|
this.onStop(e); |
|
}; |
|
onScrollEndDebounced = useDebounce(this.onScrollEnd, () => this.idle); |
|
} |
|
|