🛠️ DragDrop: Refactor

Hey ,

I'm thrilled to help you learn JavaScript. Unfortunately, you've landed on a page where you cannot access with your current purchase.

Please upgrade (use this link) access this content.

I'm super eager to help you learn more!

🛠️ DragDrop: Refactor

Let’s refactor the Drag/drop component now. We’ll start from the top as usual.

Using CSS instead of JavaScript

We changed some of the original element’s styles to prepare it for dragging. Here are the styles we changed:

draggable.addEventListener('pointerdown', event => {
  // ...
  target.style.position = 'absolute'
  target.style.transform = 'rotate(-5deg)'
  target.style.pointerEvents = 'none'

  target.style.left = `${box.left}px`
  target.style.top = `${box.top}px`
  target.style.width = `${box.width}px`
  target.style.height = `${box.height}px`
  // ...
})

The first three styles (position, transform, and pointerEvents) can be written directly with CSS. If you can write something in CSS, you should, because it reduces JavaScript complexity.

In this case, let’s say we set a custom attribute, data-dragging, to true when we’re dragging element. We can write the CSS like this:

[data-draggable][data-dragging="true"] {
  position: absolute;
  transform: rotate(-5deg);
  pointer-events: none;
}

We can change the JavaScript this way:

draggable.addEventListener('pointerdown', event => {
  // ...
  // Remove these
  target.style.position = 'absolute'
  target.style.transform = 'rotate(-5deg)'
  target.style.pointerEvents = 'none'

  // Add this
  target.dataset.dragging = 'true'
})

When the user releases their mouse, we want to reset these three lines of JavaScript.

function up (event) {
  // ...
  target.style.position = 'static'
  target.style.transform = ''
  target.style.pointerEvents = 'auto'
}

But since we changed these properties in CSS, we can set data-dragging to false to reset these properties.

function up (event) {
  // ...
  // Remove these
  target.style.position = 'static'
  target.style.transform = ''
  target.style.pointerEvents = 'auto'

  // Add this
  target.dataset.dragging = 'false'
}

Removing a redundant line

We used document.body.append(target) directly after target.remove(). In this case, remove is redundant because appendremoves the original element anyway.

We can remove this redundant line.

draggable.addEventListener('pointerdown', event => {
  // ...
  target.remove() // Remove because redundant
  document.body.append(target)
  // ...
})

Getting the dropzone

We used two lines of code to find the dropzone.

function move (event) {
  // ...
  const hitTest = document.elementFromPoint(left, top)
  const dropzone = hitTest.closest('[data-dropzone]')
}

We can put these two lines into a dedicated function. Let’s call it getDropzone.

function getDropzone () {
  // ...
}

To write getDropzone, we start by copy-pasting what we have into the function.

function getDropzone () {
  const hitTest = document.elementFromPoint(left, top)
  const dropzone = hitTest.closest('[data-dropzone]')
}

We need to return the dropzone.

function getDropzone () {
  const hitTest = document.elementFromPoint(left, top)
  return hitTest.closest('[data-dropzone]')
}

elementFromPoint requires left and top values from the dragged element’s bounding box. We can pass the element into getDropzone to get these values.

function getDropzone (element) {
  const {top, left} = element.getBoundingClientRect()
  const hitTest = document.elementFromPoint(left, top)
  return hitTest.closest('[data-dropzone]')
}

Using getDropzone:

function move (event) {
  // ...
  const dropzone = getDropzone(target)
}

Removing errors when you drag outside the screen

If you drag the element outside the screen, you’ll get some errors.

Why? elementFromPoint returns null if the specified point is outside the document. null is a primitive and it does not have a closest method. You’ll get an error if you try to call null.closest().

This is easy to fix. We’ll return nothing if hitTest is null.

const getDropzone = element => {
  const { top, left } = element.getBoundingClientRect()
  const hitTest = document.elementFromPoint(left, top)
  if (!hitTest) return
  return hitTest.closest('[data-dropzone]')
}

Merging previewExists and previewPos

In move, we used find to check whether the preview element exists in the dropzone. Later we used findIndex to find the index of the preview element.

function move (event) {
  const previewExists = [...dropzone.children].find(element => {
    return element === preview
  })

  if (!previewExists) {
    dropzone.append(preview)
  }

  // ...

  const previewPos = [...dropzone.children].findIndex(element => {
    return element === preview
  })
}

find and findIndex do similar things. We can use findIndex to handle them both.

First, we use findIndex to check whether the preview element exists in the dropzone. If the preview element does not exist, findIndex will return -1.

function move (event) {
  const previewPos = [...dropzone.children].findIndex(element => {
    return element === preview
  })

  if (previewPos === -1) {
    // preview element does not exist
  }
}

If the preview element does not exist, we append it into the dropzone.

function move (event) {
  // ...
  const previewPos = [...dropzone.children].findIndex(element => {
    return element === preview
  })

  if (previewPos === -1) {
    dropzone.append(preview)
  }
  // ...
}

And since we append the preview element in the dropzone, we know it’s going to be in the last position. We can assign this position back into previewPos.

function move (event) {
  // ...
  let previewPos = [...dropzone.children].findIndex(element => {
    return element === preview
  })

  if (previewPos === -1) {
    dropzone.append(preview)
    previewPos = dropzone.children.length - 1
  }
  // ...
}

We can further simplify the findIndex part with a function. Let’s call it getCurrentPreviewPosition.

function getCurrentPreviewPosition (dropzone, preview) {
  return [...dropzone.children].findIndex(element => {
    return element === preview
  })
}

Using: getCurrentPreviewPosition

function move (event) {
  // ...
  let previewPos = getCurrentPreviewPosition(dropzone, preview)
  if (previewPos === -1) {
    dropzone.append(preview)
    previewPos = dropzone.children.length - 1
  }
  // ...
}

Getting the desired preview position

We did two things to get the desired position of the preview element:

  1. We get possible preview positions
  2. We check if the dragged element falls into any of these preview positions
function move (event) {
  // ...
  // Getting possible preview positions
  const positions = [...dropzone.children].map(element => {
    return element.getBoundingClientRect()
  })

  // Finding the desired preview's position
  const position = positions.findIndex(pos => {
    return (pos.left < left && left < pos.right) &&
      (pos.top < top && top < pos.bottom)
  })
  // ...
}

We can put these lines of code into a function. Let’s call it getDesiredPreviewPosition.

function getDesiredPreviewPosition () {
  // ...
})

We’ll copy-paste the code we have:

function getDesiredPreviewPosition () {
  const positions = [...dropzone.children]
    .map(element => element.getBoundingClientRect())

  const position = positions.findIndex(pos => {
    return (pos.left < left && left < pos.right) &&
      (pos.top < top && top < pos.bottom)
  })
}

And we return the position.

function getDesiredPreviewPosition () {
  const positions = [...dropzone.children]
    .map(element => element.getBoundingClientRect())

  // Return the position
  return positions.findIndex(pos => {
    return (pos.left < left && left < pos.right) &&
      (pos.top < top && top < pos.bottom)
  })
}

getDesiredPreviewPosition needs three variables to work:

  1. The dropzone.
  2. The left of the target’s bounding box.
  3. The top of the target’s bounding box.

We can pass the dropzone directly into getDesiredPreviewPosition.

function getDesiredPreviewPosition (dropzone) {
  // ...
}

Next, we can get left and top by passing in the dragged element. Once we have the element, we can use element.getBoundingClientRect to get the left and top position.

function getDesiredPreviewPosition (dropzone, element) {
  const { left, top } = element.getBoundingClientRect()
  const positions = [...dropzone.children]
    .map(element => element.getBoundingClientRect())

  return positions.findIndex(pos => {
    return (pos.left < left && left < pos.right) &&
      (pos.top < top && top < pos.bottom)
  })
}

Using getDesiredPreviewPosition:

function move (event) {
  // ...
  const position = getDesiredPreviewPosition(dropzone, target)
  // ...
}

That’s it!