Why does a neighboring element move when I drag and drop an element?

Tags: ,



The Problem

I’m creating a game in which the player has a hand of cards. These cards can be moved onto a map (using Mapbox). When a card is moved on the map and it meets some prerequisites, it will be ‘placed’ on that location of the map.

Unfortunately, when I drag a valid card onto the map, it gets ‘placed’ on the location, but the neighboring card moves from the hand to the last location of the placed card.

I made a quick video of the current behavior: https://vimeo.com/459003505

The code

The front-end is a React application and I’m using vanilla javascript to implement the drag and drop functionality. Basically, I have a component containing a number of cards called ProjectCardsHand. The Cards are ProjectCard components. I’m using MapBox to render a map of a city with neighborhoods in App.js.

Here’s the abridged version of my code:

ProjectCardsHand.js

import React from 'react';
import ProjectCard from './ProjectCard';

function addEventListenersToCards(map, $this) {
    let container = document.querySelector("#project-cards-hand");
    let activeItem = null;
    let active = false;

    container.addEventListener("touchstart", dragStart, {once: false, passive: false, capture: false});
    container.addEventListener("touchend", dragEnd, {once: false, passive: false, capture: false});
    container.addEventListener("touchmove", drag, {once: false, passive: false, capture: false});

    container.addEventListener("mousedown", dragStart, {once: false, passive: false, capture: false});
    container.addEventListener("mouseup", dragEnd, {once: false, passive: false, capture: false});
    container.addEventListener("mousemove", drag, {once: false, passive: false, capture: false});

    function dragStart(e) {
      
      if ((e.target !== e.currentTarget)) {
        active = true;
        activeItem = null;

        // this is the item we are interacting with
        activeItem = e.target.closest('.project-card');

        if (activeItem !== null) {
          if (!activeItem.xOffset) {
            activeItem.xOffset = 0;
          }

          if (!activeItem.yOffset) {
            activeItem.yOffset = 0;
          }

          activeItem.initialX = e.clientX - activeItem.xOffset;
          activeItem.initialY = e.clientY - activeItem.yOffset;


          // Move the project card up by 180px to cancel out the hover effect.
          activeItem.style.bottom = '180px';

        }
      }
    }

    function dragEnd(e) {

      if (activeItem !== null) {

        activeItem.initialX = activeItem.currentX;
        activeItem.initialY = activeItem.currentY;
        let neighborhoods = '';
        let projectId = activeItem.id.replace('project-','');

        // If the project is moved to a valid neighborhood, process the assignment of the project
        // to that neighborhood. Otherwise, nothing should happen and the project card is returned to the hand.
        neighborhoods = map.queryRenderedFeatures([[e.clientX,e.clientY],[e.clientX,e.clientY]], {layers: ['hoods']});


        if (neighborhoods.length > 0) {
          let projects = $this.state.projects;

          // Check if there are still project cards left in the hand.
          if (projects.length > 0) {
            for (let i = 0; i < projects.length; i++) {
              if (projects[i].id === projectId) {

                // Extract the neighborhood name from the neighborhood data.
                projects[i].neighborhood = neighborhoods[0].properties.BU_NAAM;

                // Get the latitude and longitue from the map based on the X and Y coordinates of the cursor.
                let projectAssignLocation = map.unproject([e.clientX,e.clientY]);

                // Subtract the cost of the project from the budget. If the remaining budget is 0 or higher, assign
                // the project to the location and update the budget.
                if ($this.props.handleBudgetChange($this.props.budget, projects[i].impact.cost*-1)) {
                  $this.props.handleProjectAssign(neighborhoods[0].properties.OBJECTID, projects[i], projectAssignLocation, function() {

                    // Remove the project from the list of projects in the hand.
                    projects.splice(i, 1);
                    $this.setState({projects : projects});
                  });
                } else {
                  // If the project card is moved to an invalid location (i.e. not a neighborhood), put the card back in the hand.
                  let itemAtInitialX = activeItem.initialX === activeItem.currentX;
                  let itemAtInitialY = activeItem.initialY === activeItem.currentY;
                  if (!itemAtInitialX && !itemAtInitialY) {
                    setTranslate(0, 0, activeItem);
                    activeItem.style.bottom = '0px';
                  }
                }
              }
            }
          }
        }
      }

      // Clean up the active item; The project card is either placed on a neighborhood or put back in the hand.
      active = false;
      activeItem = null;
      return;
    }

    function drag(e) {

      if (active) {
        activeItem.currentX = e.clientX - activeItem.initialX;
        activeItem.currentY = e.clientY - activeItem.initialY;

        activeItem.xOffset = activeItem.currentX;
        activeItem.yOffset = activeItem.currentY;
        setTranslate(activeItem.currentX, activeItem.currentY, activeItem);
      }
    }

    function setTranslate(xPos, yPos, el) {
      el.style.transform = "translate3d(" + xPos + "px, " + yPos + "px, 0)";
    }
}

export default class ProjectCardsHand extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            map: {},
            // This contains an array of project objects. I've removed it in this example for clarity's sake.
            projects: []
        }
    }

    componentWillReceiveProps(newProps) {
        // The project cards hand recieves the map as properties so that it can be queried by
        // the projects when they are dragged onto neighborhoods.
        this.setState({
            map: newProps.map
        })
        addEventListenersToCards(newProps.map, this);

    }
     

    render() {
        const projects = this.state.projects;
        const projectList = projects.map((project) =>
          <ProjectCard project={project}/>
        );

        return (
          <div id="project-cards-hand" className="row justify-content-center">
            {projectList}      
          </div>
        )
    }
}

App.js

import React from 'react';
import mapboxgl from 'mapbox-gl';
import axios from "axios";
import ProjectCardsHand from './ProjectCardsHand';

mapboxgl.accessToken = 'myAccessTokenNotGonnaTellYou';

export default class App extends React.Component {
  constructor(props) {
      super(props);
      this.handleProjectAssign = this.handleProjectAssign.bind(this);
      this.handleBudgetChange = this.handleBudgetChange.bind(this);
      this.state = {
        lng: 4.3220,
        lat: 52.0377,
        zoom: 12,
        hoods: [],
        projects: [],
        currentYear: 2020,
        budget: 3000000,
        map: {},
        pitch: 0
      };
  }
  
  // Functionality to initialize the map and add mouse event listeners to it goes here. Assumption
  // is that this does not affect the behavior in this problem. hoods is an array of objects containing
  // the neighborhoods. I store these in a mongoDB database. And call them in the component.

  // Handle the assignment of a project to a neighborhood.
  handleProjectAssign(hoodId, project, projectAssignLocation, callback) {
    let hoods = this.state.hoods.map(hood => {
        if (hood.properties.OBJECTID === hoodId) {
            try {
                hood.properties.droughtModifier += parseInt(project.impact.drought);
                hood.properties.precipitationModifier += parseInt(project.impact.precipitation);
                hood.properties.heatModifier += parseInt(project.impact.heat);
                hood.properties.subsidenceModifier += parseInt(project.impact.subsidence);
                hood.properties.biodiversityModifier += parseInt(project.impact.biodiversity);
            } catch (err) {
                console.error("Unable to assign modifiers to hood", hoodId, "Error:", err);
            }
        }

        return {
            type: 'Feature',
            geometry: hood.geometry,
            properties: hood.properties
        };
    })

    this.state.map.getSource('hoods').setData({
        type: 'FeatureCollection',
        features: hoods
      });
    
    let projects = this.state.projects;

    projects.push({
      'type': 'Feature',
      'geometry': {
        'type': 'Point',
        'coordinates': [
          projectAssignLocation.lng,
          projectAssignLocation.lat
        ]
      },
      'properties': {
        'title': project.name
      }
    });

    this.setState({ projects: projects });

    this.state.map.getSource('projects').setData({
        type: 'FeatureCollection',
        features: this.state.projects
    });

    callback(); 
  }

  handleBudgetChange(budget, delta) {
    let newBudget = budget + delta;
    if ((newBudget) >= 0) {
      this.setState({budget: newBudget});
      return true;
    }
    return false;
  }

  componentDidMount() {
    const map = new mapboxgl.Map({
      container: this.mapContainer,
      style: 'mapbox://styles/mapbox/streets-v11',
      center: [this.state.lng, this.state.lat],
      zoom: this.state.zoom,
      pitch: this.state.pitch || 0
    });

    this.setState({map: map});

    try {
      axios.get("/api/v1/hoods").then((response) => {

        const hoods = response.data.data.map(hood => {
     
          return {
            type: 'Feature',
            geometry: hood.geometry,
            properties: hood.properties
          };
        });

        this.setState({hoods: hoods});

        // Load the map. I've commented out this function in this question to keep it brief.
        this.loadMap(hoods, map, this);

      });
    } catch (err) {
      console.error("Failed to fetch hoods data:",err);
    }
    
  }

  render() {
    const hoods = this.state.hoods;
    return (
      <div className="container">
        <div ref={el => this.mapContainer = el} className="mapContainer" >
        </div>
        <ProjectCardsHand 
          map = {this.state.map}
          budget = {this.state.budget}
          handleProjectAssign = {this.handleProjectAssign}
          handleBudgetChange = {this.handleBudgetChange}
           />
      </div>
    )
  }   
}

What I’ve tried I’ve tried a number of things:

  • To follow this guide: https://javascript.info/mouse-drag-and-drop;
  • Set all of the event listener options to true and all of them to false (https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener);
  • Create a flag in the component’s state to indicate props have been received once, so as to avoid assigning the event listeners more than once. This did work to reduce the number of listeners, but didn’t solve the problem;
  • Set flags inside the drag, dragStart and dragEnd functions to check if the activeItem is the one being dragged, but each time activeItem seems to be set to the neighboring item, even though the drag function shouldn’t be called for it.

I’d love to know what I’m doing wrong. How can I fix this so that the project cards that aren’t being dragged stay put?

Answer

I managed to fix it, although it feels more like a workaround. After assigning a card, I loop through the full hand of cards and reset them to the starting position:

// Make sure the remaining cards stay in the hand.
for (let i = 0; i < allProjectCards.length; i++) {
    setTranslate(0, 0, allProjectCards[i]);
    activeItem.style.bottom = '0px';
}

The setTranslate() function looks like this:

function setTranslate(xPos, yPos, el) {
      el.style.transform = "translate3d(" + xPos + "px, " + yPos + "px, 0)";
}

Next, I ensure the xOffset and yOffset are reset for the item being dragged. These values are used to determine the starting position when starting to drag a card:

// Clean up the active item; The project card is either placed on a neighborhood or put back in the hand.
activeItem.xOffset = 0;
activeItem.yOffset = 0;

So, in sum, it kind of feels like I’m playing a card, then throwing the rest of my hand of cards on the floor, and picking them all up again to make sure they are still in my hand.

Better answers are welcome.



Source: stackoverflow