diff --git a/frontend/src/components/WorkflowPanel/ConnectionLine/index.tsx b/frontend/src/components/WorkflowPanel/ConnectionLine/index.tsx index 3ffd9d55..a27caa35 100644 --- a/frontend/src/components/WorkflowPanel/ConnectionLine/index.tsx +++ b/frontend/src/components/WorkflowPanel/ConnectionLine/index.tsx @@ -29,7 +29,6 @@ export const CustomConnectionLine: React.FC = ({ style={{ strokeWidth: 2, }} - markerEnd={`url(#custom-arrow)`} /> ); diff --git a/frontend/src/components/WorkflowPanel/DefaultNode/Handle.tsx b/frontend/src/components/WorkflowPanel/DefaultNode/Handle.tsx deleted file mode 100644 index aea35c44..00000000 --- a/frontend/src/components/WorkflowPanel/DefaultNode/Handle.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import theme from "providers/theme.config"; -import React, { type CSSProperties, useMemo } from "react"; -import { Handle, type HandleProps } from "reactflow"; - -const _targetStyle: React.CSSProperties = { - width: 10, - height: 10, - borderRadius: "16px", - borderColor: "transparent", - backgroundColor: theme.palette.grey[400], - transition: "ease 100ms", - zIndex: 2, -}; - -const sourceStyle: React.CSSProperties = { - width: 10, - height: 10, - borderRadius: "16px", - borderColor: "transparent", - backgroundColor: theme.palette.grey[400], - transition: "ease 100", - zIndex: 2, -}; - -interface CustomHandleProps extends HandleProps { - hovered: boolean; -} - -const CustomHandle: React.FC = ({ - hovered, - ...props -}: CustomHandleProps) => { - const styles = useMemo(() => { - if (!hovered) { - return { - border: 0, - borderRadius: "16px", - backgroundColor: "transparent", - transition: "ease 100ms", - zIndex: 2, - }; - } else { - return sourceStyle; - } - }, [props.type, props.position, hovered]); - - return ; -}; - -export default CustomHandle; diff --git a/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx b/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx index 9c79e095..2cf2d826 100644 --- a/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx +++ b/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx @@ -1,148 +1,154 @@ import { Paper, Typography } from "@mui/material"; import theme from "providers/theme.config"; -import React, { type CSSProperties, memo, useMemo, useState } from "react"; -import { Handle, type Position } from "reactflow"; -import { getUuidSlice } from "utils"; +import React, { type CSSProperties, memo, useMemo } from "react"; +import { Handle, Position } from "reactflow"; +import { getUuidSlice, useMouseProximity } from "utils"; import { type DefaultNodeProps } from "../types"; -export const CustomNode = memo( - ({ id, sourcePosition, targetPosition, data, selected }) => { - const [hovered, setHovered] = useState(false); +export const CustomNode = memo(({ id, data, selected }) => { + const [isNear, ElementRef] = useMouseProximity(150); - const handleStyle = useMemo( - () => - hovered - ? { - border: 0, - borderRadius: "16px", - backgroundColor: theme.palette.grey[400], - transition: "ease 100", - zIndex: 2, - } - : { - border: 0, - borderRadius: "16px", - backgroundColor: "transparent", - transition: "ease 100", - zIndex: 2, - }, - [hovered], - ); + const handleStyle = useMemo( + () => + isNear + ? { + border: 0, + borderRadius: "16px", + backgroundColor: theme.palette.info.main, + transition: "ease 100", + zIndex: 2, + width: "12px", + height: "12px", + } + : { + border: 0, + borderRadius: "16px", + backgroundColor: "transparent", + transition: "ease 100", + zIndex: 2, + }, + [isNear], + ); - const extendedClassExt = useMemo<"input" | "default" | "output">(() => { - const dominoReactflowClassTypeMap = Object.freeze({ - source: "input", - default: "default", - sink: "output", - }); - if ( - !data?.style.nodeType || - !["default", "source", "sink"].includes(data?.style.nodeType) - ) { - return "default"; - } else { - return dominoReactflowClassTypeMap[data.style.nodeType]; - } - }, [data]); + const extendedClassExt = useMemo<"input" | "default" | "output">(() => { + const dominoReactflowClassTypeMap = Object.freeze({ + source: "input", + default: "default", + sink: "output", + }); + if ( + !data?.style.nodeType || + !["default", "source", "sink"].includes(data?.style.nodeType) + ) { + return "default"; + } else { + return dominoReactflowClassTypeMap[data.style.nodeType]; + } + }, [data]); - const nodeTypeRenderHandleMap = useMemo( - () => ({ - input: { - renderTargetHandle: false, - renderSourceHandle: true, - }, - output: { - renderTargetHandle: true, - renderSourceHandle: false, - }, - default: { - renderTargetHandle: true, - renderSourceHandle: true, - }, - }), - [], - ); + const nodeTypeRenderHandleMap = useMemo( + () => ({ + input: { + renderTargetHandle: false, + renderSourceHandle: true, + }, + output: { + renderTargetHandle: true, + renderSourceHandle: false, + }, + default: { + renderTargetHandle: true, + renderSourceHandle: true, + }, + }), + [], + ); - const nodeStyle = useMemo(() => { - return { - ...data.style.nodeStyle, - display: "flex", - flexDirection: "row", - justifyContent: "center", - alignItems: "center", + const nodeStyle = useMemo(() => { + return { + ...data.style.nodeStyle, + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", - position: "relative", - width: 150, - height: 70, - lineHeight: "60px", - border: selected ? "2px" : "", - borderStyle: selected ? "solid" : "", - borderColor: selected ? theme.palette.info.dark : "", - borderRadius: selected ? "3px" : "", - ...(data.validationError && { - backgroundColor: theme.palette.error.light, - color: theme.palette.error.contrastText, - }), - }; - }, [data, selected]); + position: "relative", + width: 150, + height: 70, + lineHeight: "60px", + border: selected ? "2px" : "", + borderStyle: selected ? "solid" : "", + borderColor: selected ? theme.palette.info.dark : "", + borderRadius: selected ? "3px" : "", + ...(data.validationError && { + backgroundColor: theme.palette.error.light, + color: theme.palette.error.contrastText, + }), + }; + }, [data, selected]); - return ( - <> - {nodeTypeRenderHandleMap[extendedClassExt].renderSourceHandle && ( - - )} - {nodeTypeRenderHandleMap[extendedClassExt].renderTargetHandle && ( - - )} - { - setHovered(true); - }} - onMouseLeave={() => { - setHovered(false); + const { sourcePosition, targetPosition } = useMemo( + () => ({ + ...(data.orientation === "horizontal" + ? { + targetPosition: Position.Left, + sourcePosition: Position.Right, + } + : { + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + }), + }), + [data], + ); + + return ( + <> + {nodeTypeRenderHandleMap[extendedClassExt].renderSourceHandle && ( + + )} + {nodeTypeRenderHandleMap[extendedClassExt].renderTargetHandle && ( + + )} + +
-
+ {data?.style?.label ? data?.style?.label : data?.name} + + - - {data?.style?.label ? data?.style?.label : data?.name} - - - {getUuidSlice(id)} - -
- - - ); - }, -); + {getUuidSlice(id)} + +
+
+ + ); +}); CustomNode.displayName = "CustomNode"; diff --git a/frontend/src/components/WorkflowPanel/RunNode/index.tsx b/frontend/src/components/WorkflowPanel/RunNode/index.tsx index 36389444..aece5146 100644 --- a/frontend/src/components/WorkflowPanel/RunNode/index.tsx +++ b/frontend/src/components/WorkflowPanel/RunNode/index.tsx @@ -2,163 +2,168 @@ import { Paper, Typography } from "@mui/material"; import { taskState } from "features/workflows/types"; import theme from "providers/theme.config"; -import React, { memo, useCallback, useMemo } from "react"; -import { type Position, Handle } from "reactflow"; +import React, { type CSSProperties, memo, useCallback, useMemo } from "react"; +import { Position, Handle } from "reactflow"; import { getUuidSlice } from "utils"; import { type RunNodeProps } from "../types"; -const RunNode = memo( - ({ id, sourcePosition, targetPosition, data, selected }) => { - const extendedClassExt = useMemo(() => { - const dominoReactflowClassTypeMap: any = { - source: "input", - default: "default", - sink: "output", - }; - if ( - data?.style.nodeType === undefined || - !["default", "source", "sink"].includes(data?.style.nodeType) - ) { - return "default"; - } else { - return dominoReactflowClassTypeMap[data?.style.nodeType]; - } - }, [data]); - - const nodeTypeRenderHandleMap = useMemo( - () => - ({ - input: { - renderTargetHandle: false, - renderSourceHandle: true, - }, - output: { - renderTargetHandle: true, - renderSourceHandle: false, - }, - default: { - renderTargetHandle: true, - renderSourceHandle: true, - }, - }) as any, - [], - ); - - const getTaskStatusColor = useCallback((state: taskState) => { - const colors = { - backgroundColor: theme.palette.background.default, - color: theme.palette.getContrastText(theme.palette.background.default), - }; - - switch (state) { - case taskState.success: - colors.backgroundColor = theme.palette.success.light; - colors.color = theme.palette.getContrastText( - theme.palette.success.light, - ); - break; - case taskState.running: - colors.backgroundColor = theme.palette.info.light; - colors.color = theme.palette.getContrastText( - theme.palette.info.light, - ); - break; - - case taskState.failed: - colors.backgroundColor = theme.palette.error.light; - colors.color = theme.palette.getContrastText( - theme.palette.error.light, - ); - break; - } - - return colors; - }, []); - - const handleStyle = useMemo( - () => ({ - border: 0, - borderRadius: "16px", - backgroundColor: theme.palette.grey[400], - transition: "ease 100", - zIndex: 2, - }), - [], - ); - - const nodeStyle = useMemo(() => { - let style: React.CSSProperties = { - display: "flex", - flexDirection: "row", - justifyContent: "center", - alignItems: "center", - - position: "relative", - - height: 60, - lineHeight: "60px", - }; - - if (data?.style.hasOwnProperty("nodeStyle")) { - style = Object.assign(style, data.style.nodeStyle); - } - - if (data.state) { - style = Object.assign(style, getTaskStatusColor(data.state)); - } - - return style; - }, [data]); - - return ( - <> - {nodeTypeRenderHandleMap[extendedClassExt].renderSourceHandle && ( - - )} - {nodeTypeRenderHandleMap[extendedClassExt].renderTargetHandle && ( - - )} - -
(({ id, data, selected }) => { + const extendedClassExt = useMemo(() => { + const dominoReactflowClassTypeMap: any = { + source: "input", + default: "default", + sink: "output", + }; + if ( + data?.style.nodeType === undefined || + !["default", "source", "sink"].includes(data?.style.nodeType) + ) { + return "default"; + } else { + return dominoReactflowClassTypeMap[data?.style.nodeType]; + } + }, [data]); + + const nodeTypeRenderHandleMap = useMemo( + () => + ({ + input: { + renderTargetHandle: false, + renderSourceHandle: true, + }, + output: { + renderTargetHandle: true, + renderSourceHandle: false, + }, + default: { + renderTargetHandle: true, + renderSourceHandle: true, + }, + }) as any, + [], + ); + + const handleStyle = useMemo( + () => ({ + border: 0, + borderRadius: "16px", + backgroundColor: theme.palette.grey[400], + transition: "ease 100", + zIndex: 2, + }), + [], + ); + + const getTaskStatusColor = useCallback((state: taskState) => { + const colors = { + backgroundColor: theme.palette.background.default, + color: theme.palette.getContrastText(theme.palette.background.default), + }; + + switch (state) { + case taskState.success: + colors.backgroundColor = theme.palette.success.light; + colors.color = theme.palette.getContrastText( + theme.palette.success.light, + ); + break; + case taskState.running: + colors.backgroundColor = theme.palette.info.light; + colors.color = theme.palette.getContrastText(theme.palette.info.light); + break; + + case taskState.failed: + colors.backgroundColor = theme.palette.error.light; + colors.color = theme.palette.getContrastText(theme.palette.error.light); + break; + } + + return colors; + }, []); + + const nodeStyle = useMemo(() => { + return { + ...data.style.nodeStyle, + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + + position: "relative", + width: 150, + height: 70, + lineHeight: "60px", + border: selected ? "2px" : "", + borderStyle: selected ? "solid" : "", + borderColor: selected ? theme.palette.info.dark : "", + borderRadius: selected ? "3px" : "", + ...(data.state && getTaskStatusColor(data.state)), + }; + }, [data, selected]); + + const { sourcePosition, targetPosition } = useMemo( + () => ({ + ...(data.orientation === "horizontal" + ? { + targetPosition: Position.Left, + sourcePosition: Position.Right, + } + : { + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + }), + }), + [data], + ); + + return ( + <> + {nodeTypeRenderHandleMap[extendedClassExt].renderSourceHandle && ( + + )} + {nodeTypeRenderHandleMap[extendedClassExt].renderTargetHandle && ( + + )} + +
+ + {data?.style?.label ? data?.style?.label : data?.name} + + - - {data?.style?.label ? data?.style?.label : data?.name} - - - {getUuidSlice(id)} - -
-
- - ); - }, -); + {getUuidSlice(id)} + +
+
+ + ); +}); RunNode.displayName = "RunNode"; diff --git a/frontend/src/components/WorkflowPanel/WorkflowPanel.tsx b/frontend/src/components/WorkflowPanel/WorkflowPanel.tsx index 16f8fed2..4db4b8aa 100644 --- a/frontend/src/components/WorkflowPanel/WorkflowPanel.tsx +++ b/frontend/src/components/WorkflowPanel/WorkflowPanel.tsx @@ -1,5 +1,4 @@ import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; -import { MenuItem, Select } from "@mui/material"; import Elk from "elkjs"; import theme from "providers/theme.config"; import React, { @@ -31,8 +30,6 @@ import ReactFlow, { MarkerType, type EdgeTypes, type NodeTypes, - Panel, - Position, } from "reactflow"; import { CustomConnectionLine } from "./ConnectionLine"; @@ -67,8 +64,6 @@ type OnDrop = | ((event: DragEvent, position: XYPosition) => Node) | ((event: DragEvent, position: XYPosition) => Promise); -export type WorkflowOrientation = "horizontal" | "vertical"; - type Props = | { editable: true; @@ -89,7 +84,6 @@ export interface WorkflowPanelRef { edges: Edge[]; setNodes: React.Dispatch>; setEdges: React.Dispatch>; - orientation: WorkflowOrientation; } const WorkflowPanel = forwardRef( (props: Props, ref: ForwardedRef) => { @@ -97,45 +91,40 @@ const WorkflowPanel = forwardRef( const [instance, setInstance] = useState(null); const [rawNodes, setNodes, onNodesChange] = useNodesState([]); const [rawEdges, setEdges, onEdgesChange] = useEdgesState([]); - const [orientation, setOrientation] = - useState("vertical"); - const onInit = useCallback(async (instance: ReactFlowInstance) => { - setInstance(instance); - if (props.onInit) { - const result = props.onInit(instance); - if (result instanceof Promise) { - result - .then(({ nodes, edges }) => { - if (nodes.length && nodes[0].data.orientation) { - setOrientation(nodes[0].data.orientation); - } - setNodes(nodes); - setEdges(edges); - }) - .catch((error) => { - console.error("Error from Promise-returning function:", error); - }); - } else { - const { nodes, edges } = result; - if (nodes.length && nodes[0].data.orientation) { - setOrientation(nodes[0].data.orientation); + const onInit = useCallback( + async (instance: ReactFlowInstance) => { + setInstance(instance); + if (props.onInit) { + const result = props.onInit(instance); + if (result instanceof Promise) { + result + .then(({ nodes, edges }) => { + setNodes(nodes); + setEdges(edges); + }) + .catch((error) => { + console.error("Error from Promise-returning function:", error); + }); + } else { + const { nodes, edges } = result; + setNodes(nodes); + setEdges(edges); } - setNodes(nodes); - setEdges(edges); } - } - window.requestAnimationFrame(() => instance.fitView()); - }, []); + window.requestAnimationFrame(() => instance.fitView()); + }, + [props], + ); const onNodesDelete = useCallback( props.editable ? props.onNodesDelete : () => {}, - [], + [props], ); const onEdgesDelete = useCallback( props.editable ? props.onEdgesDelete : () => {}, - [], + [props], ); const onNodeDoubleClick = useCallback( @@ -148,7 +137,7 @@ const WorkflowPanel = forwardRef( instance.setCenter(n.position.x + nodeCenter, n.position.y); } }, - [instance], + [instance, props], ); const onDragOver = (event: DragEvent) => { @@ -186,7 +175,7 @@ const WorkflowPanel = forwardRef( } } }, - [instance, setNodes], + [instance, setNodes, props], ); const onConnect = useCallback((connection: Connection) => { @@ -211,12 +200,7 @@ const WorkflowPanel = forwardRef( const elk = new Elk(); try { - const elkLayout = await elk.layout(elkGraph, { - layoutOptions: { - "org.eclipse.elk.direction": - orientation === "horizontal" ? "RIGHT" : "DOWN", - }, - }); + const elkLayout = await elk.layout(elkGraph); if (elkLayout?.children && elkLayout.edges) { const updatedNodes = elkLayout.children.map((elkNode) => { @@ -230,9 +214,6 @@ const WorkflowPanel = forwardRef( ) { return { ...node, - targetPosition: orientation === "horizontal" ? "left" : "top", - sourcePosition: - orientation === "horizontal" ? "right" : "bottom", position: { x: elkNode.x - elkNode.width / 2, y: elkNode.y - elkNode.height / 2, @@ -253,33 +234,29 @@ const WorkflowPanel = forwardRef( } catch (error) { console.error("Error during layout:", error); } - }, [rawNodes, rawEdges, orientation]); + }, [rawNodes, rawEdges]); const { nodes, edges } = useMemo(() => { - const sourcePosition = - orientation === "horizontal" ? Position.Right : Position.Bottom; - const targetPosition = - orientation === "horizontal" ? Position.Left : Position.Top; - const nodes = [...rawNodes].map((node: Node) => ({ ...node, - targetPosition, - sourcePosition, data: { ...node.data, - orientation, }, })); const edges = [...rawEdges].map((edge: Edge) => ({ ...edge, - markerEnd: { type: MarkerType.ArrowClosed, width: 20, height: 20 }, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 20, + height: 20, + }, })); return { nodes, edges, }; - }, [rawNodes, rawEdges, orientation]); + }, [rawNodes, rawEdges]); useImperativeHandle( ref, @@ -289,10 +266,9 @@ const WorkflowPanel = forwardRef( nodes: rawNodes, setEdges, setNodes, - orientation, }; }, - [rawEdges, rawNodes, setEdges, setNodes, orientation, setOrientation], + [rawEdges, rawNodes, setEdges, setNodes], ); return ( @@ -320,17 +296,6 @@ const WorkflowPanel = forwardRef( onDrop={onDrop} onDragOver={onDragOver} > - - - ( void; children?: ReactNode; sidePanel?: ReactNode; + setOrientation: React.Dispatch< + React.SetStateAction<"horizontal" | "vertical"> + >; + orientation: "vertical" | "horizontal"; } export const PermanentDrawerRightWorkflows: FC< PermanentDrawerRightWorkflowsProps -> = () => { +> = ({ setOrientation, orientation }) => { const theme = useTheme(); const [openDrawer, setOpenDrawer] = useState(true); @@ -59,7 +63,10 @@ export const PermanentDrawerRightWorkflows: FC< - + diff --git a/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx b/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx index 7fe72122..9278551a 100644 --- a/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx +++ b/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx @@ -5,6 +5,8 @@ import { AccordionSummary, Alert, Box, + ToggleButton, + ToggleButtonGroup, Typography, } from "@mui/material"; import { useWorkflowsEditor } from "features/workflowEditor/context"; @@ -18,7 +20,14 @@ import PiecesSidebarNode from "./sidebarNode"; * @todo improve loading/error/empty states */ -const SidebarAddNode: FC = () => { +interface Props { + setOrientation: React.Dispatch< + React.SetStateAction<"horizontal" | "vertical"> + >; + orientation: "vertical" | "horizontal"; +} + +const SidebarAddNode: FC = ({ setOrientation, orientation }) => { const { repositories, repositoriesLoading, repositoryPieces } = useWorkflowsEditor(); @@ -33,6 +42,24 @@ const SidebarAddNode: FC = () => { {repositoriesLoading && ( Loading repositories... )} + {!repositoriesLoading && ( + { + console.log("value", value); + if (value) setOrientation(value); + }} + > + + horizontal + + + vertical + + + )} {!repositoriesLoading && repositories.map((repo) => ( { const [formSchema, setFormSchema] = useState({}); const [menuOpen, setMenuOpen] = useState(false); const [loading, setBackdropIsOpen] = useState(false); + const [orientation, setOrientation] = useState<"horizontal" | "vertical">( + "horizontal", + ); const { workspace } = useWorkspaces(); @@ -228,13 +231,17 @@ export const WorkflowsEditorComponent: React.FC = () => { const nodeData = event.dataTransfer.getData("application/reactflow"); const { ...data } = JSON.parse(nodeData); + console.log("orientation", orientation); + const newNodeData: DefaultNode["data"] = { name: data.name, style: data.style, validationError: false, - orientation: workflowPanelRef.current?.orientation ?? "horizontal", + orientation, }; + console.log("newNodeData", newNodeData); + const newNode = { id: getId(data.id), type: "CustomNode", @@ -267,6 +274,7 @@ export const WorkflowsEditorComponent: React.FC = () => { return newNode; }, [ + orientation, fetchForagePieceById, setForageWorkflowPieces, getForageWorkflowPieces, @@ -369,6 +377,8 @@ export const WorkflowsEditorComponent: React.FC = () => { { setMenuOpen(!menuOpen); }} diff --git a/frontend/src/features/workflows/components/WorkflowDetail/WorkflowRunsTable.tsx b/frontend/src/features/workflows/components/WorkflowDetail/WorkflowRunsTable.tsx index b6cfb509..9d27593c 100644 --- a/frontend/src/features/workflows/components/WorkflowDetail/WorkflowRunsTable.tsx +++ b/frontend/src/features/workflows/components/WorkflowDetail/WorkflowRunsTable.tsx @@ -177,6 +177,9 @@ export const WorkflowRunsTable: React.FC = ({ "&.MuiDataGrid-root .MuiDataGrid-cell:focus": { outline: "none", }, + "& .MuiDataGrid-row:hover": { + cursor: "pointer", + }, }} /> )} diff --git a/frontend/src/features/workflows/components/WorkflowsList/index.tsx b/frontend/src/features/workflows/components/WorkflowsList/index.tsx index 3efbc235..6f751985 100644 --- a/frontend/src/features/workflows/components/WorkflowsList/index.tsx +++ b/frontend/src/features/workflows/components/WorkflowsList/index.tsx @@ -184,9 +184,14 @@ export const WorkflowList: React.FC = () => { disableColumnSelector slots={{ noRowsOverlay: NoDataOverlay }} sx={{ + // disable cell selection style "&.MuiDataGrid-root .MuiDataGrid-cell:focus": { outline: "none", }, + // pointer cursor on ALL rows + "& .MuiDataGrid-row:hover": { + cursor: "pointer", + }, }} />
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 0acdac1d..9df3ba86 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -6,3 +6,4 @@ export { generateTaskName } from "./generateTaskName"; export { getDefinition } from "./getDefinition"; export { lazyImport } from "./lazyImports"; export { useInterval } from "./useInterval"; +export { useMouseProximity } from "./useMouseProximity"; diff --git a/frontend/src/utils/useMouseProximity.ts b/frontend/src/utils/useMouseProximity.ts new file mode 100644 index 00000000..abb0354d --- /dev/null +++ b/frontend/src/utils/useMouseProximity.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useRef } from "react"; + +/** + * A custom React hook to determine if the mouse pointer is within a specified + * percentage of an element's size from its center. + * + * @param {number} initialDistancePercentage - The distance in percentage from the + * element's center within which the mouse is considered "near." Default is 80. + * @returns {Array} A tuple containing a boolean value indicating whether the mouse + * is near the element's center and a ref object to attach to the target element. + */ +export function useMouseProximity( + initialDistancePercentage: number = 80, +): [boolean, React.MutableRefObject] { + const [isNear, setIsNear] = useState(false); + const elementRef = useRef(null); + + useEffect(() => { + function handleMouseMove(event: MouseEvent) { + const element = elementRef.current; + if (!element) return; + + // Get the coordinates of the mouse pointer + const mouseX = event.clientX; + const mouseY = event.clientY; + + // Get the position and dimensions of the element + const elementRect = element.getBoundingClientRect(); + const elementWidth = elementRect.width; + const elementHeight = elementRect.height; + const elementX = elementRect.left; + const elementY = elementRect.top; + + // Calculate the distance between the mouse and the center of the element + const centerX = elementX + elementWidth / 2; + const centerY = elementY + elementHeight / 2; + const distanceX = Math.abs(mouseX - centerX); + const distanceY = Math.abs(mouseY - centerY); + + // Calculate the threshold distance based on the specified percentage + const thresholdX = + ((initialDistancePercentage / 100) * elementWidth + elementWidth) / 2; + const thresholdY = + ((initialDistancePercentage / 100) * +elementHeight) / 2; + + // Check if the mouse is within the threshold distance on both X and Y axes + if (distanceX <= thresholdX && distanceY <= thresholdY) { + setIsNear(true); + } else { + setIsNear(false); + } + } + + // Add mousemove event listener to track mouse position + document.addEventListener("mousemove", handleMouseMove); + + return () => { + // Clean up the event listener when the component unmounts + document.removeEventListener("mousemove", handleMouseMove); + }; + }, [initialDistancePercentage]); + + return [isNear, elementRef]; +}