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

React中为grid-row元素绑定鼠标事件实现谷歌日历拖拽选时间功能

React实现日历拖拽选择时间段方案

核心实现思路

通过状态管理拖拽的全生命周期:鼠标按下时记录起始位置、鼠标移动时实时更新当前位置、鼠标抬起时换算选中的时间段,全局绑定鼠标移动和抬起事件避免鼠标移出元素后监听失效。


完整实现代码

1. 常量与状态定义

import { useState, useRef, useCallback, useEffect } from 'react';

// 适配你给出的容器参数,可根据实际业务调整
const GRID_TOTAL_HEIGHT = 576; // 单个grid-row的总高度,对应一天可预约的总时长
const DAY_START_HOUR = 8; // 当日可预约起始时间,例:8点
const DAY_TOTAL_MINUTES = 16 * 60; // 当日可预约总分钟数,例:8点到24点共16小时
const PX_PER_MINUTE = GRID_TOTAL_HEIGHT / DAY_TOTAL_MINUTES; // 1像素对应多少分钟,用于坐标转时间

const TimeGrid = () => {
  // 拖拽状态标记
  const [isDragging, setIsDragging] = useState(false);
  // 拖拽起始信息:所属行ID、相对于行顶部的Y轴偏移
  const [dragStart, setDragStart] = useState({ rowId: '', yOffset: 0 });
  // 拖拽当前位置信息
  const [dragCurrent, setDragCurrent] = useState({ rowId: '', yOffset: 0 });
  // 网格容器ref用于坐标计算
  const gridContainerRef = useRef(null);

2. 鼠标事件处理函数

// 鼠标按下事件,绑定到每个grid-row上
  const handleMouseDown = useCallback((e, rowId) => {
    // 只响应鼠标左键点击
    if (e.button !== 0) return;
    e.preventDefault();
    const currentRow = e.currentTarget;
    const rowRect = currentRow.getBoundingClientRect();
    // 计算点击位置相对于当前行顶部的偏移
    const yOffset = e.clientY - rowRect.top;
    
    setIsDragging(true);
    setDragStart({ rowId, yOffset });
    setDragCurrent({ rowId, yOffset });
  }, []);

  // 鼠标移动事件,全局绑定
  const handleMouseMove = useCallback((e) => {
    if (!isDragging) return;
    e.preventDefault();
    // 找到当前鼠标悬浮的grid-row元素
    const hoveredRow = document.elementFromPoint(e.clientX, e.clientY)?.closest('.grid-row');
    if (!hoveredRow) return;

    const rowId = hoveredRow.id;
    const rowRect = hoveredRow.getBoundingClientRect();
    // 限制偏移在当前行高度范围内,避免超出上下边界
    const yOffset = Math.max(0, Math.min(e.clientY - rowRect.top, GRID_TOTAL_HEIGHT));

    setDragCurrent({ rowId, yOffset });
  }, [isDragging]);

  // 鼠标抬起事件,全局绑定
  const handleMouseUp = useCallback(() => {
    if (!isDragging) return;
    setIsDragging(false);

    // 坐标换算成时间,保证开始时间小于结束时间
    const startMinute = Math.round(dragStart.yOffset / PX_PER_MINUTE);
    const endMinute = Math.round(dragCurrent.yOffset / PX_PER_MINUTE);
    const [realStart, realEnd] = startMinute > endMinute ? [endMinute, startMinute] : [startMinute, endMinute];
    
    // 转成时分格式
    const formatTime = (totalMinute) => {
      const hour = DAY_START_HOUR + Math.floor(totalMinute / 60);
      const minute = totalMinute % 60;
      return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
    };

    // 此处可替换为你的业务逻辑,比如弹出预约创建弹窗
    console.log('选中时间段信息:', {
      startSpaceId: dragStart.rowId.split('-')[3], // 从行ID提取场地ID
      endSpaceId: dragCurrent.rowId.split('-')[3],
      startTime: formatTime(realStart),
      endTime: formatTime(realEnd),
      duration: realEnd - realStart
    });

    // 重置状态
    setDragStart({ rowId: '', yOffset: 0 });
    setDragCurrent({ rowId: '', yOffset: 0 });
  }, [isDragging, dragStart, dragCurrent]);

3. 全局事件绑定与渲染逻辑

// 全局绑定/解绑鼠标事件
  useEffect(() => {
    if (isDragging) {
      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
    }
    return () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
  }, [isDragging, handleMouseMove, handleMouseUp]);

  // 渲染拖拽选中的预览块
  const renderDragPreview = () => {
    if (!isDragging || dragStart.rowId !== dragCurrent.rowId) return null;
    const minOffset = Math.min(dragStart.yOffset, dragCurrent.yOffset);
    const height = Math.abs(dragCurrent.yOffset - dragStart.yOffset);
    // 拖拽高度太小时不显示,避免误触
    if (height < 10) return null;

    return (
      <div 
        className="reservation-card d-flex flex-column justify-content-around align-items-center"
        style={{
          position: 'absolute',
          top: minOffset,
          width: '100%',
          backgroundColor: 'rgba(52, 137, 235, 0.7)',
          height,
          fontSize: '0.6vw'
        }}
      >
        <span>{`${Math.round(height / PX_PER_MINUTE)}分钟`}</span>
      </div>
    );
  };

  // 你的网格行数据,可替换为实际接口返回的数据
  const gridRows = [
    { id: 'row-0-spaceID-1', width: '56.4375px' },
    { id: 'row-1-spaceID-2', width: '56.4375px' },
    { id: 'row-2-spaceID-3', width: '56.4375px' },
    { id: 'row-3-spaceID-4', width: '69.3438px' },
    { id: 'row-4-spaceID-5', width: '69.3438px' },
    { id: 'row-5-spaceID-6', width: '170.375px' },
    { id: 'row-6-spaceID-7', width: '99.5312px' },
  ];

  return (
    <div ref={gridContainerRef} className="time-grid-item gap-1 d-flex justify-content-between" style={{userSelect: 'none'}}>
      {gridRows.map(row => (
        <div
          key={row.id}
          id={row.id}
          className="grid-row"
          style={{ width: row.width, height: '576px', position: 'relative' }}
          onMouseDown={(e) => handleMouseDown(e, row.id)}
        >
          {/* 此处渲染你原有的预约卡片内容 */}
          {/* 拖拽预览 */}
          {dragCurrent.rowId === row.id && renderDragPreview()}
        </div>
      ))}
    </div>
  );
};

export default TimeGrid;

注意事项

  • 每个grid-row必须设置position: relative,否则预览块的绝对定位会错位
  • 根容器加userSelect: none可以避免拖拽时意外选中文本,影响交互体验
  • 如果需要支持跨场地(跨行)选择时间段,只需扩展预览块渲染逻辑,在起始和结束行之间的所有行分别渲染对应高度的预览块即可
  • 移动端适配只需替换监听事件为touchstart/touchmove/touchend,坐标取值逻辑稍作调整即可

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.30 12:45:04