Skip to content

Commit 209448b

Browse files
committed
feat: enhance auto-scroll behavior with user interaction detection for better UX
1 parent c98a237 commit 209448b

File tree

1 file changed

+115
-27
lines changed

1 file changed

+115
-27
lines changed

playground/src/App.vue

Lines changed: 115 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -150,51 +150,113 @@ function handleContainerScroll() {
150150
lastScrollTop.value = currentScrollTop
151151
}
152152
153-
// Extra listeners to detect explicit user interactions that should disable auto-scroll.
154-
// These help when the user uses the wheel, touch, keyboard or drags the scrollbar.
155-
function disableAutoScrollOnUserInteraction(e?: Event | WheelEvent | TouchEvent | PointerEvent | KeyboardEvent) {
153+
// Track touch/pointer start positions to detect direction
154+
const touchStartY = ref<number | null>(null)
155+
const pointerStartY = ref<number | null>(null)
156+
157+
// Wheel: only disable auto-scroll when user scrolls up (deltaY < 0).
158+
function handleWheel(e: WheelEvent) {
156159
try {
157-
// If it's a wheel event and it scrolls down while already at bottom, ignore.
158-
if (e && 'deltaY' in (e as WheelEvent)) {
159-
const we = e as WheelEvent
160-
if (messagesContainer.value && we.deltaY > 0 && isAtBottom(messagesContainer.value)) {
161-
return
162-
}
160+
if (!messagesContainer.value)
161+
return
162+
163+
// User scrolled up (want older content)
164+
if (e.deltaY < 0) {
165+
autoScrollEnabled.value = false
166+
}
167+
else {
168+
// Scrolling down: if near bottom, re-enable
169+
if (isAtBottom(messagesContainer.value))
170+
autoScrollEnabled.value = true
163171
}
164172
}
165173
catch {
166174
// ignore
167175
}
176+
}
177+
178+
// Touch handlers: detect move direction between touchstart and touchmove
179+
function handleTouchStart(e: TouchEvent) {
180+
if (e.touches && e.touches.length > 0) {
181+
touchStartY.value = e.touches[0].clientY
182+
}
183+
}
168184
169-
autoScrollEnabled.value = false
185+
function handleTouchMove(e: TouchEvent) {
186+
if (!messagesContainer.value || touchStartY.value == null || !e.touches || e.touches.length === 0)
187+
return
188+
189+
const currentY = e.touches[0].clientY
190+
const delta = currentY - touchStartY.value
191+
// Positive delta means finger moved down -> content scrolls up (towards top) -> user viewing earlier content
192+
if (delta > 0) {
193+
autoScrollEnabled.value = false
194+
}
195+
else {
196+
if (isAtBottom(messagesContainer.value))
197+
autoScrollEnabled.value = true
198+
}
170199
}
171200
172-
// Keyboard interactions that imply user navigation (PageUp, ArrowUp, Home, etc.)
201+
// Pointer handlers for scrollbar drag / pointer-based dragging
202+
function handlePointerDown(e: PointerEvent) {
203+
pointerStartY.value = (e as PointerEvent).clientY
204+
// Attach move/up listeners to document to track the drag
205+
const move = (ev: PointerEvent) => {
206+
if (pointerStartY.value == null)
207+
return
208+
const delta = ev.clientY - pointerStartY.value
209+
if (delta > 0) {
210+
autoScrollEnabled.value = false
211+
}
212+
else {
213+
if (messagesContainer.value && isAtBottom(messagesContainer.value))
214+
autoScrollEnabled.value = true
215+
}
216+
}
217+
218+
const up = () => {
219+
document.removeEventListener('pointermove', move)
220+
document.removeEventListener('pointerup', up)
221+
pointerStartY.value = null
222+
}
223+
224+
document.addEventListener('pointermove', move)
225+
document.addEventListener('pointerup', up)
226+
}
227+
228+
// Keyboard interactions: only treat upward navigation as disabling; downward navigation may re-enable when near bottom.
173229
function handleKeyDown(e: KeyboardEvent) {
174-
const keysThatMove = ['PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Space']
175-
if (keysThatMove.includes(e.key)) {
176-
disableAutoScrollOnUserInteraction(e)
230+
const upKeys = ['PageUp', 'ArrowUp', 'Home']
231+
const downKeys = ['PageDown', 'ArrowDown', 'End', 'Space']
232+
if (upKeys.includes(e.key)) {
233+
autoScrollEnabled.value = false
234+
}
235+
else if (downKeys.includes(e.key)) {
236+
if (messagesContainer.value && isAtBottom(messagesContainer.value))
237+
autoScrollEnabled.value = true
177238
}
178239
}
179240
180241
onMounted(() => {
181242
// Initialize lastScrollTop and attach extra listeners
182243
if (messagesContainer.value) {
183244
lastScrollTop.value = messagesContainer.value.scrollTop
184-
185-
messagesContainer.value.addEventListener('wheel', disableAutoScrollOnUserInteraction, { passive: true })
186-
messagesContainer.value.addEventListener('touchstart', disableAutoScrollOnUserInteraction, { passive: true })
187-
messagesContainer.value.addEventListener('pointerdown', disableAutoScrollOnUserInteraction)
245+
messagesContainer.value.addEventListener('wheel', handleWheel, { passive: true })
246+
messagesContainer.value.addEventListener('touchstart', handleTouchStart, { passive: true })
247+
messagesContainer.value.addEventListener('touchmove', handleTouchMove, { passive: true })
248+
messagesContainer.value.addEventListener('pointerdown', handlePointerDown)
188249
// keydown could be on document
189250
document.addEventListener('keydown', handleKeyDown)
190251
}
191252
})
192253
193254
onUnmounted(() => {
194255
if (messagesContainer.value) {
195-
messagesContainer.value.removeEventListener('wheel', disableAutoScrollOnUserInteraction)
196-
messagesContainer.value.removeEventListener('touchstart', disableAutoScrollOnUserInteraction)
197-
messagesContainer.value.removeEventListener('pointerdown', disableAutoScrollOnUserInteraction)
256+
messagesContainer.value.removeEventListener('wheel', handleWheel)
257+
messagesContainer.value.removeEventListener('touchstart', handleTouchStart)
258+
messagesContainer.value.removeEventListener('touchmove', handleTouchMove)
259+
messagesContainer.value.removeEventListener('pointerdown', handlePointerDown)
198260
document.removeEventListener('keydown', handleKeyDown)
199261
}
200262
})
@@ -203,14 +265,40 @@ watch(content, () => {
203265
// Only auto-scroll if enabled (user hasn't scrolled away from bottom)
204266
if (!autoScrollEnabled.value)
205267
return
268+
// Sometimes the rendered height isn't stable immediately (async rendering, images, third-party
269+
// renderers like mermaid, or syntax highlighting). Retry a few times while yielding to the
270+
// browser (nextTick + requestAnimationFrame) so we reliably end up at the bottom.
271+
async function scrollToBottomWithRetries(maxAttempts = 6, delay = 20) {
272+
if (!messagesContainer.value)
273+
return
274+
275+
for (let i = 0; i < maxAttempts; i++) {
276+
// Wait for Vue DOM updates
277+
278+
await nextTick()
279+
// Wait for a frame so layout/paint settle
206280
207-
nextTick(() => {
208-
if (messagesContainer.value) {
209-
// Use scrollTo with behavior 'auto' to force immediate jump to bottom and avoid issues with
210-
// CSS smooth scrolling that can make programmatic jumps behave inconsistently.
211-
messagesContainer.value.scrollTo({ top: messagesContainer.value.scrollHeight, behavior: 'auto' })
281+
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)))
282+
283+
if (!messagesContainer.value)
284+
return
285+
286+
const el = messagesContainer.value
287+
const prevScrollHeight = el.scrollHeight
288+
// Force immediate jump to bottom
289+
el.scrollTo({ top: el.scrollHeight, behavior: 'auto' })
290+
291+
// If height didn't change much or we're at bottom, stop retrying
292+
if (Math.abs(el.scrollHeight - prevScrollHeight) < 2 || isAtBottom(el, 2))
293+
return
294+
295+
// Small delay before next attempt
296+
297+
await new Promise(resolve => setTimeout(resolve, delay))
212298
}
213-
})
299+
}
300+
301+
scrollToBottomWithRetries()
214302
})
215303
</script>
216304

0 commit comments

Comments
 (0)