Dragging a element at a position of Cursor in ReactJs

63 views Asked by At

I am trying to build a game in React, It needs a Drag-and-drop feature. The game has two major components, SideBar and a GameBoard. SideBar contains elements to be dragged onto GameBoard. The Drag and Drop works, but the element is getting dropped at the incorrect position (Offset from the position of the cursor), I looked for resources online on how to drop at the position of cursor but most of the answers are only using a third party package and the ones that are not do not really have an answer

You can see the output of the below code here: https://gist.github.com/assets/31410839/48cbdaae-2323-461e-a660-286d0fc971bd

Here is complete code

App.tsx which holds the SideBar and GameBoard with draggedElement state

// App.tsx
import React, { useState } from "react";
import GameBoard from "../src/components/GameBoard/GameBoard";
import SideBar from "../src/components/SideBar/SideBar";
import { ElementData } from "./Types";
function App() {
  const [draggedElement, setDraggedElement] = useState<ElementData | null>(null);
  return (
    <div className="min-h-screen flex flex-col md:flex-row bg-gray-100">
      <div className="md:w-1/5 bg-fuchsia-50 p-4">
        <h1 className="text-xl font-bold mb-4">Infinite Things ♾️</h1>
        <SideBar setDraggedElement={setDraggedElement} />
      </div>
      <div className="md:w-4/5 bg-amber-50 flex-grow">
        <GameBoard draggedElement={draggedElement} />
      </div>
    </div>
  );
}

export default App;

GameBoard.tsx

import React, { useState } from "react";
import Element from "../Element";
import { ElementData } from "../../Types";

interface GameBoardProps {
  draggedElement: ElementData | null;
}

const GameBoard: React.FC<GameBoardProps> = ({ draggedElement }) => {
  const [elements, setElements] = useState<ElementData[]>([
    { id: "Water", dx: 0, dy: 0, color: "#3498db" },
    { id: "Fire", dx: 0, dy: 0, color: "#e74c3c" },
    { id: "Wind", dx: 0, dy: 0, color: "#2ecc71" },
  ]);

  const [draggingElement, setDraggingElement] = useState<ElementData | null>(null);

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.currentTarget.style.border = "none";

    if (draggedElement) {
      // Check if the dropped element is from the sidebar or internal drag
      const isExternalDrop = !draggingElement;  // Corrected this line

      if (isExternalDrop) {
        // External drop: add the dragged element from the sidebar
        const rect = e.currentTarget.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;

        const newElement = { ...draggedElement, dx: x, dy: y };
        setElements((prevElements) => [...prevElements, newElement]);
      } else {
        // Internal drop: update the position of the dragged element
        const rect = e.currentTarget.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;

        const updatedElement = { ...draggingElement, dx: x, dy: y };

        setElements((prevElements) =>
          prevElements.map((el) => (el.id === draggingElement.id ? updatedElement : el))
        );
      }
    }

    // Reset the dragging element in the state
    setDraggingElement(null);
  };


  const handleDragStart = (
    e: React.DragEvent<HTMLDivElement>,
    element: ElementData
  ) => {
    console.log("drag start", element.id);
    // Set the dragged element in the state
    setDraggingElement(element);

    // Set data to be transferred during the drag
    e.dataTransfer.setData("elementId", element.id.toString());
  };

  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();

    // Check if there's a dragging element
    if (draggingElement) {
      // Calculate new position based on cursor location
      const rect = e.currentTarget.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      // Update dx and dy in draggingElement (or state)
      setDraggingElement((prevElement) => {
        if (prevElement) {
          return { ...prevElement, dx: x, dy: y };
        }
        return prevElement;
      });
    }
  };


  const handleDragEnd = () => {
    // Reset the dragging element in the state
    setDraggingElement(null);
  };

  const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.currentTarget.style.border = "2px dashed #333";

    // Get the elementId from dataTransfer only if draggingElement is null
    if (!draggingElement && draggedElement) {
      console.log("drag enter", draggedElement?.id);
      setDraggingElement(draggedElement);
    }
  };

  const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
    e.currentTarget.style.border = "none";
  };

  return (
    <div
      className="game-board bg-gray-200 relative h-full p-4"
      onDrop={handleDrop}
      onDragOver={handleDragOver}
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragEnd={handleDragEnd}
    >
      <h2 className="text-xl font-bold mb-4">Game Board</h2>
      <div className="min-w-0 absolute">
        {elements.map((element) => (
          <Element
            key={element.id}
            element={element}
            onDragStart={(e) => {
              e.dataTransfer.setData("elementId", element.id.toString());
              handleDragStart(e, element);
            }}
          />
        ))}
      </div>
    </div>
  );
};

export default GameBoard;

Sidebar which holds the elements

// SideBar.tsx
import React from "react";
import Element from "../Element";
import { ElementData } from "../../Types";

interface SideBarProps {
  setDraggedElement: React.Dispatch<React.SetStateAction<ElementData | null>>;
}

const SideBar: React.FC<SideBarProps> = ({ setDraggedElement }) => {
  const elements: ElementData[] = [
    { id: "Water", dx: 0, dy: 0, color: "#3498db" },
    { id: "Fire", dx: 0, dy: 0, color: "#e74c3c" },
    { id: "Wind", dx: 0, dy: 0, color: "#2ecc71" },
  ];

  const handleDragStart = (
    e: React.DragEvent<HTMLDivElement>,
    element: ElementData
  ) => {
    console.log("drag start in Sidebar for", element.id);
    setDraggedElement(element);
  };

  return (
    <div className="sidebar">
      <h2 className="text-xl font-bold mb-4">Side Bar</h2>
      <div className="grid grid-cols-2 gap-4">
        {elements.map((element) => (
          <Element
            key={element.id}
            element={element}
            onDragStart={(e) => handleDragStart(e, element)}
          />
        ))}
      </div>
    </div>
  );
};

export default SideBar;

Element component which will be dragged

// Element.tsx
import React from "react";
import { ElementData } from "../Types";
interface ElementProps {
  element: ElementData;
  onDragStart: (e: React.DragEvent<HTMLDivElement>, element: ElementData) => void;
}

function Element({ onDragStart, element }: ElementProps) {
  return (
    <div
      className={`element p-4 min-w-4 text-white text-center font-bold rounded-2xl shadow-lg cursor-pointer`}
      onDragStart={(e) => {
        onDragStart(e, element);
      }}
      style={{
        background: `${element.color}`,
        transform: `translate(${element.dx}px, ${element.dy}px)`,
      }}
      draggable
    >
      {element.id}
    </div>
  );
}

export default Element;

Apologies if the code looks messy, I am a beginner in React.

1

There are 1 answers

1
Wongjn On

In handleDrop() in GameBoard, you need to fully account for the starting position of the element being repositioned. This is because translate() moves the element relative to its "starting" position.

// Get the cursor position, relative to the top-left corner of the `.game-board` element.
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;

// Get reference to the element of the inner `<div class="min-w-0 absolute">`
// the contains the draggables.
const inner = e.currentTarget.children[1];

// Find the index of the draggable we dropped.
const i = elements.findIndex(el => el.id === draggingElement.id);
// Get the DOM element of the draggable inside the game board.
const domElement = inner.children[i];
        
const updatedElement = {
  ...draggingElement,
  // Offset `dx` and `dy` values so they are relative to the "starting" position of the draggable.
 dx: x - inner.offsetLeft - domElement.offsetLeft - domElement.offsetWidth/2,
 dy: y - inner.offsetTop - domElement.offsetTop - domElement.offsetHeight/2,
};

const { useState, useRef } = React;

function Element({ onDragStart, element }) {
  return (
    <div
      className={`element p-4 min-w-4 text-white text-center font-bold rounded-2xl shadow-lg cursor-pointer`}
      onDragStart={(e) => {
        onDragStart(e, element);
      }}
      style={{
        background: `${element.color}`,
        transform: `translate(${element.dx}px, ${element.dy}px)`,
      }}
      draggable
    >
      {element.id}
    </div>
  );
}

const GameBoard = ({ draggedElement }) => {
  const [elements, setElements] = useState([
    { id: "Water", dx: 0, dy: 0, color: "#3498db" },
    { id: "Fire", dx: 0, dy: 0, color: "#e74c3c" },
    { id: "Wind", dx: 0, dy: 0, color: "#2ecc71" },
  ]);

  const [draggingElement, setDraggingElement] = useState(null);

  const handleDrop = (e) => {
    e.preventDefault();
    e.currentTarget.style.border = "none";

    if (draggedElement) {
      // Check if the dropped element is from the sidebar or internal drag
      const isExternalDrop = !draggingElement;  // Corrected this line

      if (isExternalDrop) {
        // External drop: add the dragged element from the sidebar
        const rect = e.currentTarget.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;

        const newElement = { ...draggedElement, dx: x, dy: y };
        setElements((prevElements) => [...prevElements, newElement]);
      } else {
        const rect = e.currentTarget.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;
        
        const inner = e.currentTarget.children[1];
        
        const i = elements.findIndex(el => el.id === draggingElement.id);
        const domElement = inner.children[i];
        
        const updatedElement = {
          ...draggingElement,
          dx: x - inner.offsetLeft - domElement.offsetLeft - domElement.offsetWidth/2,
          dy: y - inner.offsetTop - domElement.offsetTop - domElement.offsetHeight/2,
        };
        setElements((prevElements) => {
          return prevElements.map((el) => (el.id === draggingElement.id ? updatedElement : el))
        });
      }
    }

    // Reset the dragging element in the state
    setDraggingElement(null);
  };


  const handleDragStart = (
    e,
    element
  ) => {
    console.log("drag start", element.id);
    // Set the dragged element in the state
    setDraggingElement(element);

    // Set data to be transferred during the drag
    e.dataTransfer.setData("elementId", element.id.toString());
  };

  const handleDragOver = (e) => {
    e.preventDefault();

    // Check if there's a dragging element
    if (draggingElement) {
      // Calculate new position based on cursor location
      const rect = e.currentTarget.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      // Update dx and dy in draggingElement (or state)
      setDraggingElement((prevElement) => {
        if (prevElement) {
          return { ...prevElement, dx: x, dy: y };
        }
        return prevElement;
      });
    }
  };

  const handleDragEnd = () => {
    // Reset the dragging element in the state
    setDraggingElement(null);
  };

  const handleDragEnter = (e) => {
    e.preventDefault();
    e.currentTarget.style.border = "2px dashed #333";

    // Get the elementId from dataTransfer only if draggingElement is null
    if (!draggingElement && draggedElement) {
      console.log("drag enter", draggedElement.id);
      setDraggingElement(draggedElement);
    }
  };

  const handleDragLeave = (e) => {
    e.currentTarget.style.border = "none";
  };

  return (
    <div
      className="game-board bg-gray-200 relative h-full p-4"
      onDrop={handleDrop}
      onDragOver={handleDragOver}
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragEnd={handleDragEnd}
    >
      <h2 className="text-xl font-bold mb-4">Game Board</h2>
      <div className="min-w-0 absolute">
        {elements.map((element) => (
          <Element
            key={element.id}
            element={element}
            onDragStart={(e) => {
              e.dataTransfer.setData("elementId", element.id.toString());
              handleDragStart(e, element);
            }}
          />
        ))}
      </div>
    </div>
  );
};

const SideBar = ({ setDraggedElement }) => {
  const elements = [
    { id: "Water", dx: 0, dy: 0, color: "#3498db" },
    { id: "Fire", dx: 0, dy: 0, color: "#e74c3c" },
    { id: "Wind", dx: 0, dy: 0, color: "#2ecc71" },
  ];

  const handleDragStart = (
    e,
    element
  ) => {
    console.log("drag start in Sidebar for", element.id);
    setDraggedElement(element);
  };

  return (
    <div className="sidebar">
      <h2 className="text-xl font-bold mb-4">Side Bar</h2>
      <div className="grid grid-cols-2 gap-4">
        {elements.map((element) => (
          <Element
            key={element.id}
            element={element}
            onDragStart={(e) => handleDragStart(e, element)}
          />
        ))}
      </div>
    </div>
  );
};

function App() {
  const [draggedElement, setDraggedElement] = useState(null);
  return (
    <div className="min-h-screen flex flex-col md:flex-row bg-gray-100">
      <div className="md:w-1/5 bg-fuchsia-50 p-4">
        <h1 className="text-xl font-bold mb-4">Infinite Things ♾️</h1>
        <SideBar setDraggedElement={setDraggedElement} />
      </div>
      <div className="md:w-4/5 bg-amber-50 flex-grow">
        <GameBoard draggedElement={draggedElement} />
      </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('app')).render(<App/>);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js" integrity="sha512-8Q6Y9XnTbOE+JNvjBQwJ2H8S+UV4uA6hiRykhdtIyDYZ2TprdNmWOUaKdGzOhyr4dCyk287OejbPvwl7lrfqrQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js" integrity="sha512-MOCpqoRoisCTwJ8vQQiciZv0qcpROCidek3GTFS6KTk2+y7munJIlKCVkFCYY+p3ErYFXCjmFjnfTTRSC1OHWQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdn.tailwindcss.com/3.4.1"></script>

<div id="app"></div>