You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React游戏开发:拖拽元素落点与光标位置偏移问题求解

解决React原生拖拽元素落点与光标偏移问题

问题描述

使用React开发一款带拖拽功能的游戏,核心包含SideBar和GameBoard组件,SideBar内的元素可拖拽至GameBoard。目前拖拽功能可正常运行,但元素落点与光标位置存在偏移。尝试过多种无依赖方案均未解决,不想依赖第三方库。

完整代码

App.tsx(状态管理)

// 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) {
      const isExternalDrop = !draggingElement;

      if (isExternalDrop) {
        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 updatedElement = { ...draggingElement, dx: x, dy: y };

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

    setDraggingElement(null);
  };


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

    e.dataTransfer.setData("elementId", element.id.toString());
  };

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

    if (draggingElement) {
      const rect = e.currentTarget.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      setDraggingElement((prevElement) => {
        if (prevElement) {
          return { ...prevElement, dx: x, dy: y };
        }
        return prevElement;
      });
    }
  };


  const handleDragEnd = () => {
    setDraggingElement(null);
  };

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

    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.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.tsx

// 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;

解决方案

偏移核心原因:直接将光标位置作为元素translate的目标坐标,但元素定位原点是自身左上角,而拖拽时光标点击的是元素内部某点,导致落点偏移。解决思路是记录拖拽起始时光标在元素内的相对位置(偏移量),放置时用光标位置减去该偏移量,让元素的点击锚点与光标重合。

步骤1:更新类型定义

在Types.ts中给ElementData添加偏移量字段:

export interface ElementData {
  id: string;
  dx: number;
  dy: number;
  color: string;
  offsetX?: number;
  offsetY?: number;
}

步骤2:记录拖拽起始偏移量

修改GameBoard的handleDragStart:

const handleDragStart = (
  e: React.DragEvent<HTMLDivElement>,
  element: ElementData
) => {
  console.log("drag start", element.id);
  // 记录光标在元素内的相对位置
  const offsetX = e.nativeEvent.offsetX;
  const offsetY = e.nativeEvent.offsetY;
  setDraggingElement({ ...element, offsetX, offsetY });

  e.dataTransfer.setData("elementId", element.id.toString());
};

修改SideBar的handleDragStart:

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

步骤3:修正落点计算

修改GameBoard的handleDrop中坐标计算逻辑:

// 外部拖拽分支
const rect = e.currentTarget.getBoundingClientRect();
// 光标位置减去偏移量,得到元素左上角的正确位置
const x = e.clientX - rect.left - (draggedElement.offsetX || 0);
const y = e.clientY - rect.top - (draggedElement.offsetY || 0);

// 内部拖拽分支
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left - (draggingElement.offsetX || 0);
const y = e.clientY - rect.top - (draggingElement.offsetY || 0);

步骤4:修正拖拽实时位置

修改GameBoard的handleDragOver:

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

  if (draggingElement) {
    const rect = e.currentTarget.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    setDraggingElement((prevElement) => {
      if (prevElement) {
        return { 
          ...prevElement, 
          dx: x - (prevElement.offsetX || 0), 
          dy: y - (prevElement.offsetY || 0) 
        };
      }
      return prevElement;
    });
  }
};

完成以上修改后,元素的落点会精准对齐光标位置,且无需依赖任何第三方库。

内容的提问来源于stack exchange,提问作者Mahesh Jamdade

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.29 19:17:33