feat: implement horizontal drag-to-scroll functionality in board views (#326)
This commit is contained in:
113
apps/web/src/hooks/useDragToScroll.ts
Normal file
113
apps/web/src/hooks/useDragToScroll.ts
Normal file
@@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import PatternedBackground from "~/components/PatternedBackground";
|
|||||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
||||||
import { Tooltip } from "~/components/Tooltip";
|
import { Tooltip } from "~/components/Tooltip";
|
||||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||||
|
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
@@ -56,6 +57,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
const [selectedPublicListId, setSelectedPublicListId] =
|
const [selectedPublicListId, setSelectedPublicListId] =
|
||||||
useState<PublicListId>("");
|
useState<PublicListId>("");
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
|
|
||||||
|
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||||
|
enabled: true,
|
||||||
|
direction: "horizontal",
|
||||||
|
});
|
||||||
|
|
||||||
const { tooltipContent: createListShortcutTooltipContent } =
|
const { tooltipContent: createListShortcutTooltipContent } =
|
||||||
useKeyboardShortcut({
|
useKeyboardShortcut({
|
||||||
@@ -479,7 +485,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
className={`scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300`}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="ml-[2rem] flex">
|
<div className="ml-[2rem] flex">
|
||||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PageHead } from "~/components/PageHead";
|
|||||||
import PatternedBackground from "~/components/PatternedBackground";
|
import PatternedBackground from "~/components/PatternedBackground";
|
||||||
import Popup from "~/components/Popup";
|
import Popup from "~/components/Popup";
|
||||||
import ThemeToggle from "~/components/ThemeToggle";
|
import ThemeToggle from "~/components/ThemeToggle";
|
||||||
|
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
@@ -29,6 +30,11 @@ export default function PublicBoardView() {
|
|||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
|
const [isRouteLoaded, setIsRouteLoaded] = useState(false);
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
|
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||||
|
enabled: true,
|
||||||
|
direction: "horizontal",
|
||||||
|
});
|
||||||
|
|
||||||
const boardSlug = Array.isArray(router.query.boardSlug)
|
const boardSlug = Array.isArray(router.query.boardSlug)
|
||||||
? router.query.boardSlug[0]
|
? router.query.boardSlug[0]
|
||||||
@@ -151,7 +157,11 @@ export default function PublicBoardView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300"
|
||||||
|
>
|
||||||
{isLoading || !router.isReady ? (
|
{isLoading || !router.isReady ? (
|
||||||
<div className="ml-[2rem] flex">
|
<div className="ml-[2rem] flex">
|
||||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||||
|
|||||||
Reference in New Issue
Block a user