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>