mirror of
https://github.com/enso-org/enso.git
synced 2025-01-03 13:09:56 +03:00
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:
parent
154d5c0516
commit
0f31fee5ef
137
app/dashboard/src/hooks/autoScrollHooks.ts
Normal file
137
app/dashboard/src/hooks/autoScrollHooks.ts
Normal 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 }
|
||||
}
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user