Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(frontend): Improve added block positioning logic to handle collisions and dynamic dimensions size #8406

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 95 additions & 10 deletions autogpt_platform/frontend/src/components/Flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ import {
import "@xyflow/react/dist/style.css";
import { CustomNode } from "./CustomNode";
import "./flow.css";
import { Link } from "@/lib/autogpt-server-api";
import { getTypeColor, filterBlocksByType } from "@/lib/utils";
import { BlockUIType, Link } from "@/lib/autogpt-server-api";
import {
getTypeColor,
filterBlocksByType,
findNewlyAddedBlockCoordinates,
} from "@/lib/utils";
import { history } from "./history";
import { CustomEdge } from "./CustomEdge";
import ConnectionLine from "./ConnectionLine";
Expand Down Expand Up @@ -57,15 +61,30 @@ type FlowContextType = {
getNextNodeId: () => string;
};

export type NodeDimension = {
[nodeId: string]: {
x: number;
y: number;
width: number;
height: number;
};
};

export const FlowContext = createContext<FlowContextType | null>(null);

const FlowEditor: React.FC<{
flowID?: string;
template?: boolean;
className?: string;
}> = ({ flowID, template, className }) => {
const { addNodes, addEdges, getNode, deleteElements, updateNode } =
useReactFlow<CustomNode, CustomEdge>();
const {
addNodes,
addEdges,
getNode,
deleteElements,
updateNode,
setViewport,
} = useReactFlow<CustomNode, CustomEdge>();
const [nodeId, setNodeId] = useState<number>(1);
const [copiedNodes, setCopiedNodes] = useState<CustomNode[]>([]);
const [copiedEdges, setCopiedEdges] = useState<CustomEdge[]>([]);
Expand Down Expand Up @@ -110,6 +129,9 @@ const FlowEditor: React.FC<{

const TUTORIAL_STORAGE_KEY = "shepherd-tour";

// It stores the dimension of all nodes with position as well
const [nodeDimensions, setNodeDimensions] = useState<NodeDimension>({});

useEffect(() => {
if (params.get("resetTutorial") === "true") {
localStorage.removeItem(TUTORIAL_STORAGE_KEY);
Expand Down Expand Up @@ -402,16 +424,36 @@ const FlowEditor: React.FC<{
return;
}

// Calculate the center of the viewport considering zoom
const viewportCenter = {
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
};
/*
Calculate a position to the right of the newly added block, allowing for some margin.
If adding to the right side causes the new block to collide with an existing block, attempt to place it at the bottom or left.
Why not the top? Because the height of the new block is unknown.
If it still collides, run a loop to find the best position where it does not collide.
Then, adjust the canvas to center on the newly added block.
Note: The width is known, e.g., w = 300px for a note and w = 500px for others, but the height is dynamic.
*/

// Alternative: We could also use D3 force, Intersection for this (React flow Pro examples)

const viewportCoordinates =
nodeDimensions && Object.keys(nodeDimensions).length > 0
? // we will get all the dimension of nodes, then store
findNewlyAddedBlockCoordinates(
nodeDimensions,
(nodeSchema.uiType == BlockUIType.NOTE ? 300 : 500) / zoom,
60 / zoom,
zoom,
)
: // we will get all the dimension of nodes, then store
{
x: (window.innerWidth / 2 - x) / zoom,
y: (window.innerHeight / 2 - y) / zoom,
};

const newNode: CustomNode = {
id: nodeId.toString(),
type: "custom",
position: viewportCenter, // Set the position to the calculated viewport center
position: viewportCoordinates, // Set the position to the calculated viewport center
data: {
blockType: nodeType,
blockCosts: nodeSchema.costs,
Expand All @@ -433,6 +475,15 @@ const FlowEditor: React.FC<{
setNodeId((prevId) => prevId + 1);
clearNodesStatusAndOutput(); // Clear status and output when a new node is added

setViewport(
{
x: -viewportCoordinates.x * zoom + window.innerWidth / 2,
y: -viewportCoordinates.y * zoom + window.innerHeight / 2 - 100,
zoom: 0.8,
majdyz marked this conversation as resolved.
Show resolved Hide resolved
},
{ duration: 500 },
);

history.push({
type: "ADD_NODE",
payload: { node: { ...newNode, ...newNode.data } },
Expand All @@ -442,8 +493,10 @@ const FlowEditor: React.FC<{
},
[
nodeId,
setViewport,
availableNodes,
addNodes,
nodeDimensions,
deleteElements,
clearNodesStatusAndOutput,
x,
Expand All @@ -452,6 +505,38 @@ const FlowEditor: React.FC<{
],
);

const findNodeDimensions = useCallback(() => {
const newNodeDimensions: NodeDimension = nodes.reduce((acc, node) => {
const nodeElement = document.querySelector(
`[data-id="custom-node-${node.id}"]`,
);
if (nodeElement) {
const rect = nodeElement.getBoundingClientRect();
const { left, top, width, height } = rect;

// Convert screen coordinates to flow coordinates
const flowX = (left - x) / zoom;
const flowY = (top - y) / zoom;
const flowWidth = width / zoom;
const flowHeight = height / zoom;

acc[node.id] = {
x: flowX,
y: flowY,
width: flowWidth,
height: flowHeight,
};
}
return acc;
}, {} as NodeDimension);

setNodeDimensions(newNodeDimensions);
}, [nodes, x, y, zoom]);

useEffect(() => {
findNodeDimensions();
}, [nodes, findNodeDimensions]);

const handleUndo = () => {
history.undo();
};
Expand Down
76 changes: 76 additions & 0 deletions autogpt_platform/frontend/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Category } from "./autogpt-server-api/types";
import { NodeDimension } from "@/components/Flow";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
Expand Down Expand Up @@ -213,3 +214,78 @@ export function getBehaveAs(): BehaveAs {
? BehaveAs.CLOUD
: BehaveAs.LOCAL;
}

function rectanglesOverlap(
rect1: { x: number; y: number; width: number; height?: number },
rect2: { x: number; y: number; width: number; height?: number },
): boolean {
const x1 = rect1.x,
y1 = rect1.y,
w1 = rect1.width,
h1 = rect1.height ?? 100;
const x2 = rect2.x,
y2 = rect2.y,
w2 = rect2.width,
h2 = rect2.height ?? 100;

// Check if the rectangles do not overlap
return !(x1 + w1 <= x2 || x1 >= x2 + w2 || y1 + h1 <= y2 || y1 >= y2 + h2);
}

export function findNewlyAddedBlockCoordinates(
nodeDimensions: NodeDimension,
newWidth: number,
margin: number,
zoom: number,
) {
const nodeDimensionArray = Object.values(nodeDimensions);

for (let i = nodeDimensionArray.length - 1; i >= 0; i--) {
const lastNode = nodeDimensionArray[i];
const lastNodeHeight = lastNode.height ?? 100;

// Right of the last node
let newX = lastNode.x + lastNode.width + margin;
let newY = lastNode.y;
let newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };

const collisionRight = nodeDimensionArray.some((node) =>
rectanglesOverlap(newRect, node),
);

if (!collisionRight) {
return { x: newX, y: newY };
}

// Left of the last node
newX = lastNode.x - newWidth - margin;
newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };

const collisionLeft = nodeDimensionArray.some((node) =>
rectanglesOverlap(newRect, node),
);

if (!collisionLeft) {
return { x: newX, y: newY };
}

// Below the last node
newX = lastNode.x;
newY = lastNode.y + lastNodeHeight + margin;
newRect = { x: newX, y: newY, width: newWidth, height: 100 / zoom };

const collisionBelow = nodeDimensionArray.some((node) =>
rectanglesOverlap(newRect, node),
);

if (!collisionBelow) {
return { x: newX, y: newY };
}
}

// Default position if no space is found
return {
x: 0,
y: 0,
};
}
Loading