diff --git a/app/dashboard/src/hooks/autoScrollHooks.ts b/app/dashboard/src/hooks/autoScrollHooks.ts new file mode 100644 index 00000000000..144c1fec6fd --- /dev/null +++ b/app/dashboard/src/hooks/autoScrollHooks.ts @@ -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, + 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 } +} diff --git a/app/dashboard/src/layouts/AssetsTable.tsx b/app/dashboard/src/layouts/AssetsTable.tsx index f21b1921e1f..60a81376177 100644 --- a/app/dashboard/src/layouts/AssetsTable.tsx +++ b/app/dashboard/src/layouts/AssetsTable.tsx @@ -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 | null>(null) + const { startAutoScroll, endAutoScroll, onMouseEvent } = autoScrollHooks.useAutoScroll(rootRef) + const dragSelectionChangeLoopHandle = React.useRef(0) const dragSelectionRangeRef = React.useRef(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) {