136 lines
3.9 KiB
TypeScript
136 lines
3.9 KiB
TypeScript
import { batch, createEffect } from "solid-js"
|
|
import { createStore } from "solid-js/store"
|
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
|
|
export interface AutoScrollOptions {
|
|
working: () => boolean
|
|
onUserInteracted?: () => void
|
|
}
|
|
|
|
export function createAutoScroll(options: AutoScrollOptions) {
|
|
let scrollRef: HTMLElement | undefined
|
|
const [store, setStore] = createStore({
|
|
contentRef: undefined as HTMLElement | undefined,
|
|
lastScrollTop: 0,
|
|
lastScrollHeight: 0,
|
|
lastContentWidth: 0,
|
|
autoScrolled: false,
|
|
userScrolled: false,
|
|
reflowing: false,
|
|
})
|
|
|
|
function scrollToBottom() {
|
|
if (!scrollRef || store.userScrolled || !options.working()) return
|
|
setStore("autoScrolled", true)
|
|
requestAnimationFrame(() => {
|
|
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
|
|
requestAnimationFrame(() => {
|
|
batch(() => {
|
|
setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
|
|
setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
|
|
setStore("autoScrolled", false)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
function handleScroll() {
|
|
if (!scrollRef || store.autoScrolled) return
|
|
|
|
const scrollTop = scrollRef.scrollTop
|
|
const scrollHeight = scrollRef.scrollHeight
|
|
|
|
if (store.reflowing) {
|
|
batch(() => {
|
|
setStore("lastScrollTop", scrollTop)
|
|
setStore("lastScrollHeight", scrollHeight)
|
|
})
|
|
return
|
|
}
|
|
|
|
const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
|
|
const scrollTopDelta = scrollTop - store.lastScrollTop
|
|
|
|
// Handle reflow-caused scroll position changes
|
|
if (scrollHeightChanged && scrollTopDelta < 0) {
|
|
const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
|
|
const expectedScrollTop = store.lastScrollTop * heightRatio
|
|
if (Math.abs(scrollTop - expectedScrollTop) < 100) {
|
|
batch(() => {
|
|
setStore("lastScrollTop", scrollTop)
|
|
setStore("lastScrollHeight", scrollHeight)
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Handle reset to top while working
|
|
const reset = scrollTop <= 0 && store.lastScrollTop > 0 && options.working() && !store.userScrolled
|
|
if (reset) {
|
|
batch(() => {
|
|
setStore("lastScrollTop", scrollTop)
|
|
setStore("lastScrollHeight", scrollHeight)
|
|
})
|
|
requestAnimationFrame(scrollToBottom)
|
|
return
|
|
}
|
|
|
|
// Detect intentional scroll up
|
|
const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
|
|
if (scrolledUp && options.working()) {
|
|
setStore("userScrolled", true)
|
|
options.onUserInteracted?.()
|
|
}
|
|
|
|
batch(() => {
|
|
setStore("lastScrollTop", scrollTop)
|
|
setStore("lastScrollHeight", scrollHeight)
|
|
})
|
|
}
|
|
|
|
function handleInteraction() {
|
|
if (options.working()) {
|
|
setStore("userScrolled", true)
|
|
options.onUserInteracted?.()
|
|
}
|
|
}
|
|
|
|
// Reset userScrolled when work completes
|
|
createEffect(() => {
|
|
if (!options.working()) setStore("userScrolled", false)
|
|
})
|
|
|
|
// Handle content resize
|
|
createResizeObserver(
|
|
() => store.contentRef,
|
|
({ width }) => {
|
|
const widthChanged = Math.abs(width - store.lastContentWidth) > 5
|
|
if (widthChanged && store.lastContentWidth > 0) {
|
|
setStore("reflowing", true)
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
setStore("reflowing", false)
|
|
if (options.working() && !store.userScrolled) {
|
|
scrollToBottom()
|
|
}
|
|
})
|
|
})
|
|
} else if (!store.reflowing) {
|
|
scrollToBottom()
|
|
}
|
|
setStore("lastContentWidth", width)
|
|
},
|
|
)
|
|
|
|
return {
|
|
scrollRef: (el: HTMLElement | undefined) => {
|
|
scrollRef = el
|
|
},
|
|
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
|
|
handleScroll,
|
|
handleInteraction,
|
|
scrollToBottom,
|
|
userScrolled: () => store.userScrolled,
|
|
}
|
|
}
|