From dd09824ca5d3787394df2889fddb83e7e24f989e Mon Sep 17 00:00:00 2001
From: Nicole Rappe <nicole.rappe@bunny-lab.io>
Date: Mon, 5 May 2025 21:41:11 -0600
Subject: [PATCH] Added Node Alignment Helper Lines

---
 Data/Server/WebUI/src/Flow_Editor.jsx | 441 +++++++++++++++-----------
 1 file changed, 259 insertions(+), 182 deletions(-)

diff --git a/Data/Server/WebUI/src/Flow_Editor.jsx b/Data/Server/WebUI/src/Flow_Editor.jsx
index 4624eec..cd6b71e 100644
--- a/Data/Server/WebUI/src/Flow_Editor.jsx
+++ b/Data/Server/WebUI/src/Flow_Editor.jsx
@@ -23,7 +23,7 @@ import { SketchPicker } from "react-color";
 import "reactflow/dist/style.css";
 
 export default function FlowEditor({
-  flowId, //Used to Fix Grid Issues Across Multiple Flow Tabs
+  flowId,
   nodes,
   edges,
   setNodes,
@@ -33,6 +33,7 @@ export default function FlowEditor({
 }) {
   const wrapperRef = useRef(null);
   const { project } = useReactFlow();
+
   const [contextMenu, setContextMenu] = useState(null);
   const [edgeContextMenu, setEdgeContextMenu] = useState(null);
   const [selectedEdgeId, setSelectedEdgeId] = useState(null);
@@ -44,6 +45,15 @@ export default function FlowEditor({
   const [tempColor, setTempColor] = useState({ hex: "#58a6ff" });
   const [pickerPos, setPickerPos] = useState({ x: 0, y: 0 });
 
+  // helper-line state
+  // guides: array of { xFlow, xPx } or { yFlow, yPx } for stationary nodes
+  const [guides, setGuides] = useState([]);
+  // activeGuides: array of { xPx } or { yPx } to draw
+  const [activeGuides, setActiveGuides] = useState([]);
+
+  // store moving node flow-size on drag start
+  const movingFlowSize = useRef({ width: 0, height: 0 });
+
   const edgeStyles = {
     step: "step",
     curved: "bezier",
@@ -52,64 +62,167 @@ export default function FlowEditor({
 
   const animationStyles = {
     dashes: { animated: true, style: { strokeDasharray: "6 3" } },
-    dots: { animated: true, style: { strokeDasharray: "2 4" } },
-    none: { animated: false, style: {} }
+    dots:   { animated: true, style: { strokeDasharray: "2 4" } },
+    none:   { animated: false, style: {} }
   };
 
-  const onDrop = useCallback(
-    (event) => {
-      event.preventDefault();
-      const type = event.dataTransfer.getData("application/reactflow");
-      if (!type) return;
-      const bounds = wrapperRef.current.getBoundingClientRect();
-      const position = project({ x: event.clientX - bounds.left, y: event.clientY - bounds.top });
-      const id = "node-" + Date.now();
-      const nodeMeta = Object.values(categorizedNodes).flat().find((n) => n.type === type);
-      const newNode = {
-        id,
-        type,
-        position,
-        data: {
-          label: nodeMeta?.label || type,
-          content: nodeMeta?.content
-        },
-        dragHandle: '.borealis-node-header'  // <-- Add this line
-      };      
-      setNodes((nds) => [...nds, newNode]);
-    },
-    [project, setNodes, categorizedNodes]
-  );
+  // Compute edge-only guides and capture moving node flow-size
+  const computeGuides = useCallback((dragNode) => {
+    if (!wrapperRef.current) return;
+    const parentRect = wrapperRef.current.getBoundingClientRect();
+
+    // measure moving node in pixel space
+    const dragEl = wrapperRef.current.querySelector(
+      `.react-flow__node[data-id="${dragNode.id}"]`
+    );
+    if (dragEl) {
+      const dr = dragEl.getBoundingClientRect();
+      const relLeft   = dr.left   - parentRect.left;
+      const relTop    = dr.top    - parentRect.top;
+      const relRight  = relLeft   + dr.width;
+      const relBottom = relTop    + dr.height;
+
+      // project pixel corners to flow coords
+      const pTL = project({ x: relLeft,    y: relTop    });
+      const pTR = project({ x: relRight,   y: relTop    });
+      const pBL = project({ x: relLeft,    y: relBottom });
+
+      movingFlowSize.current = {
+        width:  pTR.x - pTL.x,
+        height: pBL.y - pTL.y
+      };
+    }
+
+    const lines = [];
+    nodes.forEach((n) => {
+      if (n.id === dragNode.id) return;
+      const el = wrapperRef.current.querySelector(
+        `.react-flow__node[data-id="${n.id}"]`
+      );
+      if (!el) return;
+      const r = el.getBoundingClientRect();
+      const relLeft   = r.left   - parentRect.left;
+      const relTop    = r.top    - parentRect.top;
+      const relRight  = relLeft + r.width;
+      const relBottom = relTop  + r.height;
+
+      // project pixel to flow coords
+      const pTL = project({ x: relLeft,  y: relTop    });
+      const pTR = project({ x: relRight, y: relTop    });
+      const pBL = project({ x: relLeft,  y: relBottom });
+
+      // vertical guides: left edge, right edge
+      lines.push({ xFlow: pTL.x, xPx: relLeft });
+      lines.push({ xFlow: pTR.x, xPx: relRight });
+
+      // horizontal guides: top edge, bottom edge
+      lines.push({ yFlow: pTL.y, yPx: relTop });
+      lines.push({ yFlow: pBL.y, yPx: relBottom });
+    });
+    setGuides(lines);
+  }, [nodes, project]);
+
+  // Snap & show only matching guides within threshold during drag
+  const onNodeDrag = useCallback((_, node) => {
+    const threshold = 5;
+    let snapX = null, snapY = null;
+    const show = [];
+    const { width: fw, height: fh } = movingFlowSize.current;
+
+    guides.forEach((ln) => {
+      if (ln.xFlow != null) {
+        // moving left edge to stationary edges
+        if (Math.abs(node.position.x - ln.xFlow) < threshold) {
+          snapX = ln.xFlow;
+          show.push({ xPx: ln.xPx });
+        }
+        // moving right edge to stationary edges
+        else if (Math.abs(node.position.x + fw - ln.xFlow) < threshold) {
+          snapX = ln.xFlow - fw;
+          show.push({ xPx: ln.xPx });
+        }
+      }
+      if (ln.yFlow != null) {
+        // moving top edge
+        if (Math.abs(node.position.y - ln.yFlow) < threshold) {
+          snapY = ln.yFlow;
+          show.push({ yPx: ln.yPx });
+        }
+        // moving bottom edge
+        else if (Math.abs(node.position.y + fh - ln.yFlow) < threshold) {
+          snapY = ln.yFlow - fh;
+          show.push({ yPx: ln.yPx });
+        }
+      }
+    });
+
+    if (snapX !== null || snapY !== null) {
+      setNodes((nds) =>
+        applyNodeChanges(
+          [{
+            id: node.id,
+            type: "position",
+            position: {
+              x: snapX !== null ? snapX : node.position.x,
+              y: snapY !== null ? snapY : node.position.y
+            }
+          }],
+          nds
+        )
+      );
+      setActiveGuides(show);
+    } else {
+      setActiveGuides([]);
+    }
+  }, [guides, setNodes]);
+
+  const onDrop = useCallback((event) => {
+    event.preventDefault();
+    const type = event.dataTransfer.getData("application/reactflow");
+    if (!type) return;
+    const bounds = wrapperRef.current.getBoundingClientRect();
+    const position = project({
+      x: event.clientX - bounds.left,
+      y: event.clientY - bounds.top
+    });
+    const id = "node-" + Date.now();
+    const nodeMeta = Object.values(categorizedNodes).flat().find((n) => n.type === type);
+    const newNode = {
+      id,
+      type,
+      position,
+      data: {
+        label: nodeMeta?.label || type,
+        content: nodeMeta?.content
+      },
+      dragHandle: ".borealis-node-header"
+    };
+    setNodes((nds) => [...nds, newNode]);
+  }, [project, setNodes, categorizedNodes]);
 
   const onDragOver = useCallback((event) => {
     event.preventDefault();
     event.dataTransfer.dropEffect = "move";
   }, []);
 
-  const onConnect = useCallback(
-    (params) => {
-      setEdges((eds) =>
-        addEdge(
-          {
-            ...params,
-            type: "bezier",
-            animated: true,
-            style: { strokeDasharray: "6 3", stroke: "#58a6ff" }
-          },
-          eds
-        )
-      );
-    },
-    [setEdges]
-  );
+  const onConnect = useCallback((params) => {
+    setEdges((eds) =>
+      addEdge({
+        ...params,
+        type: "bezier",
+        animated: true,
+        style: { strokeDasharray: "6 3", stroke: "#58a6ff" }
+      }, eds)
+    );
+  }, [setEdges]);
 
-  const onNodesChange = useCallback(
-    (changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
-    [setNodes]
-  );
-  const onEdgesChange = useCallback(
-    (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
-    [setEdges]
-  );
+  const onNodesChange = useCallback((changes) => {
+    setNodes((nds) => applyNodeChanges(changes, nds));
+  }, [setNodes]);
+
+  const onEdgesChange = useCallback((changes) => {
+    setEdges((eds) => applyEdgeChanges(changes, eds));
+  }, [setEdges]);
 
   const handleRightClick = (e, node) => {
     e.preventDefault();
@@ -123,76 +236,64 @@ export default function FlowEditor({
   };
 
   const changeEdgeType = (newType) => {
-    setEdges((eds) =>
-      eds.map((e) =>
-        e.id === selectedEdgeId ? { ...e, type: edgeStyles[newType] } : e
-      )
-    );
+    setEdges((eds) => eds.map((e) =>
+      e.id === selectedEdgeId ? { ...e, type: edgeStyles[newType] } : e
+    ));
     setEdgeContextMenu(null);
   };
 
   const changeEdgeAnimation = (newAnim) => {
-    setEdges((eds) =>
-      eds.map((e) => {
-        if (e.id !== selectedEdgeId) return e;
-        const strokeColor = e.style?.stroke || "#58a6ff";
-        const anim = animationStyles[newAnim] || {};
-        return {
-          ...e,
-          animated: anim.animated,
-          style: { ...anim.style, stroke: strokeColor },
-          markerEnd: e.markerEnd ? { ...e.markerEnd, color: strokeColor } : undefined
-        };
-      })
-    );
+    setEdges((eds) => eds.map((e) => {
+      if (e.id !== selectedEdgeId) return e;
+      const strokeColor = e.style?.stroke || "#58a6ff";
+      const anim = animationStyles[newAnim] || {};
+      return {
+        ...e,
+        animated: anim.animated,
+        style: { ...anim.style, stroke: strokeColor },
+        markerEnd: e.markerEnd ? { ...e.markerEnd, color: strokeColor } : undefined
+      };
+    }));
     setEdgeContextMenu(null);
   };
 
   const handleColorChange = (color) => {
-    setEdges((eds) =>
-      eds.map((e) => {
-        if (e.id !== selectedEdgeId) return e;
-        const updated = { ...e };
-        if (colorPickerMode === "stroke") {
-          updated.style = { ...e.style, stroke: color.hex };
-          if (e.markerEnd) updated.markerEnd = { ...e.markerEnd, color: color.hex };
-        } else if (colorPickerMode === "labelText") {
-          updated.labelStyle = { ...e.labelStyle, fill: color.hex };
-        } else if (colorPickerMode === "labelBg") {
-          updated.labelBgStyle = { ...e.labelBgStyle, fill: color.hex, fillOpacity: labelOpacity };
-        }
-        return updated;
-      })
-    );
+    setEdges((eds) => eds.map((e) => {
+      if (e.id !== selectedEdgeId) return e;
+      const updated = { ...e };
+      if (colorPickerMode === "stroke") {
+        updated.style = { ...e.style, stroke: color.hex };
+        if (e.markerEnd) updated.markerEnd = { ...e.markerEnd, color: color.hex };
+      } else if (colorPickerMode === "labelText") {
+        updated.labelStyle = { ...e.labelStyle, fill: color.hex };
+      } else if (colorPickerMode === "labelBg") {
+        updated.labelBgStyle = { ...e.labelBgStyle, fill: color.hex, fillOpacity: labelOpacity };
+      }
+      return updated;
+    }));
   };
 
   const handleAddLabel = () => {
-    setEdges((eds) =>
-      eds.map((e) =>
-        e.id === selectedEdgeId ? { ...e, label: "New Label" } : e
-      )
-    );
+    setEdges((eds) => eds.map((e) =>
+      e.id === selectedEdgeId ? { ...e, label: "New Label" } : e
+    ));
     setEdgeContextMenu(null);
   };
 
   const handleEditLabel = () => {
     const newText = prompt("Enter label text:");
     if (newText !== null) {
-      setEdges((eds) =>
-        eds.map((e) =>
-          e.id === selectedEdgeId ? { ...e, label: newText } : e
-        )
-      );
+      setEdges((eds) => eds.map((e) =>
+        e.id === selectedEdgeId ? { ...e, label: newText } : e
+      ));
     }
     setEdgeContextMenu(null);
   };
 
   const handleRemoveLabel = () => {
-    setEdges((eds) =>
-      eds.map((e) =>
-        e.id === selectedEdgeId ? { ...e, label: undefined } : e
-      )
-    );
+    setEdges((eds) => eds.map((e) =>
+      e.id === selectedEdgeId ? { ...e, label: undefined } : e
+    ));
     setEdgeContextMenu(null);
   };
 
@@ -204,22 +305,20 @@ export default function FlowEditor({
   };
 
   const applyLabelStyleExtras = () => {
-    setEdges((eds) =>
-      eds.map((e) =>
-        e.id === selectedEdgeId
-          ? {
-              ...e,
-              labelBgPadding: labelPadding,
-              labelBgStyle: {
-                ...e.labelBgStyle,
-                fillOpacity: labelOpacity,
-                rx: labelBorderRadius,
-                ry: labelBorderRadius
-              }
+    setEdges((eds) => eds.map((e) =>
+      e.id === selectedEdgeId
+        ? {
+            ...e,
+            labelBgPadding: labelPadding,
+            labelBgStyle: {
+              ...e.labelBgStyle,
+              fillOpacity: labelOpacity,
+              rx: labelBorderRadius,
+              ry: labelBorderRadius
             }
-          : e
-      )
-    );
+          }
+        : e
+    ));
     setEdgeContextMenu(null);
   };
 
@@ -242,66 +341,60 @@ export default function FlowEditor({
         onNodeContextMenu={handleRightClick}
         onEdgeContextMenu={handleEdgeRightClick}
         defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
-        edgeOptions={{
-          type: "bezier",
-          animated: true,
-          style: { strokeDasharray: "6 3", stroke: "#58a6ff" }
-        }}
+        edgeOptions={{ type: "bezier", animated: true, style: { strokeDasharray: "6 3", stroke: "#58a6ff" } }}
         proOptions={{ hideAttribution: true }}
+        onNodeDragStart={(_, node) => computeGuides(node)}
+        onNodeDrag={onNodeDrag}
+        onNodeDragStop={() => { setGuides([]); setActiveGuides([]); }}
       >
         <Background id={flowId} variant="lines" gap={65} size={1} color="rgba(255,255,255,0.2)" />
       </ReactFlow>
 
+      {/* helper lines */}
+      {activeGuides.map((ln, i) =>
+        ln.xPx != null ? (
+          <div
+            key={i}
+            className="helper-line helper-line-vertical"
+            style={{ left: ln.xPx + "px", top: 0 }}
+          />
+        ) : (
+          <div
+            key={i}
+            className="helper-line helper-line-horizontal"
+            style={{ top: ln.yPx + "px", left: 0 }}
+          />
+        )
+      )}
+
       <Menu
         open={Boolean(contextMenu)}
         onClose={() => setContextMenu(null)}
         anchorReference="anchorPosition"
-        anchorPosition={
-          contextMenu
-            ? { top: contextMenu.mouseY, left: contextMenu.mouseX }
-            : undefined
-        }
+        anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
         PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
       >
-        <MenuItem
-          onClick={() => {
-            if (contextMenu?.nodeId) {
-              setEdges((eds) =>
-                eds.filter(
-                  (e) =>
-                    e.source !== contextMenu.nodeId &&
-                    e.target !== contextMenu.nodeId
-                )
-              );
-            }
-            setContextMenu(null);
-          }}
-        >
-          <PolylineIcon
-            sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }}
-          />
+        <MenuItem onClick={() => {
+          if (contextMenu?.nodeId) {
+            setEdges((eds) => eds.filter((e) =>
+              e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId
+            ));
+          }
+          setContextMenu(null);
+        }}>
+          <PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
           Disconnect All Edges
         </MenuItem>
-        <MenuItem
-          onClick={() => {
-            if (contextMenu?.nodeId) {
-              setNodes((nds) =>
-                nds.filter((n) => n.id !== contextMenu.nodeId)
-              );
-              setEdges((eds) =>
-                eds.filter(
-                  (e) =>
-                    e.source !== contextMenu.nodeId &&
-                    e.target !== contextMenu.nodeId
-                )
-              );
-            }
-            setContextMenu(null);
-          }}
-        >
-          <DeleteForeverIcon
-            sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }}
-          />
+        <MenuItem onClick={() => {
+          if (contextMenu?.nodeId) {
+            setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
+            setEdges((eds) => eds.filter((e) =>
+              e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId
+            ));
+          }
+          setContextMenu(null);
+        }}>
+          <DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
           Remove Node
         </MenuItem>
       </Menu>
@@ -310,23 +403,11 @@ export default function FlowEditor({
         open={Boolean(edgeContextMenu)}
         onClose={() => setEdgeContextMenu(null)}
         anchorReference="anchorPosition"
-        anchorPosition={
-          edgeContextMenu
-            ? { top: edgeContextMenu.mouseY, left: edgeContextMenu.mouseX }
-            : undefined
-        }
+        anchorPosition={edgeContextMenu ? { top: edgeContextMenu.mouseY, left: edgeContextMenu.mouseX } : undefined}
         PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
       >
-        <MenuItem
-          onClick={() =>
-            setEdges((eds) =>
-              eds.filter((e) => e.id !== selectedEdgeId)
-            )
-          }
-        >
-          <DeleteForeverIcon
-            sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }}
-          />
+        <MenuItem onClick={() => setEdges((eds) => eds.filter((e) => e.id !== selectedEdgeId))}>
+          <DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
           Unlink Edge
         </MenuItem>
         <MenuItem>
@@ -360,13 +441,11 @@ export default function FlowEditor({
               Padding:
               <input
                 type="text"
-                defaultValue={`${labelPadding[0]},${labelPadding[1]}`} 
+                defaultValue={`${labelPadding[0]},${labelPadding[1]}`}
                 style={{ width: 80, marginLeft: 8 }}
                 onBlur={(e) => {
                   const parts = e.target.value.split(",").map((v) => parseInt(v.trim()));
-                  if (parts.length === 2 && parts.every(Number.isFinite)) {
-                    setLabelPadding(parts);
-                  }
+                  if (parts.length === 2 && parts.every(Number.isFinite)) setLabelPadding(parts);
                 }}
               />
             </MenuItem>
@@ -409,9 +488,7 @@ export default function FlowEditor({
                 />
               </Box>
             </MenuItem>
-            <MenuItem onClick={applyLabelStyleExtras}>
-              Apply Label Style Changes
-            </MenuItem>
+            <MenuItem onClick={applyLabelStyleExtras}>Apply Label Style Changes</MenuItem>
           </MenuList>
         </MenuItem>
         <MenuItem onClick={() => handlePickColor("stroke")}>Color</MenuItem>