streetmix/streetmix

View on GitHub
assets/scripts/segments/drag_and_drop.js

Summary

Maintainability
F
3 days
Test Coverage
import { nanoid } from 'nanoid'
import { infoBubble } from '../info_bubble/info_bubble'
import { app } from '../preinit/app_settings'
import { setIgnoreStreetChanges } from '../streets/data_model'
import { getElAbsolutePos } from '../util/helpers'
import store, { observeStore } from '../store'
import { addSegment, removeSegment, moveSegment } from '../store/slices/street'
import {
  initDraggingState,
  updateDraggingState,
  clearDraggingState,
  setActiveSegment,
  setDraggingType
} from '../store/slices/ui'
import { generateRandSeed } from '../util/random'
import { getWidthInMetric } from '../util/width_units'
import { SegmentTypes, getSegmentInfo, getSegmentVariantInfo } from './info'
import {
  RESIZE_TYPE_INITIAL,
  RESIZE_TYPE_DRAGGING,
  RESIZE_TYPE_PRECISE_DRAGGING,
  resizeSegment,
  handleSegmentResizeEnd,
  resolutionForResizeType,
  normalizeSegmentWidth,
  hideControls,
  cancelSegmentResizeTransitions
} from './resizing'
import { getVariantArray, getVariantString } from './variant_utils'
import {
  TILE_SIZE,
  MIN_SEGMENT_WIDTH,
  DRAGGING_MOVE_HOLE_WIDTH,
  DRAGGING_TYPE_NONE,
  DRAGGING_TYPE_MOVE,
  DRAGGING_TYPE_RESIZE
} from './constants'
import { segmentsChanged } from './view'

export const draggingResize = {
  segmentEl: null,
  floatingEl: null,
  mouseX: null,
  mouseY: null,
  elX: null,
  elY: null,
  width: null,
  originalX: null,
  originalWidth: null,
  right: false
}

let __BUGFIX_SUPPRESS_WRONG_MOUSEENTER_HANDLER = false

export function _resetBugfix () {
  __BUGFIX_SUPPRESS_WRONG_MOUSEENTER_HANDLER = false
}

export function _getBugfix () {
  return __BUGFIX_SUPPRESS_WRONG_MOUSEENTER_HANDLER
}

export function initDragTypeSubscriber () {
  const select = (state) => state.ui.draggingType

  const onChange = (draggingType) => {
    document.body.classList.remove('segment-move-dragging')
    document.body.classList.remove('segment-resize-dragging')

    switch (draggingType) {
      case DRAGGING_TYPE_RESIZE:
        document.body.classList.add('segment-resize-dragging')
        break
      case DRAGGING_TYPE_MOVE:
        document.body.classList.add('segment-move-dragging')
        break
    }
  }

  return observeStore(select, onChange)
}

function handleSegmentResizeStart (event) {
  let x, y
  if (app.readOnly) {
    return
  }

  if (event.touches && event.touches[0]) {
    x = event.touches[0].pageX
    y = event.touches[0].pageY
  } else {
    x = event.pageX
    y = event.pageY
  }

  setIgnoreStreetChanges(true)

  const el = event.target.closest('.drag-handle')

  store.dispatch(setDraggingType(DRAGGING_TYPE_RESIZE))

  const pos = getElAbsolutePos(el)

  draggingResize.right = el.classList.contains('drag-handle-right')

  draggingResize.floatingEl = document.createElement('div')
  draggingResize.floatingEl.classList.add('drag-handle')
  draggingResize.floatingEl.classList.add('floating')

  if (el.classList.contains('drag-handle-left')) {
    draggingResize.floatingEl.classList.add('drag-handle-left')
  } else {
    draggingResize.floatingEl.classList.add('drag-handle-right')
  }

  draggingResize.floatingEl.style.left =
    pos[0] - document.querySelector('#street-section-outer').scrollLeft + 'px'
  draggingResize.floatingEl.style.top = pos[1] + 'px'
  document.body.appendChild(draggingResize.floatingEl)

  draggingResize.mouseX = x
  draggingResize.mouseY = y

  draggingResize.elX = pos[0]
  draggingResize.elY = pos[1]

  draggingResize.originalX = draggingResize.elX
  draggingResize.originalWidth =
    store.getState().street.segments[el.parentNode.dataNo].width
  draggingResize.segmentEl = el.parentNode

  draggingResize.segmentEl.classList.add('hover')

  infoBubble.hide()
  infoBubble.hideSegment(true)
  hideControls()

  window.setTimeout(function () {
    draggingResize.segmentEl.classList.add('hover')
  }, 0)
}

function handleSegmentResizeMove (event) {
  let x, y, resizeType
  if (event.touches && event.touches[0]) {
    x = event.touches[0].pageX
    y = event.touches[0].pageY
  } else {
    x = event.pageX
    y = event.pageY
  }

  const deltaX = x - draggingResize.mouseX

  let deltaFromOriginal = draggingResize.elX - draggingResize.originalX
  if (!draggingResize.right) {
    deltaFromOriginal = -deltaFromOriginal
  }

  draggingResize.width =
    draggingResize.originalWidth + (deltaFromOriginal / TILE_SIZE) * 2
  draggingResize.elX += deltaX
  draggingResize.floatingEl.style.left =
    draggingResize.elX -
    document.querySelector('#street-section-outer').scrollLeft +
    'px'

  const precise = event.shiftKey

  if (precise) {
    resizeType = RESIZE_TYPE_PRECISE_DRAGGING
  } else {
    resizeType = RESIZE_TYPE_DRAGGING
  }

  draggingResize.width = resizeSegment(
    draggingResize.segmentEl.dataNo,
    resizeType,
    draggingResize.width
  )

  draggingResize.mouseX = x
  draggingResize.mouseY = y
}

export function onBodyMouseDown (event) {
  if (app.readOnly || (event.touches && event.touches.length !== 1)) {
    return
  }

  if (event.target.closest('.drag-handle')) {
    handleSegmentResizeStart(event)
    event.preventDefault()
  }
}

export function isSegmentWithinCanvas (event, canvasEl) {
  const { remainingWidth } = store.getState().street

  let x, y
  if (event.touches && event.touches[0]) {
    x = event.touches[0].pageX
    y = event.touches[0].pageY
  } else {
    x = event.x
    y = event.y
  }

  const { top, bottom, left, right } = canvasEl.getBoundingClientRect()

  const withinCanvasY = y >= top && y <= bottom
  let withinCanvasX = x >= left && x <= right

  if (!withinCanvasX && remainingWidth < 0) {
    const margin = (remainingWidth * TILE_SIZE) / 2
    const newLeft = left + margin - DRAGGING_MOVE_HOLE_WIDTH
    const newRight = right - margin + DRAGGING_MOVE_HOLE_WIDTH

    withinCanvasX = x >= newLeft && x <= newRight
  }

  const withinCanvas = withinCanvasX && withinCanvasY

  if (oldDraggingState) {
    oldDraggingState.withinCanvas = withinCanvas
  }

  return withinCanvas
}

export function onBodyMouseMove (event) {
  const { draggingType } = store.getState().ui

  if (draggingType === DRAGGING_TYPE_NONE) {
    return
  } else if (draggingType === DRAGGING_TYPE_RESIZE) {
    handleSegmentResizeMove(event)
  }

  event.preventDefault()
}

function doDropHeuristics (draggedItem, draggedItemType) {
  // Automatically figure out width
  const street = store.getState().street
  const { variantString, type, actualWidth } = draggedItem

  if (draggedItemType === Types.PALETTE_SEGMENT) {
    if (street.remainingWidth > 0 && actualWidth > street.remainingWidth) {
      const segmentMinWidth =
        getWidthInMetric(
          getSegmentVariantInfo(type, variantString).minWidth,
          street.units
        ) ?? 0

      if (
        street.remainingWidth >= MIN_SEGMENT_WIDTH &&
        street.remainingWidth >= segmentMinWidth
      ) {
        draggedItem.actualWidth = normalizeSegmentWidth(
          street.remainingWidth,
          resolutionForResizeType(RESIZE_TYPE_INITIAL, street.units)
        )
      }
    }
  }

  // Automatically figure out variants
  const { segmentBeforeEl, segmentAfterEl } = store.getState().ui.draggingState

  const left =
    segmentAfterEl !== undefined ? street.segments[segmentAfterEl] : null
  const right =
    segmentBeforeEl !== undefined ? street.segments[segmentBeforeEl] : null

  const leftOwner = left && SegmentTypes[getSegmentInfo(left.type).owner]
  const rightOwner = right && SegmentTypes[getSegmentInfo(right.type).owner]

  const leftOwnerAsphalt =
    leftOwner === SegmentTypes.CAR ||
    leftOwner === SegmentTypes.BIKE ||
    leftOwner === SegmentTypes.TRANSIT
  const rightOwnerAsphalt =
    rightOwner === SegmentTypes.CAR ||
    rightOwner === SegmentTypes.BIKE ||
    rightOwner === SegmentTypes.TRANSIT

  const leftVariant = left && getVariantArray(left.type, left.variantString)
  const rightVariant = right && getVariantArray(right.type, right.variantString)

  const variant = getVariantArray(type, variantString)
  const segmentInfo = getSegmentInfo(type)

  // Direction

  if (segmentInfo.variants.indexOf('direction') !== -1) {
    if (leftVariant && leftVariant.direction) {
      variant.direction = leftVariant.direction
    } else if (rightVariant && rightVariant.direction) {
      variant.direction = rightVariant.direction
    }
  }

  // Parking lane orientation

  if (segmentInfo.variants.indexOf('parking-lane-orientation') !== -1) {
    if (!right || !rightOwnerAsphalt) {
      variant['parking-lane-orientation'] = 'right'
    } else if (!left || !leftOwnerAsphalt) {
      variant['parking-lane-orientation'] = 'left'
    }
  }

  // Parklet orientation

  if (type === 'parklet') {
    if (left && leftOwnerAsphalt) {
      variant.orientation = 'right'
    } else if (right && rightOwnerAsphalt) {
      variant.orientation = 'left'
    }
  }

  // Turn lane orientation

  if (segmentInfo.variants.indexOf('turn-lane-orientation') !== -1) {
    if (!right || !rightOwnerAsphalt) {
      variant['turn-lane-orientation'] = 'right'
    } else if (!left || !leftOwnerAsphalt) {
      variant['turn-lane-orientation'] = 'left'
    }
  }

  // Transit shelter orientation and elevation

  if (type === 'transit-shelter') {
    if (left && leftOwner === SegmentTypes.TRANSIT) {
      variant.orientation = 'right'
    } else if (right && rightOwner === SegmentTypes.TRANSIT) {
      variant.orientation = 'left'
    }
  }

  if (segmentInfo.variants.indexOf('transit-shelter-elevation') !== -1) {
    if (variant.orientation === 'right' && left && left.type === 'light-rail') {
      variant['transit-shelter-elevation'] = 'light-rail'
    } else if (
      variant.orientation === 'left' &&
      right &&
      right.type === 'light-rail'
    ) {
      variant['transit-shelter-elevation'] = 'light-rail'
    }
  }

  // BRT station orientation

  if (type === 'brt-station') {
    // Default orientation is center
    variant['brt-station-orientation'] = 'center'
    if (left && leftOwner === SegmentTypes.TRANSIT) {
      variant['brt-station-orientation'] = 'right'
    } else if (right && rightOwner === SegmentTypes.TRANSIT) {
      variant['brt-station-orientation'] = 'left'
    }
  }

  // Bike rack orientation

  if (type === 'sidewalk-bike-rack') {
    if (left && leftOwner !== SegmentTypes.PEDESTRIAN) {
      variant.orientation = 'left'
    } else if (right && rightOwner !== SegmentTypes.PEDESTRIAN) {
      variant.orientation = 'right'
    }
  }

  // Lamp orientation

  if (segmentInfo.variants.indexOf('lamp-orientation') !== -1) {
    if (left && right && leftOwnerAsphalt && rightOwnerAsphalt) {
      variant['lamp-orientation'] = 'both'
    } else if (left && leftOwnerAsphalt) {
      variant['lamp-orientation'] = 'left'
    } else if (right && rightOwnerAsphalt) {
      variant['lamp-orientation'] = 'right'
    } else if (left && right) {
      variant['lamp-orientation'] = 'both'
    } else if (left) {
      variant['lamp-orientation'] = 'left'
    } else if (right) {
      variant['lamp-orientation'] = 'right'
    } else {
      variant['lamp-orientation'] = 'both'
    }
  }

  draggedItem.variantString = getVariantString(variant)
}

export function onBodyMouseUp (event) {
  const { draggingType } = store.getState().ui

  switch (draggingType) {
    case DRAGGING_TYPE_NONE:
      return
    case DRAGGING_TYPE_RESIZE:
      handleSegmentResizeEnd(event)
      break
  }

  event.preventDefault()
}

function handleSegmentDragStart () {
  infoBubble.hide()
  hideControls()
}

function handleSegmentDragEnd () {
  oldDraggingState = null
  cancelSegmentResizeTransitions()
  segmentsChanged(false)

  document.body.classList.remove('not-within-canvas')
}

export const Types = {
  SEGMENT: 'SEGMENT',
  PALETTE_SEGMENT: 'PALETTE_SEGMENT'
}

export const segmentSource = {
  canDrag (props) {
    return !store.getState().app.readOnly
  },

  isDragging (props, monitor) {
    return monitor.getItem().id === props.segment.id
  },

  beginDrag (props, monitor, component) {
    handleSegmentDragStart()

    store.dispatch(setDraggingType(DRAGGING_TYPE_MOVE))

    return {
      id: props.segment.id,
      dataNo: props.dataNo,
      variantString: props.segment.variantString,
      type: props.segment.type,
      label: props.segment.label,
      actualWidth: props.segment.width,
      elevation: props.segment.elevation
    }
  },

  endDrag (props, monitor, component) {
    store.dispatch(clearDraggingState())

    if (!monitor.didDrop()) {
      // if no object returned by a drop handler, check if it is still within the canvas
      const withinCanvas = oldDraggingState && oldDraggingState.withinCanvas
      if (withinCanvas) {
        handleSegmentCanvasDrop(monitor.getItem(), monitor.getItemType())
      } else if (monitor.getItemType() === Types.SEGMENT) {
        // if existing segment is dropped outside canvas, delete it
        store.dispatch(removeSegment(props.dataNo))
      }
    }

    handleSegmentDragEnd()
  }
}

export const paletteSegmentSource = {
  canDrag (props) {
    return !store.getState().app.readOnly
  },

  beginDrag (props, monitor, component) {
    handleSegmentDragStart()

    // Initialize an empty draggingState object in Redux for palette segments
    // in order to add event listener in StreetEditable once dragging begins.
    // Also set the dragging type to MOVE. We use one action creator here and
    // one dispatch to reduce batch renders.
    store.dispatch(initDraggingState(DRAGGING_TYPE_MOVE))

    const { units } = store.getState().street
    const type = props.segment.id

    // The preview drag should match artwork in the thumbnail. The variant
    // string is specified by `defaultVariant`. If the property isn't present,
    // use the first defined variant in segment details.
    const variantString =
      props.segment.defaultVariant || Object.keys(props.segment.details).shift()

    // This allows dropped segment to be created with the correct elevation value
    let elevation = 0
    if (props.segment.defaultElevation !== undefined) {
      elevation = props.segment.defaultElevation
    } else {
      const variantInfo = getSegmentVariantInfo(type, variantString)
      elevation = variantInfo.elevation
    }

    return {
      id: generateRandSeed(),
      type,
      variantString,
      actualWidth: getWidthInMetric(props.segment.defaultWidth, units),
      elevation
    }
  },

  endDrag (props, monitor, component) {
    store.dispatch(clearDraggingState())

    const withinCanvas = oldDraggingState?.withinCanvas
    if (!monitor.didDrop() && withinCanvas) {
      handleSegmentCanvasDrop(monitor.getItem(), monitor.getItemType())
    }

    handleSegmentDragEnd()
  }
}

export function collectDragSource (connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    connectDragPreview: connect.dragPreview(),
    isDragging: monitor.isDragging()
  }
}

/**
 * Calculates the additional space needed before/after a segment during dragging
 *
 * @param {Number} dataNo - position of the current segment whose segment position
 *    is being calculated
 * @param {Object} draggingState - includes the positions of the segment the dragged
 *    segment is after (segmentAfterEl) and the segment the dragged segment is before
 *    (segmentBeforeEl), and undefined if it does not have one
 *
 */
export function makeSpaceBetweenSegments (dataNo, draggingState) {
  const { segmentBeforeEl, segmentAfterEl } = draggingState

  let spaceBetweenSegments = 0

  if (dataNo >= segmentBeforeEl) {
    spaceBetweenSegments += DRAGGING_MOVE_HOLE_WIDTH

    if (segmentAfterEl === undefined) {
      spaceBetweenSegments += DRAGGING_MOVE_HOLE_WIDTH
    }
  }

  if (dataNo > segmentAfterEl) {
    spaceBetweenSegments += DRAGGING_MOVE_HOLE_WIDTH

    if (segmentBeforeEl === undefined) {
      spaceBetweenSegments += DRAGGING_MOVE_HOLE_WIDTH
    }
  }

  return spaceBetweenSegments
}

let oldDraggingState

// Checks to see if Redux dragging state needs to be updated, and if so, dispatches action.
// This prevents a constant dispatch of the updateDraggingState action which causes the
// dragging of the segment to be laggy and choppy.

function updateIfDraggingStateChanged (
  segmentBeforeEl,
  segmentAfterEl,
  draggedItem,
  draggedItemType
) {
  let changed = false

  if (oldDraggingState) {
    changed =
      segmentBeforeEl !== oldDraggingState.segmentBeforeEl ||
      segmentAfterEl !== oldDraggingState.segmentAfterEl ||
      draggedItem.dataNo !== oldDraggingState.draggedSegment
  } else {
    changed = true
  }

  if (changed) {
    oldDraggingState = {
      segmentBeforeEl,
      segmentAfterEl,
      draggedSegment: draggedItem.dataNo
    }

    store.dispatch(
      updateDraggingState({
        segmentBeforeEl,
        segmentAfterEl,
        draggedSegment: draggedItem.dataNo
      })
    )
    doDropHeuristics(draggedItem, draggedItemType)
  }

  return changed
}

export const segmentTarget = {
  canDrop (props, monitor) {
    const type = monitor.getItemType()
    return type === Types.SEGMENT || type === Types.PALETTE_SEGMENT
  },

  hover (props, monitor, component) {
    if (!monitor.canDrop()) return

    const dragIndex = monitor.getItem().dataNo
    const hoverIndex = props.dataNo

    const hoveredSegment = component.streetSegment
    const { left } = hoveredSegment.getBoundingClientRect()
    const hoverMiddleX = Math.round(left + (props.actualWidth * TILE_SIZE) / 2)
    const { x } = monitor.getClientOffset()

    // Ignore hovering over the dragged segment after dragging state is already set.
    // This prevents react-dnd's hover method from being confused on what to update
    // draggingState as when the dragged segment is behind another segment.
    if (dragIndex === hoverIndex && oldDraggingState) return

    if (dragIndex === hoverIndex) {
      updateIfDraggingStateChanged(
        dragIndex,
        undefined,
        monitor.getItem(),
        monitor.getItemType()
      )
    } else {
      const { segments } = store.getState().street

      const segmentBeforeEl =
        x > hoverMiddleX && hoverIndex !== segments.length - 1
          ? hoverIndex + 1
          : hoverIndex === segments.length - 1
            ? undefined
            : hoverIndex

      const segmentAfterEl =
        x > hoverMiddleX && hoverIndex !== 0
          ? hoverIndex
          : hoverIndex === 0
            ? undefined
            : hoverIndex - 1

      updateIfDraggingStateChanged(
        segmentBeforeEl,
        segmentAfterEl,
        monitor.getItem(),
        monitor.getItemType()
      )
    }
  }
}

function handleSegmentCanvasDrop (draggedItem, type) {
  // `oldDraggingState` can be `null` or undefined, if so, bail
  if (!oldDraggingState) return

  const { segmentBeforeEl, segmentAfterEl, draggedSegment } = oldDraggingState

  // If dropped in same position as dragged segment was before, return
  if (segmentBeforeEl === draggedSegment && segmentAfterEl === undefined) {
    store.dispatch(setActiveSegment(draggedSegment))
    return
  }

  const newSegment = {
    id: draggedItem.id ?? nanoid(),
    type: draggedItem.type,
    variantString: draggedItem.variantString,
    width: draggedItem.actualWidth,
    elevation: draggedItem.elevation,
    label: draggedItem.label
  }

  newSegment.variant =
    draggedItem.variant ||
    getVariantArray(newSegment.type, newSegment.variantString)

  let newIndex =
    segmentAfterEl !== undefined ? segmentAfterEl + 1 : segmentBeforeEl

  if (type === Types.SEGMENT) {
    newIndex = newIndex <= draggedSegment ? newIndex : newIndex - 1
    store.dispatch(moveSegment(draggedSegment, newIndex))

    // Immediately after a segment move action, react-dnd can incorrectly trigger
    // the onMouseEnter handler on another <Segment /> component that is in the
    // previous component's location. This sets a variable which <Segment /> uses
    // to suppress a single instance of the onMouseEnter handler. The bug is tracked here
    // (https://github.com/streetmix/streetmix/pull/1262) and here (https://github.com/react-dnd/react-dnd/issues/1102).
    __BUGFIX_SUPPRESS_WRONG_MOUSEENTER_HANDLER = true
  } else {
    store.dispatch(addSegment(newIndex, newSegment))
  }

  store.dispatch(setActiveSegment(newIndex))
}

/**
 * Determines if segment was dropped/hovered on left or right side of street
 *
 * @param {Node} segment - reference to StreetEditable
 * @param {Number} droppedPosition - x position of dropped segment in reference
 *    to StreetEditable
 * @returns {string} - left, right, or null if dropped/hovered over a segment
 */
function isOverLeftOrRightCanvas (segment, droppedPosition) {
  const { remainingWidth } = store.getState().street
  const { left, right } = segment.getBoundingClientRect()

  const emptySegmentWidth = (remainingWidth * TILE_SIZE) / 2

  return droppedPosition < left + emptySegmentWidth
    ? 'left'
    : droppedPosition > right - emptySegmentWidth
      ? 'right'
      : null
}

export const canvasTarget = {
  hover (props, monitor, component) {
    if (!monitor.canDrop()) return

    if (monitor.isOver({ shallow: true })) {
      const position = isOverLeftOrRightCanvas(
        component.streetSectionEditable,
        monitor.getClientOffset().x
      )

      if (!position) return

      const { segments } = store.getState().street
      const segmentBeforeEl = position === 'left' ? 0 : undefined
      const segmentAfterEl =
        position === 'left' ? undefined : segments.length - 1

      updateIfDraggingStateChanged(
        segmentBeforeEl,
        segmentAfterEl,
        monitor.getItem(),
        monitor.getItemType()
      )
    }
  },

  drop (props, monitor, component) {
    const draggedItem = monitor.getItem()
    const draggedItemType = monitor.getItemType()

    handleSegmentCanvasDrop(draggedItem, draggedItemType)

    return { withinCanvas: true }
  }
}

export function collectDropTarget (connect, monitor) {
  return {
    connectDropTarget: connect.dropTarget()
  }
}