create-auto-scroll.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { createEffect, on, onCleanup } from "solid-js"
  2. import { createStore } from "solid-js/store"
  3. import { createResizeObserver } from "@solid-primitives/resize-observer"
  4. export interface AutoScrollOptions {
  5. working: () => boolean
  6. onUserInteracted?: () => void
  7. }
  8. export function createAutoScroll(options: AutoScrollOptions) {
  9. let scroll: HTMLElement | undefined
  10. let settling = false
  11. let settleTimer: ReturnType<typeof setTimeout> | undefined
  12. let down = false
  13. let cleanup: (() => void) | undefined
  14. const [store, setStore] = createStore({
  15. contentRef: undefined as HTMLElement | undefined,
  16. userScrolled: false,
  17. })
  18. const active = () => options.working() || settling
  19. const distanceFromBottom = () => {
  20. const el = scroll
  21. if (!el) return 0
  22. return el.scrollHeight - el.clientHeight - el.scrollTop
  23. }
  24. const scrollToBottomNow = (behavior: ScrollBehavior) => {
  25. const el = scroll
  26. if (!el) return
  27. el.scrollTo({ top: el.scrollHeight, behavior })
  28. }
  29. const scrollToBottom = (force: boolean) => {
  30. if (!force && !active()) return
  31. if (!scroll) return
  32. if (!force && store.userScrolled) return
  33. if (force && store.userScrolled) setStore("userScrolled", false)
  34. const distance = distanceFromBottom()
  35. if (distance < 2) return
  36. const behavior: ScrollBehavior = force || distance > 96 ? "auto" : "smooth"
  37. scrollToBottomNow(behavior)
  38. }
  39. const stop = () => {
  40. if (!active()) return
  41. if (store.userScrolled) return
  42. setStore("userScrolled", true)
  43. options.onUserInteracted?.()
  44. }
  45. const handleWheel = (e: WheelEvent) => {
  46. if (e.deltaY >= 0) return
  47. stop()
  48. }
  49. const handlePointerUp = () => {
  50. down = false
  51. window.removeEventListener("pointerup", handlePointerUp)
  52. }
  53. const handlePointerDown = () => {
  54. if (down) return
  55. down = true
  56. window.addEventListener("pointerup", handlePointerUp)
  57. }
  58. const handleTouchEnd = () => {
  59. down = false
  60. window.removeEventListener("touchend", handleTouchEnd)
  61. }
  62. const handleTouchStart = () => {
  63. if (down) return
  64. down = true
  65. window.addEventListener("touchend", handleTouchEnd)
  66. }
  67. const handleScroll = () => {
  68. if (!active()) return
  69. if (!scroll) return
  70. if (distanceFromBottom() < 10) {
  71. if (store.userScrolled) setStore("userScrolled", false)
  72. return
  73. }
  74. if (down) stop()
  75. }
  76. const handleInteraction = () => {
  77. stop()
  78. }
  79. createResizeObserver(
  80. () => store.contentRef,
  81. () => {
  82. if (!active()) return
  83. if (store.userScrolled) return
  84. scrollToBottom(false)
  85. },
  86. )
  87. createEffect(
  88. on(options.working, (working) => {
  89. settling = false
  90. if (settleTimer) clearTimeout(settleTimer)
  91. settleTimer = undefined
  92. setStore("userScrolled", false)
  93. if (working) {
  94. scrollToBottom(true)
  95. return
  96. }
  97. settling = true
  98. settleTimer = setTimeout(() => {
  99. settling = false
  100. }, 300)
  101. }),
  102. )
  103. onCleanup(() => {
  104. if (settleTimer) clearTimeout(settleTimer)
  105. if (cleanup) cleanup()
  106. })
  107. return {
  108. scrollRef: (el: HTMLElement | undefined) => {
  109. if (cleanup) {
  110. cleanup()
  111. cleanup = undefined
  112. }
  113. scroll = el
  114. down = false
  115. if (!el) return
  116. el.style.overflowAnchor = "none"
  117. el.addEventListener("wheel", handleWheel, { passive: true })
  118. el.addEventListener("pointerdown", handlePointerDown)
  119. el.addEventListener("touchstart", handleTouchStart, { passive: true })
  120. cleanup = () => {
  121. el.removeEventListener("wheel", handleWheel)
  122. el.removeEventListener("pointerdown", handlePointerDown)
  123. el.removeEventListener("touchstart", handleTouchStart)
  124. window.removeEventListener("pointerup", handlePointerUp)
  125. window.removeEventListener("touchend", handleTouchEnd)
  126. }
  127. },
  128. contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
  129. handleScroll,
  130. handleInteraction,
  131. scrollToBottom: () => scrollToBottom(false),
  132. forceScrollToBottom: () => scrollToBottom(true),
  133. userScrolled: () => store.userScrolled,
  134. }
  135. }