260 lines
9.0 KiB
TypeScript
260 lines
9.0 KiB
TypeScript
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
|
|
|
|
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
|
|
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
|
|
|
interface FollowScrollOptions {
|
|
getScrollTopSnapshot: Accessor<number>
|
|
setScrollTopSnapshot: (next: number) => void
|
|
sentinelMarginPx: number
|
|
sentinelClassName: string
|
|
intentWindowMs?: number
|
|
intentKeys?: ReadonlySet<string>
|
|
}
|
|
|
|
export interface FollowScrollHelpers {
|
|
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
|
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
|
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
|
restoreAfterRender: (options?: { forceBottom?: boolean }) => void
|
|
autoScroll: Accessor<boolean>
|
|
}
|
|
|
|
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
|
|
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
|
|
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
|
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
|
|
|
let scrollContainerRef: HTMLDivElement | undefined
|
|
let detachScrollIntentListeners: (() => void) | undefined
|
|
|
|
let pendingScrollFrame: number | null = null
|
|
let pendingAnchorScroll: number | null = null
|
|
let userScrollIntentUntil = 0
|
|
let lastKnownScrollTop = options.getScrollTopSnapshot()
|
|
let pointerInteractionActive = false
|
|
let suppressNextScrollHandling = false
|
|
|
|
function restoreScrollPosition(forceBottom = false) {
|
|
const container = scrollContainerRef
|
|
if (!container) return
|
|
suppressNextScrollHandling = true
|
|
if (forceBottom) {
|
|
container.scrollTop = container.scrollHeight
|
|
lastKnownScrollTop = container.scrollTop
|
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
|
} else {
|
|
container.scrollTop = lastKnownScrollTop
|
|
}
|
|
}
|
|
|
|
function persistScrollSnapshot(element?: HTMLElement | null) {
|
|
if (!element) return
|
|
lastKnownScrollTop = element.scrollTop
|
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
|
}
|
|
|
|
function markUserScrollIntent() {
|
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
|
|
}
|
|
|
|
function hasUserScrollIntent() {
|
|
if (pointerInteractionActive) {
|
|
return true
|
|
}
|
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
|
return now <= userScrollIntentUntil
|
|
}
|
|
|
|
function attachScrollIntentListeners(element: HTMLDivElement) {
|
|
if (detachScrollIntentListeners) {
|
|
detachScrollIntentListeners()
|
|
detachScrollIntentListeners = undefined
|
|
}
|
|
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
|
|
const handlePointerIntent = () => {
|
|
pointerInteractionActive = true
|
|
markUserScrollIntent()
|
|
}
|
|
const clearPointerIntent = () => {
|
|
pointerInteractionActive = false
|
|
}
|
|
const handleKeyIntent = (event: KeyboardEvent) => {
|
|
if (intentKeys.has(event.key)) {
|
|
markUserScrollIntent()
|
|
}
|
|
}
|
|
element.addEventListener("wheel", handlePointerIntent, { passive: true })
|
|
element.addEventListener("pointerdown", handlePointerIntent)
|
|
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
|
element.addEventListener("keydown", handleKeyIntent)
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("pointerup", clearPointerIntent)
|
|
window.addEventListener("pointercancel", clearPointerIntent)
|
|
window.addEventListener("mouseup", clearPointerIntent)
|
|
window.addEventListener("touchend", clearPointerIntent)
|
|
window.addEventListener("touchcancel", clearPointerIntent)
|
|
}
|
|
detachScrollIntentListeners = () => {
|
|
element.removeEventListener("wheel", handlePointerIntent)
|
|
element.removeEventListener("pointerdown", handlePointerIntent)
|
|
element.removeEventListener("touchstart", handlePointerIntent)
|
|
element.removeEventListener("keydown", handleKeyIntent)
|
|
if (typeof window !== "undefined") {
|
|
window.removeEventListener("pointerup", clearPointerIntent)
|
|
window.removeEventListener("pointercancel", clearPointerIntent)
|
|
window.removeEventListener("mouseup", clearPointerIntent)
|
|
window.removeEventListener("touchend", clearPointerIntent)
|
|
window.removeEventListener("touchcancel", clearPointerIntent)
|
|
}
|
|
pointerInteractionActive = false
|
|
}
|
|
}
|
|
|
|
function scheduleAnchorScroll(immediate = false) {
|
|
if (!autoScroll()) return
|
|
const sentinel = bottomSentinel()
|
|
const container = scrollContainerRef
|
|
if (!sentinel || !container) return
|
|
if (pendingAnchorScroll !== null) {
|
|
cancelAnimationFrame(pendingAnchorScroll)
|
|
pendingAnchorScroll = null
|
|
}
|
|
pendingAnchorScroll = requestAnimationFrame(() => {
|
|
pendingAnchorScroll = null
|
|
const containerRect = container.getBoundingClientRect()
|
|
const sentinelRect = sentinel.getBoundingClientRect()
|
|
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
|
|
if (Math.abs(delta) > 1) {
|
|
suppressNextScrollHandling = true
|
|
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
|
|
}
|
|
lastKnownScrollTop = container.scrollTop
|
|
options.setScrollTopSnapshot(lastKnownScrollTop)
|
|
})
|
|
}
|
|
|
|
function isAtBottom(container: HTMLDivElement) {
|
|
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
|
|
}
|
|
|
|
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
|
|
const container = containerOverride ?? scrollContainer()
|
|
if (!container) return
|
|
if (suppressNextScrollHandling) {
|
|
suppressNextScrollHandling = false
|
|
return
|
|
}
|
|
const isUserScroll = hasUserScrollIntent()
|
|
const atBottomFromScroll = isAtBottom(container)
|
|
const atBottom = atBottomFromScroll || bottomSentinelVisible()
|
|
|
|
if (isUserScroll || !atBottom) {
|
|
if (atBottom) {
|
|
if (!autoScroll()) setAutoScroll(true)
|
|
} else if (autoScroll()) {
|
|
setAutoScroll(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
updateFollowModeFromScroll(event.currentTarget)
|
|
persistScrollSnapshot(event.currentTarget)
|
|
}
|
|
|
|
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
|
|
const next = element || undefined
|
|
if (next === scrollContainerRef) {
|
|
return
|
|
}
|
|
scrollContainerRef = next
|
|
setScrollContainer(scrollContainerRef)
|
|
if (scrollContainerRef) {
|
|
lastKnownScrollTop = options.getScrollTopSnapshot()
|
|
restoreScrollPosition(autoScroll())
|
|
}
|
|
}
|
|
|
|
const renderSentinel = (config?: { disableTracking?: boolean }) => {
|
|
if (config?.disableTracking) return null
|
|
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
|
}
|
|
|
|
const restoreAfterRender = (config?: { forceBottom?: boolean }) => {
|
|
const container = scrollContainerRef
|
|
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
|
if (autoScroll()) {
|
|
setAutoScroll(false)
|
|
}
|
|
requestAnimationFrame(() => {
|
|
restoreScrollPosition(false)
|
|
})
|
|
return
|
|
}
|
|
|
|
const shouldFollow = config?.forceBottom ?? autoScroll()
|
|
requestAnimationFrame(() => {
|
|
restoreScrollPosition(shouldFollow)
|
|
if (shouldFollow) {
|
|
scheduleAnchorScroll(true)
|
|
}
|
|
})
|
|
}
|
|
|
|
createEffect(() => {
|
|
const container = scrollContainer()
|
|
if (!container) return
|
|
attachScrollIntentListeners(container)
|
|
onCleanup(() => {
|
|
if (detachScrollIntentListeners) {
|
|
detachScrollIntentListeners()
|
|
detachScrollIntentListeners = undefined
|
|
}
|
|
})
|
|
})
|
|
|
|
createEffect(() => {
|
|
const container = scrollContainer()
|
|
const sentinel = bottomSentinel()
|
|
if (!container || !sentinel) return
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.target === sentinel) {
|
|
setBottomSentinelVisible(entry.isIntersecting)
|
|
}
|
|
})
|
|
},
|
|
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
|
|
)
|
|
observer.observe(sentinel)
|
|
onCleanup(() => observer.disconnect())
|
|
})
|
|
|
|
onCleanup(() => {
|
|
if (pendingScrollFrame !== null) {
|
|
cancelAnimationFrame(pendingScrollFrame)
|
|
pendingScrollFrame = null
|
|
}
|
|
if (pendingAnchorScroll !== null) {
|
|
cancelAnimationFrame(pendingAnchorScroll)
|
|
pendingAnchorScroll = null
|
|
}
|
|
if (detachScrollIntentListeners) {
|
|
detachScrollIntentListeners()
|
|
detachScrollIntentListeners = undefined
|
|
}
|
|
})
|
|
|
|
return {
|
|
registerContainer,
|
|
handleScroll,
|
|
renderSentinel,
|
|
restoreAfterRender,
|
|
autoScroll,
|
|
}
|
|
}
|