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 lastResetKey: string | number | undefined
|
||||||
let suppressAutoScrollOnce = false
|
let suppressAutoScrollOnce = false
|
||||||
let pendingInitialScroll = true
|
let pendingInitialScroll = true
|
||||||
|
let lastObservedScrollOffset = 0
|
||||||
|
let lastObservedPinnedAtBottom = false
|
||||||
|
|
||||||
const state: VirtualFollowListState = {
|
const state: VirtualFollowListState = {
|
||||||
autoScroll,
|
autoScroll,
|
||||||
@@ -239,15 +241,29 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
if (!handle || !element) return
|
if (!handle || !element) return
|
||||||
|
|
||||||
const offset = handle.scrollOffset
|
const offset = handle.scrollOffset
|
||||||
|
const scrolledUp = offset < lastObservedScrollOffset - 1
|
||||||
|
const wasPinnedAtBottom = lastObservedPinnedAtBottom
|
||||||
const scrollHeight = handle.scrollSize
|
const scrollHeight = handle.scrollSize
|
||||||
const clientHeight = element.clientHeight
|
const clientHeight = element.clientHeight
|
||||||
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||||
const atTop = offset <= (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
|
const hasItems = props.items().length > 0
|
||||||
setShowScrollBottomButton(hasItems && !atBottom)
|
setShowScrollBottomButton(hasItems && !atBottom)
|
||||||
setShowScrollTopButton(hasItems && !atTop)
|
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
|
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||||
if (hasUserScrollIntent()) {
|
if (hasUserScrollIntent()) {
|
||||||
if (atBottom && heldItemCount() !== null) {
|
if (atBottom && heldItemCount() !== null) {
|
||||||
@@ -259,6 +275,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
setAutoScroll(false)
|
setAutoScroll(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastObservedPinnedAtBottom = autoScroll() && atBottom
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
||||||
@@ -395,6 +413,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
createEffect(on(() => props.resetKey?.(), () => {
|
createEffect(on(() => props.resetKey?.(), () => {
|
||||||
itemElements.clear()
|
itemElements.clear()
|
||||||
setHeldItemCount(null)
|
setHeldItemCount(null)
|
||||||
|
lastObservedScrollOffset = 0
|
||||||
|
lastObservedPinnedAtBottom = false
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Handle autoScroll (Follow) on items change
|
// Handle autoScroll (Follow) on items change
|
||||||
|
|||||||
Reference in New Issue
Block a user