2023-08-28 07:55:47 +00:00
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
2024-02-12 09:38:17 +00:00
|
|
|
import { ArrowRight } from "lucide-react";
|
2023-08-28 07:55:47 +00:00
|
|
|
// hooks
|
2024-02-12 09:38:17 +00:00
|
|
|
import { IGanttBlock, useChart } from "components/gantt-chart";
|
|
|
|
// helpers
|
|
|
|
import { cn } from "helpers/common.helper";
|
|
|
|
// constants
|
|
|
|
import { SIDEBAR_WIDTH } from "../constants";
|
2023-08-11 10:29:13 +00:00
|
|
|
|
|
|
|
type Props = {
|
|
|
|
block: IGanttBlock;
|
2023-11-02 10:32:34 +00:00
|
|
|
blockToRender: (data: any) => React.ReactNode;
|
2023-08-28 07:55:47 +00:00
|
|
|
handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right" | "move") => void;
|
|
|
|
enableBlockLeftResize: boolean;
|
|
|
|
enableBlockRightResize: boolean;
|
|
|
|
enableBlockMove: boolean;
|
2023-08-11 10:29:13 +00:00
|
|
|
};
|
2023-05-20 12:00:15 +00:00
|
|
|
|
2024-01-30 08:55:15 +00:00
|
|
|
export const ChartDraggable: React.FC<Props> = (props) => {
|
|
|
|
const { block, blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, enableBlockMove } = props;
|
|
|
|
// states
|
2023-08-11 10:29:13 +00:00
|
|
|
const [isLeftResizing, setIsLeftResizing] = useState(false);
|
|
|
|
const [isRightResizing, setIsRightResizing] = useState(false);
|
2023-08-28 07:55:47 +00:00
|
|
|
const [isMoving, setIsMoving] = useState(false);
|
2024-02-12 09:38:17 +00:00
|
|
|
const [isHidden, setIsHidden] = useState(true);
|
2024-01-30 08:55:15 +00:00
|
|
|
// refs
|
2023-08-11 10:29:13 +00:00
|
|
|
const resizableRef = useRef<HTMLDivElement>(null);
|
2024-01-30 08:55:15 +00:00
|
|
|
// chart hook
|
2023-08-28 07:55:47 +00:00
|
|
|
const { currentViewData, scrollLeft } = useChart();
|
|
|
|
// check if cursor reaches either end while resizing/dragging
|
2023-08-14 08:57:02 +00:00
|
|
|
const checkScrollEnd = (e: MouseEvent): number => {
|
2023-08-28 07:55:47 +00:00
|
|
|
const SCROLL_THRESHOLD = 70;
|
|
|
|
|
2023-08-14 08:57:02 +00:00
|
|
|
let delWidth = 0;
|
|
|
|
|
2024-02-12 09:38:17 +00:00
|
|
|
const ganttContainer = document.querySelector("#gantt-container") as HTMLDivElement;
|
|
|
|
const ganttSidebar = document.querySelector("#gantt-sidebar") as HTMLDivElement;
|
2023-08-28 07:55:47 +00:00
|
|
|
|
2024-02-12 09:38:17 +00:00
|
|
|
if (!ganttContainer || !ganttSidebar) return 0;
|
2023-08-14 08:57:02 +00:00
|
|
|
|
|
|
|
const posFromLeft = e.clientX;
|
|
|
|
// manually scroll to left if reached the left end while dragging
|
2023-10-16 14:57:22 +00:00
|
|
|
if (posFromLeft - (ganttContainer.getBoundingClientRect().left + ganttSidebar.clientWidth) <= SCROLL_THRESHOLD) {
|
2023-08-14 08:57:02 +00:00
|
|
|
if (e.movementX > 0) return 0;
|
|
|
|
|
|
|
|
delWidth = -5;
|
|
|
|
|
2024-02-12 09:38:17 +00:00
|
|
|
ganttContainer.scrollBy(delWidth, 0);
|
2023-08-14 08:57:02 +00:00
|
|
|
} else delWidth = e.movementX;
|
|
|
|
|
|
|
|
// manually scroll to right if reached the right end while dragging
|
2023-08-28 07:55:47 +00:00
|
|
|
const posFromRight = ganttContainer.getBoundingClientRect().right - e.clientX;
|
|
|
|
if (posFromRight <= SCROLL_THRESHOLD) {
|
2023-08-14 08:57:02 +00:00
|
|
|
if (e.movementX < 0) return 0;
|
|
|
|
|
|
|
|
delWidth = 5;
|
|
|
|
|
2024-02-12 09:38:17 +00:00
|
|
|
ganttContainer.scrollBy(delWidth, 0);
|
2023-08-14 08:57:02 +00:00
|
|
|
} else delWidth = e.movementX;
|
|
|
|
|
|
|
|
return delWidth;
|
|
|
|
};
|
2023-08-28 07:55:47 +00:00
|
|
|
// handle block resize from the left end
|
2023-09-01 05:53:43 +00:00
|
|
|
const handleBlockLeftResize = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
2023-08-28 07:55:47 +00:00
|
|
|
if (!currentViewData || !resizableRef.current || !block.position) return;
|
2023-08-11 10:29:13 +00:00
|
|
|
|
2023-09-01 05:53:43 +00:00
|
|
|
if (e.button !== 0) return;
|
|
|
|
|
2023-08-11 10:29:13 +00:00
|
|
|
const resizableDiv = resizableRef.current;
|
|
|
|
|
|
|
|
const columnWidth = currentViewData.data.width;
|
|
|
|
|
2023-10-16 14:57:22 +00:00
|
|
|
const blockInitialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
|
2023-08-11 10:29:13 +00:00
|
|
|
|
|
|
|
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
|
2023-08-28 07:55:47 +00:00
|
|
|
let initialMarginLeft = parseInt(resizableDiv.style.marginLeft);
|
2023-08-11 10:29:13 +00:00
|
|
|
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
|
|
let delWidth = 0;
|
|
|
|
|
2023-08-14 08:57:02 +00:00
|
|
|
delWidth = checkScrollEnd(e);
|
|
|
|
|
|
|
|
// calculate new width and update the initialMarginLeft using -=
|
|
|
|
const newWidth = Math.round((initialWidth -= delWidth) / columnWidth) * columnWidth;
|
|
|
|
// calculate new marginLeft and update the initial marginLeft to the newly calculated one
|
|
|
|
const newMarginLeft = initialMarginLeft - (newWidth - (block.position?.width ?? 0));
|
|
|
|
initialMarginLeft = newMarginLeft;
|
|
|
|
|
|
|
|
// block needs to be at least 1 column wide
|
|
|
|
if (newWidth < columnWidth) return;
|
|
|
|
|
|
|
|
resizableDiv.style.width = `${newWidth}px`;
|
2023-08-28 07:55:47 +00:00
|
|
|
resizableDiv.style.marginLeft = `${newMarginLeft}px`;
|
2023-08-14 08:57:02 +00:00
|
|
|
|
|
|
|
if (block.position) {
|
|
|
|
block.position.width = newWidth;
|
|
|
|
block.position.marginLeft = newMarginLeft;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2023-08-28 07:55:47 +00:00
|
|
|
// remove event listeners and call block handler with the updated start date
|
2023-08-14 08:57:02 +00:00
|
|
|
const handleMouseUp = () => {
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
|
|
|
2023-10-16 14:57:22 +00:00
|
|
|
const totalBlockShifts = Math.ceil((resizableDiv.clientWidth - blockInitialWidth) / columnWidth);
|
2023-08-14 08:57:02 +00:00
|
|
|
|
|
|
|
handleBlock(totalBlockShifts, "left");
|
|
|
|
};
|
|
|
|
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
};
|
2023-08-28 07:55:47 +00:00
|
|
|
// handle block resize from the right end
|
2023-09-01 05:53:43 +00:00
|
|
|
const handleBlockRightResize = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
2023-08-28 07:55:47 +00:00
|
|
|
if (!currentViewData || !resizableRef.current || !block.position) return;
|
2023-08-11 10:29:13 +00:00
|
|
|
|
2023-09-01 05:53:43 +00:00
|
|
|
if (e.button !== 0) return;
|
|
|
|
|
2023-08-14 08:57:02 +00:00
|
|
|
const resizableDiv = resizableRef.current;
|
2023-08-11 10:29:13 +00:00
|
|
|
|
2023-08-14 08:57:02 +00:00
|
|
|
const columnWidth = currentViewData.data.width;
|
2023-05-20 12:00:15 +00:00
|
|
|
|
2023-10-16 14:57:22 +00:00
|
|
|
const blockInitialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
|
2023-08-11 10:29:13 +00:00
|
|
|
|
2023-08-14 08:57:02 +00:00
|
|
|
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
|
2023-08-11 10:29:13 +00:00
|
|
|
|
2023-08-14 08:57:02 +00:00
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
|
|
let delWidth = 0;
|
2023-08-11 10:29:13 +00:00
|
|
|
|
2023-08-14 08:57:02 +00:00
|
|
|
delWidth = checkScrollEnd(e);
|
2023-08-11 10:29:13 +00:00
|
|
|
|
|
|
|
// calculate new width and update the initialMarginLeft using +=
|
|
|
|
const newWidth = Math.round((initialWidth += delWidth) / columnWidth) * columnWidth;
|
|
|
|
|
|
|
|
// block needs to be at least 1 column wide
|
|
|
|
if (newWidth < columnWidth) return;
|
|
|
|
|
2023-08-14 08:57:02 +00:00
|
|
|
resizableDiv.style.width = `${Math.max(newWidth, 80)}px`;
|
|
|
|
if (block.position) block.position.width = Math.max(newWidth, 80);
|
2023-08-11 10:29:13 +00:00
|
|
|
};
|
|
|
|
|
2023-08-28 07:55:47 +00:00
|
|
|
// remove event listeners and call block handler with the updated target date
|
2023-08-11 10:29:13 +00:00
|
|
|
const handleMouseUp = () => {
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
|
|
|
2023-10-16 14:57:22 +00:00
|
|
|
const totalBlockShifts = Math.ceil((resizableDiv.clientWidth - blockInitialWidth) / columnWidth);
|
2023-08-11 10:29:13 +00:00
|
|
|
|
2023-08-14 08:57:02 +00:00
|
|
|
handleBlock(totalBlockShifts, "right");
|
2023-08-11 10:29:13 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
2023-05-20 12:00:15 +00:00
|
|
|
};
|
2023-08-28 07:55:47 +00:00
|
|
|
// handle block x-axis move
|
|
|
|
const handleBlockMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
|
|
|
if (!enableBlockMove || !currentViewData || !resizableRef.current || !block.position) return;
|
|
|
|
|
2023-09-01 05:53:43 +00:00
|
|
|
if (e.button !== 0) return;
|
|
|
|
|
2023-08-28 07:55:47 +00:00
|
|
|
const resizableDiv = resizableRef.current;
|
|
|
|
|
|
|
|
const columnWidth = currentViewData.data.width;
|
|
|
|
|
|
|
|
const blockInitialMarginLeft = parseInt(resizableDiv.style.marginLeft);
|
|
|
|
|
|
|
|
let initialMarginLeft = parseInt(resizableDiv.style.marginLeft);
|
|
|
|
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
2023-09-27 07:38:35 +00:00
|
|
|
setIsMoving(true);
|
|
|
|
|
2023-08-28 07:55:47 +00:00
|
|
|
let delWidth = 0;
|
|
|
|
|
|
|
|
delWidth = checkScrollEnd(e);
|
|
|
|
|
|
|
|
// calculate new marginLeft and update the initial marginLeft using -=
|
|
|
|
const newMarginLeft = Math.round((initialMarginLeft += delWidth) / columnWidth) * columnWidth;
|
|
|
|
|
|
|
|
resizableDiv.style.marginLeft = `${newMarginLeft}px`;
|
|
|
|
|
|
|
|
if (block.position) block.position.marginLeft = newMarginLeft;
|
|
|
|
};
|
|
|
|
|
|
|
|
// remove event listeners and call block handler with the updated dates
|
|
|
|
const handleMouseUp = () => {
|
|
|
|
setIsMoving(false);
|
|
|
|
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
|
|
|
|
|
|
const totalBlockShifts = Math.ceil(
|
|
|
|
(parseInt(resizableDiv.style.marginLeft) - blockInitialMarginLeft) / columnWidth
|
|
|
|
);
|
|
|
|
|
|
|
|
handleBlock(totalBlockShifts, "move");
|
|
|
|
};
|
|
|
|
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
|
|
};
|
|
|
|
// scroll to a hidden block
|
|
|
|
const handleScrollToBlock = () => {
|
2024-02-12 09:38:17 +00:00
|
|
|
const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement;
|
2023-08-28 07:55:47 +00:00
|
|
|
if (!scrollContainer || !block.position) return;
|
|
|
|
// update container's scroll position to the block's position
|
|
|
|
scrollContainer.scrollLeft = block.position.marginLeft - 4;
|
|
|
|
};
|
|
|
|
// check if block is hidden on either side
|
|
|
|
const isBlockHiddenOnLeft =
|
|
|
|
block.position?.marginLeft &&
|
|
|
|
block.position?.width &&
|
|
|
|
scrollLeft > block.position.marginLeft + block.position.width;
|
2024-02-12 09:38:17 +00:00
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement;
|
|
|
|
const resizableBlock = resizableRef.current;
|
|
|
|
if (!resizableBlock || !intersectionRoot) return;
|
|
|
|
|
|
|
|
const observer = new IntersectionObserver(
|
|
|
|
(entries) => {
|
|
|
|
entries.forEach((entry) => {
|
|
|
|
setIsHidden(!entry.isIntersecting);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
{
|
|
|
|
root: intersectionRoot,
|
|
|
|
rootMargin: `0px 0px 0px -${SIDEBAR_WIDTH}px`,
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
observer.observe(resizableBlock);
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
observer.unobserve(resizableBlock);
|
|
|
|
};
|
|
|
|
}, [block.data.name]);
|
2023-08-28 07:55:47 +00:00
|
|
|
|
2023-05-20 12:00:15 +00:00
|
|
|
return (
|
2023-08-28 07:55:47 +00:00
|
|
|
<>
|
2024-02-12 09:38:17 +00:00
|
|
|
{/* move to the hidden block */}
|
|
|
|
{isHidden && (
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
className="sticky z-[1] grid h-8 w-8 translate-y-1.5 cursor-pointer place-items-center rounded border border-custom-border-300 bg-custom-background-80 text-custom-text-200 hover:text-custom-text-100"
|
|
|
|
style={{
|
|
|
|
left: `${SIDEBAR_WIDTH + 4}px`,
|
|
|
|
}}
|
2023-08-28 07:55:47 +00:00
|
|
|
onClick={handleScrollToBlock}
|
|
|
|
>
|
2024-02-12 09:38:17 +00:00
|
|
|
<ArrowRight
|
|
|
|
className={cn("h-3.5 w-3.5", {
|
|
|
|
"rotate-180": isBlockHiddenOnLeft,
|
|
|
|
})}
|
|
|
|
/>
|
|
|
|
</button>
|
2023-08-11 10:29:13 +00:00
|
|
|
)}
|
2023-08-28 07:55:47 +00:00
|
|
|
<div
|
|
|
|
ref={resizableRef}
|
2023-12-10 10:18:10 +00:00
|
|
|
className="group relative inline-flex h-full cursor-pointer items-center font-medium transition-all"
|
2023-08-28 07:55:47 +00:00
|
|
|
style={{
|
|
|
|
marginLeft: `${block.position?.marginLeft}px`,
|
|
|
|
width: `${block.position?.width}px`,
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{/* left resize drag handle */}
|
|
|
|
{enableBlockLeftResize && (
|
|
|
|
<>
|
|
|
|
<div
|
|
|
|
onMouseDown={handleBlockLeftResize}
|
|
|
|
onMouseEnter={() => setIsLeftResizing(true)}
|
|
|
|
onMouseLeave={() => setIsLeftResizing(false)}
|
2024-02-12 09:38:17 +00:00
|
|
|
className="absolute -left-2.5 top-1/2 -translate-y-1/2 z-[3] h-full w-6 cursor-col-resize rounded-md"
|
2023-08-28 07:55:47 +00:00
|
|
|
/>
|
|
|
|
<div
|
2024-02-12 09:38:17 +00:00
|
|
|
className={cn(
|
|
|
|
"absolute left-1 top-1/2 -translate-y-1/2 h-7 w-1 rounded-sm bg-custom-background-100 transition-all duration-300",
|
|
|
|
{
|
|
|
|
"-left-2.5": isLeftResizing,
|
|
|
|
}
|
|
|
|
)}
|
2023-08-28 07:55:47 +00:00
|
|
|
/>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
<div
|
2024-02-12 09:38:17 +00:00
|
|
|
className={cn("relative z-[2] flex h-8 w-full items-center rounded", {
|
|
|
|
"pointer-events-none": isMoving,
|
|
|
|
})}
|
2023-08-28 07:55:47 +00:00
|
|
|
onMouseDown={handleBlockMove}
|
|
|
|
>
|
2023-11-02 10:32:34 +00:00
|
|
|
{blockToRender(block.data)}
|
2023-08-28 07:55:47 +00:00
|
|
|
</div>
|
|
|
|
{/* right resize drag handle */}
|
|
|
|
{enableBlockRightResize && (
|
|
|
|
<>
|
|
|
|
<div
|
|
|
|
onMouseDown={handleBlockRightResize}
|
|
|
|
onMouseEnter={() => setIsRightResizing(true)}
|
|
|
|
onMouseLeave={() => setIsRightResizing(false)}
|
2024-02-12 09:38:17 +00:00
|
|
|
className="absolute -right-2.5 top-1/2 -translate-y-1/2 z-[2] h-full w-6 cursor-col-resize rounded-md"
|
2023-08-28 07:55:47 +00:00
|
|
|
/>
|
|
|
|
<div
|
2024-02-12 09:38:17 +00:00
|
|
|
className={cn(
|
|
|
|
"absolute right-1 top-1/2 -translate-y-1/2 h-7 w-1 rounded-sm bg-custom-background-100 transition-all duration-300",
|
|
|
|
{
|
|
|
|
"-right-2.5": isRightResizing,
|
|
|
|
}
|
|
|
|
)}
|
2023-08-28 07:55:47 +00:00
|
|
|
/>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</>
|
2023-05-20 12:00:15 +00:00
|
|
|
);
|
|
|
|
};
|