From a7251eb8d461454231eee61bce117bc298889cef Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 27 Feb 2024 02:49:49 +1000 Subject: [PATCH] Improve mouse and keyboard UX (#9100) - Close https://github.com/enso-org/cloud-v2/issues/914 - Add selection brush for selecting multiple assets using mouse - Port selection brush over from GUI2 - Support Ctrl-select to select multiple ranges - Add various actions when *exactly one* asset is selected: - Enter for various assets to trigger their double-click actions - Projects are opened - Directories are toggled open/closed - Secrets show the "upsert secret modal" - ArrowLeft now collapses the selected folder - ArrowRight now expands the selected folder - ArrowUp and ArrowDown change the selected asset to the previous/next asset - The newly selected asset (technically: any asset that is the only selected asset, whether this is a result of a drag, mouse click, or keypress) is automatically smoothly scrolled to. - Improvements to the search bar - Escape cancels tabbing through suggestions (and discards the selected suggestion) - ArrowUp and ArrowDown behave like Shift+Tab and Tab to move to the previous/next suggestion respectively - Shift+ArrowUp and Shift+ArrowDown to select multiple assets using the keyboard - Ctrl+Space to toggle assets using the keyboard - Escape to deselect all assets - Add CSS-only focus ring to highlight most recently selected item, but only when navigating via keyboard - Enter and double-click to temporarily open the sidebar to edit a Data Link Optional features that have not yet been implemented: - Move the "update secret" modal to the sidebar as well # Important Notes None --- .../src/components/SelectionBrush.tsx | 184 +++++++ .../src/components/dashboard/AssetRow.tsx | 68 +-- .../dashboard/DataLinkNameColumn.tsx | 4 +- .../src/components/dashboard/column.ts | 2 +- .../lib/dashboard/src/hooks/animationHooks.ts | 115 +++++ .../src/layouts/dashboard/AssetSearchBar.tsx | 19 +- .../src/layouts/dashboard/AssetsTable.tsx | 459 +++++++++++++++--- .../dashboard/src/layouts/dashboard/Drive.tsx | 8 +- .../dashboard/Settings/AccountSettingsTab.tsx | 7 +- .../Settings/OrganizationSettingsTab.tsx | 7 +- .../layouts/dashboard/UpsertSecretModal.tsx | 1 + .../src/layouts/dashboard/UserBar.tsx | 1 + .../src/layouts/dashboard/UserMenu.tsx | 7 +- .../src/pages/dashboard/Dashboard.tsx | 8 +- .../src/pages/subscribe/Subscribe.tsx | 2 +- .../lib/dashboard/src/tailwind.css | 2 +- .../src/utilities/ShortcutManager.ts | 2 +- .../lib/dashboard/src/utilities/geometry.ts | 19 + .../lib/dashboard/tailwind.config.js | 5 +- 19 files changed, 810 insertions(+), 110 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/animationHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/utilities/geometry.ts diff --git a/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx b/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx new file mode 100644 index 000000000000..cc9f98a56fd6 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/components/SelectionBrush.tsx @@ -0,0 +1,184 @@ +/** @file A selection brush to indicate the area being selected by the mouse drag action. */ +import * as React from 'react' + +import * as reactDom from 'react-dom' + +import * as animationHooks from '#/hooks/animationHooks' + +import * as modalProvider from '#/providers/ModalProvider' + +import type * as geometry from '#/utilities/geometry' + +// ====================== +// === SelectionBrush === +// ====================== + +/** Props for a {@link SelectionBrush}. */ +export interface SelectionBrushProps { + 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 { onDrag, onDragEnd, onDragCancel } = props + const { modalRef } = modalProvider.useModalRef() + const isMouseDownRef = React.useRef(false) + const didMoveWhileDraggingRef = React.useRef(false) + const onDragRef = React.useRef(onDrag) + const onDragEndRef = React.useRef(onDragEnd) + const onDragCancelRef = React.useRef(onDragCancel) + const lastMouseEvent = React.useRef(null) + const [anchor, setAnchor] = React.useState(null) + // This will be `null` if `anchor` is `null`. + const [position, setPosition] = React.useState(null) + const [lastSetAnchor, setLastSetAnchor] = React.useState(null) + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const anchorAnimFactor = animationHooks.useApproach(anchor != null ? 1 : 0, 60) + const hidden = + anchor == null || + position == null || + (anchor.left === position.left && anchor.top === position.top) + + React.useEffect(() => { + onDragRef.current = onDrag + }, [onDrag]) + + React.useEffect(() => { + onDragEndRef.current = onDragEnd + }, [onDragEnd]) + + React.useEffect(() => { + onDragCancelRef.current = onDragCancel + }, [onDragCancel]) + + React.useEffect(() => { + if (anchor != null) { + anchorAnimFactor.skip() + } + }, [anchorAnimFactor, anchor]) + + React.useEffect(() => { + const onMouseDown = (event: MouseEvent) => { + if ( + modalRef.current == null && + !(event.target instanceof HTMLInputElement) && + !(event.target instanceof HTMLTextAreaElement) && + (!(event.target instanceof HTMLElement) || !event.target.isContentEditable) && + !(event.target instanceof HTMLButtonElement) && + !(event.target instanceof HTMLAnchorElement) + ) { + isMouseDownRef.current = true + didMoveWhileDraggingRef.current = false + lastMouseEvent.current = event + const newAnchor = { left: event.pageX, top: event.pageY } + setAnchor(newAnchor) + setLastSetAnchor(newAnchor) + setPosition(newAnchor) + } + } + const onMouseUp = (event: MouseEvent) => { + if (didMoveWhileDraggingRef.current) { + onDragEndRef.current(event) + } + // The `setTimeout` is required, otherwise the values are changed before the `onClick` handler + // is executed. + window.setTimeout(() => { + isMouseDownRef.current = false + didMoveWhileDraggingRef.current = false + }) + setAnchor(null) + } + const onMouseMove = (event: MouseEvent) => { + if (!(event.buttons & 1)) { + isMouseDownRef.current = false + } + if (isMouseDownRef.current) { + // Left click is being held. + didMoveWhileDraggingRef.current = true + lastMouseEvent.current = event + setPosition({ left: event.pageX, top: event.pageY }) + } + } + const onClick = (event: MouseEvent) => { + if (isMouseDownRef.current && didMoveWhileDraggingRef.current) { + event.stopImmediatePropagation() + } + } + const onDragStart = () => { + if (isMouseDownRef.current) { + isMouseDownRef.current = false + onDragCancelRef.current() + setAnchor(null) + } + } + document.addEventListener('mousedown', onMouseDown) + document.addEventListener('mouseup', onMouseUp) + document.addEventListener('dragstart', onDragStart, { capture: true }) + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('click', onClick) + return () => { + document.removeEventListener('mousedown', onMouseDown) + document.removeEventListener('mouseup', onMouseUp) + document.removeEventListener('dragstart', onDragStart, { capture: true }) + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('click', onClick) + } + }, [/* should never change */ modalRef]) + + 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) + } + // `onChange` is a callback, not a dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectionRectangle]) + + const brushStyle = + rectangle == null + ? {} + : { + left: `${rectangle.left}px`, + top: `${rectangle.top}px`, + width: `${rectangle.width}px`, + height: `${rectangle.height}px`, + } + + return reactDom.createPortal( +