From 76e6c986eaf12d6c9cace90b4b0b5ccbaf21ca30 Mon Sep 17 00:00:00 2001 From: Cristhian Zanforlin Lousa Date: Mon, 14 Jul 2025 11:36:29 -0300 Subject: [PATCH] feat: add node alignment helper lines with toggle control in flow editor (#8279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📝 (frontend): Add helper lines feature to display alignment guides for nodes during drag and drop operations. This feature includes the ability to toggle helper lines on and off, snap nodes to alignment positions, and visually display horizontal and vertical lines for alignment. * ♻️ (helper-lines.ts): Remove unnecessary comments and improve code readability by removing redundant comments explaining basic logic in helper-lines.ts * 🔧 (canvasControlsComponent): improve tooltip text and icon based on the state of helperLineEnabled to provide better user experience * ✨ (PageComponent/index.tsx): Add support for dragging nodes with helper lines and snapping to grid position during drag for better user experience. * 🔧 (applies.css): reduce stroke width from 1.5 to 1 for better visual appearance --- .../core/canvasControlsComponent/index.tsx | 19 ++ .../PageComponent/components/helper-lines.tsx | 37 ++++ .../PageComponent/helpers/helper-lines.ts | 180 ++++++++++++++++++ .../components/PageComponent/index.tsx | 130 +++++++++++-- src/frontend/src/stores/flowStore.ts | 4 + src/frontend/src/style/applies.css | 20 ++ src/frontend/src/types/zustand/flow/index.ts | 2 + 7 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 src/frontend/src/pages/FlowPage/components/PageComponent/components/helper-lines.tsx create mode 100644 src/frontend/src/pages/FlowPage/components/PageComponent/helpers/helper-lines.ts 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; };