Added Node Alignment Helper Lines

This commit is contained in:
2025-05-05 21:41:11 -06:00
parent e6d5ea41e4
commit dd09824ca5

View File

@ -23,7 +23,7 @@ import { SketchPicker } from "react-color";
import "reactflow/dist/style.css"; import "reactflow/dist/style.css";
export default function FlowEditor({ export default function FlowEditor({
flowId, //Used to Fix Grid Issues Across Multiple Flow Tabs flowId,
nodes, nodes,
edges, edges,
setNodes, setNodes,
@ -33,6 +33,7 @@ export default function FlowEditor({
}) { }) {
const wrapperRef = useRef(null); const wrapperRef = useRef(null);
const { project } = useReactFlow(); const { project } = useReactFlow();
const [contextMenu, setContextMenu] = useState(null); const [contextMenu, setContextMenu] = useState(null);
const [edgeContextMenu, setEdgeContextMenu] = useState(null); const [edgeContextMenu, setEdgeContextMenu] = useState(null);
const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null);
@ -44,6 +45,15 @@ export default function FlowEditor({
const [tempColor, setTempColor] = useState({ hex: "#58a6ff" }); const [tempColor, setTempColor] = useState({ hex: "#58a6ff" });
const [pickerPos, setPickerPos] = useState({ x: 0, y: 0 }); 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 = { const edgeStyles = {
step: "step", step: "step",
curved: "bezier", curved: "bezier",
@ -56,13 +66,125 @@ export default function FlowEditor({
none: { animated: false, style: {} } none: { animated: false, style: {} }
}; };
const onDrop = useCallback( // Compute edge-only guides and capture moving node flow-size
(event) => { 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(); event.preventDefault();
const type = event.dataTransfer.getData("application/reactflow"); const type = event.dataTransfer.getData("application/reactflow");
if (!type) return; if (!type) return;
const bounds = wrapperRef.current.getBoundingClientRect(); const bounds = wrapperRef.current.getBoundingClientRect();
const position = project({ x: event.clientX - bounds.left, y: event.clientY - bounds.top }); const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
});
const id = "node-" + Date.now(); const id = "node-" + Date.now();
const nodeMeta = Object.values(categorizedNodes).flat().find((n) => n.type === type); const nodeMeta = Object.values(categorizedNodes).flat().find((n) => n.type === type);
const newNode = { const newNode = {
@ -73,43 +195,34 @@ export default function FlowEditor({
label: nodeMeta?.label || type, label: nodeMeta?.label || type,
content: nodeMeta?.content content: nodeMeta?.content
}, },
dragHandle: '.borealis-node-header' // <-- Add this line dragHandle: ".borealis-node-header"
}; };
setNodes((nds) => [...nds, newNode]); setNodes((nds) => [...nds, newNode]);
}, }, [project, setNodes, categorizedNodes]);
[project, setNodes, categorizedNodes]
);
const onDragOver = useCallback((event) => { const onDragOver = useCallback((event) => {
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = "move"; event.dataTransfer.dropEffect = "move";
}, []); }, []);
const onConnect = useCallback( const onConnect = useCallback((params) => {
(params) => {
setEdges((eds) => setEdges((eds) =>
addEdge( addEdge({
{
...params, ...params,
type: "bezier", type: "bezier",
animated: true, animated: true,
style: { strokeDasharray: "6 3", stroke: "#58a6ff" } style: { strokeDasharray: "6 3", stroke: "#58a6ff" }
}, }, eds)
eds
)
);
},
[setEdges]
); );
}, [setEdges]);
const onNodesChange = useCallback( const onNodesChange = useCallback((changes) => {
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)), setNodes((nds) => applyNodeChanges(changes, nds));
[setNodes] }, [setNodes]);
);
const onEdgesChange = useCallback( const onEdgesChange = useCallback((changes) => {
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)), setEdges((eds) => applyEdgeChanges(changes, eds));
[setEdges] }, [setEdges]);
);
const handleRightClick = (e, node) => { const handleRightClick = (e, node) => {
e.preventDefault(); e.preventDefault();
@ -123,17 +236,14 @@ export default function FlowEditor({
}; };
const changeEdgeType = (newType) => { const changeEdgeType = (newType) => {
setEdges((eds) => setEdges((eds) => eds.map((e) =>
eds.map((e) =>
e.id === selectedEdgeId ? { ...e, type: edgeStyles[newType] } : e e.id === selectedEdgeId ? { ...e, type: edgeStyles[newType] } : e
) ));
);
setEdgeContextMenu(null); setEdgeContextMenu(null);
}; };
const changeEdgeAnimation = (newAnim) => { const changeEdgeAnimation = (newAnim) => {
setEdges((eds) => setEdges((eds) => eds.map((e) => {
eds.map((e) => {
if (e.id !== selectedEdgeId) return e; if (e.id !== selectedEdgeId) return e;
const strokeColor = e.style?.stroke || "#58a6ff"; const strokeColor = e.style?.stroke || "#58a6ff";
const anim = animationStyles[newAnim] || {}; const anim = animationStyles[newAnim] || {};
@ -143,14 +253,12 @@ export default function FlowEditor({
style: { ...anim.style, stroke: strokeColor }, style: { ...anim.style, stroke: strokeColor },
markerEnd: e.markerEnd ? { ...e.markerEnd, color: strokeColor } : undefined markerEnd: e.markerEnd ? { ...e.markerEnd, color: strokeColor } : undefined
}; };
}) }));
);
setEdgeContextMenu(null); setEdgeContextMenu(null);
}; };
const handleColorChange = (color) => { const handleColorChange = (color) => {
setEdges((eds) => setEdges((eds) => eds.map((e) => {
eds.map((e) => {
if (e.id !== selectedEdgeId) return e; if (e.id !== selectedEdgeId) return e;
const updated = { ...e }; const updated = { ...e };
if (colorPickerMode === "stroke") { if (colorPickerMode === "stroke") {
@ -162,37 +270,30 @@ export default function FlowEditor({
updated.labelBgStyle = { ...e.labelBgStyle, fill: color.hex, fillOpacity: labelOpacity }; updated.labelBgStyle = { ...e.labelBgStyle, fill: color.hex, fillOpacity: labelOpacity };
} }
return updated; return updated;
}) }));
);
}; };
const handleAddLabel = () => { const handleAddLabel = () => {
setEdges((eds) => setEdges((eds) => eds.map((e) =>
eds.map((e) =>
e.id === selectedEdgeId ? { ...e, label: "New Label" } : e e.id === selectedEdgeId ? { ...e, label: "New Label" } : e
) ));
);
setEdgeContextMenu(null); setEdgeContextMenu(null);
}; };
const handleEditLabel = () => { const handleEditLabel = () => {
const newText = prompt("Enter label text:"); const newText = prompt("Enter label text:");
if (newText !== null) { if (newText !== null) {
setEdges((eds) => setEdges((eds) => eds.map((e) =>
eds.map((e) =>
e.id === selectedEdgeId ? { ...e, label: newText } : e e.id === selectedEdgeId ? { ...e, label: newText } : e
) ));
);
} }
setEdgeContextMenu(null); setEdgeContextMenu(null);
}; };
const handleRemoveLabel = () => { const handleRemoveLabel = () => {
setEdges((eds) => setEdges((eds) => eds.map((e) =>
eds.map((e) =>
e.id === selectedEdgeId ? { ...e, label: undefined } : e e.id === selectedEdgeId ? { ...e, label: undefined } : e
) ));
);
setEdgeContextMenu(null); setEdgeContextMenu(null);
}; };
@ -204,8 +305,7 @@ export default function FlowEditor({
}; };
const applyLabelStyleExtras = () => { const applyLabelStyleExtras = () => {
setEdges((eds) => setEdges((eds) => eds.map((e) =>
eds.map((e) =>
e.id === selectedEdgeId e.id === selectedEdgeId
? { ? {
...e, ...e,
@ -218,8 +318,7 @@ export default function FlowEditor({
} }
} }
: e : e
) ));
);
setEdgeContextMenu(null); setEdgeContextMenu(null);
}; };
@ -242,66 +341,60 @@ export default function FlowEditor({
onNodeContextMenu={handleRightClick} onNodeContextMenu={handleRightClick}
onEdgeContextMenu={handleEdgeRightClick} onEdgeContextMenu={handleEdgeRightClick}
defaultViewport={{ x: 0, y: 0, zoom: 1.5 }} defaultViewport={{ x: 0, y: 0, zoom: 1.5 }}
edgeOptions={{ edgeOptions={{ type: "bezier", animated: true, style: { strokeDasharray: "6 3", stroke: "#58a6ff" } }}
type: "bezier",
animated: true,
style: { strokeDasharray: "6 3", stroke: "#58a6ff" }
}}
proOptions={{ hideAttribution: true }} 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)" /> <Background id={flowId} variant="lines" gap={65} size={1} color="rgba(255,255,255,0.2)" />
</ReactFlow> </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 <Menu
open={Boolean(contextMenu)} open={Boolean(contextMenu)}
onClose={() => setContextMenu(null)} onClose={() => setContextMenu(null)}
anchorReference="anchorPosition" anchorReference="anchorPosition"
anchorPosition={ anchorPosition={contextMenu ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
contextMenu
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
> >
<MenuItem <MenuItem onClick={() => {
onClick={() => {
if (contextMenu?.nodeId) { if (contextMenu?.nodeId) {
setEdges((eds) => setEdges((eds) => eds.filter((e) =>
eds.filter( e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId
(e) => ));
e.source !== contextMenu.nodeId &&
e.target !== contextMenu.nodeId
)
);
} }
setContextMenu(null); setContextMenu(null);
}} }}>
> <PolylineIcon sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }} />
<PolylineIcon
sx={{ fontSize: 18, color: "#58a6ff", mr: 1 }}
/>
Disconnect All Edges Disconnect All Edges
</MenuItem> </MenuItem>
<MenuItem <MenuItem onClick={() => {
onClick={() => {
if (contextMenu?.nodeId) { if (contextMenu?.nodeId) {
setNodes((nds) => setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
nds.filter((n) => n.id !== contextMenu.nodeId) setEdges((eds) => eds.filter((e) =>
); e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId
setEdges((eds) => ));
eds.filter(
(e) =>
e.source !== contextMenu.nodeId &&
e.target !== contextMenu.nodeId
)
);
} }
setContextMenu(null); setContextMenu(null);
}} }}>
> <DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
<DeleteForeverIcon
sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }}
/>
Remove Node Remove Node
</MenuItem> </MenuItem>
</Menu> </Menu>
@ -310,23 +403,11 @@ export default function FlowEditor({
open={Boolean(edgeContextMenu)} open={Boolean(edgeContextMenu)}
onClose={() => setEdgeContextMenu(null)} onClose={() => setEdgeContextMenu(null)}
anchorReference="anchorPosition" anchorReference="anchorPosition"
anchorPosition={ anchorPosition={edgeContextMenu ? { top: edgeContextMenu.mouseY, left: edgeContextMenu.mouseX } : undefined}
edgeContextMenu
? { top: edgeContextMenu.mouseY, left: edgeContextMenu.mouseX }
: undefined
}
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }} PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
> >
<MenuItem <MenuItem onClick={() => setEdges((eds) => eds.filter((e) => e.id !== selectedEdgeId))}>
onClick={() => <DeleteForeverIcon sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }} />
setEdges((eds) =>
eds.filter((e) => e.id !== selectedEdgeId)
)
}
>
<DeleteForeverIcon
sx={{ fontSize: 18, color: "#ff4f4f", mr: 1 }}
/>
Unlink Edge Unlink Edge
</MenuItem> </MenuItem>
<MenuItem> <MenuItem>
@ -364,9 +445,7 @@ export default function FlowEditor({
style={{ width: 80, marginLeft: 8 }} style={{ width: 80, marginLeft: 8 }}
onBlur={(e) => { onBlur={(e) => {
const parts = e.target.value.split(",").map((v) => parseInt(v.trim())); const parts = e.target.value.split(",").map((v) => parseInt(v.trim()));
if (parts.length === 2 && parts.every(Number.isFinite)) { if (parts.length === 2 && parts.every(Number.isFinite)) setLabelPadding(parts);
setLabelPadding(parts);
}
}} }}
/> />
</MenuItem> </MenuItem>
@ -409,9 +488,7 @@ export default function FlowEditor({
/> />
</Box> </Box>
</MenuItem> </MenuItem>
<MenuItem onClick={applyLabelStyleExtras}> <MenuItem onClick={applyLabelStyleExtras}>Apply Label Style Changes</MenuItem>
Apply Label Style Changes
</MenuItem>
</MenuList> </MenuList>
</MenuItem> </MenuItem>
<MenuItem onClick={() => handlePickColor("stroke")}>Color</MenuItem> <MenuItem onClick={() => handlePickColor("stroke")}>Color</MenuItem>