feat: add node alignment helper lines with toggle control in flow editor (#8279)

* 📝 (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
This commit is contained in:
Cristhian Zanforlin Lousa 2025-07-14 11:36:29 -03:00 committed by GitHub
commit 76e6c986ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 371 additions and 21 deletions

View file

@ -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 (
<Panel
data-testid="canvas_controls"
@ -150,6 +158,17 @@ const CanvasControls = ({ children }) => {
}
testId="lock_unlock"
/>
{/* Display Helper Lines */}
<CustomControlButton
iconName={helperLineEnabled ? "FoldHorizontal" : "UnfoldHorizontal"}
tooltipText={
helperLineEnabled ? "Hide Helper Lines" : "Show Helper Lines"
}
onClick={onToggleHelperLines}
backgroundClasses={cn(helperLineEnabled && "bg-muted")}
iconClasses={cn(helperLineEnabled && "text-muted-foreground")}
testId="helper_lines"
/>
</Panel>
);
};

View file

@ -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 (
<svg className="helper-lines">
{helperLines.horizontal && (
<line
x1={0}
y1={helperLines.horizontal.position * zoom + viewportY}
x2="100%"
y2={helperLines.horizontal.position * zoom + viewportY}
className="helper-line horizontal"
/>
)}
{helperLines.vertical && (
<line
x1={helperLines.vertical.position * zoom + viewportX}
y1={0}
x2={helperLines.vertical.position * zoom + viewportX}
y2="100%"
className="helper-line vertical"
/>
)}
</svg>
);
}

View file

@ -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;
}

View file

@ -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<HelperLinesState>({});
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<AllNodeType>[]) => {
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({
<ReactFlow<AllNodeType, EdgeType>
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({
<FlowBuildingComponent />
<UpdateAllComponents />
<MemoizedBackground />
{helperLineEnabled && <HelperLines helperLines={helperLines} />}
</ReactFlow>
<div
id="shadow-box"

View file

@ -1066,6 +1066,10 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
);
set({ dismissedNodes: newDismissedNodes });
},
helperLineEnabled: false,
setHelperLineEnabled: (helperLineEnabled: boolean) => {
set({ helperLineEnabled });
},
setNewChatOnPlayground: (newChat: boolean) => {
set({ newChatOnPlayground: newChat });
},

View file

@ -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 */

View file

@ -285,6 +285,8 @@ export type FlowStoreType = {
setCurrentBuildingNodeId: (nodeIds: string[] | undefined) => void;
clearEdgesRunningByNodes: () => Promise<void>;
updateToolMode: (nodeId: string, toolMode: boolean) => void;
helperLineEnabled: boolean;
setHelperLineEnabled: (helperLineEnabled: boolean) => void;
newChatOnPlayground: boolean;
setNewChatOnPlayground: (newChat: boolean) => void;
};