拖拽小demo(课程安排表)

583 阅读4分钟
  • 这段代码是一个基于 React 18+ 和 Tailwind CSS 的练手 Demo,灵感来自渡一的拖拽教程。直接上代码,注释已经在代码里面了。由于我的 React 水平有限,而且 Demo 也不符合 React 的最佳实践和规范,如果发现任何问题,请大佬们多多指教。

截屏2024-04-11 17.05.20.png

import { useEffect, useRef, useState } from "react";
import { Button } from "antd";
import { PlusOutlined } from "@ant-design/icons";

import "./DropTable.scss"; // 就一个类名 .active_drag { background-color: #eee;}

function DropTable() {
  const WEEK_DAYS = [
    { label: "星期一", value: "monday" },
    { label: "星期二", value: "tuesday" },
    { label: "星期三", value: "wednesday" },
    { label: "星期四", value: "thursday" },
    { label: "星期五", value: "friday" },
    { label: "星期六", value: "saturday" },
    { label: "星期日", value: "sunday" },
  ];
  const COURSES = 4; // 上午下午课的数量
  const COURSE_NAMES = [
    { label: "语文", value: "chinese", bgColor: "#463f34" },
    { label: "数学", value: "math", bgColor: "#4387fb" },
    { label: "英语", value: "english", bgColor: "#ccc" },
    { label: "政治", value: "politics", bgColor: "tomato" },
    { label: "历史", value: "history", bgColor: "green" },
    { label: "地理", value: "geography", bgColor: "#658682" },
    { label: "化学", value: "chemical", bgColor: "#23fb67" },
    { label: "物理", value: "physics", bgColor: "#986fb2" },
    { label: "体育", value: "sports", bgColor: "#ff1900" },
  ];
  let currentSource; // 当前目标源
  let currentTarget; // 当前目标

  const divRef = useRef(null);
  const [tableData, setTableData] = useState(() => {
    const initData = JSON.parse(localStorage.getItem("submitData") || "[]");
    return initData.map(item => item.data).flat(1);
  }); // 表格数据

  // * 获取拖拽放手时节点
  const getDropNode = node => {
    while (node) {
      if (node.dataset?.drop) return node;
      node = node.parentNode;
    }
    return null; // 如果未找到具有 data-drop 属性的节点,则返回 null 或适当的默认值
  };

  // * 清空样式
  const clearStyles = () => {
    document.querySelectorAll(".active_drag").forEach(node => {
      node.classList.remove("active_drag");
    });
  };

  // * 处理拖拽事件
  const handleDrag = () => {
    // * 通过事件委派进行处理
    const DOM = divRef.current;
    // * 拖拽开始
    DOM.ondragstart = event => {
      const target = event.target;
      currentSource = target;
      // DataTransfer 对象用于保存拖动并放下(drag and drop)过程中的数据。这个对象可以从所有拖动事件 drag events 的 dataTransfer 属性上获取
      event.dataTransfer.effectAllowed = target.dataset["effect"]; // 自定义属性改变鼠标样式
      // 保存当前拖动元素的值
      event.dataTransfer.setData("value", target.dataset["value"]);
      // 如果是表格移动到课程列表 则查询父节点 也就是 td 节点信息
      if (target.dataset["effect"] === "move") {
        currentTarget = target.parentNode;
      }
    };
    // * 拖拽结束 在可拖动的元素或者被选择的文本被拖进一个有效的放置目标时(每几百毫秒)触发
    DOM.ondragover = event => {
      // 为确保 drop 事件始终按预期触发,应当在处理 dragover 事件的代码部分始终包含 preventDefault() 调用
      event.preventDefault();
    };
    // * 拖拽结束 和 ondragover 一样 ,只是 ondragenter 只触发一次
    DOM.ondragenter = event => {
      // 清空其他元素上的样式
      clearStyles();
      const dropNode = getDropNode(event.target);
      // 没有节点表示整个区域不允许拖拽
      if (!dropNode) return;
      // 如果拖拽的元素和放置的元素 自定义属性的值相等 则添加激活样式
      if (currentSource.dataset["effect"] === dropNode.dataset["drop"]) {
        dropNode.classList.add("active_drag");
      }
    };
    // * drop 事件在元素或文本选择被放置到有效的放置目标上时触发
    DOM.ondrop = event => {
      const dropNode = getDropNode(event.target);
      // 没有节点表示整个区域不允许拖拽
      if (!dropNode) return;
      if (currentSource.dataset["effect"] !== dropNode.dataset["drop"]) return;
      // 清空其他元素上的样式
      clearStyles();
      // 如果自定义属性 drop 的值为 copy
      if (dropNode.dataset["drop"] === "copy") {
        // 克隆拖拽元素
        const cloneNode = currentSource.cloneNode(true);
        cloneNode.dataset["effect"] = "move";
        // 把之前的元素内容清空
        dropNode.innerHTML = "";
        // 添加拖拽元素
        dropNode.appendChild(cloneNode);
        // 添加成功时的数据
        const sourceData = event.dataTransfer.getData("value");
        const targetData = dropNode.dataset["value"];
        const tableValue = { courses: JSON.parse(sourceData), weeks: JSON.parse(targetData) };

        setTableData(prevData => {
          const existingItem = prevData.find(({ weeks }) => {
            // 放置在同一个位置上
            if (weeks.week === tableValue.weeks.week && weeks.course === tableValue.weeks.course) {
              return true;
            }
            return false;
          });
          if (existingItem) {
            // 说明是同样的课程放在同一个放在位置上
            if (existingItem.courses.value === tableValue.courses.value) {
              return prevData;
            } else {
              // 不同的课程放在一个位置上 懒得用id 直接字符串化对比对象(不建议)
              return [...prevData.filter(item => JSON.stringify(item) !== JSON.stringify(existingItem)), tableValue];
            }
          }
          return [...prevData, tableValue];
        });
      } else {
        // 否则则是表格拖拽到课程列表中
        // 直接删除节点
        currentSource.remove();
        // 删除成功时的数据
        const sourceData = event.dataTransfer.getData("value");
        const targetData = currentTarget.dataset["value"];
        const tableValue = { courses: JSON.parse(sourceData), weeks: JSON.parse(targetData) };
        setTableData(prevData => {
          return prevData.filter(item => JSON.stringify(item) !== JSON.stringify(tableValue));
        });
      }
    };
  };

  // * 辅助函数 处理表格数据
  const handleTableData = array => {
    // const list = Array.from(WEEK_DAYS.map(item => ({ [item.value]: { ...item } })));
    const list = Array.from(WEEK_DAYS.map(item => ({ ...item, data: [] })));
    for (let i = 0, len = list.length; i < len; i++) {
      const element = list[i];
      array.forEach(item => {
        // 将星期数据进行拼接
        if (element.value === item.weeks.week) element.data.push(item);
      });
      // 按照第几节课进行排序
      element.data.sort((item1, item2) => item1.weeks.course - item2.weeks.course);
    }
    return list;
  };

  // * 保存
  const onSubmit = () => {
    const submitData = handleTableData(tableData);
    // 不想写后端 直接存本地
    localStorage.setItem("submitData", JSON.stringify(submitData));
  };

  // * 处理回显数据
  const handleEchoData = () => {
    const echoData = JSON.parse(localStorage.getItem("submitData") || "[]");
    if (!echoData.length) return;
    // 获取 tbody 下所有 td
    const tdNodes = document.querySelectorAll("tbody td");
    const nodeValueList = Array.from(tdNodes)
      .filter(node => node.dataset.value)
      .map(node => ({ weeks: JSON.parse(node.dataset.value), node }));
    const nodeData = handleTableData(nodeValueList);
    // 拿到每一天的数据
    for (let index = 0, len = echoData.length; index < len; index++) {
      const nodeItem = nodeData[index];
      const weekItem = echoData[index];
      const nodeLen = nodeItem.data.length;
      const weekLen = weekItem.data.length;
      // 对比两个列表 然后生成数据
      outerLoop: for (let i = 0; i < nodeLen; i++) {
        const item = nodeItem.data[i];
        if (item.node.children.length) item.node.innerHTML = "";
        //
        for (let j = 0; j < weekLen; j++) {
          const { weeks, courses } = weekItem.data[j];
          // 如果第几节课相等
          if (item.weeks.course === weeks.course) {
            const div = document.createElement("div");
            div.className = "h-40 leading-[40px] text-center text-white mt-4 first:mt-0";
            div.style.backgroundColor = courses.bgColor;
            // 添加 div 属性
            div.setAttribute("draggable", "true");
            div.setAttribute("data-effect", "move");
            div.setAttribute("data-value", JSON.stringify({ courses }));
            div.innerHTML = courses.label;
            item.node.appendChild(div);
            continue outerLoop; // 匹配到后跳出对应命名循环
          }
        }
      }
    }
  };

  // 初始化 只需要执行一次
  useEffect(() => {
    handleEchoData();
    // eslint-disable-next-line
  }, []);
  useEffect(() => {
    handleDrag();
    // 修复 eslint 警告: React Hook useEffect has a missing dependency: 'handleDrag'. Either include it or remove the dependency array
    // eslint-disable-next-line
  }, [tableData]);
  return (
    <div>
      <div className="text-center font-bold text-24">课程安排</div>
      {/*  */}
      <div className="flex items-center justify-center mt-24" ref={divRef}>
        {/* 课程名称列表 */}
        <div className="mr-24 w-100 h-full border border-[#ccc] border-solid p-4" data-drop="move">
          {COURSE_NAMES.map(item => {
            return (
              <div
                className="h-40 leading-[40px] text-center text-white mt-4 first:mt-0"
                style={{ backgroundColor: item.bgColor }}
                key={item.label}
                draggable
                data-effect="copy"
                data-value={JSON.stringify(item)}
              >
                {item.label}
              </div>
            );
          })}
        </div>
        {/* 课程表 */}
        <table className="border-collapse w-800 text-center select-none">
          <thead className="h-40">
            <tr>
              {/* 占位 */}
              <th className="border border-[#ccc] border-solid"></th>
              {WEEK_DAYS.map(item => {
                return (
                  <td className="border border-[#ccc] border-solid" key={item.value}>
                    {item.label}
                  </td>
                );
              })}
            </tr>
          </thead>
          <tbody>
            <tr className="h-43 box-border">
              <td className="border border-[#ccc] border-solid" rowSpan={4}>
                上午
              </td>
              {WEEK_DAYS.map(item => {
                return (
                  <td
                    className="border border-[#ccc] border-solid"
                    key={item.value}
                    data-drop="copy"
                    data-value={JSON.stringify({ week: item.value, course: 1 })}
                  ></td>
                );
              })}
            </tr>
            {Array.from({ length: COURSES - 1 }).map((_, index) => {
              return (
                <tr className="h-43 box-border" key={index}>
                  {WEEK_DAYS.map(item => {
                    return (
                      <td
                        className="border border-[#ccc] border-solid"
                        key={item.value}
                        data-drop="copy"
                        data-value={JSON.stringify({ week: item.value, course: index + 2 })}
                      ></td>
                    );
                  })}
                </tr>
              );
            })}
            {/*  */}
            <tr className="h-43 box-border">
              <td className="border border-[#ccc] border-solid" rowSpan={4}>
                下午
              </td>
              {WEEK_DAYS.map(item => {
                return (
                  <td
                    className="border border-[#ccc] border-solid"
                    key={item.value}
                    data-drop="copy"
                    data-value={JSON.stringify({ week: item.value, course: 5 })}
                  ></td>
                );
              })}
            </tr>
            {Array.from({ length: COURSES - 1 }).map((_, index) => {
              return (
                <tr className="h-43 box-border" key={index}>
                  {WEEK_DAYS.map(item => {
                    return (
                      <td
                        className="border border-[#ccc] border-solid"
                        key={item.value}
                        data-drop="copy"
                        data-value={JSON.stringify({ week: item.value, course: index + 6 })}
                      ></td>
                    );
                  })}
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      {/*  */}
      <div className="flex justify-center mt-24">
        <Button type="primary" icon={<PlusOutlined />} onClick={onSubmit}>
          保存课程表
        </Button>
      </div>
    </div>
  );
}
export default DropTable;