create-auto-scroll.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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. overflowAnchor?: "none" | "auto" | "dynamic"
  8. bottomThreshold?: number
  9. }
  10. export function createAutoScroll(options: AutoScrollOptions) {
  11. let scroll: HTMLElement | undefined
  12. let settling = false
  13. let settleTimer: ReturnType<typeof setTimeout> | undefined
  14. let autoTimer: ReturnType<typeof setTimeout> | undefined
  15. let cleanup: (() => void) | undefined
  16. let auto: { top: number; time: number } | undefined
  17. const threshold = () => options.bottomThreshold ?? 10
  18. const [store, setStore] = createStore({
  19. contentRef: undefined as HTMLElement | undefined,
  20. userScrolled: false,
  21. })
  22. const active = () => options.working() || settling
  23. const distanceFromBottom = (el: HTMLElement) => {
  24. return el.scrollHeight - el.clientHeight - el.scrollTop
  25. }
  26. // Browsers can dispatch scroll events asynchronously. If new content arrives
  27. // between us calling `scrollTo()` and the subsequent `scroll` event firing,
  28. // the handler can see a non-zero `distanceFromBottom` and incorrectly assume
  29. // the user scrolled.
  30. const markAuto = (el: HTMLElement) => {
  31. auto = {
  32. top: Math.max(0, el.scrollHeight - el.clientHeight),
  33. time: Date.now(),
  34. }
  35. if (autoTimer) clearTimeout(autoTimer)
  36. autoTimer = setTimeout(() => {
  37. auto = undefined
  38. autoTimer = undefined
  39. }, 250)
  40. }
  41. const isAuto = (el: HTMLElement) => {
  42. const a = auto
  43. if (!a) return false
  44. if (Date.now() - a.time > 250) {
  45. auto = undefined
  46. return false
  47. }
  48. return Math.abs(el.scrollTop - a.top) < 2
  49. }
  50. const scrollToBottomNow = (behavior: ScrollBehavior) => {
  51. const el = scroll
  52. if (!el) return
  53. markAuto(el)
  54. if (behavior === "smooth") {
  55. el.scrollTo({ top: el.scrollHeight, behavior })
  56. return
  57. }
  58. // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
  59. el.scrollTop = el.scrollHeight
  60. }
  61. const scrollToBottom = (force: boolean) => {
  62. if (!force && !active()) return
  63. const el = scroll
  64. if (!el) return
  65. if (!force && store.userScrolled) return
  66. if (force && store.userScrolled) setStore("userScrolled", false)
  67. const distance = distanceFromBottom(el)
  68. if (distance < 2) return
  69. // For auto-following content we prefer immediate updates to avoid
  70. // visible "catch up" animations while content is still settling.
  71. scrollToBottomNow("auto")
  72. }
  73. const stop = () => {
  74. if (store.userScrolled) return
  75. setStore("userScrolled", true)
  76. options.onUserInteracted?.()
  77. }
  78. const handleWheel = (e: WheelEvent) => {
  79. if (e.deltaY >= 0) return
  80. // If the user is scrolling within a nested scrollable region (tool output,
  81. // code block, etc), don't treat it as leaving the "follow bottom" mode.
  82. // Those regions opt in via `data-scrollable`.
  83. const el = scroll
  84. const target = e.target instanceof Element ? e.target : undefined
  85. const nested = target?.closest("[data-scrollable]")
  86. if (el && nested && nested !== el) return
  87. stop()
  88. }
  89. const handleScroll = () => {
  90. const el = scroll
  91. if (!el) return
  92. if (distanceFromBottom(el) < threshold()) {
  93. if (store.userScrolled) setStore("userScrolled", false)
  94. return
  95. }
  96. // Ignore scroll events triggered by our own scrollToBottom calls.
  97. if (!store.userScrolled && isAuto(el)) {
  98. scrollToBottom(false)
  99. return
  100. }
  101. stop()
  102. }
  103. const handleInteraction = () => {
  104. if (!active()) return
  105. stop()
  106. }
  107. const updateOverflowAnchor = (el: HTMLElement) => {
  108. const mode = options.overflowAnchor ?? "dynamic"
  109. if (mode === "none") {
  110. el.style.overflowAnchor = "none"
  111. return
  112. }
  113. if (mode === "auto") {
  114. el.style.overflowAnchor = "auto"
  115. return
  116. }
  117. el.style.overflowAnchor = store.userScrolled ? "auto" : "none"
  118. }
  119. createResizeObserver(
  120. () => store.contentRef,
  121. () => {
  122. if (!active()) return
  123. if (store.userScrolled) return
  124. // ResizeObserver fires after layout, before paint.
  125. // Keep the bottom locked in the same frame to avoid visible
  126. // "jump up then catch up" artifacts while streaming content.
  127. scrollToBottom(false)
  128. },
  129. )
  130. createEffect(
  131. on(options.working, (working) => {
  132. settling = false
  133. if (settleTimer) clearTimeout(settleTimer)
  134. settleTimer = undefined
  135. if (working) {
  136. if (!store.userScrolled) scrollToBottom(true)
  137. return
  138. }
  139. settling = true
  140. settleTimer = setTimeout(() => {
  141. settling = false
  142. }, 300)
  143. }),
  144. )
  145. createEffect(() => {
  146. // Track `userScrolled` even before `scrollRef` is attached, so we can
  147. // update overflow anchoring once the element exists.
  148. store.userScrolled
  149. const el = scroll
  150. if (!el) return
  151. updateOverflowAnchor(el)
  152. })
  153. onCleanup(() => {
  154. if (settleTimer) clearTimeout(settleTimer)
  155. if (autoTimer) clearTimeout(autoTimer)
  156. if (cleanup) cleanup()
  157. })
  158. return {
  159. scrollRef: (el: HTMLElement | undefined) => {
  160. if (cleanup) {
  161. cleanup()
  162. cleanup = undefined
  163. }
  164. scroll = el
  165. if (!el) return
  166. updateOverflowAnchor(el)
  167. el.addEventListener("wheel", handleWheel, { passive: true })
  168. cleanup = () => {
  169. el.removeEventListener("wheel", handleWheel)
  170. }
  171. },
  172. contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
  173. handleScroll,
  174. handleInteraction,
  175. pause: stop,
  176. resume: () => {
  177. if (store.userScrolled) setStore("userScrolled", false)
  178. scrollToBottom(true)
  179. },
  180. scrollToBottom: () => scrollToBottom(false),
  181. forceScrollToBottom: () => scrollToBottom(true),
  182. userScrolled: () => store.userScrolled,
  183. }
  184. }