Auto scroll for all types of drags (#10584)

- Partly addresses https://github.com/enso-org/cloud-v2/issues/1350
- Enable autoscroll for any type of drag (previously only enabled for selections)

# Important Notes
- So I implemented this ages ago, not sure why I never opened a PR... I guess it's possible that I just never got around to testing whether it worked properly
This commit is contained in:
somebody1234 2024-07-18 18:49:39 +10:00 committed by GitHub
parent 154d5c0516
commit 0f31fee5ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 151 additions and 35 deletions

View File

@ -0,0 +1,137 @@
/** @file Hooks for. */
import * as React from 'react'
// =================
// === Constants ===
// =================
/** See {@link AutoScrollOptions.threshold}. */
const AUTOSCROLL_THRESHOLD_PX = 50
/** See {@link AutoScrollOptions.speed}. */
const AUTOSCROLL_SPEED = 100
/** See {@link AutoScrollOptions.falloff}. */
const AUTOSCROLL_FALLOFF = 10
// ===========================
// === AutoScrollDirection ===
// ===========================
/** The direction(s) in which autoscroll should happen. */
export type AutoScrollDirection = 'both' | 'horizontal' | 'none' | 'vertical'
// =========================
// === AutoScrollOptions ===
// =========================
/** Options for {@link useAutoScroll}. */
export interface AutoScrollOptions {
readonly direction?: AutoScrollDirection
readonly insets?: AutoScrollInsets
/** If the drag pointer is less than this distance away from the top or bottom of the
* scroll container, then the scroll container automatically scrolls upwards if the cursor is near
* the top of the scroll container, or downwards if the cursor is near the bottom. */
readonly threshold?: number
/** An arbitrary constant that controls the speed of autoscroll. */
readonly speed?: number
/** The autoscroll speed is `speed / (distance + falloff)`. */
readonly falloff?: number
}
// =========================
// === AutoScrollOffsets ===
// =========================
/** The amount of space on which side on which scrolling should have no effect.
* The container is treated as this much smaller, meaning that autoscroll speed will be calculated
* as though the pointer is that much closer to an edge. */
interface AutoScrollInsets {
readonly top?: number
readonly bottom?: number
readonly left?: number
readonly right?: number
}
// =====================
// === useAutoScroll ===
// =====================
/** Scroll a container when the mouse is near the edges of a container. */
export function useAutoScroll(
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>,
options: AutoScrollOptions = {}
) {
const isScrolling = React.useRef(false)
const animationFrameHandle = React.useRef(0)
const pointerX = React.useRef(0)
const pointerY = React.useRef(0)
const optionsRef = React.useRef(options)
optionsRef.current = options
const onMouseEvent = React.useCallback((event: MouseEvent | React.MouseEvent) => {
pointerX.current = event.clientX
pointerY.current = event.clientY
}, [])
const onAnimationFrame = React.useCallback(() => {
const scrollContainer = scrollContainerRef.current
if (isScrolling.current && scrollContainer) {
const {
direction = 'vertical',
insets = {},
threshold = AUTOSCROLL_THRESHOLD_PX,
speed = AUTOSCROLL_SPEED,
falloff = AUTOSCROLL_FALLOFF,
} = optionsRef.current
const {
top: insetTop = 0,
bottom: insetBottom = 0,
left: insetLeft = 0,
right: insetRight = 0,
} = insets
const rect = scrollContainer.getBoundingClientRect()
if (direction === 'vertical' || direction === 'both') {
if (scrollContainer.scrollTop > 0) {
const distanceToTop = Math.max(0, pointerY.current - rect.top - insetTop)
if (distanceToTop < threshold) {
scrollContainer.scrollTop -= Math.floor(speed / (distanceToTop + falloff))
}
}
if (scrollContainer.scrollTop + rect.height < scrollContainer.scrollHeight) {
const distanceToBottom = Math.max(0, rect.bottom - pointerY.current - insetBottom)
if (distanceToBottom < threshold) {
scrollContainer.scrollTop += Math.floor(speed / (distanceToBottom + falloff))
}
}
}
if (direction === 'horizontal' || direction === 'both') {
if (scrollContainer.scrollLeft > 0) {
const distanceToLeft = Math.max(0, pointerX.current - rect.top - insetLeft)
if (distanceToLeft < threshold) {
scrollContainer.scrollLeft -= Math.floor(speed / (distanceToLeft + falloff))
}
}
if (scrollContainer.scrollLeft + rect.width < scrollContainer.scrollWidth) {
const distanceToRight = Math.max(0, rect.right - pointerX.current - insetRight)
if (distanceToRight < threshold) {
scrollContainer.scrollLeft += Math.floor(speed / (distanceToRight + falloff))
}
}
}
animationFrameHandle.current = requestAnimationFrame(onAnimationFrame)
}
}, [scrollContainerRef])
const startAutoScroll = React.useCallback(() => {
if (!isScrolling.current) {
isScrolling.current = true
animationFrameHandle.current = requestAnimationFrame(onAnimationFrame)
}
}, [onAnimationFrame])
const endAutoScroll = React.useCallback(() => {
isScrolling.current = false
window.cancelAnimationFrame(animationFrameHandle.current)
}, [])
return { startAutoScroll, endAutoScroll, onMouseEvent }
}

View File

@ -8,6 +8,7 @@ import DropFilesImage from '#/assets/drop_files.svg'
import * as mimeTypes from '#/data/mimeTypes'
import * as autoScrollHooks from '#/hooks/autoScrollHooks'
import * as backendHooks from '#/hooks/backendHooks'
import * as intersectionHooks from '#/hooks/intersectionHooks'
import * as projectHooks from '#/hooks/projectHooks'
@ -105,14 +106,6 @@ LocalStorage.registerKey('enabledColumns', {
/** If the ratio of intersection between the main dropzone that should be visible, and the
* scrollable container, is below this value, then the backup dropzone will be shown. */
const MINIMUM_DROPZONE_INTERSECTION_RATIO = 0.5
/** If the drag pointer is less than this distance away from the top or bottom of the
* scroll container, then the scroll container automatically scrolls upwards if the cursor is near
* the top of the scroll container, or downwards if the cursor is near the bottom. */
const AUTOSCROLL_THRESHOLD_PX = 50
/** An arbitrary constant that controls the speed of autoscroll. */
const AUTOSCROLL_SPEED = 100
/** The autoscroll speed is `AUTOSCROLL_SPEED / (distance + AUTOSCROLL_DAMPENING)`. */
const AUTOSCROLL_DAMPENING = 10
/** The height of each row in the table body. MUST be identical to the value as set by the
* Tailwind styling. */
const ROW_HEIGHT_PX = 38
@ -2109,10 +2102,14 @@ export default function AssetsTable(props: AssetsTableProps) {
const [visuallySelectedKeysOverride, setVisuallySelectedKeysOverride] =
React.useState<ReadonlySet<backendModule.AssetId> | null>(null)
const { startAutoScroll, endAutoScroll, onMouseEvent } = autoScrollHooks.useAutoScroll(rootRef)
const dragSelectionChangeLoopHandle = React.useRef(0)
const dragSelectionRangeRef = React.useRef<DragSelectionInfo | null>(null)
const onSelectionDrag = React.useCallback(
(rectangle: geometry.DetailedRectangle, event: MouseEvent) => {
startAutoScroll()
onMouseEvent(event)
if (mostRecentlySelectedIndexRef.current != null) {
setKeyboardSelectedIndex(null)
}
@ -2120,31 +2117,6 @@ export default function AssetsTable(props: AssetsTableProps) {
const scrollContainer = rootRef.current
if (scrollContainer != null) {
const rect = scrollContainer.getBoundingClientRect()
if (rectangle.signedHeight <= 0 && scrollContainer.scrollTop > 0) {
const distanceToTop = Math.max(0, rectangle.top - rect.top - ROW_HEIGHT_PX)
if (distanceToTop < AUTOSCROLL_THRESHOLD_PX) {
scrollContainer.scrollTop -= Math.floor(
AUTOSCROLL_SPEED / (distanceToTop + AUTOSCROLL_DAMPENING)
)
dragSelectionChangeLoopHandle.current = requestAnimationFrame(() => {
onSelectionDrag(rectangle, event)
})
}
}
if (
rectangle.signedHeight >= 0 &&
scrollContainer.scrollTop + rect.height < scrollContainer.scrollHeight
) {
const distanceToBottom = Math.max(0, rect.bottom - rectangle.bottom)
if (distanceToBottom < AUTOSCROLL_THRESHOLD_PX) {
scrollContainer.scrollTop += Math.floor(
AUTOSCROLL_SPEED / (distanceToBottom + AUTOSCROLL_DAMPENING)
)
dragSelectionChangeLoopHandle.current = requestAnimationFrame(() => {
onSelectionDrag(rectangle, event)
})
}
}
const overlapsHorizontally = rect.right > rectangle.left && rect.left < rectangle.right
const selectionTop = Math.max(0, rectangle.top - rect.top - ROW_HEIGHT_PX)
const selectionBottom = Math.max(
@ -2180,11 +2152,13 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
},
[displayItems, calculateNewKeys]
[startAutoScroll, onMouseEvent, displayItems, calculateNewKeys]
)
const onSelectionDragEnd = React.useCallback(
(event: MouseEvent) => {
endAutoScroll()
onMouseEvent(event)
const range = dragSelectionRangeRef.current
if (range != null) {
const keys = displayItems.slice(range.start, range.end).map(node => node.key)
@ -2193,7 +2167,7 @@ export default function AssetsTable(props: AssetsTableProps) {
setVisuallySelectedKeysOverride(null)
dragSelectionRangeRef.current = null
},
[displayItems, calculateNewKeys, setSelectedKeys]
[endAutoScroll, onMouseEvent, displayItems, setSelectedKeys, calculateNewKeys]
)
const onSelectionDragCancel = React.useCallback(() => {
@ -2319,6 +2293,8 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}}
onDragStart={event => {
startAutoScroll()
onMouseEvent(event)
let newSelectedKeys = selectedKeysRef.current
if (!newSelectedKeys.has(key)) {
setMostRecentlySelectedIndex(visibleItems.indexOf(item))
@ -2370,6 +2346,7 @@ export default function AssetsTable(props: AssetsTableProps) {
)
}}
onDragOver={event => {
onMouseEvent(event)
const payload = drag.LABELS.lookup(event)
if (payload != null) {
event.preventDefault()
@ -2405,6 +2382,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}}
onDragEnd={() => {
endAutoScroll()
lastSelectedIdsRef.current = null
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
@ -2413,6 +2391,7 @@ export default function AssetsTable(props: AssetsTableProps) {
})
}}
onDrop={event => {
endAutoScroll()
const ids = new Set(selectedKeysRef.current.has(key) ? selectedKeysRef.current : [key])
const payload = drag.LABELS.lookup(event)
if (payload != null) {