Skip to content

Commit ff2f1a6

Browse files
committed
feat(diagrams): add sub-side handles at 20/50/80 so excalidraw arrow anchors preserve author intent
1 parent be34a62 commit ff2f1a6

2 files changed

Lines changed: 93 additions & 5 deletions

File tree

src/components/DrawioReactFlow/ClickableNode.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,82 @@ export function ClickableNode({ data }: NodeProps<NodeData>) {
7676
tabIndex={isClickable ? 0 : -1}
7777
aria-label={isClickable ? `Navigate to ${link || label}` : label}
7878
>
79-
{/* Connection handles on all 4 sides for Draw.io edge routing */}
79+
{/* Connection handles — 4 sides, 3 positions per side (20/50/80%), plus legacy
80+
midpoint-only ids (e.g. "top-source") for the draw.io converter that doesn't
81+
emit along-side offsets. */}
82+
{/* Top — legacy midpoint */}
8083
<Handle id="top-target" type="target" position={Position.Top} />
8184
<Handle id="top-source" type="source" position={Position.Top} />
85+
{/* Top — 20/50/80 */}
86+
<Handle id="top-20-target" type="target" position={Position.Top} style={{ left: '20%' }} />
87+
<Handle id="top-20-source" type="source" position={Position.Top} style={{ left: '20%' }} />
88+
<Handle id="top-50-target" type="target" position={Position.Top} style={{ left: '50%' }} />
89+
<Handle id="top-50-source" type="source" position={Position.Top} style={{ left: '50%' }} />
90+
<Handle id="top-80-target" type="target" position={Position.Top} style={{ left: '80%' }} />
91+
<Handle id="top-80-source" type="source" position={Position.Top} style={{ left: '80%' }} />
92+
93+
{/* Right — legacy midpoint */}
8294
<Handle id="right-target" type="target" position={Position.Right} />
8395
<Handle id="right-source" type="source" position={Position.Right} />
96+
{/* Right — 20/50/80 */}
97+
<Handle id="right-20-target" type="target" position={Position.Right} style={{ top: '20%' }} />
98+
<Handle id="right-20-source" type="source" position={Position.Right} style={{ top: '20%' }} />
99+
<Handle id="right-50-target" type="target" position={Position.Right} style={{ top: '50%' }} />
100+
<Handle id="right-50-source" type="source" position={Position.Right} style={{ top: '50%' }} />
101+
<Handle id="right-80-target" type="target" position={Position.Right} style={{ top: '80%' }} />
102+
<Handle id="right-80-source" type="source" position={Position.Right} style={{ top: '80%' }} />
103+
104+
{/* Bottom — legacy midpoint */}
84105
<Handle id="bottom-target" type="target" position={Position.Bottom} />
85106
<Handle id="bottom-source" type="source" position={Position.Bottom} />
107+
{/* Bottom — 20/50/80 */}
108+
<Handle
109+
id="bottom-20-target"
110+
type="target"
111+
position={Position.Bottom}
112+
style={{ left: '20%' }}
113+
/>
114+
<Handle
115+
id="bottom-20-source"
116+
type="source"
117+
position={Position.Bottom}
118+
style={{ left: '20%' }}
119+
/>
120+
<Handle
121+
id="bottom-50-target"
122+
type="target"
123+
position={Position.Bottom}
124+
style={{ left: '50%' }}
125+
/>
126+
<Handle
127+
id="bottom-50-source"
128+
type="source"
129+
position={Position.Bottom}
130+
style={{ left: '50%' }}
131+
/>
132+
<Handle
133+
id="bottom-80-target"
134+
type="target"
135+
position={Position.Bottom}
136+
style={{ left: '80%' }}
137+
/>
138+
<Handle
139+
id="bottom-80-source"
140+
type="source"
141+
position={Position.Bottom}
142+
style={{ left: '80%' }}
143+
/>
144+
145+
{/* Left — legacy midpoint */}
86146
<Handle id="left-target" type="target" position={Position.Left} />
87147
<Handle id="left-source" type="source" position={Position.Left} />
148+
{/* Left — 20/50/80 */}
149+
<Handle id="left-20-target" type="target" position={Position.Left} style={{ top: '20%' }} />
150+
<Handle id="left-20-source" type="source" position={Position.Left} style={{ top: '20%' }} />
151+
<Handle id="left-50-target" type="target" position={Position.Left} style={{ top: '50%' }} />
152+
<Handle id="left-50-source" type="source" position={Position.Left} style={{ top: '50%' }} />
153+
<Handle id="left-80-target" type="target" position={Position.Left} style={{ top: '80%' }} />
154+
<Handle id="left-80-source" type="source" position={Position.Left} style={{ top: '80%' }} />
88155

89156
<div className="node-content" style={contentStyle}>
90157
{labelContent}

src/components/DrawioReactFlow/utils/excalidrawToReactFlow.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,25 @@ function remapFixedPoint(
6666
return [(absX - toEl.x) / toEl.width, (absY - toEl.y) / toEl.height];
6767
}
6868

69+
type AlongSidePercent = 20 | 50 | 80;
70+
71+
function pickAlongSidePercent(coord: number): AlongSidePercent {
72+
if (coord < 0.35) return 20;
73+
if (coord > 0.65) return 80;
74+
return 50;
75+
}
76+
77+
function fixedPointToSideAndOffset(
78+
fp: [number, number] | undefined,
79+
self: ExcalidrawElement,
80+
other: ExcalidrawElement | undefined,
81+
): { side: Position; alongPercent: AlongSidePercent } {
82+
const side = fixedPointToSide(fp, self, other);
83+
if (!fp) return { side, alongPercent: 50 };
84+
const along = side === Position.Top || side === Position.Bottom ? fp[0] : fp[1];
85+
return { side, alongPercent: pickAlongSidePercent(along) };
86+
}
87+
6988
function fixedPointToSide(
7089
fixedPoint: [number, number] | undefined,
7190
self: ExcalidrawElement,
@@ -344,8 +363,10 @@ export async function convertExcalidrawToReactFlow(
344363
? remapFixedPoint(el.endBinding.fixedPoint, tgtRawEl, tgtEl)
345364
: el.endBinding.fixedPoint;
346365

347-
const sourcePosition = fixedPointToSide(sfp, srcEl, tgtEl);
348-
const targetPosition = fixedPointToSide(efp, tgtEl, srcEl);
366+
const sourceAnchor = fixedPointToSideAndOffset(sfp, srcEl, tgtEl);
367+
const targetAnchor = fixedPointToSideAndOffset(efp, tgtEl, srcEl);
368+
const sourcePosition = sourceAnchor.side;
369+
const targetPosition = targetAnchor.side;
349370

350371
const labelEl = containerToText.get(el.id);
351372
const label = labelEl?.text ?? '';
@@ -355,8 +376,8 @@ export async function convertExcalidrawToReactFlow(
355376
id: el.id,
356377
source: srcShapeId,
357378
target: tgtShapeId,
358-
sourceHandle: `${sourcePosition}-source`,
359-
targetHandle: `${targetPosition}-target`,
379+
sourceHandle: `${sourcePosition}-${sourceAnchor.alongPercent}-source`,
380+
targetHandle: `${targetPosition}-${targetAnchor.alongPercent}-target`,
360381
type: hoverKey ? 'hoverEdge' : 'smoothstep',
361382
animated: false,
362383
style: { strokeWidth: 1.5 },

0 commit comments

Comments
 (0)