fix(ui): stabilize timeline follow scroll from bottom (#327)
## Summary - fix the sticky-bottom state where dragging the scrollbar to the bottom makes `PageUp` jump to the previous timeline block and then snap immediately back down - keep the change scoped to `virtual-follow-list.tsx`, where follow mode, scroll intent, and bottom pinning are coordinated ## Root Cause The list only disabled follow mode when it saw an explicit local "user intent" signal. After reaching the bottom through the native scrollbar, `PageUp` could move the viewport without tripping that path, so the next render notification re-enabled the bottom snap immediately. ## Validation - `npx tsc --noEmit --project packages/ui/tsconfig.json` - `npm run build --prefix packages/ui` - manual desktop test: `PageUp` works again from the bottom sticky state
This commit is contained in:
@@ -174,6 +174,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
let lastResetKey: string | number | undefined
|
||||
let suppressAutoScrollOnce = false
|
||||
let pendingInitialScroll = true
|
||||
let lastObservedScrollOffset = 0
|
||||
let lastObservedPinnedAtBottom = false
|
||||
|
||||
const state: VirtualFollowListState = {
|
||||
autoScroll,
|
||||
@@ -239,15 +241,29 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
if (!handle || !element) return
|
||||
|
||||
const offset = handle.scrollOffset
|
||||
const scrolledUp = offset < lastObservedScrollOffset - 1
|
||||
const wasPinnedAtBottom = lastObservedPinnedAtBottom
|
||||
const scrollHeight = handle.scrollSize
|
||||
const clientHeight = element.clientHeight
|
||||
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||
lastObservedScrollOffset = offset
|
||||
|
||||
const hasItems = props.items().length > 0
|
||||
setShowScrollBottomButton(hasItems && !atBottom)
|
||||
setShowScrollTopButton(hasItems && !atTop)
|
||||
|
||||
// Keyboard/PageUp scrolls can move the viewport without ever hitting our
|
||||
// local key intent listeners (for example after dragging the native
|
||||
// scrollbar). If follow mode stays enabled, the next render notification
|
||||
// snaps the list straight back to bottom. A real upward viewport move away
|
||||
// from bottom should always break follow unless a hold target is active.
|
||||
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && heldItemCount() === null) {
|
||||
setAutoScroll(false)
|
||||
lastObservedPinnedAtBottom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||
if (hasUserScrollIntent()) {
|
||||
if (atBottom && heldItemCount() !== null) {
|
||||
@@ -259,6 +275,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}
|
||||
|
||||
lastObservedPinnedAtBottom = autoScroll() && atBottom
|
||||
}
|
||||
|
||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
||||
@@ -395,6 +413,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
createEffect(on(() => props.resetKey?.(), () => {
|
||||
itemElements.clear()
|
||||
setHeldItemCount(null)
|
||||
lastObservedScrollOffset = 0
|
||||
lastObservedPinnedAtBottom = false
|
||||
}))
|
||||
|
||||
// Handle autoScroll (Follow) on items change
|
||||
|
||||
Reference in New Issue
Block a user