Address PR review comments

Edge calculation improvements:
- Add zero radius/radii guards in circle and ellipse intersection functions
- Add clamping for pill straight edge intersections to prevent overflow
- Ensure intersection points stay within valid pill boundaries

Handle improvements:
- Add bidirectional connection support with overlapping source/target handles
- Each edge now has both source and target handles (8 total per node)
- Allows edges to connect in any direction from any side
- Fixes handle type restrictions that prevented flexible connections

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2026-01-24 16:17:23 +01:00
parent 318cdee15c
commit 4b865762a1
3 changed files with 180 additions and 10 deletions

View file

@ -82,10 +82,13 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
}}
>
{/* Invisible handles positioned around edges - center remains free for dragging */}
{/* Top edge handle */}
{/* Bidirectional handles (source + target overlapping at each edge) */}
{/* Top edge handles */}
<Handle
type="target"
position={Position.Top}
id="top-target"
isConnectable={true}
style={{
width: "100%",
@ -99,10 +102,29 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
cursor: "crosshair",
}}
/>
{/* Right edge handle */}
<Handle
type="source"
position={Position.Top}
id="top-source"
isConnectable={true}
style={{
width: "100%",
height: "30px",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Right edge handles */}
<Handle
type="target"
position={Position.Right}
id="right-target"
isConnectable={true}
style={{
width: "30px",
@ -116,10 +138,29 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
cursor: "crosshair",
}}
/>
{/* Bottom edge handle */}
<Handle
type="source"
position={Position.Right}
id="right-source"
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0,
right: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Bottom edge handles */}
<Handle
type="target"
position={Position.Bottom}
id="bottom-target"
isConnectable={true}
style={{
width: "100%",
@ -133,10 +174,46 @@ const CustomNode = ({ data, selected }: NodeProps<Actor>) => {
cursor: "crosshair",
}}
/>
{/* Left edge handle */}
<Handle
type="source"
position={Position.Bottom}
id="bottom-source"
isConnectable={true}
style={{
width: "100%",
height: "30px",
bottom: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
{/* Left edge handles */}
<Handle
type="target"
position={Position.Left}
id="left-target"
isConnectable={true}
style={{
width: "30px",
height: "100%",
top: 0,
left: 0,
opacity: 0,
border: "none",
background: "transparent",
transform: "none",
cursor: "crosshair",
}}
/>
<Handle
type="source"
position={Position.Left}
id="left-source"
isConnectable={true}
style={{
width: "30px",

View file

@ -221,10 +221,13 @@ const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
}}
>
{/* Invisible handles positioned around edges - center remains free for dragging */}
{/* Top edge handle */}
{/* Bidirectional handles (source + target overlapping at each edge) */}
{/* Top edge handles */}
<Handle
type="target"
position={Position.Top}
id="top-target"
isConnectable={true}
style={{
width: '100%',
@ -238,10 +241,29 @@ const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
cursor: 'crosshair',
}}
/>
{/* Right edge handle */}
<Handle
type="source"
position={Position.Top}
id="top-source"
isConnectable={true}
style={{
width: '100%',
height: '30px',
top: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
{/* Right edge handles */}
<Handle
type="target"
position={Position.Right}
id="right-target"
isConnectable={true}
style={{
width: '30px',
@ -255,10 +277,29 @@ const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
cursor: 'crosshair',
}}
/>
{/* Bottom edge handle */}
<Handle
type="source"
position={Position.Right}
id="right-source"
isConnectable={true}
style={{
width: '30px',
height: '100%',
top: 0,
right: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
{/* Bottom edge handles */}
<Handle
type="target"
position={Position.Bottom}
id="bottom-target"
isConnectable={true}
style={{
width: '100%',
@ -272,10 +313,46 @@ const GroupNode = ({ id, data, selected }: NodeProps<Group>) => {
cursor: 'crosshair',
}}
/>
{/* Left edge handle */}
<Handle
type="source"
position={Position.Bottom}
id="bottom-source"
isConnectable={true}
style={{
width: '100%',
height: '30px',
bottom: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
{/* Left edge handles */}
<Handle
type="target"
position={Position.Left}
id="left-target"
isConnectable={true}
style={{
width: '30px',
height: '100%',
top: 0,
left: 0,
opacity: 0,
border: 'none',
background: 'transparent',
transform: 'none',
cursor: 'crosshair',
}}
/>
<Handle
type="source"
position={Position.Left}
id="left-source"
isConnectable={true}
style={{
width: '30px',

View file

@ -20,6 +20,11 @@ function getCircleIntersection(
targetY: number,
offset: number = 3
): { x: number; y: number; angle: number } {
// Guard against zero radius
if (radius === 0) {
return { x: centerX + offset, y: centerY, angle: 0 };
}
const dx = targetX - centerX;
const dy = targetY - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
@ -53,6 +58,11 @@ function getEllipseIntersection(
targetY: number,
offset: number = 3
): { x: number; y: number; angle: number } {
// Guard against zero radii
if (radiusX === 0 || radiusY === 0) {
return { x: centerX + offset, y: centerY, angle: 0 };
}
const dx = targetX - centerX;
const dy = targetY - centerY;
@ -131,10 +141,13 @@ function getPillIntersection(
// Calculate x position where line from target to center intersects the horizontal edge
// Line equation: (y - centerY) / (x - centerX) = dy / dx
// Solving for x when y = intersectY: x = centerX + dx * (intersectY - centerY) / dy
const intersectX = Math.abs(dy) > 0.001
let intersectX = Math.abs(dy) > 0.001
? centerX + dx * (intersectY - centerY) / dy
: centerX;
// Clamp intersection to the straight horizontal segment between the caps
intersectX = Math.min(Math.max(intersectX, leftCapX), rightCapX);
const normalAngle = side < 0 ? -Math.PI / 2 : Math.PI / 2;
return {
@ -164,10 +177,13 @@ function getPillIntersection(
// Calculate y position where line from target to center intersects the vertical edge
// Line equation: (y - centerY) / (x - centerX) = dy / dx
// Solving for y when x = intersectX: y = centerY + dy * (intersectX - centerX) / dx
const intersectY = Math.abs(dx) > 0.001
let intersectY = Math.abs(dx) > 0.001
? centerY + dy * (intersectX - centerX) / dx
: centerY;
// Clamp intersection to the straight vertical segment between the caps
intersectY = Math.min(Math.max(intersectY, topCapY), bottomCapY);
const normalAngle = side < 0 ? Math.PI : 0;
return {