mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
feat(sidebar): add jump-to-top button for hard upward scrolling (#13864)
* feat(sidebar): add jump-to-top button for hard upward scrolling Detect intentional hard scroll-up gestures (wheel or scrollbar drag) and offer a one-click jump-to-top affordance. Auto-hide after idle to avoid persistent visual clutter. Addresses the common case of fast navigation through long worktree lists ranked by agent activity. * test(sidebar): improve scroll-to-top detection for active gestures Only detect velocity from active scrollbar drag or touch, not programmatic scrolls. Return focus to list after jump-to-top. Add comprehensive hook tests with gesture simulation. Improve cumulative down-delta tracking for dismissal. * test(sidebar): add scroll-to-top gesture detection tests Add comprehensive test coverage for the hard-upward-scroll detection hook, verifying idle timer behavior, gesture suppression, scrollability checks, and cleanup on unmount. Extract the post-jump suppression window into a named constant for maintainability.
This commit is contained in:
@@ -147,6 +147,8 @@ import {
|
||||
} from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry'
|
||||
import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top'
|
||||
import { WorktreeListScrollToTopButton } from './WorktreeListScrollToTopButton'
|
||||
import {
|
||||
getFolderWorkspacePathStatusDescription,
|
||||
getFolderWorkspacePathStatusTitle
|
||||
@@ -1374,6 +1376,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
scrollAnchorRef
|
||||
}: VirtualizedWorktreeViewportProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
// Why: callback-ref only mutates scrollRef; state re-runs the scroll-to-top listener attach.
|
||||
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
|
||||
const suppressMeasurementAdjustmentUntilRef = useRef(0)
|
||||
const directScrollInputUntilRef = useRef(0)
|
||||
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
|
||||
@@ -2038,6 +2042,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
suppressMeasurementAdjustmentUntilRef.current = suppressUntil
|
||||
directScrollInputUntilRef.current = suppressUntil
|
||||
}, [])
|
||||
const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({
|
||||
scrollElement,
|
||||
onUserScrollIntent: markDirectScrollInput
|
||||
})
|
||||
const hasDirectScrollInput = useCallback(
|
||||
() => window.performance.now() < directScrollInputUntilRef.current,
|
||||
[]
|
||||
@@ -2656,6 +2664,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
clearWorktreeDrag()
|
||||
}
|
||||
scrollRef.current = node
|
||||
setScrollElement(node)
|
||||
},
|
||||
[
|
||||
cancelPendingRevealFrames,
|
||||
@@ -5171,6 +5180,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToTop ? <WorktreeListScrollToTopButton onClick={scrollToTop} /> : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ChevronsUp } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function WorktreeListScrollToTopButton({
|
||||
onClick,
|
||||
className
|
||||
}: {
|
||||
onClick: () => void
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const label = translate(
|
||||
'auto.components.sidebar.WorktreeListScrollToTopButton.jumpToTop',
|
||||
'Jump to top'
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-x-0 top-2 z-40 flex justify-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'pointer-events-auto size-6 text-muted-foreground',
|
||||
'hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground',
|
||||
'animate-in fade-in-0 duration-150 motion-reduce:animate-none'
|
||||
)}
|
||||
>
|
||||
<ChevronsUp className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top'
|
||||
import { HARD_SCROLL_UP } from './worktree-list-hard-scroll-up'
|
||||
|
||||
let clockMs = 0
|
||||
|
||||
function advance(ms: number): void {
|
||||
clockMs += ms
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(ms)
|
||||
})
|
||||
}
|
||||
|
||||
function createViewportElement({
|
||||
scrollTop = 1200,
|
||||
scrollHeight = 5000,
|
||||
clientHeight = 1000
|
||||
}: { scrollTop?: number; scrollHeight?: number; clientHeight?: number } = {}): HTMLElement {
|
||||
const element = document.createElement('div')
|
||||
// happy-dom reports zero layout; stub the metrics the detector reads.
|
||||
Object.defineProperty(element, 'scrollHeight', { value: scrollHeight, configurable: true })
|
||||
Object.defineProperty(element, 'clientHeight', { value: clientHeight, configurable: true })
|
||||
Object.defineProperty(element, 'scrollTop', {
|
||||
value: scrollTop,
|
||||
configurable: true,
|
||||
writable: true
|
||||
})
|
||||
element.scrollTo = vi.fn(() => {
|
||||
element.scrollTop = 0
|
||||
}) as unknown as HTMLElement['scrollTo']
|
||||
element.focus = vi.fn()
|
||||
document.body.append(element)
|
||||
return element
|
||||
}
|
||||
|
||||
/** Enough upward wheel travel to clear `hardTotalDeltaPx` inside the intent window. */
|
||||
function dispatchHardWheelUp(element: HTMLElement, samples = 4): void {
|
||||
for (let i = 0; i < samples; i += 1) {
|
||||
clockMs += 16
|
||||
act(() => {
|
||||
element.dispatchEvent(new WheelEvent('wheel', { deltaY: -240 }))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('useWorktreeListScrollToTop', () => {
|
||||
beforeEach(() => {
|
||||
clockMs = 0
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(window.performance, 'now').mockImplementation(() => clockMs)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('shows on a hard upward wheel gesture and hides on the idle deadline', () => {
|
||||
const element = createViewportElement()
|
||||
const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
|
||||
|
||||
dispatchHardWheelUp(element)
|
||||
expect(result.current.showScrollToTop).toBe(true)
|
||||
|
||||
advance(HARD_SCROLL_UP.hideAfterIdleMs)
|
||||
expect(result.current.showScrollToTop).toBe(false)
|
||||
})
|
||||
|
||||
it('does not extend the idle deadline on non-intent scroll noise', () => {
|
||||
const element = createViewportElement()
|
||||
const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
|
||||
|
||||
dispatchHardWheelUp(element)
|
||||
const intentAt = clockMs
|
||||
// Let the hard samples age out so later ticks are judged on their own.
|
||||
advance(HARD_SCROLL_UP.windowMs + 16)
|
||||
|
||||
// Gentle upward ticks keep arriving but never refresh intent.
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
advance(200)
|
||||
act(() => {
|
||||
element.dispatchEvent(new WheelEvent('wheel', { deltaY: -4 }))
|
||||
})
|
||||
}
|
||||
expect(clockMs - intentAt).toBeLessThan(HARD_SCROLL_UP.hideAfterIdleMs)
|
||||
expect(result.current.showScrollToTop).toBe(true)
|
||||
|
||||
advance(intentAt + HARD_SCROLL_UP.hideAfterIdleMs - clockMs)
|
||||
expect(result.current.showScrollToTop).toBe(false)
|
||||
})
|
||||
|
||||
it('re-arms the idle timer when intent is refreshed', () => {
|
||||
const element = createViewportElement()
|
||||
const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
|
||||
|
||||
dispatchHardWheelUp(element)
|
||||
advance(HARD_SCROLL_UP.hideAfterIdleMs - 200)
|
||||
expect(result.current.showScrollToTop).toBe(true)
|
||||
|
||||
dispatchHardWheelUp(element)
|
||||
advance(HARD_SCROLL_UP.hideAfterIdleMs - 200)
|
||||
expect(result.current.showScrollToTop).toBe(true)
|
||||
|
||||
advance(200)
|
||||
expect(result.current.showScrollToTop).toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses detection for the post-jump window after scrollToTop', () => {
|
||||
const element = createViewportElement()
|
||||
const onUserScrollIntent = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useWorktreeListScrollToTop({ scrollElement: element, onUserScrollIntent })
|
||||
)
|
||||
|
||||
dispatchHardWheelUp(element)
|
||||
act(() => {
|
||||
result.current.scrollToTop()
|
||||
})
|
||||
const jumpAt = clockMs
|
||||
expect(onUserScrollIntent).toHaveBeenCalledTimes(1)
|
||||
expect(element.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'auto' })
|
||||
expect(result.current.showScrollToTop).toBe(false)
|
||||
|
||||
// Momentum events land back at depth while suppression is active.
|
||||
element.scrollTop = 1200
|
||||
dispatchHardWheelUp(element)
|
||||
expect(clockMs).toBeLessThan(jumpAt + HARD_SCROLL_UP.suppressAfterJumpMs)
|
||||
expect(result.current.showScrollToTop).toBe(false)
|
||||
|
||||
advance(HARD_SCROLL_UP.suppressAfterJumpMs)
|
||||
dispatchHardWheelUp(element)
|
||||
expect(result.current.showScrollToTop).toBe(true)
|
||||
})
|
||||
|
||||
it('force-hides when the list stops being scrollable, even mid-gesture', () => {
|
||||
const element = createViewportElement()
|
||||
const { result } = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
|
||||
|
||||
dispatchHardWheelUp(element)
|
||||
expect(result.current.showScrollToTop).toBe(true)
|
||||
|
||||
Object.defineProperty(element, 'scrollHeight', { value: 1100, configurable: true })
|
||||
act(() => {
|
||||
element.dispatchEvent(new WheelEvent('wheel', { deltaY: -240 }))
|
||||
})
|
||||
expect(result.current.showScrollToTop).toBe(false)
|
||||
|
||||
// The pending idle timer was cleared, so nothing flips state later.
|
||||
advance(HARD_SCROLL_UP.hideAfterIdleMs * 2)
|
||||
expect(result.current.showScrollToTop).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the timer and detaches listeners when the scroll element goes away', () => {
|
||||
const element = createViewportElement()
|
||||
const { result, rerender } = renderHook(
|
||||
({ scrollElement }: { scrollElement: HTMLElement | null }) =>
|
||||
useWorktreeListScrollToTop({ scrollElement }),
|
||||
{ initialProps: { scrollElement: element as HTMLElement | null } }
|
||||
)
|
||||
|
||||
dispatchHardWheelUp(element)
|
||||
expect(result.current.showScrollToTop).toBe(true)
|
||||
|
||||
rerender({ scrollElement: null })
|
||||
expect(result.current.showScrollToTop).toBe(false)
|
||||
|
||||
dispatchHardWheelUp(element)
|
||||
advance(HARD_SCROLL_UP.hideAfterIdleMs * 2)
|
||||
expect(result.current.showScrollToTop).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useWorktreeListScrollToTop } from './use-worktree-list-scroll-to-top'
|
||||
|
||||
function createScroller(): HTMLElement {
|
||||
const element = document.createElement('div')
|
||||
element.tabIndex = 0
|
||||
document.body.append(element)
|
||||
Object.defineProperties(element, {
|
||||
scrollHeight: { configurable: true, value: 5000 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
// 16px narrower than offsetWidth so the scrollbar hit-test has a realistic gutter.
|
||||
clientWidth: { configurable: true, value: 284 },
|
||||
offsetWidth: { configurable: true, value: 300 }
|
||||
})
|
||||
element.getBoundingClientRect = () => ({
|
||||
bottom: 500,
|
||||
height: 500,
|
||||
left: 0,
|
||||
right: 300,
|
||||
top: 0,
|
||||
width: 300,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
})
|
||||
return element
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('useWorktreeListScrollToTop', () => {
|
||||
it('ignores fast programmatic scrolling', () => {
|
||||
const element = createScroller()
|
||||
let now = 0
|
||||
vi.spyOn(window.performance, 'now').mockImplementation(() => now)
|
||||
const view = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
|
||||
|
||||
for (const sample of [
|
||||
{ t: 0, scrollTop: 2200 },
|
||||
{ t: 80, scrollTop: 2000 },
|
||||
{ t: 170, scrollTop: 1700 }
|
||||
]) {
|
||||
now = sample.t
|
||||
element.scrollTop = sample.scrollTop
|
||||
act(() => element.dispatchEvent(new Event('scroll')))
|
||||
}
|
||||
|
||||
expect(view.result.current.showScrollToTop).toBe(false)
|
||||
})
|
||||
|
||||
it('detects velocity while the scrollbar is actively dragged', () => {
|
||||
const element = createScroller()
|
||||
let now = 0
|
||||
vi.spyOn(window.performance, 'now').mockImplementation(() => now)
|
||||
const view = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
|
||||
|
||||
act(() =>
|
||||
element.dispatchEvent(new PointerEvent('pointerdown', { clientX: 295, pointerType: 'mouse' }))
|
||||
)
|
||||
for (const sample of [
|
||||
{ t: 0, scrollTop: 2200 },
|
||||
{ t: 80, scrollTop: 2000 },
|
||||
{ t: 170, scrollTop: 1700 }
|
||||
]) {
|
||||
now = sample.t
|
||||
element.scrollTop = sample.scrollTop
|
||||
act(() => element.dispatchEvent(new Event('scroll')))
|
||||
}
|
||||
|
||||
expect(view.result.current.showScrollToTop).toBe(true)
|
||||
})
|
||||
|
||||
it('returns focus to the list after jumping to the top', () => {
|
||||
const element = createScroller()
|
||||
element.scrollTop = 1200
|
||||
element.scrollTo = vi.fn(({ top }) => {
|
||||
element.scrollTop = Number(top)
|
||||
})
|
||||
const view = renderHook(() => useWorktreeListScrollToTop({ scrollElement: element }))
|
||||
|
||||
act(() => {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
element.dispatchEvent(new WheelEvent('wheel', { deltaY: -200 }))
|
||||
}
|
||||
})
|
||||
const button = document.createElement('button')
|
||||
document.body.append(button)
|
||||
button.focus()
|
||||
|
||||
act(() => view.result.current.scrollToTop())
|
||||
|
||||
expect(element.scrollTop).toBe(0)
|
||||
expect(document.activeElement).toBe(element)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
createHardScrollUpDetectorState,
|
||||
HARD_SCROLL_UP,
|
||||
reduceHardScrollUpOnDismiss,
|
||||
reduceHardScrollUpOnIdle,
|
||||
reduceHardScrollUpOnScroll,
|
||||
reduceHardScrollUpOnWheel,
|
||||
type HardScrollUpDetectorState
|
||||
} from './worktree-list-hard-scroll-up'
|
||||
|
||||
function readViewport(element: HTMLElement): { scrollTop: number; maxScroll: number } {
|
||||
const maxScroll = Math.max(0, element.scrollHeight - element.clientHeight)
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
maxScroll
|
||||
}
|
||||
}
|
||||
|
||||
function shouldForceHide(viewport: { scrollTop: number; maxScroll: number }): boolean {
|
||||
return (
|
||||
viewport.scrollTop <= HARD_SCROLL_UP.nearTopPx ||
|
||||
viewport.maxScroll < HARD_SCROLL_UP.minScrollablePx
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers a jump-to-top affordance when the user is hard-scrolling up a long
|
||||
* worktree list (common with smart/agent-activity ranking).
|
||||
*
|
||||
* Visibility is deadline-based from `lastIntentAt` so scroll noise cannot keep
|
||||
* the button stuck forever.
|
||||
*/
|
||||
export function useWorktreeListScrollToTop({
|
||||
scrollElement,
|
||||
onUserScrollIntent
|
||||
}: {
|
||||
scrollElement: HTMLElement | null
|
||||
/** Called when the user clicks jump-to-top so virtualizer scroll guards engage. */
|
||||
onUserScrollIntent?: () => void
|
||||
}): {
|
||||
showScrollToTop: boolean
|
||||
scrollToTop: () => void
|
||||
} {
|
||||
const detectorRef = useRef<HardScrollUpDetectorState>(createHardScrollUpDetectorState())
|
||||
const [showScrollToTop, setShowScrollToTop] = useState(false)
|
||||
const showScrollToTopRef = useRef(false)
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
const scrollbarDragRef = useRef(false)
|
||||
const touchScrollRef = useRef(false)
|
||||
// Why: jump-to-top can still emit a burst of scroll events; ignore them briefly so the button does not reappear.
|
||||
const suppressDetectionUntilRef = useRef(0)
|
||||
|
||||
const publishVisible = useCallback((next: HardScrollUpDetectorState) => {
|
||||
detectorRef.current = next
|
||||
if (showScrollToTopRef.current !== next.visible) {
|
||||
showScrollToTopRef.current = next.visible
|
||||
setShowScrollToTop(next.visible)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearIdleTimer = useCallback(() => {
|
||||
if (idleTimerRef.current !== null) {
|
||||
window.clearTimeout(idleTimerRef.current)
|
||||
idleTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Arm once per intent timestamp; do not reset on every scroll tick (that stuck the button).
|
||||
const armIdleHide = useCallback(
|
||||
(element: HTMLElement, lastIntentAt: number) => {
|
||||
clearIdleTimer()
|
||||
const fireAt = lastIntentAt + HARD_SCROLL_UP.hideAfterIdleMs
|
||||
const delayMs = Math.max(0, fireAt - window.performance.now())
|
||||
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
idleTimerRef.current = null
|
||||
const now = window.performance.now()
|
||||
|
||||
// Jump-to-top already dismissed; if a stale timer lands in the suppress window, force-hide.
|
||||
if (now < suppressDetectionUntilRef.current) {
|
||||
publishVisible(createHardScrollUpDetectorState())
|
||||
return
|
||||
}
|
||||
|
||||
const viewport = readViewport(element)
|
||||
const next = reduceHardScrollUpOnIdle(detectorRef.current, {
|
||||
...viewport,
|
||||
t: now
|
||||
})
|
||||
// Belt-and-suspenders: if still visible past the deadline, force dismiss.
|
||||
if (next.visible && now - next.lastIntentAt >= HARD_SCROLL_UP.hideAfterIdleMs) {
|
||||
publishVisible(createHardScrollUpDetectorState())
|
||||
return
|
||||
}
|
||||
publishVisible(next)
|
||||
}, delayMs)
|
||||
},
|
||||
[clearIdleTimer, publishVisible]
|
||||
)
|
||||
|
||||
const applyDetectorResult = useCallback(
|
||||
(
|
||||
element: HTMLElement,
|
||||
previous: HardScrollUpDetectorState,
|
||||
next: HardScrollUpDetectorState
|
||||
) => {
|
||||
publishVisible(next)
|
||||
if (!next.visible) {
|
||||
clearIdleTimer()
|
||||
return
|
||||
}
|
||||
// Only re-arm when intent is refreshed; scroll spam must not extend lifetime.
|
||||
if (next.lastIntentAt !== previous.lastIntentAt) {
|
||||
armIdleHide(element, next.lastIntentAt)
|
||||
}
|
||||
},
|
||||
[armIdleHide, clearIdleTimer, publishVisible]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollElement) {
|
||||
clearIdleTimer()
|
||||
publishVisible(createHardScrollUpDetectorState())
|
||||
return
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
const now = window.performance.now()
|
||||
const viewport = readViewport(scrollElement)
|
||||
|
||||
// Always allow force-hide even while jump-to-top is suppressing detection.
|
||||
if (shouldForceHide(viewport)) {
|
||||
publishVisible(createHardScrollUpDetectorState())
|
||||
clearIdleTimer()
|
||||
return
|
||||
}
|
||||
|
||||
if (now < suppressDetectionUntilRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = detectorRef.current
|
||||
const next = reduceHardScrollUpOnWheel(previous, {
|
||||
...viewport,
|
||||
t: now,
|
||||
deltaY: event.deltaY,
|
||||
deltaMode: event.deltaMode
|
||||
})
|
||||
applyDetectorResult(scrollElement, previous, next)
|
||||
}
|
||||
|
||||
const onScroll = (): void => {
|
||||
const now = window.performance.now()
|
||||
const viewport = readViewport(scrollElement)
|
||||
|
||||
if (shouldForceHide(viewport)) {
|
||||
publishVisible(createHardScrollUpDetectorState())
|
||||
clearIdleTimer()
|
||||
return
|
||||
}
|
||||
|
||||
if (now < suppressDetectionUntilRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Scroll events do not expose their origin; only velocity-detect gestures we observed directly.
|
||||
if (!scrollbarDragRef.current && !touchScrollRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = detectorRef.current
|
||||
const next = reduceHardScrollUpOnScroll(previous, {
|
||||
...viewport,
|
||||
t: now
|
||||
})
|
||||
applyDetectorResult(scrollElement, previous, next)
|
||||
}
|
||||
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
if (event.pointerType === 'touch') {
|
||||
touchScrollRef.current = true
|
||||
return
|
||||
}
|
||||
const rect = scrollElement.getBoundingClientRect()
|
||||
const nativeScrollbarWidth = scrollElement.offsetWidth - scrollElement.clientWidth
|
||||
const scrollbarHitWidth = Math.max(12, nativeScrollbarWidth)
|
||||
scrollbarDragRef.current =
|
||||
event.target === scrollElement && event.clientX >= rect.right - scrollbarHitWidth
|
||||
}
|
||||
|
||||
const onPointerEnd = (): void => {
|
||||
scrollbarDragRef.current = false
|
||||
touchScrollRef.current = false
|
||||
}
|
||||
|
||||
scrollElement.addEventListener('wheel', onWheel, { passive: true })
|
||||
scrollElement.addEventListener('scroll', onScroll, { passive: true })
|
||||
scrollElement.addEventListener('pointerdown', onPointerDown, { passive: true })
|
||||
window.addEventListener('pointerup', onPointerEnd, { passive: true })
|
||||
window.addEventListener('pointercancel', onPointerEnd, { passive: true })
|
||||
return () => {
|
||||
scrollElement.removeEventListener('wheel', onWheel)
|
||||
scrollElement.removeEventListener('scroll', onScroll)
|
||||
scrollElement.removeEventListener('pointerdown', onPointerDown)
|
||||
window.removeEventListener('pointerup', onPointerEnd)
|
||||
window.removeEventListener('pointercancel', onPointerEnd)
|
||||
onPointerEnd()
|
||||
clearIdleTimer()
|
||||
}
|
||||
}, [applyDetectorResult, clearIdleTimer, publishVisible, scrollElement])
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
if (!scrollElement) {
|
||||
return
|
||||
}
|
||||
onUserScrollIntent?.()
|
||||
publishVisible(reduceHardScrollUpOnDismiss(detectorRef.current))
|
||||
clearIdleTimer()
|
||||
suppressDetectionUntilRef.current =
|
||||
window.performance.now() + HARD_SCROLL_UP.suppressAfterJumpMs
|
||||
scrollElement.scrollTo({ top: 0, behavior: 'auto' })
|
||||
scrollElement.focus({ preventScroll: true })
|
||||
}, [clearIdleTimer, onUserScrollIntent, publishVisible, scrollElement])
|
||||
|
||||
return {
|
||||
showScrollToTop,
|
||||
scrollToTop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createHardScrollUpDetectorState,
|
||||
HARD_SCROLL_UP,
|
||||
normalizeWheelDeltaY,
|
||||
reduceHardScrollUpOnDismiss,
|
||||
reduceHardScrollUpOnIdle,
|
||||
reduceHardScrollUpOnScroll,
|
||||
reduceHardScrollUpOnWheel
|
||||
} from './worktree-list-hard-scroll-up'
|
||||
|
||||
const DEEP = {
|
||||
scrollTop: 1200,
|
||||
maxScroll: 4000
|
||||
} as const
|
||||
|
||||
const SHORT_LIST = {
|
||||
scrollTop: 200,
|
||||
maxScroll: 200
|
||||
} as const
|
||||
|
||||
describe('normalizeWheelDeltaY', () => {
|
||||
it('keeps pixel mode and expands line/page modes', () => {
|
||||
expect(normalizeWheelDeltaY(-40, 0)).toBe(-40)
|
||||
expect(normalizeWheelDeltaY(-3, 1)).toBe(-48)
|
||||
expect(normalizeWheelDeltaY(-1, 2)).toBe(-600)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reduceHardScrollUpOnWheel', () => {
|
||||
it('stays hidden for short lists and near-top viewports', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...SHORT_LIST,
|
||||
t: i * 16,
|
||||
deltaY: -120
|
||||
})
|
||||
}
|
||||
expect(state.visible).toBe(false)
|
||||
|
||||
state = createHardScrollUpDetectorState()
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
scrollTop: 20,
|
||||
maxScroll: 4000,
|
||||
t: i * 16,
|
||||
deltaY: -120
|
||||
})
|
||||
}
|
||||
expect(state.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show on gentle upward scrolling', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
// Small trackpad ticks — effort exists but is not "hard".
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
t: i * 40,
|
||||
deltaY: -18
|
||||
})
|
||||
}
|
||||
expect(state.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('shows after a sustained hard upward wheel burst', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
// ~5 hard ticks in < window: 5 * 160 = 800 >= hardTotalDeltaPx
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
scrollTop: DEEP.scrollTop - i * 40,
|
||||
t: 1000 + i * 40,
|
||||
deltaY: -160
|
||||
})
|
||||
}
|
||||
expect(state.visible).toBe(true)
|
||||
expect(state.lastIntentAt).toBe(1000 + 4 * 40)
|
||||
})
|
||||
|
||||
it('shows after a trackpad fling (high peak + enough total)', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
const deltas = [-40, -120, -200, -80]
|
||||
deltas.forEach((deltaY, i) => {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
t: 2000 + i * 30,
|
||||
deltaY
|
||||
})
|
||||
})
|
||||
expect(state.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('hides on significant downward scroll', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
t: i * 40,
|
||||
deltaY: -160
|
||||
})
|
||||
}
|
||||
expect(state.visible).toBe(true)
|
||||
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
t: 500,
|
||||
deltaY: 80
|
||||
})
|
||||
expect(state.visible).toBe(false)
|
||||
expect(state.wheelSamples).toEqual([])
|
||||
expect(state.scrollSamples).toEqual([])
|
||||
})
|
||||
|
||||
it('hides after cumulative small downward wheel events', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
t: i * 40,
|
||||
deltaY: -160
|
||||
})
|
||||
}
|
||||
|
||||
state = reduceHardScrollUpOnWheel(state, { ...DEEP, t: 220, deltaY: 20 })
|
||||
state = reduceHardScrollUpOnWheel(state, { ...DEEP, t: 260, deltaY: 20 })
|
||||
expect(state.visible).toBe(true)
|
||||
expect(state.lastIntentAt).toBe(160)
|
||||
|
||||
state = reduceHardScrollUpOnWheel(state, { ...DEEP, t: 300, deltaY: 20 })
|
||||
expect(state).toEqual(createHardScrollUpDetectorState())
|
||||
})
|
||||
|
||||
it('clears when the user reaches the top', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
t: i * 40,
|
||||
deltaY: -160
|
||||
})
|
||||
}
|
||||
expect(state.visible).toBe(true)
|
||||
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
scrollTop: 10,
|
||||
maxScroll: 4000,
|
||||
t: 400,
|
||||
deltaY: -20
|
||||
})
|
||||
expect(state.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reduceHardScrollUpOnScroll', () => {
|
||||
it('shows when scrollbar drag velocity is hard upward', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
// Drop 500px in 250ms => 2000 px/s
|
||||
state = reduceHardScrollUpOnScroll(state, {
|
||||
scrollTop: 1500,
|
||||
maxScroll: 4000,
|
||||
t: 0
|
||||
})
|
||||
state = reduceHardScrollUpOnScroll(state, {
|
||||
scrollTop: 1000,
|
||||
maxScroll: 4000,
|
||||
t: HARD_SCROLL_UP.velocitySustainMs + 90
|
||||
})
|
||||
expect(state.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores slow scrollbar movement', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
state = reduceHardScrollUpOnScroll(state, {
|
||||
scrollTop: 1500,
|
||||
maxScroll: 4000,
|
||||
t: 0
|
||||
})
|
||||
state = reduceHardScrollUpOnScroll(state, {
|
||||
scrollTop: 1450,
|
||||
maxScroll: 4000,
|
||||
t: 300
|
||||
})
|
||||
expect(state.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('hides after cumulative small downward scrollbar movement', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1500, t: 0 })
|
||||
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1000, t: 250 })
|
||||
expect(state.visible).toBe(true)
|
||||
|
||||
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1020, t: 280 })
|
||||
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1040, t: 310 })
|
||||
expect(state.visible).toBe(true)
|
||||
expect(state.lastIntentAt).toBe(250)
|
||||
|
||||
state = reduceHardScrollUpOnScroll(state, { ...DEEP, scrollTop: 1060, t: 340 })
|
||||
expect(state).toEqual(createHardScrollUpDetectorState())
|
||||
})
|
||||
})
|
||||
|
||||
describe('reduceHardScrollUpOnIdle / dismiss', () => {
|
||||
it('auto-hides after idle while still deep', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
t: i * 40,
|
||||
deltaY: -160
|
||||
})
|
||||
}
|
||||
expect(state.visible).toBe(true)
|
||||
|
||||
state = reduceHardScrollUpOnIdle(state, {
|
||||
...DEEP,
|
||||
t: state.lastIntentAt + HARD_SCROLL_UP.hideAfterIdleMs - 1
|
||||
})
|
||||
expect(state.visible).toBe(true)
|
||||
|
||||
state = reduceHardScrollUpOnIdle(state, {
|
||||
...DEEP,
|
||||
t: state.lastIntentAt + HARD_SCROLL_UP.hideAfterIdleMs
|
||||
})
|
||||
expect(state.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('hides on later non-intent scroll after the idle deadline (scroll spam must not extend)', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
t: 1000 + i * 40,
|
||||
deltaY: -160
|
||||
})
|
||||
}
|
||||
const intentAt = state.lastIntentAt
|
||||
expect(state.visible).toBe(true)
|
||||
|
||||
// Tiny non-intent scrolls after the deadline must still clear visibility.
|
||||
state = reduceHardScrollUpOnScroll(state, {
|
||||
scrollTop: DEEP.scrollTop - 2,
|
||||
maxScroll: DEEP.maxScroll,
|
||||
t: intentAt + HARD_SCROLL_UP.hideAfterIdleMs + 10
|
||||
})
|
||||
expect(state.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('dismiss resets state', () => {
|
||||
let state = createHardScrollUpDetectorState()
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
state = reduceHardScrollUpOnWheel(state, {
|
||||
...DEEP,
|
||||
t: i * 40,
|
||||
deltaY: -160
|
||||
})
|
||||
}
|
||||
state = reduceHardScrollUpOnDismiss(state)
|
||||
expect(state).toEqual(createHardScrollUpDetectorState())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Detects intentional "hard scroll up" on a long worktree list so we can offer
|
||||
* a jump-to-top control. Tuned for trackpad wheel streams and scrollbar drags.
|
||||
*/
|
||||
|
||||
export const HARD_SCROLL_UP = {
|
||||
/** Only samples inside this window contribute to intent. */
|
||||
windowMs: 480,
|
||||
/** List must be scrollable by at least this much. */
|
||||
minScrollablePx: 480,
|
||||
/** Must be at least this far from the top before the button can appear. */
|
||||
minDepthPx: 280,
|
||||
/** Hide once the viewport is this close to the top. */
|
||||
nearTopPx: 56,
|
||||
/** Minimum upward wheel samples in the window. */
|
||||
minUpEvents: 3,
|
||||
/** Accumulated |deltaY| (px) for a sustained hard-up gesture. */
|
||||
hardTotalDeltaPx: 720,
|
||||
/** Peak single-sample |deltaY| that marks a fling/burst. */
|
||||
burstPeakDeltaPx: 90,
|
||||
/** Accumulated |deltaY| paired with a burst peak. */
|
||||
burstTotalDeltaPx: 320,
|
||||
/** Scroll-position velocity (px/s upward) that counts as hard drag/fling. */
|
||||
hardVelocityPxPerSec: 1600,
|
||||
/** How long hard velocity must be sustained. */
|
||||
velocitySustainMs: 160,
|
||||
/** Clear intent after idle so the button does not linger forever. */
|
||||
hideAfterIdleMs: 2600,
|
||||
/** Significant down-scroll cancels upward intent. */
|
||||
significantDownDeltaPx: 48,
|
||||
/** Cap stored samples so long sessions stay cheap. */
|
||||
maxSamples: 32,
|
||||
/** Ignore scroll input this long after a programmatic jump to top. */
|
||||
suppressAfterJumpMs: 120
|
||||
} as const
|
||||
|
||||
export type HardScrollUpWheelSample = {
|
||||
t: number
|
||||
/** Positive = toward top (up). Normalized to CSS pixels. */
|
||||
upDeltaPx: number
|
||||
}
|
||||
|
||||
export type HardScrollUpScrollSample = {
|
||||
t: number
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
export type HardScrollUpDetectorState = {
|
||||
// Why: wheel + scroll both fire for the same gesture; keep them separate so
|
||||
// wheel magnitude is not double-counted with scrollTop deltas.
|
||||
wheelSamples: HardScrollUpWheelSample[]
|
||||
scrollSamples: HardScrollUpScrollSample[]
|
||||
wheelDownDeltaPx: number
|
||||
scrollDownDeltaPx: number
|
||||
visible: boolean
|
||||
lastIntentAt: number
|
||||
}
|
||||
|
||||
export type HardScrollUpViewport = {
|
||||
scrollTop: number
|
||||
maxScroll: number
|
||||
t: number
|
||||
}
|
||||
|
||||
export function createHardScrollUpDetectorState(): HardScrollUpDetectorState {
|
||||
return {
|
||||
wheelSamples: [],
|
||||
scrollSamples: [],
|
||||
wheelDownDeltaPx: 0,
|
||||
scrollDownDeltaPx: 0,
|
||||
visible: false,
|
||||
lastIntentAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeWheelDeltaY(deltaY: number, deltaMode: number): number {
|
||||
// WheelEvent.DOM_DELTA_LINE / DOM_DELTA_PAGE — convert to approximate CSS px.
|
||||
if (deltaMode === 1) {
|
||||
return deltaY * 16
|
||||
}
|
||||
if (deltaMode === 2) {
|
||||
return deltaY * 600
|
||||
}
|
||||
return deltaY
|
||||
}
|
||||
|
||||
function pruneByTime<T extends { t: number }>(samples: readonly T[], t: number): T[] {
|
||||
const cutoff = t - HARD_SCROLL_UP.windowMs
|
||||
const pruned = samples.filter((sample) => sample.t >= cutoff)
|
||||
if (pruned.length <= HARD_SCROLL_UP.maxSamples) {
|
||||
return pruned
|
||||
}
|
||||
return pruned.slice(pruned.length - HARD_SCROLL_UP.maxSamples)
|
||||
}
|
||||
|
||||
function isListLongEnough(maxScroll: number): boolean {
|
||||
return maxScroll >= HARD_SCROLL_UP.minScrollablePx
|
||||
}
|
||||
|
||||
function isDeepEnough(scrollTop: number): boolean {
|
||||
return scrollTop >= HARD_SCROLL_UP.minDepthPx
|
||||
}
|
||||
|
||||
function isNearTop(scrollTop: number): boolean {
|
||||
return scrollTop <= HARD_SCROLL_UP.nearTopPx
|
||||
}
|
||||
|
||||
function computeWheelIntent(samples: readonly HardScrollUpWheelSample[]): boolean {
|
||||
const upSamples = samples.filter((sample) => sample.upDeltaPx > 0)
|
||||
if (upSamples.length < HARD_SCROLL_UP.minUpEvents) {
|
||||
return false
|
||||
}
|
||||
|
||||
let totalUp = 0
|
||||
let peakUp = 0
|
||||
for (const sample of upSamples) {
|
||||
totalUp += sample.upDeltaPx
|
||||
if (sample.upDeltaPx > peakUp) {
|
||||
peakUp = sample.upDeltaPx
|
||||
}
|
||||
}
|
||||
|
||||
if (totalUp >= HARD_SCROLL_UP.hardTotalDeltaPx) {
|
||||
return true
|
||||
}
|
||||
|
||||
return peakUp >= HARD_SCROLL_UP.burstPeakDeltaPx && totalUp >= HARD_SCROLL_UP.burstTotalDeltaPx
|
||||
}
|
||||
|
||||
function computeVelocityIntent(samples: readonly HardScrollUpScrollSample[], t: number): boolean {
|
||||
if (samples.length < 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
const newest = samples.at(-1)
|
||||
if (!newest || t - newest.t > HARD_SCROLL_UP.velocitySustainMs) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Walk backward to the oldest sample still inside the window, then measure
|
||||
// average upward velocity across that span.
|
||||
let oldest = newest
|
||||
for (let i = samples.length - 2; i >= 0; i -= 1) {
|
||||
const sample = samples.at(i)
|
||||
if (!sample) {
|
||||
continue
|
||||
}
|
||||
if (newest.t - sample.t > HARD_SCROLL_UP.windowMs) {
|
||||
break
|
||||
}
|
||||
oldest = sample
|
||||
}
|
||||
|
||||
const elapsedMs = newest.t - oldest.t
|
||||
if (elapsedMs < HARD_SCROLL_UP.velocitySustainMs) {
|
||||
return false
|
||||
}
|
||||
|
||||
const upwardPx = oldest.scrollTop - newest.scrollTop
|
||||
if (upwardPx <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const velocity = (upwardPx / elapsedMs) * 1000
|
||||
return velocity >= HARD_SCROLL_UP.hardVelocityPxPerSec
|
||||
}
|
||||
|
||||
function withVisibility(
|
||||
state: HardScrollUpDetectorState,
|
||||
{ t, scrollTop, maxScroll, intent }: HardScrollUpViewport & { intent: boolean }
|
||||
): HardScrollUpDetectorState {
|
||||
if (!isListLongEnough(maxScroll) || isNearTop(scrollTop)) {
|
||||
if (
|
||||
!state.visible &&
|
||||
state.wheelSamples.length === 0 &&
|
||||
state.scrollSamples.length === 0 &&
|
||||
state.wheelDownDeltaPx === 0 &&
|
||||
state.scrollDownDeltaPx === 0 &&
|
||||
state.lastIntentAt === 0
|
||||
) {
|
||||
return state
|
||||
}
|
||||
return createHardScrollUpDetectorState()
|
||||
}
|
||||
|
||||
if (intent && isDeepEnough(scrollTop)) {
|
||||
return {
|
||||
...state,
|
||||
visible: true,
|
||||
lastIntentAt: t
|
||||
}
|
||||
}
|
||||
|
||||
// Why: hide on the deadline itself even if only non-intent scroll noise arrives;
|
||||
// the hook also arms a timer, but event-driven hide must not depend on timer re-arms.
|
||||
if (state.visible && t - state.lastIntentAt >= HARD_SCROLL_UP.hideAfterIdleMs) {
|
||||
return createHardScrollUpDetectorState()
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest a wheel event. `deltaY` follows the browser convention (negative = up).
|
||||
*/
|
||||
export function reduceHardScrollUpOnWheel(
|
||||
state: HardScrollUpDetectorState,
|
||||
{
|
||||
deltaY,
|
||||
deltaMode = 0,
|
||||
scrollTop,
|
||||
maxScroll,
|
||||
t
|
||||
}: HardScrollUpViewport & { deltaY: number; deltaMode?: number }
|
||||
): HardScrollUpDetectorState {
|
||||
if (!isListLongEnough(maxScroll)) {
|
||||
return createHardScrollUpDetectorState()
|
||||
}
|
||||
|
||||
if (isNearTop(scrollTop)) {
|
||||
return withVisibility(state, { t, scrollTop, maxScroll, intent: false })
|
||||
}
|
||||
|
||||
const pixelDeltaY = normalizeWheelDeltaY(deltaY, deltaMode)
|
||||
// Browser: negative deltaY = scroll up (content moves down, viewport toward top).
|
||||
const upDeltaPx = -pixelDeltaY
|
||||
const wheelDownDeltaPx =
|
||||
upDeltaPx < 0
|
||||
? state.wheelDownDeltaPx + Math.abs(upDeltaPx)
|
||||
: upDeltaPx > 0
|
||||
? 0
|
||||
: state.wheelDownDeltaPx
|
||||
|
||||
if (wheelDownDeltaPx >= HARD_SCROLL_UP.significantDownDeltaPx) {
|
||||
return createHardScrollUpDetectorState()
|
||||
}
|
||||
|
||||
const nextWheelSamples = pruneByTime(
|
||||
upDeltaPx > 0 ? [...state.wheelSamples, { t, upDeltaPx }] : [],
|
||||
t
|
||||
)
|
||||
const nextScrollSamples = pruneByTime(state.scrollSamples, t)
|
||||
|
||||
const intent = upDeltaPx > 0 && computeWheelIntent(nextWheelSamples)
|
||||
return withVisibility(
|
||||
{
|
||||
...state,
|
||||
wheelSamples: nextWheelSamples,
|
||||
scrollSamples: nextScrollSamples,
|
||||
wheelDownDeltaPx
|
||||
},
|
||||
{ t, scrollTop, maxScroll, intent }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingest a scroll position sample from an active scrollbar/touch gesture.
|
||||
* Velocity-based; ignores tiny no-op scrolls.
|
||||
*/
|
||||
export function reduceHardScrollUpOnScroll(
|
||||
state: HardScrollUpDetectorState,
|
||||
{ scrollTop, maxScroll, t }: HardScrollUpViewport
|
||||
): HardScrollUpDetectorState {
|
||||
if (!isListLongEnough(maxScroll)) {
|
||||
return createHardScrollUpDetectorState()
|
||||
}
|
||||
|
||||
if (isNearTop(scrollTop)) {
|
||||
return withVisibility(state, { t, scrollTop, maxScroll, intent: false })
|
||||
}
|
||||
|
||||
const last = state.scrollSamples.at(-1)
|
||||
const downDeltaPx = last ? Math.max(0, scrollTop - last.scrollTop) : 0
|
||||
const upDeltaPx = last ? Math.max(0, last.scrollTop - scrollTop) : 0
|
||||
const scrollDownDeltaPx =
|
||||
downDeltaPx > 0
|
||||
? state.scrollDownDeltaPx + downDeltaPx
|
||||
: upDeltaPx > 0
|
||||
? 0
|
||||
: state.scrollDownDeltaPx
|
||||
|
||||
if (scrollDownDeltaPx >= HARD_SCROLL_UP.significantDownDeltaPx) {
|
||||
return createHardScrollUpDetectorState()
|
||||
}
|
||||
|
||||
// Ignore pure no-ops so a stalled scrollbar does not pad the window.
|
||||
if (last && last.scrollTop === scrollTop && t - last.t < 16) {
|
||||
return withVisibility(state, {
|
||||
t,
|
||||
scrollTop,
|
||||
maxScroll,
|
||||
intent: false
|
||||
})
|
||||
}
|
||||
|
||||
const nextScrollSamples = pruneByTime(
|
||||
[...(downDeltaPx > 0 ? [] : state.scrollSamples), { t, scrollTop }],
|
||||
t
|
||||
)
|
||||
const nextWheelSamples = pruneByTime(state.wheelSamples, t)
|
||||
|
||||
const intent = upDeltaPx > 0 && computeVelocityIntent(nextScrollSamples, t)
|
||||
return withVisibility(
|
||||
{
|
||||
...state,
|
||||
wheelSamples: nextWheelSamples,
|
||||
scrollSamples: nextScrollSamples,
|
||||
scrollDownDeltaPx
|
||||
},
|
||||
{ t, scrollTop, maxScroll, intent }
|
||||
)
|
||||
}
|
||||
|
||||
/** Idle tick so the button auto-hides without requiring more input. */
|
||||
export function reduceHardScrollUpOnIdle(
|
||||
state: HardScrollUpDetectorState,
|
||||
{ scrollTop, maxScroll, t }: HardScrollUpViewport
|
||||
): HardScrollUpDetectorState {
|
||||
if (!state.visible) {
|
||||
return state
|
||||
}
|
||||
return withVisibility(state, { t, scrollTop, maxScroll, intent: false })
|
||||
}
|
||||
|
||||
export function reduceHardScrollUpOnDismiss(
|
||||
state: HardScrollUpDetectorState
|
||||
): HardScrollUpDetectorState {
|
||||
if (
|
||||
!state.visible &&
|
||||
state.wheelSamples.length === 0 &&
|
||||
state.scrollSamples.length === 0 &&
|
||||
state.wheelDownDeltaPx === 0 &&
|
||||
state.scrollDownDeltaPx === 0
|
||||
) {
|
||||
return state
|
||||
}
|
||||
return createHardScrollUpDetectorState()
|
||||
}
|
||||
@@ -5386,6 +5386,9 @@
|
||||
"a0f9863597": "Force Delete {{count}} Branches",
|
||||
"a0f9863597_one": "Force Delete {{count}} Branch",
|
||||
"a0f9863597_other": "Force Delete {{count}} Branches"
|
||||
},
|
||||
"WorktreeListScrollToTopButton": {
|
||||
"jumpToTop": "Jump to top"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
Reference in New Issue
Block a user