feat: init
This commit is contained in:
102
apps/web/core/components/gantt-chart/helpers/add-block.tsx
Normal file
102
apps/web/core/components/gantt-chart/helpers/add-block.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addDays } from "date-fns";
|
||||
import { observer } from "mobx-react";
|
||||
import { Plus } from "lucide-react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IBlockUpdateData, IGanttBlock } from "@plane/types";
|
||||
// helpers
|
||||
import { renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
|
||||
//
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
};
|
||||
|
||||
export const ChartAddBlock: React.FC<Props> = observer((props) => {
|
||||
const { block, blockUpdateHandler } = props;
|
||||
// states
|
||||
const [isButtonVisible, setIsButtonVisible] = useState(false);
|
||||
const [buttonXPosition, setButtonXPosition] = useState(0);
|
||||
const [buttonStartDate, setButtonStartDate] = useState<Date | null>(null);
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// chart hook
|
||||
const { currentViewData, currentView } = useTimeLineChartStore();
|
||||
|
||||
const handleButtonClick = () => {
|
||||
if (!currentViewData) return;
|
||||
|
||||
const { startDate: chartStartDate, dayWidth } = currentViewData.data;
|
||||
const columnNumber = buttonXPosition / dayWidth;
|
||||
|
||||
let numberOfDays = 1;
|
||||
|
||||
if (currentView === "quarter") numberOfDays = 7;
|
||||
|
||||
const startDate = addDays(chartStartDate, columnNumber);
|
||||
const endDate = addDays(startDate, numberOfDays);
|
||||
|
||||
blockUpdateHandler(block.data, {
|
||||
start_date: renderFormattedPayloadDate(startDate) ?? undefined,
|
||||
target_date: renderFormattedPayloadDate(endDate) ?? undefined,
|
||||
meta: block.meta,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!currentViewData) return;
|
||||
|
||||
setButtonXPosition(e.offsetX);
|
||||
|
||||
const { startDate: chartStartDate, dayWidth } = currentViewData.data;
|
||||
const columnNumber = buttonXPosition / dayWidth;
|
||||
|
||||
const startDate = addDays(chartStartDate, columnNumber);
|
||||
setButtonStartDate(startDate);
|
||||
};
|
||||
|
||||
container.addEventListener("mousemove", handleMouseMove);
|
||||
|
||||
return () => {
|
||||
container?.removeEventListener("mousemove", handleMouseMove);
|
||||
};
|
||||
}, [buttonXPosition, currentViewData]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
onMouseEnter={() => setIsButtonVisible(true)}
|
||||
onMouseLeave={() => setIsButtonVisible(false)}
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
{isButtonVisible && (
|
||||
<Tooltip tooltipContent={buttonStartDate && renderFormattedDate(buttonStartDate)} isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-8 w-8 bg-custom-background-80 p-1.5 rounded border border-custom-border-300 grid place-items-center text-custom-text-200 hover:text-custom-text-100"
|
||||
style={{
|
||||
marginLeft: `${buttonXPosition}px`,
|
||||
}}
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane utils
|
||||
import { cn, renderFormattedDate } from "@plane/utils";
|
||||
//helpers
|
||||
//
|
||||
//hooks
|
||||
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
|
||||
|
||||
type LeftResizableProps = {
|
||||
enableBlockLeftResize: boolean;
|
||||
handleBlockDrag: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, dragDirection: "left" | "right" | "move") => void;
|
||||
isMoving: "left" | "right" | "move" | undefined;
|
||||
position?: {
|
||||
marginLeft: number;
|
||||
width: number;
|
||||
};
|
||||
};
|
||||
export const LeftResizable = observer((props: LeftResizableProps) => {
|
||||
const { enableBlockLeftResize, isMoving, handleBlockDrag, position } = props;
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const { getDateFromPositionOnGantt } = useTimeLineChartStore();
|
||||
|
||||
const date = position ? getDateFromPositionOnGantt(position.marginLeft, 0) : undefined;
|
||||
const dateString = date ? renderFormattedDate(date) : undefined;
|
||||
|
||||
const isLeftResizing = isMoving === "left" || isMoving === "move";
|
||||
|
||||
if (!enableBlockLeftResize) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isHovering || isLeftResizing) && dateString && (
|
||||
<div className="absolute flex text-xs font-normal text-custom-text-300 h-full w-32 -left-36 justify-end items-center">
|
||||
<div className="px-2 py-1 bg-custom-primary-20 rounded">{dateString}</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
handleBlockDrag(e, "left");
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
setIsHovering(true);
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setIsHovering(false);
|
||||
}}
|
||||
className="absolute -left-1.5 top-1/2 -translate-y-1/2 z-[6] h-full w-3 cursor-col-resize rounded-md"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-1 top-1/2 -translate-y-1/2 h-7 w-1 z-[5] rounded-sm bg-custom-background-100 transition-all duration-300 opacity-0 group-hover:opacity-100",
|
||||
{
|
||||
"-left-1.5 opacity-100": isLeftResizing,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane utils
|
||||
import { cn, renderFormattedDate } from "@plane/utils";
|
||||
//helpers
|
||||
//
|
||||
//hooks
|
||||
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
|
||||
|
||||
type RightResizableProps = {
|
||||
enableBlockRightResize: boolean;
|
||||
handleBlockDrag: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, dragDirection: "left" | "right" | "move") => void;
|
||||
isMoving: "left" | "right" | "move" | undefined;
|
||||
position?: {
|
||||
marginLeft: number;
|
||||
width: number;
|
||||
};
|
||||
};
|
||||
export const RightResizable = observer((props: RightResizableProps) => {
|
||||
const { enableBlockRightResize, handleBlockDrag, isMoving, position } = props;
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const { getDateFromPositionOnGantt } = useTimeLineChartStore();
|
||||
|
||||
const date = position ? getDateFromPositionOnGantt(position.marginLeft + position.width, -1) : undefined;
|
||||
const dateString = date ? renderFormattedDate(date) : undefined;
|
||||
|
||||
const isRightResizing = isMoving === "right" || isMoving === "move";
|
||||
|
||||
if (!enableBlockRightResize) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isHovering || isRightResizing) && dateString && (
|
||||
<div className="z-[10] absolute flex text-xs font-normal text-custom-text-300 h-full w-32 -right-36 justify-start items-center">
|
||||
<div className="px-2 py-1 bg-custom-primary-20 rounded">{dateString}</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
onMouseDown={(e) => handleBlockDrag(e, "right")}
|
||||
onMouseOver={() => {
|
||||
setIsHovering(true);
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setIsHovering(false);
|
||||
}}
|
||||
className="absolute -right-1.5 top-1/2 -translate-y-1/2 z-[6] h-full w-3 cursor-col-resize rounded-md"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 -translate-y-1/2 h-7 w-1 z-[5] rounded-sm bg-custom-background-100 transition-all duration-300 opacity-0 group-hover:opacity-100",
|
||||
{
|
||||
"-right-1.5 opacity-100": isRightResizing,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useRef, useState } from "react";
|
||||
// Plane
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IBlockUpdateDependencyData, IGanttBlock } from "@plane/types";
|
||||
// hooks
|
||||
import { useTimeLineChartStore } from "@/hooks/use-timeline-chart";
|
||||
//
|
||||
import { DEFAULT_BLOCK_WIDTH, SIDEBAR_WIDTH } from "../../constants";
|
||||
|
||||
export const useGanttResizable = (
|
||||
block: IGanttBlock,
|
||||
resizableRef: React.RefObject<HTMLDivElement>,
|
||||
ganttContainerRef: React.RefObject<HTMLDivElement>,
|
||||
updateBlockDates?: (updates: IBlockUpdateDependencyData[]) => Promise<void>
|
||||
) => {
|
||||
// refs
|
||||
const initialPositionRef = useRef<{ marginLeft: number; width: number; offsetX: number }>({
|
||||
marginLeft: 0,
|
||||
width: 0,
|
||||
offsetX: 0,
|
||||
});
|
||||
const ganttContainerDimensions = useRef<DOMRect | undefined>();
|
||||
const currMouseEvent = useRef<MouseEvent | undefined>();
|
||||
// states
|
||||
const { currentViewData, updateBlockPosition, setIsDragging, getUpdatedPositionAfterDrag } = useTimeLineChartStore();
|
||||
const [isMoving, setIsMoving] = useState<"left" | "right" | "move" | undefined>();
|
||||
|
||||
// handle block resize from the left end
|
||||
const handleBlockDrag = (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
dragDirection: "left" | "right" | "move"
|
||||
) => {
|
||||
const ganttContainerElement = ganttContainerRef.current;
|
||||
if (!currentViewData || !resizableRef.current || !block.position || !ganttContainerElement) return;
|
||||
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const resizableDiv = resizableRef.current;
|
||||
|
||||
ganttContainerDimensions.current = ganttContainerElement.getBoundingClientRect();
|
||||
|
||||
const dayWidth = currentViewData.data.dayWidth;
|
||||
const mouseX = e.clientX - ganttContainerDimensions.current.left - SIDEBAR_WIDTH + ganttContainerElement.scrollLeft;
|
||||
|
||||
// record position on drag start
|
||||
initialPositionRef.current = {
|
||||
width: block.position.width ?? 0,
|
||||
marginLeft: block.position.marginLeft ?? 0,
|
||||
offsetX: mouseX - block.position.marginLeft,
|
||||
};
|
||||
|
||||
const handleOnScroll = () => {
|
||||
if (currMouseEvent.current) handleMouseMove(currMouseEvent.current);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
currMouseEvent.current = e;
|
||||
setIsMoving(dragDirection);
|
||||
setIsDragging(true);
|
||||
|
||||
if (!ganttContainerDimensions.current) return;
|
||||
|
||||
const { left: containerLeft } = ganttContainerDimensions.current;
|
||||
|
||||
const mouseX = e.clientX - containerLeft - SIDEBAR_WIDTH + ganttContainerElement.scrollLeft;
|
||||
|
||||
let width = initialPositionRef.current.width;
|
||||
let marginLeft = initialPositionRef.current.marginLeft;
|
||||
|
||||
if (dragDirection === "left") {
|
||||
// calculate new marginLeft and update the initial marginLeft to the newly calculated one
|
||||
marginLeft = Math.round(mouseX / dayWidth) * dayWidth;
|
||||
// get Dimensions from dom's style
|
||||
const prevMarginLeft = parseFloat(resizableDiv.style.marginLeft.slice(0, -2));
|
||||
const prevWidth = parseFloat(resizableDiv.style.width.slice(0, -2));
|
||||
// calculate new width
|
||||
const marginDelta = prevMarginLeft - marginLeft;
|
||||
// If target date does not exist while dragging with left handle the revert to default width
|
||||
width = block.target_date ? prevWidth + marginDelta : DEFAULT_BLOCK_WIDTH;
|
||||
} else if (dragDirection === "right") {
|
||||
// calculate new width and update the initialMarginLeft using +=
|
||||
width = Math.round(mouseX / dayWidth) * dayWidth - marginLeft;
|
||||
|
||||
// If start date does not exist while dragging with right handle the revert to default width and adjust marginLeft accordingly
|
||||
if (!block.start_date) {
|
||||
// calculate new right and update the marginLeft to the newly calculated one
|
||||
const marginRight = Math.round(mouseX / dayWidth) * dayWidth;
|
||||
marginLeft = marginRight - DEFAULT_BLOCK_WIDTH;
|
||||
width = DEFAULT_BLOCK_WIDTH;
|
||||
}
|
||||
} else if (dragDirection === "move") {
|
||||
// calculate new marginLeft and update the initial marginLeft using -=
|
||||
marginLeft = Math.round((mouseX - initialPositionRef.current.offsetX) / dayWidth) * dayWidth;
|
||||
}
|
||||
|
||||
// block needs to be at least 1 dayWidth Wide
|
||||
if (width < dayWidth) return;
|
||||
|
||||
resizableDiv.style.width = `${width}px`;
|
||||
resizableDiv.style.marginLeft = `${marginLeft}px`;
|
||||
|
||||
const deltaLeft = Math.round((marginLeft - (block.position?.marginLeft ?? 0)) / dayWidth) * dayWidth;
|
||||
const deltaWidth = Math.round((width - (block.position?.width ?? 0)) / dayWidth) * dayWidth;
|
||||
|
||||
// call update blockPosition
|
||||
if (deltaWidth || deltaLeft) updateBlockPosition(block.id, deltaLeft, deltaWidth);
|
||||
};
|
||||
|
||||
// remove event listeners and call updateBlockDates
|
||||
const handleMouseUp = () => {
|
||||
setIsMoving(undefined);
|
||||
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
ganttContainerElement.removeEventListener("scroll", handleOnScroll);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
||||
// update half blocks only when the missing side of the block is directly dragged
|
||||
const shouldUpdateHalfBlock =
|
||||
(dragDirection === "left" && !block.start_date) || (dragDirection === "right" && !block.target_date);
|
||||
|
||||
try {
|
||||
const blockUpdates = getUpdatedPositionAfterDrag(block.id, shouldUpdateHalfBlock);
|
||||
if (updateBlockDates) updateBlockDates(blockUpdates);
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error",
|
||||
message: "Something went wrong while updating block dates",
|
||||
});
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
ganttContainerElement.addEventListener("scroll", handleOnScroll);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
return {
|
||||
isMoving,
|
||||
handleBlockDrag,
|
||||
};
|
||||
};
|
||||
71
apps/web/core/components/gantt-chart/helpers/draggable.tsx
Normal file
71
apps/web/core/components/gantt-chart/helpers/draggable.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { RefObject } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import type { IGanttBlock } from "@plane/types";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// Plane-web
|
||||
import { LeftDependencyDraggable, RightDependencyDraggable } from "@/plane-web/components/gantt-chart";
|
||||
//
|
||||
import { LeftResizable } from "./blockResizables/left-resizable";
|
||||
import { RightResizable } from "./blockResizables/right-resizable";
|
||||
|
||||
type Props = {
|
||||
block: IGanttBlock;
|
||||
blockToRender: (data: any) => React.ReactNode;
|
||||
handleBlockDrag: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, dragDirection: "left" | "right" | "move") => void;
|
||||
isMoving: "left" | "right" | "move" | undefined;
|
||||
enableBlockLeftResize: boolean;
|
||||
enableBlockRightResize: boolean;
|
||||
enableBlockMove: boolean;
|
||||
enableDependency: boolean | ((blockId: string) => boolean);
|
||||
ganttContainerRef: RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const ChartDraggable: React.FC<Props> = observer((props) => {
|
||||
const {
|
||||
block,
|
||||
blockToRender,
|
||||
handleBlockDrag,
|
||||
enableBlockLeftResize,
|
||||
enableBlockRightResize,
|
||||
enableBlockMove,
|
||||
enableDependency,
|
||||
isMoving,
|
||||
ganttContainerRef,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="group w-full z-[5] relative inline-flex h-full cursor-pointer items-center font-medium transition-all">
|
||||
{/* left resize drag handle */}
|
||||
{(typeof enableDependency === "function" ? enableDependency(block.id) : enableDependency) && (
|
||||
<LeftDependencyDraggable block={block} ganttContainerRef={ganttContainerRef} />
|
||||
)}
|
||||
<LeftResizable
|
||||
enableBlockLeftResize={enableBlockLeftResize}
|
||||
handleBlockDrag={handleBlockDrag}
|
||||
isMoving={isMoving}
|
||||
position={block.position}
|
||||
/>
|
||||
<div
|
||||
className={cn("relative z-[6] flex h-8 w-full items-center rounded", {
|
||||
"pointer-events-none": isMoving,
|
||||
})}
|
||||
onMouseDown={(e) => enableBlockMove && handleBlockDrag(e, "move")}
|
||||
>
|
||||
{blockToRender({ ...block.data, meta: block.meta })}
|
||||
</div>
|
||||
{/* right resize drag handle */}
|
||||
<RightResizable
|
||||
enableBlockRightResize={enableBlockRightResize}
|
||||
handleBlockDrag={handleBlockDrag}
|
||||
isMoving={isMoving}
|
||||
position={block.position}
|
||||
/>
|
||||
{(typeof enableDependency === "function" ? enableDependency(block.id) : enableDependency) && (
|
||||
<RightDependencyDraggable block={block} ganttContainerRef={ganttContainerRef} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
2
apps/web/core/components/gantt-chart/helpers/index.ts
Normal file
2
apps/web/core/components/gantt-chart/helpers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./add-block";
|
||||
export * from "./draggable";
|
||||
Reference in New Issue
Block a user