// //////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Flow_Editor.jsx // Import Node Configuration Sidebar import NodeConfigurationSidebar from "./Node_Configuration_Sidebar"; import React, { useState, useEffect, useCallback, useRef } from "react"; import ReactFlow, { Background, addEdge, applyNodeChanges, applyEdgeChanges, useReactFlow } from "reactflow"; import { Menu, MenuItem, MenuList, Slider, Box } from "@mui/material"; import { Polyline as PolylineIcon, DeleteForever as DeleteForeverIcon, Edit as EditIcon } from "@mui/icons-material"; import { SketchPicker } from "react-color"; import "reactflow/dist/style.css"; export default function FlowEditor({ flowId, nodes, edges, setNodes, setEdges, nodeTypes, categorizedNodes }) { // Node Configuration Sidebar State const [drawerOpen, setDrawerOpen] = useState(false); const [selectedNodeLabel, setSelectedNodeLabel] = useState(null); useEffect(() => { window.BorealisOpenDrawer = (label) => { setSelectedNodeLabel(label); setDrawerOpen(true); }; return () => { delete window.BorealisOpenDrawer; }; }, [nodes]); const wrapperRef = useRef(null); const { project } = useReactFlow(); const [contextMenu, setContextMenu] = useState(null); const [edgeContextMenu, setEdgeContextMenu] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [showColorPicker, setShowColorPicker] = useState(false); const [colorPickerMode, setColorPickerMode] = useState(null); const [labelPadding, setLabelPadding] = useState([8, 4]); const [labelBorderRadius, setLabelBorderRadius] = useState(4); const [labelOpacity, setLabelOpacity] = useState(0.8); 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", straight: "straight", smoothstep: "smoothstep" }; const animationStyles = { dashes: { animated: true, style: { strokeDasharray: "6 3" } }, dots: { animated: true, style: { strokeDasharray: "2 4" } }, none: { animated: false, style: {} } }; // 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 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(); setContextMenu({ mouseX: e.clientX + 2, mouseY: e.clientY - 6, nodeId: node.id }); }; const handleEdgeRightClick = (e, edge) => { e.preventDefault(); setEdgeContextMenu({ edgeId: edge.id, mouseX: e.clientX + 2, mouseY: e.clientY - 6 }); setSelectedEdgeId(edge.id); }; const changeEdgeType = (newType) => { 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 }; })); 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; })); }; const handleAddLabel = () => { 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 )); } setEdgeContextMenu(null); }; const handleRemoveLabel = () => { setEdges((eds) => eds.map((e) => e.id === selectedEdgeId ? { ...e, label: undefined } : e )); setEdgeContextMenu(null); }; const handlePickColor = (mode) => { setColorPickerMode(mode); setTempColor({ hex: "#58a6ff" }); setPickerPos({ x: edgeContextMenu?.mouseX || 0, y: edgeContextMenu?.mouseY || 0 }); setShowColorPicker(true); }; const applyLabelStyleExtras = () => { setEdges((eds) => eds.map((e) => e.id === selectedEdgeId ? { ...e, labelBgPadding: labelPadding, labelBgStyle: { ...e.labelBgStyle, fillOpacity: labelOpacity, rx: labelBorderRadius, ry: labelBorderRadius } } : e )); setEdgeContextMenu(null); }; useEffect(() => { const nodeCountEl = document.getElementById("nodeCount"); if (nodeCountEl) nodeCountEl.innerText = nodes.length; }, [nodes]); const selectedNode = nodes.find((n) => n.data?.label === selectedNodeLabel); const nodeDef = selectedNode ? Object.values(categorizedNodes).flat().find((def) => def.type === selectedNode.type) : null; return ( <div className="flow-editor-container" ref={wrapperRef} style={{ position: "relative" }} > <NodeConfigurationSidebar drawerOpen={drawerOpen} setDrawerOpen={setDrawerOpen} title={selectedNode?.data?.label || ""} nodeData={ selectedNode && nodeDef ? { ...nodeDef, ...selectedNode.data, nodeId: selectedNode.id } : null } /> <ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onDrop={onDrop} onDragOver={onDragOver} onNodeContextMenu={handleRightClick} onEdgeContextMenu={handleEdgeRightClick} defaultViewport={{ x: 0, y: 0, zoom: 1.5 }} 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} 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 }} /> 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 }} /> Remove Node </MenuItem> </Menu> <Menu open={Boolean(edgeContextMenu)} onClose={() => setEdgeContextMenu(null)} anchorReference="anchorPosition" 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 }} /> Unlink Edge </MenuItem> <MenuItem> Edge Styles <MenuList> <MenuItem onClick={() => changeEdgeType("step")}>Step</MenuItem> <MenuItem onClick={() => changeEdgeType("curved")}>Curved</MenuItem> <MenuItem onClick={() => changeEdgeType("straight")}>Straight</MenuItem> <MenuItem onClick={() => changeEdgeType("smoothstep")}>Smoothstep</MenuItem> </MenuList> </MenuItem> <MenuItem> Animations <MenuList> <MenuItem onClick={() => changeEdgeAnimation("dashes")}>Dashes</MenuItem> <MenuItem onClick={() => changeEdgeAnimation("dots")}>Dots</MenuItem> <MenuItem onClick={() => changeEdgeAnimation("none")}>Solid Line</MenuItem> </MenuList> </MenuItem> <MenuItem> Label <MenuList> <MenuItem onClick={handleAddLabel}>Add</MenuItem> <MenuItem onClick={handleRemoveLabel}>Remove</MenuItem> <MenuItem onClick={handleEditLabel}> <EditIcon sx={{ fontSize: 16, mr: 1 }} /> Edit </MenuItem> <MenuItem onClick={() => handlePickColor("labelText")}>Text Color</MenuItem> <MenuItem onClick={() => handlePickColor("labelBg")}>Background Color</MenuItem> <MenuItem> Padding: <input type="text" 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); }} /> </MenuItem> <MenuItem> Radius: <input type="number" min="0" max="20" defaultValue={labelBorderRadius} style={{ width: 60, marginLeft: 8 }} onBlur={(e) => { const val = parseInt(e.target.value); if (!isNaN(val)) setLabelBorderRadius(val); }} /> </MenuItem> <MenuItem> Opacity: <Box display="flex" alignItems="center" ml={1}> <Slider value={labelOpacity} onChange={(_, v) => setLabelOpacity(v)} step={0.05} min={0} max={1} style={{ width: 100 }} /> <input type="number" step="0.05" min="0" max="1" value={labelOpacity} style={{ width: 60, marginLeft: 8 }} onChange={(e) => { const v = parseFloat(e.target.value); if (!isNaN(v)) setLabelOpacity(v); }} /> </Box> </MenuItem> <MenuItem onClick={applyLabelStyleExtras}>Apply Label Style Changes</MenuItem> </MenuList> </MenuItem> <MenuItem onClick={() => handlePickColor("stroke")}>Color</MenuItem> </Menu> {showColorPicker && ( <div style={{ position: "absolute", top: pickerPos.y, left: pickerPos.x, zIndex: 9999, background: "#1e1e1e", padding: "10px", borderRadius: "8px" }} > <SketchPicker color={tempColor.hex} onChange={(c) => setTempColor(c)} /> <div style={{ marginTop: "10px", textAlign: "center" }}> <button onClick={() => { handleColorChange(tempColor); setShowColorPicker(false); }} style={{ backgroundColor: "#58a6ff", color: "#121212", border: "none", padding: "6px 12px", borderRadius: "4px", cursor: "pointer", fontWeight: "bold" }} > Set Color </button> </div> </div> )} </div> ); }