diff --git a/src/frontend/src/components/core/canvasControlsComponent/index.tsx b/src/frontend/src/components/core/canvasControlsComponent/index.tsx index 6d55fb450..a818c3243 100644 --- a/src/frontend/src/components/core/canvasControlsComponent/index.tsx +++ b/src/frontend/src/components/core/canvasControlsComponent/index.tsx @@ -79,6 +79,10 @@ const CanvasControls = ({ children }) => { ); const setCurrentFlow = useFlowStore((state) => state.setCurrentFlow); const autoSaving = useFlowsManagerStore((state) => state.autoSaving); + const setHelperLineEnabled = useFlowStore( + (state) => state.setHelperLineEnabled, + ); + const helperLineEnabled = useFlowStore((state) => state.helperLineEnabled); useEffect(() => { store.setState({ @@ -109,6 +113,10 @@ const CanvasControls = ({ children }) => { handleSaveFlow(); }, [isInteractive, store, handleSaveFlow]); + const onToggleHelperLines = useCallback(() => { + setHelperLineEnabled(!helperLineEnabled); + }, [setHelperLineEnabled, helperLineEnabled]); + return ( { } testId="lock_unlock" /> + {/* Display Helper Lines */} + ); }; diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/components/helper-lines.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/components/helper-lines.tsx new file mode 100644 index 000000000..2464d66fb --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/components/helper-lines.tsx @@ -0,0 +1,37 @@ +import { useViewport } from "@xyflow/react"; +import { HelperLinesState } from "../helpers/helper-lines"; + +interface HelperLinesProps { + helperLines: HelperLinesState; +} + +export default function HelperLines({ helperLines }: HelperLinesProps) { + const { x: viewportX, y: viewportY, zoom } = useViewport(); + + if (!helperLines.horizontal && !helperLines.vertical) { + return null; + } + + return ( + + {helperLines.horizontal && ( + + )} + {helperLines.vertical && ( + + )} + + ); +} diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/helpers/helper-lines.ts b/src/frontend/src/pages/FlowPage/components/PageComponent/helpers/helper-lines.ts new file mode 100644 index 000000000..846fef30d --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/helpers/helper-lines.ts @@ -0,0 +1,180 @@ +import { Node, XYPosition } from "@xyflow/react"; + +export interface HelperLine { + id: string; + position: number; + orientation: "horizontal" | "vertical"; +} + +export interface HelperLinesState { + horizontal?: HelperLine; + vertical?: HelperLine; +} + +const SNAP_DISTANCE = 5; + +export function getHelperLines( + draggingNode: Node, + nodes: Node[], + nodeWidth = 150, + nodeHeight = 50, +): HelperLinesState { + const helperLines: HelperLinesState = {}; + + const draggingNodeBounds = { + left: draggingNode.position.x, + right: + draggingNode.position.x + (draggingNode.measured?.width || nodeWidth), + top: draggingNode.position.y, + bottom: + draggingNode.position.y + (draggingNode.measured?.height || nodeHeight), + centerX: + draggingNode.position.x + (draggingNode.measured?.width || nodeWidth) / 2, + centerY: + draggingNode.position.y + + (draggingNode.measured?.height || nodeHeight) / 2, + }; + + const otherNodes = nodes.filter((node) => node.id !== draggingNode.id); + + for (const node of otherNodes) { + const nodeBounds = { + left: node.position.x, + right: node.position.x + (node.measured?.width || nodeWidth), + top: node.position.y, + bottom: node.position.y + (node.measured?.height || nodeHeight), + centerX: node.position.x + (node.measured?.width || nodeWidth) / 2, + centerY: node.position.y + (node.measured?.height || nodeHeight) / 2, + }; + + if (Math.abs(draggingNodeBounds.top - nodeBounds.top) < SNAP_DISTANCE) { + helperLines.horizontal = { + id: `horizontal-top-${node.id}`, + position: nodeBounds.top, + orientation: "horizontal", + }; + } + + if ( + Math.abs(draggingNodeBounds.bottom - nodeBounds.bottom) < SNAP_DISTANCE + ) { + helperLines.horizontal = { + id: `horizontal-bottom-${node.id}`, + position: nodeBounds.bottom, + orientation: "horizontal", + }; + } + + if ( + Math.abs(draggingNodeBounds.centerY - nodeBounds.centerY) < SNAP_DISTANCE + ) { + helperLines.horizontal = { + id: `horizontal-center-${node.id}`, + position: nodeBounds.centerY, + orientation: "horizontal", + }; + } + } + + for (const node of otherNodes) { + const nodeBounds = { + left: node.position.x, + right: node.position.x + (node.measured?.width || nodeWidth), + top: node.position.y, + bottom: node.position.y + (node.measured?.height || nodeHeight), + centerX: node.position.x + (node.measured?.width || nodeWidth) / 2, + centerY: node.position.y + (node.measured?.height || nodeHeight) / 2, + }; + + if (Math.abs(draggingNodeBounds.left - nodeBounds.left) < SNAP_DISTANCE) { + helperLines.vertical = { + id: `vertical-left-${node.id}`, + position: nodeBounds.left, + orientation: "vertical", + }; + } + + if (Math.abs(draggingNodeBounds.right - nodeBounds.right) < SNAP_DISTANCE) { + helperLines.vertical = { + id: `vertical-right-${node.id}`, + position: nodeBounds.right, + orientation: "vertical", + }; + } + + if ( + Math.abs(draggingNodeBounds.centerX - nodeBounds.centerX) < SNAP_DISTANCE + ) { + helperLines.vertical = { + id: `vertical-center-${node.id}`, + position: nodeBounds.centerX, + orientation: "vertical", + }; + } + } + + return helperLines; +} + +export function getSnapPosition( + draggingNode: Node, + nodes: Node[], + nodeWidth = 150, + nodeHeight = 50, +): XYPosition { + const helperLines = getHelperLines( + draggingNode, + nodes, + nodeWidth, + nodeHeight, + ); + let snapPosition = { ...draggingNode.position }; + + if (helperLines.horizontal) { + const draggingNodeBounds = { + top: draggingNode.position.y, + bottom: + draggingNode.position.y + (draggingNode.measured?.height || nodeHeight), + centerY: + draggingNode.position.y + + (draggingNode.measured?.height || nodeHeight) / 2, + }; + + if (helperLines.horizontal.id.includes("top")) { + snapPosition.y = helperLines.horizontal.position; + } else if (helperLines.horizontal.id.includes("bottom")) { + snapPosition.y = + helperLines.horizontal.position - + (draggingNode.measured?.height || nodeHeight); + } else if (helperLines.horizontal.id.includes("center")) { + snapPosition.y = + helperLines.horizontal.position - + (draggingNode.measured?.height || nodeHeight) / 2; + } + } + + if (helperLines.vertical) { + const draggingNodeBounds = { + left: draggingNode.position.x, + right: + draggingNode.position.x + (draggingNode.measured?.width || nodeWidth), + centerX: + draggingNode.position.x + + (draggingNode.measured?.width || nodeWidth) / 2, + }; + + if (helperLines.vertical.id.includes("left")) { + snapPosition.x = helperLines.vertical.position; + } else if (helperLines.vertical.id.includes("right")) { + snapPosition.x = + helperLines.vertical.position - + (draggingNode.measured?.width || nodeWidth); + } else if (helperLines.vertical.id.includes("center")) { + snapPosition.x = + helperLines.vertical.position - + (draggingNode.measured?.width || nodeWidth) / 2; + } + } + + return snapPosition; +} diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx index d343941aa..2ebd8ddbc 100644 --- a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx @@ -15,13 +15,16 @@ import { useAddComponent } from "@/hooks/use-add-component"; import { nodeColorsName } from "@/utils/styleUtils"; import { cn, isSupportedNodeTypes } from "@/utils/utils"; import { + applyNodeChanges, Connection, Edge, + NodeChange, OnNodeDrag, OnSelectionChangeParams, ReactFlow, reconnectEdge, SelectionDragHandler, + XYPosition, } from "@xyflow/react"; import { AnimatePresence } from "framer-motion"; import _, { cloneDeep } from "lodash"; @@ -68,6 +71,12 @@ import { MemoizedLogCanvasControls, MemoizedSidebarTrigger, } from "./MemoizedComponents"; +import HelperLines from "./components/helper-lines"; +import { + getHelperLines, + getSnapPosition, + HelperLinesState, +} from "./helpers/helper-lines"; import getRandomName from "./utils/get-random-name"; import isWrappedWithClass from "./utils/is-wrapped-with-class"; @@ -354,30 +363,107 @@ export default function Page({ [takeSnapshot, onConnect], ); - const onNodeDragStart: OnNodeDrag = useCallback(() => { - // 👇 make dragging a node undoable + const [helperLines, setHelperLines] = useState({}); + const [isDragging, setIsDragging] = useState(false); + const helperLineEnabled = useFlowStore((state) => state.helperLineEnabled); - takeSnapshot(); - // 👉 you can place your event handlers here - }, [takeSnapshot]); + const onNodeDrag: OnNodeDrag = useCallback( + (_, node) => { + if (helperLineEnabled) { + const currentHelperLines = getHelperLines(node, nodes); + setHelperLines(currentHelperLines); + } + }, + [helperLineEnabled, nodes], + ); - const onNodeDragStop: OnNodeDrag = useCallback(() => { - // 👇 make moving the canvas undoable - autoSaveFlow(); - updateCurrentFlow({ nodes }); - setPositionDictionary({}); - }, [ - takeSnapshot, - autoSaveFlow, - nodes, - edges, - reactFlowInstance, - setPositionDictionary, - ]); + const onNodeDragStart: OnNodeDrag = useCallback( + (_, node) => { + // 👇 make dragging a node undoable + takeSnapshot(); + setIsDragging(true); + // 👉 you can place your event handlers here + }, + [takeSnapshot], + ); + + const onNodeDragStop: OnNodeDrag = useCallback( + (_, node) => { + // 👇 make moving the canvas undoable + autoSaveFlow(); + updateCurrentFlow({ nodes }); + setPositionDictionary({}); + setIsDragging(false); + setHelperLines({}); + }, + [ + takeSnapshot, + autoSaveFlow, + nodes, + edges, + reactFlowInstance, + setPositionDictionary, + ], + ); + + const onNodesChangeWithHelperLines = useCallback( + (changes: NodeChange[]) => { + if (!helperLineEnabled) { + onNodesChange(changes); + return; + } + + // Apply snapping to position changes during drag + const modifiedChanges = changes.map((change) => { + if ( + change.type === "position" && + "dragging" in change && + "position" in change && + "id" in change && + isDragging + ) { + const nodeId = change.id as string; + const draggedNode = nodes.find((n) => n.id === nodeId); + + if (draggedNode && change.position) { + const updatedNode = { + ...draggedNode, + position: change.position, + }; + + const snapPosition = getSnapPosition(updatedNode, nodes); + + // Only snap if we're actively dragging + if (change.dragging) { + // Apply snap if there's a significant difference + if ( + Math.abs(snapPosition.x - change.position.x) > 0.1 || + Math.abs(snapPosition.y - change.position.y) > 0.1 + ) { + return { + ...change, + position: snapPosition, + }; + } + } else { + // This is the final position change when drag ends + // Force snap to ensure it stays where it should + return { + ...change, + position: snapPosition, + }; + } + } + } + return change; + }); + + onNodesChange(modifiedChanges); + }, + [onNodesChange, nodes, isDragging, helperLineEnabled], + ); const onSelectionDragStart: SelectionDragHandler = useCallback(() => { - // 👇 make dragging a selection undoable - takeSnapshot(); }, [takeSnapshot]); @@ -596,7 +682,7 @@ export default function Page({ nodes={nodes} edges={edges} - onNodesChange={onNodesChange} + onNodesChange={onNodesChangeWithHelperLines} onEdgesChange={onEdgesChange} onConnect={isLocked ? undefined : onConnectMod} disableKeyboardA11y={true} @@ -605,6 +691,7 @@ export default function Page({ onReconnect={isLocked ? undefined : onEdgeUpdate} onReconnectStart={isLocked ? undefined : onEdgeUpdateStart} onReconnectEnd={isLocked ? undefined : onEdgeUpdateEnd} + onNodeDrag={onNodeDrag} onNodeDragStart={onNodeDragStart} onSelectionDragStart={onSelectionDragStart} elevateEdgesOnSelect={true} @@ -634,6 +721,7 @@ export default function Page({ + {helperLineEnabled && }
((set, get) => ({ ); set({ dismissedNodes: newDismissedNodes }); }, + helperLineEnabled: false, + setHelperLineEnabled: (helperLineEnabled: boolean) => { + set({ helperLineEnabled }); + }, setNewChatOnPlayground: (newChat: boolean) => { set({ newChatOnPlayground: newChat }); }, diff --git a/src/frontend/src/style/applies.css b/src/frontend/src/style/applies.css index ac28563f2..4de67ddac 100644 --- a/src/frontend/src/style/applies.css +++ b/src/frontend/src/style/applies.css @@ -1270,6 +1270,26 @@ --tw-ring-offset-shadow: none !important; --tw-ring-color: none !important; } + + .helper-lines { + @apply pointer-events-none absolute left-0 top-0 z-10 h-full w-full; + } + + .helper-line { + stroke: hsl(var(--primary)); + stroke-width: 1; + stroke-dasharray: 4 4; + opacity: 0.8; + filter: drop-shadow(0 0 2px hsl(var(--primary) / 0.3)); + } + + .helper-line.horizontal { + stroke: hsl(var(--primary)); + } + + .helper-line.vertical { + stroke: hsl(var(--primary)); + } } /* Gradient background */ diff --git a/src/frontend/src/types/zustand/flow/index.ts b/src/frontend/src/types/zustand/flow/index.ts index 1ce0907f4..a6cb473c1 100644 --- a/src/frontend/src/types/zustand/flow/index.ts +++ b/src/frontend/src/types/zustand/flow/index.ts @@ -285,6 +285,8 @@ export type FlowStoreType = { setCurrentBuildingNodeId: (nodeIds: string[] | undefined) => void; clearEdgesRunningByNodes: () => Promise; updateToolMode: (nodeId: string, toolMode: boolean) => void; + helperLineEnabled: boolean; + setHelperLineEnabled: (helperLineEnabled: boolean) => void; newChatOnPlayground: boolean; setNewChatOnPlayground: (newChat: boolean) => void; };