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:
parent
3df1eb8594
commit
76e6c986ea
7 changed files with 371 additions and 21 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue