From 6aac8f29846555f5778e1ae7e5308af65c233362 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Wed, 25 Dec 2024 15:33:04 +0400 Subject: [PATCH 1/8] Reimplement selection --- app/common/src/services/Backend.ts | 1 + .../dashboard/components/SelectionBrush.tsx | 246 ++++++++++++++++++ .../components/dashboard/AssetRow.tsx | 15 +- .../src/dashboard/hooks/dragAndDropHooks.ts | 16 +- .../src/dashboard/hooks/eventCallbackHooks.ts | 6 +- .../src/dashboard/hooks/eventListenerHooks.ts | 165 ++++++++++++ app/gui/src/dashboard/hooks/useRaf.ts | 64 +++++ app/gui/src/dashboard/layouts/AssetsTable.tsx | 42 ++- .../src/dashboard/providers/DriveProvider.tsx | 2 + app/gui/src/dashboard/utilities/geometry.ts | 12 + 10 files changed, 550 insertions(+), 19 deletions(-) create mode 100644 app/gui/src/dashboard/hooks/eventListenerHooks.ts create mode 100644 app/gui/src/dashboard/hooks/useRaf.ts diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 118b795c5567..e7b11425db68 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -73,6 +73,7 @@ export const S3ObjectVersionId = newtype.newtypeConstructor() /** Unique identifier for an arbitrary asset. */ export type AssetId = IdType[keyof IdType] +export const AssetId = newtype.newtypeConstructor() /** Unique identifier for a payment checkout session. */ export type CheckoutSessionId = newtype.Newtype diff --git a/app/gui/src/dashboard/components/SelectionBrush.tsx b/app/gui/src/dashboard/components/SelectionBrush.tsx index 8caaf04f14da..2f6064fc026a 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -4,10 +4,13 @@ import * as React from 'react' import Portal from '#/components/Portal' import * as animationHooks from '#/hooks/animationHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { useEventListener } from '#/hooks/eventListenerHooks' import * as modalProvider from '#/providers/ModalProvider' import * as eventModule from '#/utilities/event' import type * as geometry from '#/utilities/geometry' import * as tailwindMerge from '#/utilities/tailwindMerge' +import { motion, useMotionValue } from 'framer-motion' +import { useRAFThrottle } from '../hooks/useRaf' // ================= // === Constants === @@ -18,6 +21,14 @@ import * as tailwindMerge from '#/utilities/tailwindMerge' * mouse is released and the selection brush collapses back to zero size. */ const ANIMATION_TIME_HORIZON = 60 +/** + * Defines the minimal distance that the mouse must move before + * we consider that user has started a selection. + */ +const DEAD_ZONE_SIZE = 12 + +// eslint-disable-next-line no-restricted-syntax +const noop = () => {} // ====================== // === SelectionBrush === @@ -36,7 +47,18 @@ export interface SelectionBrushProps { export default function SelectionBrush(props: SelectionBrushProps) { const { targetRef, margin = 0 } = props const { modalRef } = modalProvider.useModalRef() + + const initialMousePositionRef = React.useRef(null) + /** + * Whether the mouse is currently down. + */ const isMouseDownRef = React.useRef(false) + /** + * Whether the user is currently dragging the selection brush. + * Unlike the isMouseDown, has a dead zone + */ + const isDraggingRef = React.useRef(false) + const didMoveWhileDraggingRef = React.useRef(false) const onDrag = useEventCallback(props.onDrag) const onDragEnd = useEventCallback(props.onDragEnd) @@ -47,6 +69,7 @@ export default function SelectionBrush(props: SelectionBrushProps) { const [anchor, setAnchor] = React.useState(null) const [position, setPosition] = React.useState(null) const [lastSetAnchor, setLastSetAnchor] = React.useState(null) + const anchorAnimFactor = animationHooks.useApproach( anchor != null ? 1 : 0, ANIMATION_TIME_HORIZON, @@ -78,6 +101,8 @@ export default function SelectionBrush(props: SelectionBrushProps) { } } const onMouseDown = (event: MouseEvent) => { + initialMousePositionRef.current = { left: event.pageX, top: event.pageY } + if ( modalRef.current == null && !eventModule.isElementTextInput(event.target) && @@ -104,12 +129,14 @@ export default function SelectionBrush(props: SelectionBrushProps) { window.setTimeout(() => { isMouseDownRef.current = false didMoveWhileDraggingRef.current = false + initialMousePositionRef.current = null }) unsetAnchor() } const onMouseMove = (event: MouseEvent) => { if (!(event.buttons & 1)) { isMouseDownRef.current = false + initialMousePositionRef.current = null } if (isMouseDownRef.current) { // Left click is being held. @@ -140,6 +167,7 @@ export default function SelectionBrush(props: SelectionBrushProps) { const onDragStart = () => { if (isMouseDownRef.current) { isMouseDownRef.current = false + initialMousePositionRef.current = null onDragCancel() unsetAnchor() } @@ -168,6 +196,7 @@ export default function SelectionBrush(props: SelectionBrushProps) { top: position.top * (1 - anchorAnimFactor.value) + lastSetAnchor.top * anchorAnimFactor.value, } + return { left: Math.min(position.left, start.left), top: Math.min(position.top, start.top), @@ -212,3 +241,220 @@ export default function SelectionBrush(props: SelectionBrushProps) { ) } + +/** + * Parameters for the onDrag callback. + */ +export interface OnDragParams { + readonly diff: geometry.Coordinate2D + readonly start: geometry.Coordinate2D + readonly current: geometry.Coordinate2D + readonly rectangle: geometry.DetailedRectangle + readonly event: PointerEvent +} + +/** + * Props for a {@link SelectionBrushV2}. + */ +export interface SelectionBrushV2Props { + readonly onDragStart?: (event: PointerEvent) => void + readonly onDrag?: (params: OnDragParams) => void + readonly onDragEnd?: (event: PointerEvent) => void + readonly onDragCancel?: () => void + + readonly targetRef: React.RefObject + readonly isDisabled?: boolean + readonly preventDrag?: (event: PointerEvent) => boolean +} + +/** + * A selection brush to indicate the area being selected by the mouse drag action. + */ +export function SelectionBrushV2(props: SelectionBrushV2Props) { + const { + targetRef, + preventDrag = () => false, + onDragStart = noop, + onDrag = noop, + onDragEnd = noop, + onDragCancel = noop, + isDisabled = false, + } = props + + const [isDragging, setIsDragging] = React.useState(false) + + const hasPassedDeadZoneRef = React.useRef(false) + const startPositionRef = React.useRef(null) + const currentPositionRef = React.useRef(null) + + const left = useMotionValue(null) + const top = useMotionValue(null) + const width = useMotionValue(null) + const height = useMotionValue(null) + + const preventDragStableCallback = useEventCallback(preventDrag) + const onDragStartStableCallback = useEventCallback(onDragStart) + const onDragStableCallback = useEventCallback(onDrag) + const onDragEndStableCallback = useEventCallback(onDragEnd) + const onDragCancelStableCallback = useEventCallback(onDragCancel) + + const { scheduleRAF, cancelRAF } = useRAFThrottle() + + const startDragging = useEventCallback(() => { + setIsDragging(true) + hasPassedDeadZoneRef.current = true + }) + + const resetState = useEventCallback(() => { + hasPassedDeadZoneRef.current = false + startPositionRef.current = null + currentPositionRef.current = null + setIsDragging(false) + cancelRAF() + }) + + useEventListener( + 'pointerdown', + (event) => { + resetState() + + const shouldSkip = preventDragStableCallback(event) + + if (shouldSkip) { + return + } + + startPositionRef.current = { left: event.pageX, top: event.pageY } + currentPositionRef.current = { left: event.pageX, top: event.pageY } + + onDragStartStableCallback(event) + }, + targetRef, + { isDisabled, capture: true, passive: true }, + ) + + useEventListener( + 'pointermove', + (event) => { + scheduleRAF(() => { + if (startPositionRef.current == null) { + return + } + + currentPositionRef.current = { left: event.pageX, top: event.pageY } + + const rectangle: geometry.DetailedRectangle = { + left: Math.min(startPositionRef.current.left, currentPositionRef.current.left), + top: Math.min(startPositionRef.current.top, currentPositionRef.current.top), + right: Math.max(startPositionRef.current.left, currentPositionRef.current.left), + bottom: Math.max(startPositionRef.current.top, currentPositionRef.current.top), + width: Math.abs(startPositionRef.current.left - currentPositionRef.current.left), + height: Math.abs(startPositionRef.current.top - currentPositionRef.current.top), + signedWidth: currentPositionRef.current.left - startPositionRef.current.left, + signedHeight: currentPositionRef.current.top - startPositionRef.current.top, + } + + const diff: geometry.Coordinate2D = { + left: currentPositionRef.current.left - startPositionRef.current.left, + top: currentPositionRef.current.top - startPositionRef.current.top, + } + + if (hasPassedDeadZoneRef.current === false) { + hasPassedDeadZoneRef.current = !isInDeadZone( + startPositionRef.current, + currentPositionRef.current, + DEAD_ZONE_SIZE, + ) + } + + if (hasPassedDeadZoneRef.current) { + targetRef.current?.setPointerCapture(event.pointerId) + + startDragging() + + left.set(rectangle.left) + top.set(rectangle.top) + width.set(rectangle.width) + height.set(rectangle.height) + + onDragStableCallback({ + diff, + start: startPositionRef.current, + current: currentPositionRef.current, + rectangle, + event, + }) + } + }) + }, + document, + { isDisabled, capture: true, passive: true }, + ) + + useEventListener( + 'pointerup', + (event) => { + resetState() + + targetRef.current?.releasePointerCapture(event.pointerId) + + if (isDragging) { + onDragEndStableCallback(event) + } + }, + document, + { isDisabled, capture: true, passive: true }, + ) + + useEventListener( + 'pointercancel', + (event) => { + resetState() + + targetRef.current?.releasePointerCapture(event.pointerId) + + if (isDragging) { + onDragEndStableCallback(event) + onDragCancelStableCallback() + } + }, + document, + { isDisabled, capture: true, passive: true }, + ) + + return ( + + + + ) +} + +/** + * Whether the current position is in the dead zone. + * @param initialPosition - The initial position. + * @param currentPosition - The current position. + * @param deadZoneSize - The size of the dead zone. + * @returns Whether the current position is in the dead zone. + */ +function isInDeadZone( + initialPosition: geometry.Coordinate2D, + currentPosition: geometry.Coordinate2D, + deadZoneSize: number, +) { + const horizontalDistance = Math.abs(initialPosition.left - currentPosition.left) + const verticalDistance = Math.abs(initialPosition.top - currentPosition.top) + + return horizontalDistance < deadZoneSize && verticalDistance < deadZoneSize +} diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index 98e42717cf18..575184c105c6 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -298,7 +298,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { driveStore, ({ selectedKeys }) => selectedKeys.size === 0 || !selected || isSoleSelected, ) - const draggableProps = dragAndDropHooks.useDraggable() + const draggableProps = dragAndDropHooks.useDraggable({ isDisabled: !selected }) const { setModal, unsetModal } = modalProvider.useSetModal() const { getText } = textProvider.useText() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() @@ -372,6 +372,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { const setSelected = useEventCallback((newSelected: boolean) => { const { selectedKeys } = driveStore.getState() + setSelectedKeys(set.withPresence(selectedKeys, id, newSelected)) }) @@ -679,6 +680,8 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { { rootRef.current = element @@ -822,7 +825,11 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { {columns.map((column) => { const Render = columnModule.COLUMN_RENDERER[column] return ( - + )} - {selected && allowContextMenu && !hidden && ( + {/* {selected && allowContextMenu && !hidden && ( // This is a copy of the context menu, since the context menu registers keyboard // shortcut handlers. This is a bit of a hack, however it is preferable to duplicating // the entire context menu (once for the keyboard actions, once for the JSX). @@ -861,7 +868,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { doPaste={doPaste} doDelete={doDelete} /> - )} + )} */} ) } diff --git a/app/gui/src/dashboard/hooks/dragAndDropHooks.ts b/app/gui/src/dashboard/hooks/dragAndDropHooks.ts index c0e3e4613111..c35252d1de1d 100644 --- a/app/gui/src/dashboard/hooks/dragAndDropHooks.ts +++ b/app/gui/src/dashboard/hooks/dragAndDropHooks.ts @@ -3,6 +3,16 @@ import * as React from 'react' import * as eventModule from '#/utilities/event' +/** + * Parameters for the `useDraggable` hook. + */ +export interface UseDraggableOptions { + /** + * Whether the drag and drop functionality should be disabled. + */ + readonly isDisabled?: boolean +} + /** * Whether an element is actually draggable. This should be used on ALL * elements that are parents of text inputs. @@ -11,11 +21,13 @@ import * as eventModule from '#/utilities/event' * https://bugzilla.mozilla.org/show_bug.cgi?id=800050 * @returns An object that should be merged into the element's props. */ -export function useDraggable() { +export function useDraggable(params: UseDraggableOptions = {}) { + const { isDisabled = false } = params + const [isDraggable, setIsDraggable] = React.useState(true) return { - draggable: isDraggable, + draggable: isDisabled ? false : isDraggable, onFocus: (event) => { if (eventModule.isElementTextInput(event.target)) { setIsDraggable(false) diff --git a/app/gui/src/dashboard/hooks/eventCallbackHooks.ts b/app/gui/src/dashboard/hooks/eventCallbackHooks.ts index 9658a512af4e..8c0fac946205 100644 --- a/app/gui/src/dashboard/hooks/eventCallbackHooks.ts +++ b/app/gui/src/dashboard/hooks/eventCallbackHooks.ts @@ -16,8 +16,10 @@ export function useEventCallback unknown>(cal return useCallback( // @ts-expect-error we know that the callbackRef.current is of type Func - // eslint-disable-next-line no-restricted-syntax - (...args: Parameters) => callbackRef.current(...args) as ReturnType, + function eventCallback(...args: Parameters) { + // eslint-disable-next-line no-restricted-syntax + return callbackRef.current(...args) as ReturnType + }, [callbackRef], ) } diff --git a/app/gui/src/dashboard/hooks/eventListenerHooks.ts b/app/gui/src/dashboard/hooks/eventListenerHooks.ts new file mode 100644 index 000000000000..0473d8798d5a --- /dev/null +++ b/app/gui/src/dashboard/hooks/eventListenerHooks.ts @@ -0,0 +1,165 @@ +/** + * @file + * + * Set of hooks to work with native event listeners. + */ +import { IS_DEV_MODE } from 'enso-common/src/detect' +import type { RefObject } from 'react' +import { useEffect, useRef } from 'react' + +import { useEventCallback } from './eventCallbackHooks' + +/** + * Options to pass to the event listener. + */ +export type UseEventListenerParams = Omit & { + readonly isDisabled?: boolean + readonly debug?: boolean +} + +function useEventListener( + eventName: K, + handler: (event: WindowEventMap[K]) => void, + element: RefObject | Window, + options?: UseEventListenerParams | boolean, +): void + +function useEventListener( + eventName: K, + handler: (event: HTMLElementEventMap[K]) => void, + element: RefObject | T, + options?: UseEventListenerParams | boolean, +): void + +function useEventListener( + eventName: K, + handler: (event: DocumentEventMap[K]) => void, + element: Document | RefObject, + options?: UseEventListenerParams | boolean, +): void + +/** + * Hook to attach an event listener to an element. + * @param eventName - The name of the event to listen for. + * @param handler - The handler to call when the event is triggered. + * @param element - The element to add the event listener to. + * @param options - The options to pass to the event listener. + */ +function useEventListener< + KW extends keyof WindowEventMap, + KH extends keyof HTMLElementEventMap, + T extends HTMLElement = HTMLElement, +>( + eventName: KH | KW, + handler: (event: Event | HTMLElementEventMap[KH] | WindowEventMap[KW]) => void, + element: RefObject | T, + options: UseEventListenerParams | boolean = { passive: true }, +) { + const { + isDisabled = false, + capture = false, + once = false, + passive = true, + debug = false, + } = typeof options === 'object' ? options : { passive: options } + + const startTime = useRef(null) + + const handlerEvent = useEventCallback((...args) => { + if (debug && IS_DEV_MODE) { + const currentTime = performance.now() + + const timeSlice = startTime.current == null ? 'N/A' : `${currentTime - startTime.current}ms` + const timestamp = (() => { + const date = new Date() + return `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}:${date.getMilliseconds()}` + })() + + /* eslint-disable no-restricted-properties */ + console.group(`Animation, Event: ${eventName}, timeStamp: ${timestamp}`) + console.debug({ + timestamp, + timeSlice, + eventName, + target: element, + options: { passive, capture, once }, + ...args, + }) + console.groupEnd() + /* eslint-enable no-restricted-properties */ + + startTime.current = performance.now() + } + + handler(...args) + }) + + useEffect(() => { + if (isDisabled) { + return + } + + const targetElement = elementIsRef(element) ? element.current : element + + if (targetElement != null && 'addEventListener' in targetElement) { + targetElement.addEventListener(eventName, handlerEvent, { passive, capture, once }) + + return () => { + targetElement.removeEventListener(eventName, handlerEvent) + } + } + }, [eventName, handlerEvent, element, isDisabled, passive, capture, once]) +} + +export { useEventListener } + +/** + * Check if the element is an SVGElement. + */ +function elementIsSVGElement(element: unknown): element is SVGElement { + return element instanceof SVGElement +} + +/** + * Check if the element is an HTMLElement. + */ +function elementIsHTMLElement(element: unknown): element is HTMLElement { + return element instanceof HTMLElement +} + +/** + * Check if the element is a RefObject. + */ +function elementIsRef(element: unknown): element is RefObject { + if (elementIsDocument(element)) { + return false + } + + if (elementIsSVGElement(element)) { + return false + } + + if (elementIsHTMLElement(element)) { + return false + } + + if (elementIsWindow(element)) { + return false + } + + return typeof element === 'object' && element != null && 'current' in element +} + +/** + * Check if the element is a Document. + */ +function elementIsDocument(element: unknown): element is Document { + return element instanceof Document +} + +/** + * Check if the element is a Window. + */ +function elementIsWindow(element: unknown): element is Window { + return element === window +} diff --git a/app/gui/src/dashboard/hooks/useRaf.ts b/app/gui/src/dashboard/hooks/useRaf.ts new file mode 100644 index 000000000000..97a34cd9f855 --- /dev/null +++ b/app/gui/src/dashboard/hooks/useRaf.ts @@ -0,0 +1,64 @@ +/** + * @file + * + * A hook that synchronizes callbacks with the RAF loop. + */ +import * as React from 'react' + +import { useEventCallback } from './eventCallbackHooks.ts' +import { useUnmount } from './unmountHooks.ts' + +/** + * Adds a callback to the RAF loop. + */ +export function useRAF() { + const callbacksRef = React.useRef>(new Set()) + const lastRafRef = React.useRef | null>(null) + + const cancelRAF = useEventCallback(() => { + if (lastRafRef.current != null) { + cancelAnimationFrame(lastRafRef.current) + lastRafRef.current = null + callbacksRef.current.clear() + } + }) + + const scheduleRAF = useEventCallback((callback: FrameRequestCallback) => { + if (lastRafRef.current == null) { + lastRafRef.current = requestAnimationFrame((time) => { + const lastCallbacks = [...callbacksRef.current] + + lastCallbacks.forEach((cb) => { + cb(time) + }) + lastRafRef.current = null + callbacksRef.current.clear() + }) + } + + callbacksRef.current.add(callback) + + return () => { + callbacksRef.current.delete(callback) + } + }) + + useUnmount(cancelRAF) + + return { scheduleRAF, cancelRAF } as const +} + +/** + * Synchronizes calbacks with the RAF loop. + * Cancels all callbacks before scheduling a new one. + */ +export function useRAFThrottle() { + const { cancelRAF, scheduleRAF: scheduleRAFRaw } = useRAF() + + const scheduleRAF = useEventCallback((callback: FrameRequestCallback) => { + cancelRAF() + scheduleRAFRaw(callback) + }) + + return { scheduleRAF, cancelRAF } +} diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 7e7d0bf7052b..a0be4852531d 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -43,7 +43,7 @@ import { COLUMN_HEADING } from '#/components/dashboard/columnHeading' import Label from '#/components/dashboard/Label' import { ErrorDisplay } from '#/components/ErrorBoundary' import { IsolateLayout } from '#/components/IsolateLayout' -import SelectionBrush from '#/components/SelectionBrush' +import { SelectionBrushV2, type OnDragParams } from '#/components/SelectionBrush' import { IndefiniteSpinner } from '#/components/Spinner' import FocusArea from '#/components/styled/FocusArea' import SvgMask from '#/components/SvgMask' @@ -102,6 +102,7 @@ import { useNavigator2D } from '#/providers/Navigator2DProvider' import { useLaunchedProjects } from '#/providers/ProjectsProvider' import { useText } from '#/providers/TextProvider' import type Backend from '#/services/Backend' +import type { AssetId } from '#/services/Backend' import { assetIsProject, AssetType, @@ -111,7 +112,6 @@ import { ProjectId, ProjectState, type AnyAsset, - type AssetId, type DirectoryAsset, type DirectoryId, type LabelName, @@ -127,7 +127,6 @@ import type { AssetRowsDragPayload } from '#/utilities/drag' import { ASSET_ROWS, LABELS, setDragImageToBlank } from '#/utilities/drag' import { fileExtension } from '#/utilities/fileInfo' import { noop } from '#/utilities/functions' -import type { DetailedRectangle } from '#/utilities/geometry' import { DEFAULT_HANDLER } from '#/utilities/inputBindings' import LocalStorage from '#/utilities/LocalStorage' import { @@ -162,7 +161,7 @@ const MINIMUM_DROPZONE_INTERSECTION_RATIO = 0.5 * 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 +const ROW_HEIGHT_PX = 36 /** The size of the loading spinner. */ const LOADING_SPINNER_SIZE_PX = 36 @@ -1390,8 +1389,8 @@ function AssetsTable(props: AssetsTableProps) { event.target.closest('.enso-portal-root') : null if (!portalRoot && driveStore.getState().selectedKeys.size !== 0) { - setSelectedKeys(EMPTY_SET) - setMostRecentlySelectedIndex(null) + // setSelectedKeys(EMPTY_SET) + // setMostRecentlySelectedIndex(null) } }, }, @@ -1437,15 +1436,27 @@ function AssetsTable(props: AssetsTableProps) { const { startAutoScroll, endAutoScroll, onMouseEvent } = useAutoScroll(rootRef) - const dragSelectionChangeLoopHandle = useRef(0) const dragSelectionRangeRef = useRef(null) - const onSelectionDrag = useEventCallback((rectangle: DetailedRectangle, event: MouseEvent) => { + + const preventSelection = useEventCallback((event: PointerEvent) => { + const { target } = event + + if (target instanceof HTMLElement) { + const row = target.closest('tr') + return Boolean(row?.dataset.selected === 'true') + } + + return false + }) + + const onSelectionDrag = useEventCallback(({ event, rectangle }: OnDragParams) => { startAutoScroll() + onMouseEvent(event) + if (mostRecentlySelectedIndexRef.current != null) { setKeyboardSelectedIndex(null) } - cancelAnimationFrame(dragSelectionChangeLoopHandle.current) const scrollContainer = rootRef.current if (scrollContainer != null) { const rect = scrollContainer.getBoundingClientRect() @@ -1456,11 +1467,13 @@ function AssetsTable(props: AssetsTableProps) { Math.min(rect.height, rectangle.bottom - rect.top - ROW_HEIGHT_PX), ) const range = dragSelectionRangeRef.current + if (!overlapsHorizontally) { dragSelectionRangeRef.current = null } else if (range == null) { const topIndex = (selectionTop + scrollContainer.scrollTop) / ROW_HEIGHT_PX const bottomIndex = (selectionBottom + scrollContainer.scrollTop) / ROW_HEIGHT_PX + dragSelectionRangeRef.current = { initialIndex: rectangle.signedHeight < 0 ? bottomIndex : topIndex, start: Math.floor(topIndex), @@ -1486,6 +1499,7 @@ function AssetsTable(props: AssetsTableProps) { }) const onSelectionDragEnd = useEventCallback((event: MouseEvent) => { + event.stopImmediatePropagation() endAutoScroll() onMouseEvent(event) const range = dragSelectionRangeRef.current @@ -1538,8 +1552,11 @@ function AssetsTable(props: AssetsTableProps) { const onRowDragStart = useEventCallback( (event: DragEvent, item: AnyAsset) => { startAutoScroll() + onMouseEvent(event) + let newSelectedKeys = driveStore.getState().selectedKeys + if (!newSelectedKeys.has(item.id)) { setMostRecentlySelectedIndex( visibleItems.findIndex((visibleItem) => visibleItem.item.id === item.id), @@ -1548,11 +1565,14 @@ function AssetsTable(props: AssetsTableProps) { newSelectedKeys = new Set([item.id]) setSelectedKeys(newSelectedKeys) } + const nodes = assetTree.preorderTraversal().filter((node) => newSelectedKeys.has(node.key)) + const payload: AssetRowsDragPayload = nodes.map((node) => ({ key: node.key, asset: node.item, })) + event.dataTransfer.setData(ASSETS_MIME_TYPE, JSON.stringify(nodes.map((node) => node.key))) setDragImageToBlank(event) ASSET_ROWS.bind(event, payload) @@ -1956,12 +1976,12 @@ function AssetsTable(props: AssetsTableProps) { > {!hidden && hiddenContextMenu} {!hidden && ( - )}
diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index e4f55407db1d..53be60e3e22a 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -125,12 +125,14 @@ export default function DriveProvider(props: ProjectsProviderProps) { }, selectedKeys: EMPTY_SET, setSelectedKeys: (selectedKeys) => { + console.trace('setSelectedKeys', selectedKeys) if (get().selectedKeys !== selectedKeys) { set({ selectedKeys }) } }, visuallySelectedKeys: null, setVisuallySelectedKeys: (visuallySelectedKeys) => { + console.trace('setVisuallySelectedKeys', visuallySelectedKeys) if (get().visuallySelectedKeys !== visuallySelectedKeys) { set({ visuallySelectedKeys }) } diff --git a/app/gui/src/dashboard/utilities/geometry.ts b/app/gui/src/dashboard/utilities/geometry.ts index 130c40e63f2b..047bac8ed0cb 100644 --- a/app/gui/src/dashboard/utilities/geometry.ts +++ b/app/gui/src/dashboard/utilities/geometry.ts @@ -17,3 +17,15 @@ export interface DetailedRectangle { readonly signedWidth: number readonly signedHeight: number } + +/** + * A bounding box, including all common measurements. + */ +export interface BoundingBox { + readonly left: number + readonly top: number + readonly right: number + readonly bottom: number + readonly width: number + readonly height: number +} From eafc97d948efc9f4df6fcf99e26dc7671975f0fd Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Wed, 25 Dec 2024 15:36:18 +0400 Subject: [PATCH 2/8] Fix merge --- app/gui/src/dashboard/components/dashboard/AssetRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index f72ab2931245..2dbbbc374bb1 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -865,7 +865,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { doPaste={doPaste} doDelete={doDelete} /> - )} */} + )} ) } From c22c0bc676dd7de40d87688beeb9e9bc6d1f8ae8 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Wed, 25 Dec 2024 16:15:53 +0400 Subject: [PATCH 3/8] Improve visuals --- .../src/dashboard/components/Badge/Badge.tsx | 2 +- .../dashboard/components/SelectionBrush.tsx | 3 +-- app/gui/src/dashboard/modals/DragModal.tsx | 26 ++++++++++++++++--- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/gui/src/dashboard/components/Badge/Badge.tsx b/app/gui/src/dashboard/components/Badge/Badge.tsx index f4b594e99021..ff7df1e1934b 100644 --- a/app/gui/src/dashboard/components/Badge/Badge.tsx +++ b/app/gui/src/dashboard/components/Badge/Badge.tsx @@ -15,7 +15,7 @@ export interface BadgeProps extends VariantProps { } export const BADGE_STYLES = tv({ - base: 'flex items-center justify-center px-[5px] border-[0.5px]', + base: 'flex items-center justify-center px-[5px] border-[0.5px] min-w-6', variants: { variant: { solid: 'border-transparent bg-[var(--badge-bg-color)] text-[var(--badge-text-color)]', diff --git a/app/gui/src/dashboard/components/SelectionBrush.tsx b/app/gui/src/dashboard/components/SelectionBrush.tsx index 2f6064fc026a..0c7c80f2b65f 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -427,8 +427,7 @@ export function SelectionBrushV2(props: SelectionBrushV2Props) { +
- {children} +
+ {React.Children.toArray(children) + .reverse() + .slice(0, 3) + .map((child, index, array) => ( +
+ {child} +
+ ))} +
+ + + {React.Children.toArray(children).length} +
) From 8bb1e4fa5a773c6f908f64055e584e12bdbf645d Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Thu, 26 Dec 2024 20:09:31 +0400 Subject: [PATCH 4/8] Improve selection --- .../AriaComponents/Dialog/Dialog.tsx | 2 +- .../AriaComponents/Tooltip/Tooltip.tsx | 2 +- .../dashboard/components/SelectionBrush.tsx | 611 ++++++++++-------- app/gui/src/dashboard/hooks/measureHooks.ts | 61 +- app/gui/src/dashboard/hooks/throttleHooks.ts | 23 + app/gui/src/dashboard/hooks/useRaf.ts | 15 - app/gui/src/dashboard/layouts/AssetsTable.tsx | 169 +++-- app/gui/src/dashboard/layouts/Drive.tsx | 4 +- app/gui/src/dashboard/layouts/Labels.tsx | 2 +- app/gui/src/dashboard/modals/DragModal.tsx | 55 +- .../src/dashboard/providers/DriveProvider.tsx | 10 +- app/gui/src/dashboard/styles.css | 2 +- app/gui/src/dashboard/utilities/geometry.ts | 89 ++- app/gui/src/dashboard/utilities/math.ts | 14 + .../dashboard/utilities/scrollContainers.ts | 193 ++++++ 15 files changed, 810 insertions(+), 442 deletions(-) create mode 100644 app/gui/src/dashboard/hooks/throttleHooks.ts create mode 100644 app/gui/src/dashboard/utilities/math.ts create mode 100644 app/gui/src/dashboard/utilities/scrollContainers.ts diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index bec43019b7b4..20dcd8cfe06d 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx @@ -28,7 +28,7 @@ import { DIALOG_BACKGROUND } from './variants' const MotionDialog = motion(aria.Dialog) const OVERLAY_STYLES = tv({ - base: 'fixed inset-0 isolate flex items-center justify-center bg-primary/20 z-tooltip', + base: 'fixed inset-0 isolate flex items-center justify-center bg-primary/20', variants: { isEntering: { true: 'animate-in fade-in duration-200 ease-out' }, isExiting: { true: 'animate-out fade-out duration-200 ease-in' }, diff --git a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx index bb78ac90b492..fc267d8a569a 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx @@ -12,7 +12,7 @@ import * as text from '../Text' // ================= export const TOOLTIP_STYLES = twv.tv({ - base: 'group flex justify-center items-center text-center text-balance [overflow-wrap:anywhere] z-tooltip', + base: 'group flex justify-center items-center text-center text-balance [overflow-wrap:anywhere]', variants: { variant: { custom: '', diff --git a/app/gui/src/dashboard/components/SelectionBrush.tsx b/app/gui/src/dashboard/components/SelectionBrush.tsx index 0c7c80f2b65f..fcc3323522b2 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -2,30 +2,23 @@ import * as React from 'react' import Portal from '#/components/Portal' -import * as animationHooks from '#/hooks/animationHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useEventListener } from '#/hooks/eventListenerHooks' -import * as modalProvider from '#/providers/ModalProvider' -import * as eventModule from '#/utilities/event' +import { useRafThrottle } from '#/hooks/throttleHooks' import type * as geometry from '#/utilities/geometry' -import * as tailwindMerge from '#/utilities/tailwindMerge' +import { getDetailedRectangle, getDetailedRectangleFromRectangle } from '#/utilities/geometry' +import { findScrollContainers, type HTMLOrSVGElement } from '#/utilities/scrollContainers' import { motion, useMotionValue } from 'framer-motion' -import { useRAFThrottle } from '../hooks/useRaf' // ================= // === Constants === // ================= -/** - * Controls the speed of animation of the {@link SelectionBrush} when the - * mouse is released and the selection brush collapses back to zero size. - */ -const ANIMATION_TIME_HORIZON = 60 /** * Defines the minimal distance that the mouse must move before * we consider that user has started a selection. */ -const DEAD_ZONE_SIZE = 12 +const DEAD_ZONE_SIZE = 24 // eslint-disable-next-line no-restricted-syntax const noop = () => {} @@ -34,214 +27,6 @@ const noop = () => {} // === SelectionBrush === // ====================== -/** Props for a {@link SelectionBrush}. */ -export interface SelectionBrushProps { - readonly targetRef: React.RefObject - readonly margin?: number - readonly onDrag: (rectangle: geometry.DetailedRectangle, event: MouseEvent) => void - readonly onDragEnd: (event: MouseEvent) => void - readonly onDragCancel: () => void -} - -/** A selection brush to indicate the area being selected by the mouse drag action. */ -export default function SelectionBrush(props: SelectionBrushProps) { - const { targetRef, margin = 0 } = props - const { modalRef } = modalProvider.useModalRef() - - const initialMousePositionRef = React.useRef(null) - /** - * Whether the mouse is currently down. - */ - const isMouseDownRef = React.useRef(false) - /** - * Whether the user is currently dragging the selection brush. - * Unlike the isMouseDown, has a dead zone - */ - const isDraggingRef = React.useRef(false) - - const didMoveWhileDraggingRef = React.useRef(false) - const onDrag = useEventCallback(props.onDrag) - const onDragEnd = useEventCallback(props.onDragEnd) - const onDragCancel = useEventCallback(props.onDragCancel) - const lastMouseEvent = React.useRef(null) - const parentBounds = React.useRef(null) - const anchorRef = React.useRef(null) - const [anchor, setAnchor] = React.useState(null) - const [position, setPosition] = React.useState(null) - const [lastSetAnchor, setLastSetAnchor] = React.useState(null) - - const anchorAnimFactor = animationHooks.useApproach( - anchor != null ? 1 : 0, - ANIMATION_TIME_HORIZON, - ) - const hidden = - anchor == null || - position == null || - (anchor.left === position.left && anchor.top === position.top) - - React.useEffect(() => { - if (anchor != null) { - anchorAnimFactor.skip() - } - }, [anchorAnimFactor, anchor]) - - React.useEffect(() => { - const isEventInBounds = (event: MouseEvent, parent?: HTMLElement | null) => { - if (parent == null) { - return true - } else { - parentBounds.current = parent.getBoundingClientRect() - return eventModule.isElementInBounds(event, parentBounds.current, margin) - } - } - const unsetAnchor = () => { - if (anchorRef.current != null) { - anchorRef.current = null - setAnchor(null) - } - } - const onMouseDown = (event: MouseEvent) => { - initialMousePositionRef.current = { left: event.pageX, top: event.pageY } - - if ( - modalRef.current == null && - !eventModule.isElementTextInput(event.target) && - !(event.target instanceof HTMLButtonElement) && - !(event.target instanceof HTMLAnchorElement) && - isEventInBounds(event, targetRef.current) - ) { - isMouseDownRef.current = true - didMoveWhileDraggingRef.current = false - lastMouseEvent.current = event - const newAnchor = { left: event.pageX, top: event.pageY } - anchorRef.current = newAnchor - setAnchor(newAnchor) - setLastSetAnchor(newAnchor) - setPosition(newAnchor) - } - } - const onMouseUp = (event: MouseEvent) => { - if (didMoveWhileDraggingRef.current) { - onDragEnd(event) - } - // The `setTimeout` is required, otherwise the values are changed before the `onClick` handler - // is executed. - window.setTimeout(() => { - isMouseDownRef.current = false - didMoveWhileDraggingRef.current = false - initialMousePositionRef.current = null - }) - unsetAnchor() - } - const onMouseMove = (event: MouseEvent) => { - if (!(event.buttons & 1)) { - isMouseDownRef.current = false - initialMousePositionRef.current = null - } - if (isMouseDownRef.current) { - // Left click is being held. - didMoveWhileDraggingRef.current = true - lastMouseEvent.current = event - const positionLeft = - parentBounds.current == null ? - event.pageX - : Math.max( - parentBounds.current.left - margin, - Math.min(parentBounds.current.right + margin, event.pageX), - ) - const positionTop = - parentBounds.current == null ? - event.pageY - : Math.max( - parentBounds.current.top - margin, - Math.min(parentBounds.current.bottom + margin, event.pageY), - ) - setPosition({ left: positionLeft, top: positionTop }) - } - } - const onClick = (event: MouseEvent) => { - if (isMouseDownRef.current && didMoveWhileDraggingRef.current) { - event.stopImmediatePropagation() - } - } - const onDragStart = () => { - if (isMouseDownRef.current) { - isMouseDownRef.current = false - initialMousePositionRef.current = null - onDragCancel() - unsetAnchor() - } - } - - document.addEventListener('mousedown', onMouseDown) - document.addEventListener('mouseup', onMouseUp) - document.addEventListener('dragstart', onDragStart, { capture: true }) - document.addEventListener('mousemove', onMouseMove) - document.addEventListener('click', onClick, { capture: true }) - return () => { - document.removeEventListener('mousedown', onMouseDown) - document.removeEventListener('mouseup', onMouseUp) - document.removeEventListener('dragstart', onDragStart, { capture: true }) - document.removeEventListener('mousemove', onMouseMove) - document.removeEventListener('click', onClick, { capture: true }) - } - }, [margin, targetRef, modalRef, onDragEnd, onDragCancel]) - - const rectangle = React.useMemo(() => { - if (position != null && lastSetAnchor != null) { - const start: geometry.Coordinate2D = { - left: - position.left * (1 - anchorAnimFactor.value) + - lastSetAnchor.left * anchorAnimFactor.value, - top: - position.top * (1 - anchorAnimFactor.value) + lastSetAnchor.top * anchorAnimFactor.value, - } - - return { - left: Math.min(position.left, start.left), - top: Math.min(position.top, start.top), - right: Math.max(position.left, start.left), - bottom: Math.max(position.top, start.top), - width: Math.abs(position.left - start.left), - height: Math.abs(position.top - start.top), - signedWidth: position.left - start.left, - signedHeight: position.top - start.top, - } - } else { - return null - } - }, [anchorAnimFactor.value, lastSetAnchor, position]) - - const selectionRectangle = React.useMemo(() => (hidden ? null : rectangle), [hidden, rectangle]) - - React.useEffect(() => { - if (selectionRectangle != null && lastMouseEvent.current != null) { - onDrag(selectionRectangle, lastMouseEvent.current) - } - }, [onDrag, selectionRectangle]) - - const brushStyle = - rectangle == null ? - {} - : { - left: `${rectangle.left}px`, - top: `${rectangle.top}px`, - width: `${rectangle.width}px`, - height: `${rectangle.height}px`, - } - return ( - - )} + {isDraggingFiles && !isMainDropzoneVisible && (
state.selectedKeys.size > 0, { + unsafeEnableTransition: true, + }) + const setSelectedKeys = useSetSelectedKeys() + + const { pressProps } = usePress({ + isDisabled: !hasSelectedKeys, + onPress: () => { + setSelectedKeys(EMPTY_SET) + }, + }) + + if (asChild) { + const childenArray = Children.toArray(children) + const onlyChild = childenArray.length === 1 ? childenArray[0] : null + + invariant(onlyChild != null, 'Children must be a single element when `asChild` is true') + invariant(isValidElement(onlyChild), 'Children must be a JSX element when `asChild` is true') + + return cloneElement(onlyChild, pressProps) + } + + return ( +
+ {children} +
+ ) +} + export default memo(AssetsTable) diff --git a/app/gui/src/dashboard/layouts/Drive.tsx b/app/gui/src/dashboard/layouts/Drive.tsx index e8310d846cf7..77ddec049223 100644 --- a/app/gui/src/dashboard/layouts/Drive.tsx +++ b/app/gui/src/dashboard/layouts/Drive.tsx @@ -15,7 +15,7 @@ import AssetListEventType from '#/events/AssetListEventType' import { AssetPanel } from '#/layouts/AssetPanel' import type * as assetsTable from '#/layouts/AssetsTable' -import AssetsTable from '#/layouts/AssetsTable' +import AssetsTable, { AssetsTableAssetsUnselector } from '#/layouts/AssetsTable' import CategorySwitcher from '#/layouts/CategorySwitcher' import * as categoryModule from '#/layouts/CategorySwitcher/Category' import * as eventListProvider from '#/layouts/Drive/EventListProvider' @@ -274,6 +274,8 @@ function DriveAssetsView(props: DriveProps) { setQuery={setQuery} /> )} + +
{status === 'offline' ? diff --git a/app/gui/src/dashboard/layouts/Labels.tsx b/app/gui/src/dashboard/layouts/Labels.tsx index d5d738ba8d02..62c99773ef4d 100644 --- a/app/gui/src/dashboard/layouts/Labels.tsx +++ b/app/gui/src/dashboard/layouts/Labels.tsx @@ -41,7 +41,7 @@ export default function Labels(props: LabelsProps) { return ( {(innerProps) => ( -
+
-
-
- {React.Children.toArray(children) - .reverse() - .slice(0, 3) - .map((child, index, array) => ( -
- {child} -
- ))} -
+ +
+
+
+ {React.Children.toArray(children) + .slice(0, 3) + .reverse() + .map((child, index, array) => ( +
+ {child} +
+ ))} +
- - {React.Children.toArray(children).length} - + + {React.Children.toArray(children).length} + +
-
+ ) } diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index 53be60e3e22a..bcc718b873b5 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -125,17 +125,11 @@ export default function DriveProvider(props: ProjectsProviderProps) { }, selectedKeys: EMPTY_SET, setSelectedKeys: (selectedKeys) => { - console.trace('setSelectedKeys', selectedKeys) - if (get().selectedKeys !== selectedKeys) { - set({ selectedKeys }) - } + set({ selectedKeys }) }, visuallySelectedKeys: null, setVisuallySelectedKeys: (visuallySelectedKeys) => { - console.trace('setVisuallySelectedKeys', visuallySelectedKeys) - if (get().visuallySelectedKeys !== visuallySelectedKeys) { - set({ visuallySelectedKeys }) - } + set({ visuallySelectedKeys }) }, })), ) diff --git a/app/gui/src/dashboard/styles.css b/app/gui/src/dashboard/styles.css index 6a0568eee6f3..d670163c2092 100644 --- a/app/gui/src/dashboard/styles.css +++ b/app/gui/src/dashboard/styles.css @@ -21,5 +21,5 @@ } :where(.enso-dashboard) { - @apply absolute inset-0 flex flex-col overflow-hidden; + @apply absolute inset-0 isolate flex flex-col overflow-hidden; } diff --git a/app/gui/src/dashboard/utilities/geometry.ts b/app/gui/src/dashboard/utilities/geometry.ts index 047bac8ed0cb..6141c0fbd6d8 100644 --- a/app/gui/src/dashboard/utilities/geometry.ts +++ b/app/gui/src/dashboard/utilities/geometry.ts @@ -6,26 +6,95 @@ export interface Coordinate2D { readonly top: number } -/** A rectangle, including all common measurements. */ -export interface DetailedRectangle { +/** + * A rectangle, including coordinates of every corner. + */ +export interface Rectangle { readonly left: number readonly top: number readonly right: number readonly bottom: number +} + +/** + * A bounding box, including all common measurements. + */ +export interface BoundingBox extends Rectangle { readonly width: number readonly height: number +} + +/** A rectangle, including all common measurements. */ +export interface DetailedRectangle extends BoundingBox { readonly signedWidth: number readonly signedHeight: number } /** - * A bounding box, including all common measurements. + * Get a rectangle from two coordinates. + * @param start - The start coordinate. + * @param end - The end coordinate. + * @returns The rectangle. */ -export interface BoundingBox { - readonly left: number - readonly top: number - readonly right: number - readonly bottom: number - readonly width: number - readonly height: number +export function getRectangle(start: Coordinate2D, end: Coordinate2D): Rectangle { + return { + left: Math.min(start.left, end.left), + top: Math.min(start.top, end.top), + right: Math.max(start.left, end.left), + bottom: Math.max(start.top, end.top), + } +} + +/** + * Get a bounding box from two coordinates. + * @param start - The start coordinate. + * @param end - The end coordinate. + * @returns The bounding box. + */ +export function getBoundingBox(start: Coordinate2D, end: Coordinate2D): BoundingBox { + return { + ...getRectangle(start, end), + width: Math.abs(start.left - end.left), + height: Math.abs(start.top - end.top), + } +} + +/** + * Get a detailed rectangle from two coordinates. + * @param start - The start coordinate. + * @param end - The end coordinate. + * @returns The rectangle. + */ +export function getDetailedRectangle(start: Coordinate2D, end: Coordinate2D): DetailedRectangle { + return { + ...getBoundingBox(start, end), + signedWidth: end.left - start.left, + signedHeight: end.top - start.top, + } +} + +/** + * Get a bounding box from a rectangle. + * @param rectangle - The rectangle. + * @returns The bounding box. + */ +export function getBoundingBoxFromRectangle(rectangle: Rectangle): BoundingBox { + return { + ...rectangle, + width: rectangle.right - rectangle.left, + height: rectangle.bottom - rectangle.top, + } +} + +/** + * Get a detailed rectangle from a rectangle. + * @param rectangle - The rectangle. + * @returns The detailed rectangle. + */ +export function getDetailedRectangleFromRectangle(rectangle: Rectangle): DetailedRectangle { + return { + ...getBoundingBoxFromRectangle(rectangle), + signedWidth: rectangle.right - rectangle.left, + signedHeight: rectangle.bottom - rectangle.top, + } } diff --git a/app/gui/src/dashboard/utilities/math.ts b/app/gui/src/dashboard/utilities/math.ts new file mode 100644 index 000000000000..1895dc1487f1 --- /dev/null +++ b/app/gui/src/dashboard/utilities/math.ts @@ -0,0 +1,14 @@ +/** + * @file Math utilities. + */ + +/** + * Clamp a value between a minimum and maximum. + * @param value - The value to clamp. + * @param min - The minimum value. + * @param max - The maximum value. + * @returns The clamped value. + */ +export function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(value, max)) +} diff --git a/app/gui/src/dashboard/utilities/scrollContainers.ts b/app/gui/src/dashboard/utilities/scrollContainers.ts new file mode 100644 index 000000000000..6621dcd88aff --- /dev/null +++ b/app/gui/src/dashboard/utilities/scrollContainers.ts @@ -0,0 +1,193 @@ +/** + * @file Functions for working with scroll containers. + */ + +/** + * A type that represents an HTML or SVG element. + */ +export type HTMLOrSVGElement = HTMLElement | SVGElement + +/** + * Finds all scroll containers that have overflow set to 'auto' or 'scroll' + * Uses Tailwind CSS classes if possible, otherwise falls back to inline overflow. + * @param element - The element to start searching from + * @returns An array of scroll containers + */ +export function findScrollContainers(element: HTMLOrSVGElement | null): HTMLOrSVGElement[] { + /** + * Recursively find scroll containers. + * @param nextElement - The element to check + * @returns An array of scroll containers + */ + const recurse = (nextElement: HTMLOrSVGElement): HTMLOrSVGElement[] => { + return [nextElement, ...findScrollContainers(nextElement.parentElement)] + } + + if (!element || element === document.body) return [] + + if (hasTailwindOverflowHidden(element)) { + return findScrollContainers(element.parentElement) + } + + if (hasInlineOverflowHidden(element)) { + return findScrollContainers(element.parentElement) + } + + if (hasTailwindOverflow(element)) { + return recurse(element) + } + + if (hasInlineOverflow(element)) { + return recurse(element) + } + + const { overflow, overflowX, overflowY } = window.getComputedStyle(element) + + if ([overflow, overflowX, overflowY].some((prop) => prop === 'auto' || prop === 'scroll')) { + return recurse(element) + } + + return findScrollContainers(element.parentElement) +} + +/** + * Finds all containers that possbly have overflow (when scrollWidth/scrollHeight > clientWidth/clientHeight) + * @param element - The element to start searching from + * @returns An array of containers that possbly have overflow + */ +export function findOverflowContainers(element: HTMLOrSVGElement | null): HTMLOrSVGElement[] { + const result: HTMLOrSVGElement[] = [] + + if (!element || element === document.body) return result + + if (hasPossibleOverflow(element)) { + result.push(element) + } + + return [...result, ...findOverflowContainers(element.parentElement)] +} + +/** + * Finds all scroll containers that have overflow set to 'auto' or 'scroll' using Tailwind CSS classes. + * This is a more efficient way to find scroll containers than using the `getComputedStyle` method, + * but works only if the element has Tailwind CSS classes applied to it. + * @param element - The element to start searching from + * @returns An array of scroll containers + */ +export function findScrollContainersUsingTailwind( + element: HTMLOrSVGElement | null, +): HTMLOrSVGElement[] { + const result: HTMLOrSVGElement[] = [] + + if (!element || element === document.body) return result + + if (hasTailwindOverflow(element)) { + result.push(element) + } + + return [...result, ...findScrollContainersUsingTailwind(element.parentElement)] +} + +/** + * Finds all overflow containers using the `getComputedStyle` method. + * @param element - The element to start searching from + * @returns An array of overflow containers + */ +export function findOverflowContainersUsingComputedStyle( + element: HTMLOrSVGElement | null, +): HTMLOrSVGElement[] { + const result: HTMLOrSVGElement[] = [] + + if (!element || element === document.body) return result + + if (hasComputedStyleOverflow(element)) { + result.push(element) + } + + return [...result, ...findOverflowContainersUsingComputedStyle(element.parentElement)] +} + +/** + * Checks if the element has overflow set to 'auto' or 'scroll' using the `getComputedStyle` method. + * @param element - The element to check + * @returns True if the element has overflow set to 'auto' or 'scroll', false otherwise + */ +function hasComputedStyleOverflow(element: HTMLOrSVGElement): boolean { + const { overflow, overflowX, overflowY } = window.getComputedStyle(element) + return [overflow, overflowX, overflowY].some((prop) => prop === 'auto' || prop === 'scroll') +} + +/** + * Checks if the element has inline overflow. + * @param element - The element to check + * @returns True if the element has inline overflow, false otherwise + */ +export function hasInlineOverflow(element: HTMLOrSVGElement): boolean { + const { overflow, overflowX, overflowY } = element.style + return ( + overflow === 'auto' || + overflow === 'scroll' || + overflowX === 'auto' || + overflowX === 'scroll' || + overflowY === 'auto' || + overflowY === 'scroll' + ) +} + +/** + * Checks if the element has Tailwind CSS classes that indicate overflow. + * @param element - The element to check + * @returns True if the element has Tailwind CSS classes that indicate overflow, false otherwise + */ +export function hasTailwindOverflow(element: HTMLOrSVGElement): boolean { + return ( + element.classList.contains('overflow-auto') || + element.classList.contains('overflow-scroll') || + element.classList.contains('overflow-x-auto') || + element.classList.contains('overflow-y-auto') || + element.classList.contains('overflow-x-scroll') || + element.classList.contains('overflow-y-scroll') + ) +} + +/** + * Checks if the element has possible overflow, when scrollWidth/scrollHeight > clientWidth/clientHeight. + * @param element - The element to check + * @returns True if the element has possible overflow, false otherwise + */ +export function hasPossibleOverflow(element: HTMLOrSVGElement): boolean { + const { scrollHeight, scrollWidth, clientHeight, clientWidth } = element + return scrollHeight > clientHeight || scrollWidth > clientWidth +} + +/** + * Checks if the element has Tailwind CSS classes that indicate overflow hidden. + * @param element - The element to check + * @returns True if the element has Tailwind CSS classes that indicate overflow hidden, false otherwise + */ +export function hasTailwindOverflowHidden(element: HTMLOrSVGElement): boolean { + return ( + element.classList.contains('overflow-hidden') || + element.classList.contains('overflow-x-hidden') || + element.classList.contains('overflow-y-hidden') || + element.classList.contains('overflow-x-clip') || + element.classList.contains('overflow-y-clip') + ) +} + +/** + * Checks if the element has inline overflow hidden. + * @param element - The element to check + * @returns True if the element has inline overflow hidden, false otherwise + */ +export function hasInlineOverflowHidden(element: HTMLOrSVGElement): boolean { + const { overflow, overflowX, overflowY } = element.style + return ( + overflow === 'hidden' || + overflowX === 'hidden' || + overflowY === 'hidden' || + overflow === 'clip' || + overflowX === 'clip' || + overflowY === 'clip' + ) +} From ffca1118dab732759111f3a0dabdaac0cbddd9e5 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Thu, 26 Dec 2024 20:20:52 +0400 Subject: [PATCH 5/8] Fix tests --- .../dashboard/actions/DrivePageActions.ts | 1 + app/gui/src/dashboard/layouts/AssetsTable.tsx | 32 ------------------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index bea14460c749..13445d403fe8 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -212,6 +212,7 @@ export default class DrivePageActions extends PageActions { dragRowToRow(from: number, to: number) { return self.step(`Drag drive table row #${from} to row #${to}`, async (page) => { const rows = locateAssetRows(page) + rows.nth(from).click() await rows.nth(from).dragTo(rows.nth(to), { sourcePosition: ASSET_ROW_SAFE_POSITION, targetPosition: ASSET_ROW_SAFE_POSITION, diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 9c10292d1299..7a7386a4236e 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -1374,38 +1374,6 @@ function AssetsTable(props: AssetsTableProps) { } }, [hidden]) - useEffect( - () => - inputBindings.attach( - document.body, - 'click', - { - selectAdditional: () => {}, - selectAdditionalRange: () => {}, - [DEFAULT_HANDLER]: (event) => { - /** - * When the document is clicked, deselect the keys, but only if the clicked element - * is not inside a `Dialog`. To detect whether an element is a `Dialog`, - * we check whether it is inside the `portal-root` where all the `Dialog`s are mounted. - * If this check is omitted, when the user clicks inside a Datalink dialog, - * the keys are deselected, causing the Datalink to be added to the root directory, - * rather than the one that was selected when the dialog was opened. - */ - const portalRoot = - event.target instanceof HTMLElement || event.target instanceof SVGElement ? - event.target.closest('.enso-portal-root') - : null - if (!portalRoot && driveStore.getState().selectedKeys.size !== 0) { - // setSelectedKeys(EMPTY_SET) - // setMostRecentlySelectedIndex(null) - } - }, - }, - false, - ), - [setSelectedKeys, inputBindings, setMostRecentlySelectedIndex, driveStore], - ) - const calculateNewKeys = useEventCallback( (event: MouseEvent | ReactMouseEvent, keys: AssetId[], getRange: () => AssetId[]) => { event.stopPropagation() From 413d427c2784e0454281cfb0229bd14599a10ef7 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Thu, 26 Dec 2024 20:31:35 +0400 Subject: [PATCH 6/8] Remove bunch of z-index rules --- .../dashboard/components/AriaComponents/Dialog/Popover.tsx | 2 +- app/gui/src/dashboard/components/SelectionBrush.tsx | 2 +- app/gui/src/dashboard/hooks/spotlightHooks.tsx | 2 +- app/gui/src/dashboard/layouts/TabBar.tsx | 2 +- app/gui/src/dashboard/modals/DragModal.tsx | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx index 1cb6cdce4598..59427fa6ac29 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx @@ -29,7 +29,7 @@ export interface PopoverProps } export const POPOVER_STYLES = twv.tv({ - base: 'shadow-xl w-full overflow-clip z-tooltip', + base: 'shadow-xl w-full overflow-clip', variants: { isEntering: { true: 'animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200', diff --git a/app/gui/src/dashboard/components/SelectionBrush.tsx b/app/gui/src/dashboard/components/SelectionBrush.tsx index fcc3323522b2..48ab37bfbe22 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -338,7 +338,7 @@ export function SelectionBrush(props: SelectionBrushV2Props) { diff --git a/app/gui/src/dashboard/hooks/spotlightHooks.tsx b/app/gui/src/dashboard/hooks/spotlightHooks.tsx index ace25d30daab..4d30a3e97851 100644 --- a/app/gui/src/dashboard/hooks/spotlightHooks.tsx +++ b/app/gui/src/dashboard/hooks/spotlightHooks.tsx @@ -129,7 +129,7 @@ function Spotlight(props: SpotlightProps) {
diff --git a/app/gui/src/dashboard/layouts/TabBar.tsx b/app/gui/src/dashboard/layouts/TabBar.tsx index 00c48ebc5857..d05d69d99ee4 100644 --- a/app/gui/src/dashboard/layouts/TabBar.tsx +++ b/app/gui/src/dashboard/layouts/TabBar.tsx @@ -101,7 +101,7 @@ export function Tab(props: TabProps) { className="h-full w-full rounded-t-3xl pl-4 pr-4" underlayElement={UNDERLAY_ELEMENT} > -
+
@@ -98,7 +98,7 @@ export default function DragModal(props: DragModalProps) { ))}
- + {React.Children.toArray(children).length}
From 5fbbc8d449499041c4b6b56d9b0525ee4ba122c6 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Thu, 26 Dec 2024 21:38:29 +0400 Subject: [PATCH 7/8] Add a few comments --- .../dashboard/components/SelectionBrush.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/gui/src/dashboard/components/SelectionBrush.tsx b/app/gui/src/dashboard/components/SelectionBrush.tsx index 48ab37bfbe22..999d5c48f9ab 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -56,14 +56,41 @@ export interface SelectionBrushV2Props { * The direction of the Drag/Scroll. */ const enum DIRECTION { + /** + * • + */ NONE = 0, + /** + * ⬅️ + */ LEFT = 1, + /** + * ➡️ + */ RIGHT = 2, + /** + * ⬆️ + */ TOP = 3, + /** + * ⬇️ + */ BOTTOM = 4, + /** + * ↙️ + */ BOTTOM_LEFT = 5, + /** + * ↘️ + */ BOTTOM_RIGHT = 6, + /** + * ↖️ + */ TOP_LEFT = 7, + /** + * ↗️ + */ TOP_RIGHT = 8, } @@ -188,8 +215,12 @@ export function SelectionBrush(props: SelectionBrushV2Props) { return } + // Calculate the direction of the scroll. + // This is used to understand, where we should extend the rectangle. const direction = getDirectionFromScrollDiff(diffX, diffY) + // Calculate the next rectangle based on the scroll direction. + // New rectangle extends by the scroll distance. const nextRectangle = calculateRectangleFromScrollDirection(currentRectangle, direction, { left: diffX, top: diffY, @@ -197,6 +228,9 @@ export function SelectionBrush(props: SelectionBrushV2Props) { const detailedRectangle = getDetailedRectangleFromRectangle(nextRectangle) + // Since we scroll the container, we need to update the start position + // (the position of the cursor when the drag started) + // to make it on sync with apropriate corner of the rectangle. startPositionRef.current = calculateNewStartPositionFromScrollDirection( start, current, @@ -274,6 +308,8 @@ export function SelectionBrush(props: SelectionBrushV2Props) { currentPositionRef.current = { left: event.pageX, top: event.pageY } + // Check if the user has passed the dead zone. + // Dead zone shall be passed only once. if (hasPassedDeadZoneRef.current === false) { hasPassedDeadZoneRef.current = !isInDeadZone(start, current, DEAD_ZONE_SIZE) } @@ -286,6 +322,8 @@ export function SelectionBrush(props: SelectionBrushV2Props) { const detailedRectangle = getDetailedRectangle(start, current) + // Capture the pointer events to lock the whole selection to the target. + // and don't invoke hover events. when the user is dragging. targetRef.current?.setPointerCapture(event.pointerId) currentRectangleRef.current = detailedRectangle previousPositionRef.current = { left: current.left, top: current.top } From 30cbe3d4efae725bbc4df9c48d9a21bcaf1fd010 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 27 Dec 2024 14:16:29 +0400 Subject: [PATCH 8/8] FIx target directory --- app/gui/src/dashboard/layouts/AssetsTable.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 7a7386a4236e..e99945387caa 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -458,9 +458,13 @@ function AssetsTable(props: AssetsTableProps) { } else if (selectedKeys.size === 1) { const [soleKey] = selectedKeys const item = soleKey == null ? null : nodeMapRef.current.get(soleKey) + if (item != null && item.isType(AssetType.directory)) { setTargetDirectory(item) + } else { + setTargetDirectory(null) } + if ( item != null && item.item.id !== assetPanelStore.getState().assetPanelProps.item?.id @@ -471,6 +475,7 @@ function AssetsTable(props: AssetsTableProps) { } else { let commonDirectoryKey: AssetId | null = null let otherCandidateDirectoryKey: AssetId | null = null + for (const key of selectedKeys) { const node = nodeMapRef.current.get(key) if (node != null) { @@ -499,8 +504,11 @@ function AssetsTable(props: AssetsTableProps) { } const node = commonDirectoryKey == null ? null : nodeMapRef.current.get(commonDirectoryKey) + if (node != null && node.isType(AssetType.directory)) { setTargetDirectory(node) + } else { + setTargetDirectory(null) } } }