create-auto-scroll.tsx 6.4 KB

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