From 927bf2fe69093fc1661aa1d9e92875dec5c4eee9 Mon Sep 17 00:00:00 2001 From: Charity <81490612+charitea@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:49:25 -0500 Subject: [PATCH] feat: implement horizontal drag-to-scroll functionality in board views (#326) --- apps/web/src/hooks/useDragToScroll.ts | 113 ++++++++++++++++++++++ apps/web/src/views/board/index.tsx | 12 ++- apps/web/src/views/public/board/index.tsx | 12 ++- 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/hooks/useDragToScroll.ts diff --git a/apps/web/src/hooks/useDragToScroll.ts b/apps/web/src/hooks/useDragToScroll.ts new file mode 100644 index 00000000..845c7de5 --- /dev/null +++ b/apps/web/src/hooks/useDragToScroll.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from "react"; + +interface UseDragToScrollOptions { + /** + * Whether drag-to-scroll is enabled + */ + enabled?: boolean; + /** + * The direction to scroll + */ + direction?: "horizontal" | "vertical" | "both"; +} + +/** + * Hook to enable drag-to-scroll functionality on a scrollable element + * @param options Configuration options + * @returns Ref to attach to the scrollable element and mouse event handlers + */ +export function useDragToScroll({ + enabled = true, + direction = "horizontal", +}: UseDragToScrollOptions = {}) { + const scrollRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const startPosRef = useRef({ x: 0, y: 0 }); + const scrollStartRef = useRef({ x: 0, y: 0 }); + + useEffect(() => { + if (!enabled || !isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!scrollRef.current) return; + + const deltaX = e.clientX - startPosRef.current.x; + const deltaY = e.clientY - startPosRef.current.y; + + if (direction === "horizontal" || direction === "both") { + scrollRef.current.scrollLeft = scrollStartRef.current.x - deltaX; + } + if (direction === "vertical" || direction === "both") { + scrollRef.current.scrollTop = scrollStartRef.current.y - deltaY; + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "grabbing"; + document.body.style.userSelect = "none"; + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [enabled, isDragging, direction]); + + const handleMouseDown = (e: React.MouseEvent) => { + if (!scrollRef.current) return; + + const target = e.target as HTMLElement; + const container = scrollRef.current; + + // Check if the click is on an interactive or draggable element + // We need to be careful not to interfere with react-beautiful-dnd dragging + const isInteractiveElement = + target.closest("a") || + target.closest("button") || + target.closest("input") || + target.closest("textarea") || + target.closest("[role='button']") || + target.closest("[draggable='true']") || + target.closest(".react-beautiful-dnd-drag-handle") || + target.closest("[data-rbd-drag-handle-draggable-id]") || + target.closest("[data-rbd-draggable-id]"); + + // Don't start dragging if clicking on interactive elements + if (isInteractiveElement) return; + + // Check if clicking directly on the container (background) + const isContainer = target === container; + + // Check if clicking on a spacer div (empty space between lists) + // Spacer divs are direct children with minimal/no content + const isDirectChild = container.contains(target) && target.parentElement === container; + const isSpacer = + isDirectChild && + target.textContent?.trim() === "" && + target.children.length === 0 && + !target.closest("[data-rbd-droppable-id]"); + + // Allow dragging if clicking on the container background or spacer elements + if (isContainer || isSpacer) { + e.preventDefault(); + setIsDragging(true); + startPosRef.current = { x: e.clientX, y: e.clientY }; + scrollStartRef.current = { + x: container.scrollLeft, + y: container.scrollTop, + }; + } + }; + + return { + ref: scrollRef, + onMouseDown: handleMouseDown, + isDragging, + }; +} diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index 052d90c7..2854386a 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -25,6 +25,7 @@ import PatternedBackground from "~/components/PatternedBackground"; import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable"; import { Tooltip } from "~/components/Tooltip"; import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal"; +import { useDragToScroll } from "~/hooks/useDragToScroll"; import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; @@ -56,6 +57,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { const [selectedPublicListId, setSelectedPublicListId] = useState(""); const [isInitialLoading, setIsInitialLoading] = useState(true); + + const { ref: scrollRef, onMouseDown } = useDragToScroll({ + enabled: true, + direction: "horizontal", + }); const { tooltipContent: createListShortcutTooltipContent } = useKeyboardShortcut({ @@ -479,7 +485,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { -
+
{isLoading ? (
diff --git a/apps/web/src/views/public/board/index.tsx b/apps/web/src/views/public/board/index.tsx index d02c8085..ff7ba5ff 100644 --- a/apps/web/src/views/public/board/index.tsx +++ b/apps/web/src/views/public/board/index.tsx @@ -12,6 +12,7 @@ import { PageHead } from "~/components/PageHead"; import PatternedBackground from "~/components/PatternedBackground"; import Popup from "~/components/Popup"; import ThemeToggle from "~/components/ThemeToggle"; +import { useDragToScroll } from "~/hooks/useDragToScroll"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -29,6 +30,11 @@ export default function PublicBoardView() { const { showPopup } = usePopup(); const [isRouteLoaded, setIsRouteLoaded] = useState(false); const { openModal } = useModal(); + + const { ref: scrollRef, onMouseDown } = useDragToScroll({ + enabled: true, + direction: "horizontal", + }); const boardSlug = Array.isArray(router.query.boardSlug) ? router.query.boardSlug[0] @@ -151,7 +157,11 @@ export default function PublicBoardView() { )}
-
+
{isLoading || !router.isReady ? (