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
相关产品推荐
相关产品推荐

