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

React手动实现拖拽选择遇滚动失效问题求助

React拖拽选择滚动后失效问题解决

问题描述

从零实现React拖拽选择功能,元素数量较少时功能正常,但元素过多出现滚动条后,拖拽选择及遮罩均无法正常工作。

相关代码

Item生成代码

const items = [
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36,
    37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70,
    71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
    104,
].map((i) => ({ item: i, selected: i === 1 }));

选择功能核心代码(原代码)

const [data, setData] = useState(items);
const [isSelecting, setIsSelecting] = useState(false);
const [start, setStart] = useState<Coords>({ x: 0, y: 0, screenX: 0, screenY: 0 });
const [end, setEnd] = useState<Coords>({ x: 0, y: 0, screenX: 0, screenY: 0 });
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
    function handleMouseDown(e: any) {
        if (e.target.closest(".selectable")) return;
        setIsSelecting(true);
        setStart({ x: e.clientX, y: e.clientY, screenX: e.screenX, screenY: e.screenY });
        setEnd({ x: e.clientX, y: e.clientY, screenX: e.screenX, screenY: e.screenY });
        setData((data) => [...data.map((item) => ({ ...item, selected: false }))]);
    }
    ref.current?.addEventListener("mousedown", handleMouseDown);
    return () => {
        ref.current?.removeEventListener("mousedown", handleMouseDown);
    };
}, [ref]);

function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
    if (!isSelecting) return;
    setEnd({ x: e.clientX, y: e.clientY, screenX: e.screenX, screenY: e.screenY });
    const selected = [...data];
    const elements = document.getElementsByClassName("selectable");
    for (let i = 0; i < elements.length; i++) {
        const rect = elements[i].getBoundingClientRect();
        const elementRect = {
            left: rect.left + window.pageXOffset,
            top: rect.top + window.pageYOffset,
            right: rect.right + window.pageXOffset,
            bottom: rect.bottom + window.pageYOffset,
        };
        if (
            ((elementRect.left >= Math.min(start.x, end.x) && elementRect.left <= Math.max(start.x, end.x)) ||
                (elementRect.right >= Math.min(start.x, end.x) && elementRect.right <= Math.max(start.x, end.x))) &&
            ((elementRect.top >= Math.min(start.y, end.y) && elementRect.top <= Math.max(start.y, end.y)) ||
                (elementRect.bottom >= Math.min(start.y, end.y) && elementRect.bottom <= Math.max(start.y, end.y)))
        ) {
            selected[i].selected = true;
        } else {
            selected[i].selected = false;
        }
    }
    setData(selected);
}

function handleMouseUp() {
    setIsSelecting(false);
    reset();
}
const reset = () => {
    setStart({ x: 0, y: 0, screenX: 0, screenY: 0 });
    setEnd({ x: 0, y: 0, screenX: 0, screenY: 0 });
};

选择遮罩样式代码(原代码)

const overlayStyle: any = {
        position: "absolute",
        backgroundColor: colors.slate[800],
        opacity: 0.5,
        border: "1px dotted",
        borderColor: colors.slate[300],
        left: `${Math.min(start.x, end.x) - (ref.current?.offsetLeft || 0)}px`,
        top: `${Math.min(start.y, end.y) - (ref.current?.offsetTop || 0) + (ref.current?.scrollTop || 0)}px`,
        width: `${Math.abs(end.x - start.x)}px`,
        height: `${Math.abs(end.y - start.y) + (ref.current?.scrollTop || 0)}px`,
        display: isSelecting ? "block" : "none",
        pointerEvents: "none",
    };

JSX模板(原代码)

return (
    <>
        <div onMouseUp={handleMouseUp} onMouseMove={handleMouseMove} className="relative p-4 overflow-auto bg-slate-600 h-96" ref={ref}>
            <ul className="flex flex-wrap gap-2">
                {data.map((item) => (
                    <li
                        onClick={() => {
                            console.log("a");
                        }}
                        className={cx(
                            "flex items-center justify-center w-24 text-white hover:border-4 hover:border-slate-500 rounded-lg select-none aspect-square  bg-slate-700 selectable cursor-pointer",
                            { "border-sky-500 border-4 hover:border-sky-500": item.selected }
                        )}
                        key={item.item}
                    >
                        Item {item.item}
                    </li>
                ))}
            </ul>
            <div style={overlayStyle}></div>
        </div>

        <button
            onClick={() => {
                console.log(ref);
            }}
        >
            1a
        </button>
    </>
);

问题预览

拖拽选择滚动失效预览

解决方案

问题根源是坐标计算未考虑容器内部滚动的偏移,以下是修正后的关键代码:

1. 修正鼠标坐标计算(基于容器相对位置)

// 修改handleMouseDown中的坐标设置
useEffect(() => {
    function handleMouseDown(e: MouseEvent) {
        if (e.target.closest(".selectable")) return;
        const container = ref.current!;
        const containerRect = container.getBoundingClientRect();
        // 计算鼠标相对于容器的坐标
        const relativeX = e.clientX - containerRect.left;
        const relativeY = e.clientY - containerRect.top;
        
        setIsSelecting(true);
        setStart({ x: relativeX, y: relativeY, screenX: e.screenX, screenY: e.screenY });
        setEnd({ x: relativeX, y: relativeY, screenX: e.screenX, screenY: e.screenY });
        setData(prev => prev.map(item => ({ ...item, selected: false })));
    }
    ref.current?.addEventListener("mousedown", handleMouseDown);
    return () => ref.current?.removeEventListener("mousedown", handleMouseDown);
}, [ref]);

// 修改handleMouseMove中的坐标设置
function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
    if (!isSelecting) return;
    const container = ref.current!;
    const containerRect = container.getBoundingClientRect();
    const relativeX = e.clientX - containerRect.left;
    const relativeY = e.clientY - containerRect.top;
    
    setEnd({ x: relativeX, y: relativeY, screenX: e.screenX, screenY: e.screenY });
    
    // 使用函数式更新避免 stale state
    setData(prevData => {
        const selected = [...prevData];
        const elements = document.getElementsByClassName("selectable");
        for (let i = 0; i < elements.length; i++) {
            const element = elements[i] as HTMLElement;
            const rect = element.getBoundingClientRect();
            // 计算元素相对于容器的位置(结合容器滚动偏移)
            const elementRect = {
                left: rect.left - containerRect.left + container.scrollLeft,
                top: rect.top - containerRect.top + container.scrollTop,
                right: rect.right - containerRect.left + container.scrollLeft,
                bottom: rect.bottom - containerRect.top + container.scrollTop,
            };
            const selectStartX = Math.min(start.x, end.x);
            const selectEndX = Math.max(start.x, end.x);
            const selectStartY = Math.min(start.y, end.y);
            const selectEndY = Math.max(start.y, end.y);
            
            // 判断元素是否与选择框相交
            const intersects = 
                elementRect.left <= selectEndX && 
                elementRect.right >= selectStartX && 
                elementRect.top <= selectEndY && 
                elementRect.bottom >= selectStartY;
            
            selected[i].selected = intersects;
        }
        return selected;
    });
}

2. 修正遮罩样式计算

const overlayStyle: any = {
    position: "absolute",
    backgroundColor: colors.slate[800],
    opacity: 0.5,
    border: "1px dotted",
    borderColor: colors.slate[300],
    left: `${Math.min(start.x, end.x)}px`,
    top: `${Math.min(start.y, end.y)}px`,
    width: `${Math.abs(end.x - start.x)}px`,
    height: `${Math.abs(end.y - start.y)}px`,
    display: isSelecting ? "block" : "none",
    pointerEvents: "none",
};

3. 全局监听鼠标抬起事件(避免移出容器后无法停止选择)

useEffect(() => {
    function handleGlobalMouseUp() {
        setIsSelecting(false);
        reset();
    }
    if (isSelecting) {
        window.addEventListener('mouseup', handleGlobalMouseUp);
        return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
    }
}, [isSelecting]);

修正后的JSX模板

移除容器上的onMouseUp,改用全局监听:

return (
    <>
        <div onMouseMove={handleMouseMove} className="relative p-4 overflow-auto bg-slate-600 h-96" ref={ref}>
            <ul className="flex flex-wrap gap-2">
                {data.map((item) => (
                    <li
                        onClick={() => console.log("a")}
                        className={cx(
                            "flex items-center justify-center w-24 text-white hover:border-4 hover:border-slate-500 rounded-lg select-none aspect-square  bg-slate-700 selectable cursor-pointer",
                            { "border-sky-500 border-4 hover:border-sky-500": item.selected }
                        )}
                        key={item.item}
                    >
                        Item {item.item}
                    </li>
                ))}
            </ul>
            <div style={overlayStyle}></div>
        </div>

        <button onClick={() => console.log(ref)}>1a</button>
    </>
);

关键说明

  • 所有坐标计算改为容器相对坐标,不再依赖window的滚动偏移,适配内部滚动场景
  • 使用函数式更新state避免 stale state 问题
  • 全局监听鼠标抬起事件,确保拖拽在任何区域松开都能停止

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.22 15:52:03