iterative/vscode-dvc

View on GitHub
webview/src/shared/hooks/useDragAndDrop.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
A
98%
import React, {
  CSSProperties,
  DragEvent,
  useCallback,
  useRef,
  useState
} from 'react'
import cx from 'classnames'
import { useDispatch, useSelector } from 'react-redux'
import { useDeferredDragLeave } from './useDeferredDragLeave'
import {
  DragEnterDirection,
  getDragEnterDirection,
  isEnteringAfter,
  isExactGroup,
  isSameGroup,
  isSameGroupOtherSection
} from '../components/dragDrop/util'
import { PlotsState } from '../../plots/store'
import {
  changeRef,
  setDirection,
  setDraggedOverGroup,
  setDraggedOverId
} from '../components/dragDrop/dragDropSlice'
import { getIDIndex } from '../../util/ids'
import { idToNode } from '../../util/helpers'
import { getStyleProperty } from '../../util/styles'
import { DropTarget } from '../components/dragDrop/DropTarget'
import styles from '../components/dragDrop/styles.module.scss'

export type OnDrop = (
  draggedId: string,
  draggedGroup: string,
  groupId: string,
  position: number
) => void

interface DragAndDropProps {
  id: string
  disabledDropIds?: string[]
  shouldShowOnDrag?: boolean
  onDrop?: OnDrop
  onDragEnd?: () => void
  order: string[]
  setOrder: (order: string[]) => void
  group: string
  dropTarget: JSX.Element
  ghostElemStyle?: CSSProperties
  isParentDraggedOver?: boolean
  vertical?: boolean
  style?: CSSProperties
  type?: JSX.Element
  isLast?: boolean
}

const orderIdxTune = (
  direction: DragEnterDirection | undefined,
  isAfter: boolean
) => {
  if (direction && isEnteringAfter(direction)) {
    return isAfter ? 0 : 1
  }

  return isAfter ? -1 : 0
}

const setStyle = (elem: HTMLElement, style: CSSProperties, reset?: boolean) => {
  for (const [rule, value] of Object.entries(style)) {
    elem.style[getStyleProperty(rule)] = reset ? '' : value
  }
}

export const useDragAndDrop = ({
  id,
  disabledDropIds = [],
  shouldShowOnDrag,
  onDragEnd,
  vertical,
  group,
  onDrop,
  order,
  setOrder,
  ghostElemStyle,
  isParentDraggedOver,
  style,
  dropTarget,
  isLast,
  type // eslint-disable-next-line sonarjs/cognitive-complexity
}: DragAndDropProps) => {
  const [isDragged, setIsDragged] = useState(false)

  const { immediateDragLeave, immediateDragEnter, deferredDragLeave } =
    useDeferredDragLeave()
  const {
    draggedRef,
    draggedOverGroup,
    direction,
    draggedOverId,
    isHoveringSomething
  } = useSelector((state: PlotsState) => state.dragAndDrop)
  const draggedOverIdTimeout = useRef<number>(0)

  const dispatch = useDispatch()

  const isDropTarget = id.includes('__drop')
  const isDisabled = disabledDropIds.includes(id)

  const isDraggedOver = draggedOverId === id
  const setIsDraggedOver = (isDraggedOver: boolean) =>
    isDraggedOver && dispatch(setDraggedOverId(id))

  const cleanup = useCallback(() => {
    immediateDragLeave()

    dispatch(setDraggedOverId(undefined))
    setIsDragged(false)
    dispatch(setDirection(undefined))
    dispatch(changeRef(undefined))
  }, [immediateDragLeave, dispatch])

  // eslint-disable-next-line sonarjs/cognitive-complexity
  const handleDragStart = (e: DragEvent<HTMLElement>) => {
    const defaultDragEnterDirection = vertical
      ? DragEnterDirection.TOP
      : DragEnterDirection.LEFT
    dispatch(setDirection(defaultDragEnterDirection))

    const idx = order.indexOf(id)
    let toIdx = shouldShowOnDrag ? idx : idx + 1
    if (!shouldShowOnDrag && toIdx === order.length) {
      toIdx = idx - 1

      if (toIdx === -1) {
        toIdx = 0
      }
    }
    e.dataTransfer.effectAllowed = 'move'
    e.dataTransfer.dropEffect = 'move'

    ghostElemStyle && setStyle(e.currentTarget, ghostElemStyle)
    draggedOverIdTimeout.current = window.setTimeout(() => {
      dispatch(
        changeRef({
          group,
          itemId: id,
          itemIndex: idx.toString()
        })
      )
      setIsDragged(true)
      setIsDraggedOver(order[toIdx] === id)
      const elem = idToNode(id)
      ghostElemStyle && elem && setStyle(elem, ghostElemStyle, true)
    }, 0)
  }

  const handleDragEnter = (e: DragEvent<HTMLElement>) => {
    immediateDragEnter()

    if (isSameGroup(draggedRef?.group, group) && !isDisabled && !isDropTarget) {
      setIsDraggedOver(true)
      dispatch(setDirection(getDragEnterDirection(e, vertical)))
      dispatch(setDraggedOverGroup(group))
    }
  }

  const handleDragOver = (e: DragEvent<HTMLElement>) => {
    e.preventDefault()
    if (isDraggedOver) {
      immediateDragEnter()
    }
    if (isSameGroup(draggedRef?.group, group)) {
      !isDisabled &&
        isDraggedOver &&
        dispatch(setDirection(getDragEnterDirection(e, vertical)))
    }
  }

  const handleDragLeave = () => {
    deferredDragLeave()
  }

  const handleDragEnd = () => {
    onDragEnd?.()
    cleanup()
  }

  const applyDrop = (
    droppedIndex: number,
    draggedIndex: number,
    newOrder: string[],
    oldDraggedId: string,
    isNew: boolean
  ) => {
    if (isNew && draggedRef) {
      newOrder.push(draggedRef.itemId)
    }
    const dragged = newOrder[draggedIndex]
    newOrder.splice(draggedIndex, 1)
    newOrder.splice(droppedIndex, 0, dragged)
    setOrder(newOrder)
    dispatch(changeRef(undefined))

    onDrop?.(oldDraggedId, draggedRef?.group || '', group, droppedIndex)
    cleanup()
  }

  const handleOnDrop = (e: DragEvent<HTMLElement>) => {
    e.stopPropagation()
    if (!draggedRef) {
      return
    }
    draggedOverIdTimeout.current = window.setTimeout(() => {
      setIsDragged(false)
    }, 0)
    if (isDragged && isDraggedOver) {
      cleanup()
      return
    }

    const dragged = draggedRef.itemId
    const isNew = !order.includes(dragged)
    const draggedIndex = isNew ? order.length : getIDIndex(draggedRef.itemIndex)
    const droppedIndex = order.indexOf(e.currentTarget.id.split('__')[0])
    const orderIdxChange = orderIdxTune(direction, droppedIndex > draggedIndex)
    const orderIdxChanged = droppedIndex + orderIdxChange
    const isEnabled = !disabledDropIds.includes(order[orderIdxChanged])

    if (isEnabled && isSameGroup(draggedRef.group, group)) {
      applyDrop(orderIdxChanged, draggedIndex, [...order], dragged, isNew)
    }
  }

  const isInOtherSection =
    draggedRef?.itemId === id &&
    isSameGroupOtherSection(draggedRef?.group, draggedOverGroup)

  const isBeingDraggedOver =
    isInOtherSection ||
    (isDraggedOver &&
      (isHoveringSomething || !isParentDraggedOver) &&
      isExactGroup(draggedOverGroup, draggedRef?.group, group))

  const atTheEnd =
    isLast &&
    isSameGroup(draggedRef?.group, group) &&
    !isHoveringSomething &&
    isParentDraggedOver

  const hasTarget = atTheEnd || isBeingDraggedOver
  const isAfter = atTheEnd || isEnteringAfter(direction)

  const dropTargetClassNames = shouldShowOnDrag
    ? cx(styles.dropTargetWhenShowingOnDrag, {
        [styles.dropTargetWhenShowingOnDragLeft]: !isAfter,
        [styles.dropTargetWhenShowingOnDragRight]: isAfter
      })
    : undefined

  const target = hasTarget && (
    <DropTarget
      key={`drop-target-${id}`}
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleOnDrop}
      id={id}
      className={dropTargetClassNames}
      wrapper={type}
    >
      {dropTarget}
    </DropTarget>
  )

  return {
    draggable: !disabledDropIds.includes(id),
    isAfter,
    onDragEnd: handleDragEnd,
    onDragEnter: handleDragEnter,
    onDragLeave: handleDragLeave,
    onDragOver: handleDragOver,
    onDragStart: handleDragStart,
    onDrop: handleOnDrop,
    style:
      (!shouldShowOnDrag && isDragged && { ...style, display: 'none' }) ||
      style,
    target
  }
}