diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index d39984bae353..4734f132c706 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/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/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/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/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/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 8caaf04f14da..999d5c48f9ab 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -2,213 +2,563 @@ import * as React from 'react' import Portal from '#/components/Portal' -import * as animationHooks from '#/hooks/animationHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' -import * as modalProvider from '#/providers/ModalProvider' -import * as eventModule from '#/utilities/event' +import { useEventListener } from '#/hooks/eventListenerHooks' +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' // ================= // === Constants === // ================= /** - * Controls the speed of animation of the {@link SelectionBrush} when the - * mouse is released and the selection brush collapses back to zero size. + * Defines the minimal distance that the mouse must move before + * we consider that user has started a selection. */ -const ANIMATION_TIME_HORIZON = 60 +const DEAD_ZONE_SIZE = 24 + +// eslint-disable-next-line no-restricted-syntax +const noop = () => {} // ====================== // === SelectionBrush === // ====================== -/** Props for a {@link SelectionBrush}. */ -export interface 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 SelectionBrush}. + */ +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 margin?: number - readonly onDrag: (rectangle: geometry.DetailedRectangle, event: MouseEvent) => void - readonly onDragEnd: (event: MouseEvent) => void - readonly onDragCancel: () => void + readonly isDisabled?: boolean + readonly preventDrag?: (event: PointerEvent) => boolean } -/** 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 isMouseDownRef = 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) +/** + * 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, +} - React.useEffect(() => { - if (anchor != null) { - anchorAnimFactor.skip() +/** + * A selection brush to indicate the area being selected by the mouse drag action. + */ +export function SelectionBrush(props: SelectionBrushV2Props) { + const { + targetRef, + preventDrag = () => false, + onDragStart = noop, + onDrag = noop, + onDragEnd = noop, + onDragCancel = noop, + isDisabled = false, + } = props + + const [isDragging, setIsDragging] = React.useState(false) + + /** + * Whether the pointer has passed the dead zone, + * and user started dragging. + * This is used to prevent the selection brush from being + * invoked when user clicks on the element with tiny movement. + */ + const hasPassedDeadZoneRef = React.useRef(false) + + const startPositionRef = React.useRef(null) + const previousPositionRef = React.useRef(null) + const currentPositionRef = React.useRef({ left: 0, top: 0 }) + const currentRectangleRef = React.useRef(null) + + const scrollContainersLastScrollPositionRef = React.useRef< + Map + >(new Map()) + + 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 { scheduleRAF: scheduleRAFScroll, cancelRAF: cancelRAFScroll } = useRafThrottle() + + const startDragging = useEventCallback(() => { + setIsDragging(true) + hasPassedDeadZoneRef.current = true + }) + + const applyBrushPosition = useEventCallback((rectangle: geometry.DetailedRectangle) => { + left.set(rectangle.left) + top.set(rectangle.top) + width.set(rectangle.width) + height.set(rectangle.height) + }) + + const resetState = useEventCallback(() => { + setIsDragging(false) + cancelRAF() + cancelRAFScroll() + hasPassedDeadZoneRef.current = false + startPositionRef.current = null + currentPositionRef.current = { left: 0, top: 0 } + previousPositionRef.current = null + currentRectangleRef.current = null + left.set(null) + top.set(null) + width.set(null) + height.set(null) + }) + + const updateBrush = useEventCallback((rectangle: geometry.DetailedRectangle) => { + if (!isDragging) { + startDragging() } - }, [anchorAnimFactor, anchor]) + + applyBrushPosition(rectangle) + }) 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) - } + if (!isDragging) { + return } - const unsetAnchor = () => { - if (anchorRef.current != null) { - anchorRef.current = null - setAnchor(null) - } + + const scrollContainers = findScrollContainers(targetRef.current) + + const callback = (event: Event) => { + const start = startPositionRef.current + const current = currentPositionRef.current + const currentRectangle = currentRectangleRef.current + + scheduleRAFScroll(() => { + // eslint-disable-next-line no-restricted-syntax + const target = event.target as unknown as HTMLOrSVGElement + + if (!scrollContainers.includes(target)) { + return + } + + // If we don't have a start position or a current rectangle, we can't update the brush. + // and thus we ignore the event. + if (currentRectangle == null || start == null) { + return + } + + const nextLeft = target.scrollLeft + const nextTop = target.scrollTop + + const lastX = scrollContainersLastScrollPositionRef.current.get(target)?.left ?? 0 + const lastY = scrollContainersLastScrollPositionRef.current.get(target)?.top ?? 0 + + const diffX = nextLeft - lastX + const diffY = nextTop - lastY + + if (diffX === 0 && diffY === 0) { + 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, + }) + + 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, + nextRectangle, + ) + + currentRectangleRef.current = detailedRectangle + + updateBrush(detailedRectangle) + + scrollContainersLastScrollPositionRef.current.set(target, { left: nextLeft, top: nextTop }) + }) } - const onMouseDown = (event: MouseEvent) => { - 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) - } + + scrollContainers.forEach((container) => { + scrollContainersLastScrollPositionRef.current.set(container, { + left: container.scrollLeft, + top: container.scrollTop, + }) + + container.addEventListener('scroll', callback, { passive: true, capture: true }) + }) + + const lastScrollContainersLastScrollPositionRef = scrollContainersLastScrollPositionRef.current + + return () => { + scrollContainers.forEach((container) => { + container.removeEventListener('scroll', callback) + lastScrollContainersLastScrollPositionRef.delete(container) + }) } - const onMouseUp = (event: MouseEvent) => { - if (didMoveWhileDraggingRef.current) { - onDragEnd(event) + }, [onDragStableCallback, targetRef, updateBrush, isDragging, scheduleRAFScroll]) + + useEventListener( + 'pointerdown', + (event) => { + resetState() + + if (preventDragStableCallback(event)) { + return } - // The `setTimeout` is required, otherwise the values are changed before the `onClick` handler - // is executed. - window.setTimeout(() => { - isMouseDownRef.current = false - didMoveWhileDraggingRef.current = false + + startPositionRef.current = { left: event.pageX, top: event.pageY } + previousPositionRef.current = startPositionRef.current + currentPositionRef.current = startPositionRef.current + + currentRectangleRef.current = getDetailedRectangle( + startPositionRef.current, + currentPositionRef.current, + ) + + onDragStartStableCallback(event) + }, + targetRef, + { isDisabled, capture: true, passive: true }, + ) + + useEventListener( + 'pointermove', + (event) => { + const start = startPositionRef.current + const current = currentPositionRef.current + const currentRectangle = currentRectangleRef.current + + const previous = previousPositionRef.current ?? start + + // Pointer events have higher priority than scroll events. + // Cancel the scroll RAF to prevent the scroll callback from being called. + cancelRAFScroll() + + scheduleRAF(() => { + if (start == null || currentRectangle == null || previous == null) { + return + } + + 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) + } + + if (hasPassedDeadZoneRef.current) { + const diff: geometry.Coordinate2D = { + left: current.left - previous.left, + top: current.top - previous.top, + } + + 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 } + + updateBrush(detailedRectangle) + + onDragStableCallback({ + diff, + start, + current, + rectangle: detailedRectangle, + event, + }) + } }) - unsetAnchor() - } - const onMouseMove = (event: MouseEvent) => { - if (!(event.buttons & 1)) { - isMouseDownRef.current = false + }, + document, + { isDisabled, capture: true, passive: true }, + ) + + useEventListener( + 'pointerup', + (event) => { + resetState() + targetRef.current?.releasePointerCapture(event.pointerId) + if (isDragging) { + onDragEndStableCallback(event) } - 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 }) + }, + document, + { isDisabled, capture: true, passive: true }, + ) + + useEventListener( + 'pointercancel', + (event) => { + resetState() + targetRef.current?.releasePointerCapture(event.pointerId) + if (isDragging) { + onDragEndStableCallback(event) + onDragCancelStableCallback() } - } - const onClick = (event: MouseEvent) => { - if (isMouseDownRef.current && didMoveWhileDraggingRef.current) { - event.stopImmediatePropagation() + }, + 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 +} + +/** + * Get the direction from the scroll difference. + * @param diffX - The difference in the x direction. + * @param diffY - The difference in the y direction. + * @returns The direction. + */ +function getDirectionFromScrollDiff(diffX: number, diffY: number): DIRECTION { + if (diffX > 0 && diffY === 0) { + return DIRECTION.RIGHT + } + + if (diffX < 0 && diffY === 0) { + return DIRECTION.LEFT + } + + if (diffX === 0 && diffY > 0) { + return DIRECTION.BOTTOM + } + + if (diffX === 0 && diffY < 0) { + return DIRECTION.TOP + } + + if (diffX > 0 && diffY > 0) { + return DIRECTION.BOTTOM_RIGHT + } + + if (diffX < 0 && diffY > 0) { + return DIRECTION.BOTTOM_LEFT + } + + if (diffX < 0 && diffY < 0) { + return DIRECTION.TOP_LEFT + } + + if (diffX > 0 && diffY < 0) { + return DIRECTION.TOP_RIGHT + } + + return DIRECTION.NONE +} + +/** + * Calculate new rectangle from the scroll direction. + * @param start - The start rectangle. + * @param direction - The direction. + * @param diff - The difference. + * @returns The rectangle. + */ +function calculateRectangleFromScrollDirection( + start: geometry.Rectangle, + direction: DIRECTION, + diff: geometry.Coordinate2D, +): geometry.Rectangle { + switch (direction) { + case DIRECTION.LEFT: + return { + ...start, + right: start.right - diff.left, } - } - const onDragStart = () => { - if (isMouseDownRef.current) { - isMouseDownRef.current = false - onDragCancel() - unsetAnchor() + case DIRECTION.RIGHT: + return { + ...start, + left: start.left + diff.left, } - } - - 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, + case DIRECTION.TOP: + return { + ...start, + bottom: start.bottom - diff.top, + } + case DIRECTION.BOTTOM: + return { + ...start, + top: start.top - diff.top, + } + case DIRECTION.BOTTOM_LEFT: + return { + ...start, + right: start.right + diff.left, + top: start.top - diff.top, } + case DIRECTION.BOTTOM_RIGHT: 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, + ...start, + left: start.left - diff.left, + top: start.top - diff.top, } - } else { - return null + case DIRECTION.TOP_LEFT: + return { + ...start, + right: start.right + diff.left, + bottom: start.bottom - diff.top, + } + case DIRECTION.TOP_RIGHT: + return { + ...start, + bottom: start.bottom - diff.top, + left: start.left - diff.left, + } + default: + return start + } +} + +/** + * Calculate new start position from the scroll direction. + * @param start - The start position of the cursor. + * @param current - The current position of the cursor. + * @param rectangle - The rectangle. + * @returns The new start position. + */ +function calculateNewStartPositionFromScrollDirection( + start: geometry.Coordinate2D, + current: geometry.Coordinate2D, + rectangle: geometry.Rectangle, +) { + const cursorPositionInRectangle = (() => { + if (start.left < current.left && start.top < current.top) { + return DIRECTION.BOTTOM_RIGHT } - }, [anchorAnimFactor.value, lastSetAnchor, position]) - const selectionRectangle = React.useMemo(() => (hidden ? null : rectangle), [hidden, rectangle]) + if (start.left > current.left && start.top > current.top) { + return DIRECTION.TOP_LEFT + } - React.useEffect(() => { - if (selectionRectangle != null && lastMouseEvent.current != null) { - onDrag(selectionRectangle, lastMouseEvent.current) + if (start.left < current.left && start.top > current.top) { + return DIRECTION.BOTTOM_LEFT + } + + if (start.left > current.left && start.top < current.top) { + return DIRECTION.TOP_RIGHT } - }, [onDrag, selectionRectangle]) - - const brushStyle = - rectangle == null ? - {} - : { - left: `${rectangle.left}px`, - top: `${rectangle.top}px`, - width: `${rectangle.width}px`, - height: `${rectangle.height}px`, + + return DIRECTION.NONE + })() + + switch (cursorPositionInRectangle) { + case DIRECTION.TOP_LEFT: + return { + top: rectangle.top, + left: rectangle.left, } - return ( - -