Search code examples
javascriptreactjstypescriptselectdrag

I am trying to implement drag select in react from scratch but stuck in the scroll


Here is the code that I made to implement drag selection.

Item generate code


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 }));

This the actual code responsible for the select

    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;
        console.log("START");
        console.log({ clientX: start.x, clientY: start.y, screenX: start.screenX, screenY: start.screenY });
        console.log("END");
        console.log({ clientX: e.clientX, clientY: e.clientY, screenX: e.screenX, screenY: e.screenY });
        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 });
    };

Generate the overlay

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 Templete


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

All are working correctly,

But the thing is when the elements are significant and show scroll it will not working select as well as the selection overlay.

Preview

Thank you for your time!


Solution

  • you are, in effect, comparing document position to screen position. Your clientX and screenX will basically be the same for mouse, if your element occupies the full screen.

    When an element scrolls off the top of screen, it will have a negative client coordinate. You are adding the scroll position, which gives the position of the element in the document.

    However, your mouse coordinates are not being offset the same way.

    You also don't seem to be using the right scroll value. You have a scrolling element, but are adding the window scroll position, which doesn't scroll. Use the scrollX and scrollY of the element, not the window.

    You should be able to save the mouse coordinates as document offsets by adding the scrolling offsets to those as well, then you will be comparing everything as document coordinates.

    Use the scroll offset at the time you save the coordinates, so

    setStart({ x: e.clientX + ref.current.scrollLeft, y: e.clientY + ref.current.scrollTop });
    

    You also probably should use state for the end position. It won't actually update the coordinate until the following render, so your selection will be 1 render and mouse move behind. Have you already noticed and wondered why?

    Do:

    const endX = e.clientX + ref.current.scrollLeft;
    const endY = e.clientY + ref.current.scrollTop;
    setEnd({x: endX, y: endY});
    

    and use those values in the calculations below. They will be more up to date (you still need to do setEnd for your overlay, but your overlay calculation should change, too. if it is absolute positioned it should scroll with the window, so just use the document position and don't worry about clipping it to the screen)

    const elementRect = {
                    left: rect.left + ref.current.scrollLeft,
                    top: rect.top + ref.current.scrollTop,
                    right: rect.right + ref.current.scrollLeft,
                    bottom: rect.bottom + ref.current.scrollTop,
                };
    
    selected[i].selected = ((elementRect.left >= Math.min(start.x, endX) && elementRect.left <= Math.max(start.x, endX)) ||
                        (elementRect.right >= Math.min(start.x, endX) && elementRect.right <= Math.max(start.x, endX))) &&
                    ((elementRect.top >= Math.min(start.y, endY) && elementRect.top <= Math.max(start.y, endY)) ||
                        (elementRect.bottom >= Math.min(start.y, endY) && elementRect.bottom <= Math.max(start.y, endY)))
                );
    
    
    // for your overlay, if you make it a child of your main
    // element, it will work better (set zIndex). Otherwise,
    // you need to do a more complex rectangle intersection than this.
    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`,