more edge case resolution on workflow builder
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Successful in 32s
CI / Web Blocking Checks (push) Failing after 26s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 2m0s
CI / Web Advisory Checks (push) Successful in 32s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Failing after 7m33s
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Successful in 32s
CI / Web Blocking Checks (push) Failing after 26s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 2m0s
CI / Web Advisory Checks (push) Successful in 32s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Failing after 7m33s
This commit is contained in:
@@ -42,6 +42,10 @@ const PRESET_BANNER_COLORS: Record<TransitionPreset, string> = {
|
|||||||
const MIN_ZOOM = 0.15;
|
const MIN_ZOOM = 0.15;
|
||||||
const MAX_ZOOM = 3;
|
const MAX_ZOOM = 3;
|
||||||
const ZOOM_SENSITIVITY = 0.0015;
|
const ZOOM_SENSITIVITY = 0.0015;
|
||||||
|
const CANVAS_SIDE_PADDING = 120;
|
||||||
|
const CANVAS_TOP_PADDING = 140;
|
||||||
|
const CANVAS_BOTTOM_PADDING = 120;
|
||||||
|
const CANVAS_RIGHT_PADDING = 380;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build CSS background style for the infinite grid.
|
* Build CSS background style for the infinite grid.
|
||||||
@@ -465,6 +469,11 @@ export default function WorkflowCanvas({
|
|||||||
maxY = Math.max(maxY, t.position.y + 140);
|
maxY = Math.max(maxY, t.position.y + 140);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
minX -= CANVAS_SIDE_PADDING;
|
||||||
|
minY -= CANVAS_TOP_PADDING;
|
||||||
|
maxX += CANVAS_RIGHT_PADDING;
|
||||||
|
maxY += CANVAS_BOTTOM_PADDING;
|
||||||
|
|
||||||
const contentW = maxX - minX;
|
const contentW = maxX - minX;
|
||||||
const contentH = maxY - minY;
|
const contentH = maxY - minY;
|
||||||
const pad = 80;
|
const pad = 80;
|
||||||
@@ -492,10 +501,10 @@ export default function WorkflowCanvas({
|
|||||||
let maxX = 4000;
|
let maxX = 4000;
|
||||||
let maxY = 4000;
|
let maxY = 4000;
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
minX = Math.min(minX, task.position.x - 100);
|
minX = Math.min(minX, task.position.x - CANVAS_SIDE_PADDING);
|
||||||
minY = Math.min(minY, task.position.y - 100);
|
minY = Math.min(minY, task.position.y - CANVAS_TOP_PADDING);
|
||||||
maxX = Math.max(maxX, task.position.x + 500);
|
maxX = Math.max(maxX, task.position.x + CANVAS_RIGHT_PADDING);
|
||||||
maxY = Math.max(maxY, task.position.y + 500);
|
maxY = Math.max(maxY, task.position.y + CANVAS_BOTTOM_PADDING + 380);
|
||||||
}
|
}
|
||||||
return { width: maxX - minX, height: maxY - minY };
|
return { width: maxX - minX, height: maxY - minY };
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ interface WorkflowEdgesProps {
|
|||||||
|
|
||||||
const NODE_WIDTH = 240;
|
const NODE_WIDTH = 240;
|
||||||
const NODE_HEIGHT = 96;
|
const NODE_HEIGHT = 96;
|
||||||
|
const SELF_LOOP_RIGHT_OFFSET = 24;
|
||||||
|
const SELF_LOOP_TOP_OFFSET = 36;
|
||||||
|
const SELF_LOOP_BOTTOM_OFFSET = 30;
|
||||||
|
const ARROW_LENGTH = 12;
|
||||||
|
const ARROW_HALF_WIDTH = 5;
|
||||||
|
const ARROW_DIRECTION_LOOKBACK_PX = 10;
|
||||||
|
const ARROW_DIRECTION_SAMPLES = 48;
|
||||||
|
const ARROW_SHAFT_OVERLAP_PX = 2;
|
||||||
|
|
||||||
/** Color for each edge type (alias for shared constant) */
|
/** Color for each edge type (alias for shared constant) */
|
||||||
const EDGE_COLORS = EDGE_TYPE_COLORS;
|
const EDGE_COLORS = EDGE_TYPE_COLORS;
|
||||||
@@ -159,13 +167,14 @@ function getBestConnectionPoints(
|
|||||||
end: { x: number; y: number };
|
end: { x: number; y: number };
|
||||||
selfLoop?: boolean;
|
selfLoop?: boolean;
|
||||||
} {
|
} {
|
||||||
// Self-loop: right side → top
|
// Self-loop uses a dedicated route that stays outside the task card so the
|
||||||
|
// arrowhead and label remain readable instead of being covered by the node.
|
||||||
if (fromTask.id === toTask.id) {
|
if (fromTask.id === toTask.id) {
|
||||||
return {
|
return {
|
||||||
start: getNodeBottomCenter(fromTask, nodeWidth, nodeHeight),
|
start: getNodeBottomCenter(fromTask, nodeWidth, nodeHeight),
|
||||||
end: {
|
end: {
|
||||||
x: fromTask.position.x + nodeWidth * 0.75,
|
x: fromTask.position.x + nodeWidth,
|
||||||
y: fromTask.position.y,
|
y: fromTask.position.y + nodeHeight * 0.28,
|
||||||
},
|
},
|
||||||
selfLoop: true,
|
selfLoop: true,
|
||||||
};
|
};
|
||||||
@@ -184,17 +193,25 @@ function getBestConnectionPoints(
|
|||||||
return { start, end };
|
return { start, end };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function buildSelfLoopRoute(
|
||||||
* Build an SVG path for a self-loop.
|
task: WorkflowTask,
|
||||||
*/
|
nodeWidth: number,
|
||||||
function buildSelfLoopPath(
|
nodeHeight: number,
|
||||||
start: { x: number; y: number },
|
): { x: number; y: number }[] {
|
||||||
end: { x: number; y: number },
|
const start = getNodeBottomCenter(task, nodeWidth, nodeHeight);
|
||||||
): string {
|
const cardRight = task.position.x + nodeWidth;
|
||||||
const loopOffset = 50;
|
const cardTop = task.position.y;
|
||||||
const cp1 = { x: start.x + loopOffset, y: start.y - 20 };
|
const loopRight = cardRight + SELF_LOOP_RIGHT_OFFSET;
|
||||||
const cp2 = { x: end.x + loopOffset, y: end.y - 40 };
|
const loopTop = cardTop + SELF_LOOP_TOP_OFFSET;
|
||||||
return `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
|
const loopBottom = start.y + SELF_LOOP_BOTTOM_OFFSET;
|
||||||
|
|
||||||
|
return [
|
||||||
|
start,
|
||||||
|
{ x: start.x, y: loopBottom },
|
||||||
|
{ x: loopRight, y: loopBottom },
|
||||||
|
{ x: loopRight, y: loopTop },
|
||||||
|
{ x: cardRight, y: task.position.y + nodeHeight * 0.28 },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -296,28 +313,13 @@ function getSegmentControlPoints(
|
|||||||
function evaluatePathAtT(
|
function evaluatePathAtT(
|
||||||
allPoints: { x: number; y: number }[],
|
allPoints: { x: number; y: number }[],
|
||||||
t: number,
|
t: number,
|
||||||
selfLoop?: boolean,
|
_selfLoop?: boolean,
|
||||||
): { x: number; y: number } {
|
): { x: number; y: number } {
|
||||||
if (allPoints.length < 2) {
|
if (allPoints.length < 2) {
|
||||||
return allPoints[0] ?? { x: 0, y: 0 };
|
return allPoints[0] ?? { x: 0, y: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-loop with no waypoints (allPoints = [start, end])
|
// Self-loop with no waypoints (allPoints = [start, end])
|
||||||
if (selfLoop && allPoints.length === 2) {
|
|
||||||
const start = allPoints[0];
|
|
||||||
const end = allPoints[1];
|
|
||||||
const loopOffset = 50;
|
|
||||||
const cp1 = { x: start.x + loopOffset, y: start.y - 20 };
|
|
||||||
const cp2 = { x: end.x + loopOffset, y: end.y - 40 };
|
|
||||||
return evaluateCubicBezier(
|
|
||||||
start,
|
|
||||||
cp1,
|
|
||||||
cp2,
|
|
||||||
end,
|
|
||||||
Math.max(0, Math.min(1, t)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const numSegments = allPoints.length - 1;
|
const numSegments = allPoints.length - 1;
|
||||||
const clampedT = Math.max(0, Math.min(1, t));
|
const clampedT = Math.max(0, Math.min(1, t));
|
||||||
const scaledT = clampedT * numSegments;
|
const scaledT = clampedT * numSegments;
|
||||||
@@ -341,7 +343,7 @@ function evaluatePathAtT(
|
|||||||
function projectOntoPath(
|
function projectOntoPath(
|
||||||
allPoints: { x: number; y: number }[],
|
allPoints: { x: number; y: number }[],
|
||||||
mousePos: { x: number; y: number },
|
mousePos: { x: number; y: number },
|
||||||
selfLoop?: boolean,
|
_selfLoop?: boolean,
|
||||||
): number {
|
): number {
|
||||||
if (allPoints.length < 2) return 0;
|
if (allPoints.length < 2) return 0;
|
||||||
|
|
||||||
@@ -349,25 +351,6 @@ function projectOntoPath(
|
|||||||
let bestT = 0.5;
|
let bestT = 0.5;
|
||||||
let bestDist = Infinity;
|
let bestDist = Infinity;
|
||||||
|
|
||||||
// Self-loop with no waypoints
|
|
||||||
if (selfLoop && allPoints.length === 2) {
|
|
||||||
const start = allPoints[0];
|
|
||||||
const end = allPoints[1];
|
|
||||||
const loopOffset = 50;
|
|
||||||
const cp1 = { x: start.x + loopOffset, y: start.y - 20 };
|
|
||||||
const cp2 = { x: end.x + loopOffset, y: end.y - 40 };
|
|
||||||
for (let s = 0; s <= samplesPerSegment; s++) {
|
|
||||||
const localT = s / samplesPerSegment;
|
|
||||||
const pt = evaluateCubicBezier(start, cp1, cp2, end, localT);
|
|
||||||
const dist = Math.hypot(pt.x - mousePos.x, pt.y - mousePos.y);
|
|
||||||
if (dist < bestDist) {
|
|
||||||
bestDist = dist;
|
|
||||||
bestT = localT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestT;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numSegments = allPoints.length - 1;
|
const numSegments = allPoints.length - 1;
|
||||||
|
|
||||||
for (let seg = 0; seg < numSegments; seg++) {
|
for (let seg = 0; seg < numSegments; seg++) {
|
||||||
@@ -466,6 +449,151 @@ function curveSegmentMidpoint(
|
|||||||
return evaluateCubicBezier(p1, cp1, cp2, p2, 0.5);
|
return evaluateCubicBezier(p1, cp1, cp2, p2, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildArrowHeadPath(
|
||||||
|
from: { x: number; y: number },
|
||||||
|
tip: { x: number; y: number },
|
||||||
|
): {
|
||||||
|
path: string;
|
||||||
|
} {
|
||||||
|
const dx = tip.x - from.x;
|
||||||
|
const dy = tip.y - from.y;
|
||||||
|
const length = Math.hypot(dx, dy) || 1;
|
||||||
|
const ux = dx / length;
|
||||||
|
const uy = dy / length;
|
||||||
|
const baseX = tip.x - ux * ARROW_LENGTH;
|
||||||
|
const baseY = tip.y - uy * ARROW_LENGTH;
|
||||||
|
const perpX = -uy;
|
||||||
|
const perpY = ux;
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: `M ${tip.x} ${tip.y} L ${baseX + perpX * ARROW_HALF_WIDTH} ${baseY + perpY * ARROW_HALF_WIDTH} L ${baseX - perpX * ARROW_HALF_WIDTH} ${baseY - perpY * ARROW_HALF_WIDTH} Z`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArrowDirectionPoint(
|
||||||
|
allPoints: { x: number; y: number }[],
|
||||||
|
lookbackPx: number = ARROW_DIRECTION_LOOKBACK_PX,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
if (allPoints.length < 2) {
|
||||||
|
return allPoints[0] ?? { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const segIdx = allPoints.length - 2;
|
||||||
|
const start = allPoints[segIdx];
|
||||||
|
const end = allPoints[segIdx + 1];
|
||||||
|
const { cp1, cp2 } = getSegmentControlPoints(allPoints, segIdx);
|
||||||
|
|
||||||
|
let prev = end;
|
||||||
|
let traversed = 0;
|
||||||
|
|
||||||
|
for (let i = ARROW_DIRECTION_SAMPLES - 1; i >= 0; i--) {
|
||||||
|
const t = i / ARROW_DIRECTION_SAMPLES;
|
||||||
|
const pt = evaluateCubicBezier(start, cp1, cp2, end, t);
|
||||||
|
traversed += Math.hypot(prev.x - pt.x, prev.y - pt.y);
|
||||||
|
if (traversed >= lookbackPx) {
|
||||||
|
return pt;
|
||||||
|
}
|
||||||
|
prev = pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerpPoint(
|
||||||
|
a: { x: number; y: number },
|
||||||
|
b: { x: number; y: number },
|
||||||
|
t: number,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: a.x + (b.x - a.x) * t,
|
||||||
|
y: a.y + (b.y - a.y) * t,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCubicAtT(
|
||||||
|
p0: { x: number; y: number },
|
||||||
|
p1: { x: number; y: number },
|
||||||
|
p2: { x: number; y: number },
|
||||||
|
p3: { x: number; y: number },
|
||||||
|
t: number,
|
||||||
|
): {
|
||||||
|
leftCp1: { x: number; y: number };
|
||||||
|
leftCp2: { x: number; y: number };
|
||||||
|
point: { x: number; y: number };
|
||||||
|
} {
|
||||||
|
const p01 = lerpPoint(p0, p1, t);
|
||||||
|
const p12 = lerpPoint(p1, p2, t);
|
||||||
|
const p23 = lerpPoint(p2, p3, t);
|
||||||
|
const p012 = lerpPoint(p01, p12, t);
|
||||||
|
const p123 = lerpPoint(p12, p23, t);
|
||||||
|
const point = lerpPoint(p012, p123, t);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftCp1: p01,
|
||||||
|
leftCp2: p012,
|
||||||
|
point,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTrimmedSegmentEnd(
|
||||||
|
allPoints: { x: number; y: number }[],
|
||||||
|
trimPx: number,
|
||||||
|
): {
|
||||||
|
segIdx: number;
|
||||||
|
t: number;
|
||||||
|
point: { x: number; y: number };
|
||||||
|
} {
|
||||||
|
const segIdx = allPoints.length - 2;
|
||||||
|
const start = allPoints[segIdx];
|
||||||
|
const end = allPoints[segIdx + 1];
|
||||||
|
const { cp1, cp2 } = getSegmentControlPoints(allPoints, segIdx);
|
||||||
|
|
||||||
|
let prev = end;
|
||||||
|
let traversed = 0;
|
||||||
|
|
||||||
|
for (let i = ARROW_DIRECTION_SAMPLES - 1; i >= 0; i--) {
|
||||||
|
const t = i / ARROW_DIRECTION_SAMPLES;
|
||||||
|
const pt = evaluateCubicBezier(start, cp1, cp2, end, t);
|
||||||
|
traversed += Math.hypot(prev.x - pt.x, prev.y - pt.y);
|
||||||
|
if (traversed >= trimPx) {
|
||||||
|
return { segIdx, t, point: pt };
|
||||||
|
}
|
||||||
|
prev = pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { segIdx, t: 0, point: start };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrimmedPath(
|
||||||
|
allPoints: { x: number; y: number }[],
|
||||||
|
trimPx: number,
|
||||||
|
): string {
|
||||||
|
if (allPoints.length < 2) return "";
|
||||||
|
if (trimPx <= 0) {
|
||||||
|
return allPoints.length === 2
|
||||||
|
? buildCurvePath(allPoints[0], allPoints[1])
|
||||||
|
: buildSmoothPath(allPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { segIdx, t } = findTrimmedSegmentEnd(allPoints, trimPx);
|
||||||
|
const start = allPoints[segIdx];
|
||||||
|
const end = allPoints[segIdx + 1];
|
||||||
|
const { cp1, cp2 } = getSegmentControlPoints(allPoints, segIdx);
|
||||||
|
const trimmed = splitCubicAtT(start, cp1, cp2, end, t);
|
||||||
|
|
||||||
|
let d = `M ${allPoints[0].x} ${allPoints[0].y}`;
|
||||||
|
|
||||||
|
for (let i = 0; i < segIdx; i++) {
|
||||||
|
const p2 = allPoints[i + 1];
|
||||||
|
const { cp1: segCp1, cp2: segCp2 } = getSegmentControlPoints(allPoints, i);
|
||||||
|
d += ` C ${segCp1.x} ${segCp1.y}, ${segCp2.x} ${segCp2.y}, ${p2.x} ${p2.y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
d += ` C ${trimmed.leftCp1.x} ${trimmed.leftCp1.y}, ${trimmed.leftCp2.x} ${trimmed.leftCp2.y}, ${trimmed.point.x} ${trimmed.point.y}`;
|
||||||
|
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
/** Check whether two SelectedEdgeInfo match the same edge */
|
/** Check whether two SelectedEdgeInfo match the same edge */
|
||||||
function edgeMatches(
|
function edgeMatches(
|
||||||
sel: SelectedEdgeInfo | null | undefined,
|
sel: SelectedEdgeInfo | null | undefined,
|
||||||
@@ -530,9 +658,12 @@ function WorkflowEdgesInner({
|
|||||||
let maxX = 0;
|
let maxX = 0;
|
||||||
let maxY = 0;
|
let maxY = 0;
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
minX = Math.min(minX, task.position.x - 100);
|
minX = Math.min(minX, task.position.x - 120);
|
||||||
minY = Math.min(minY, task.position.y - 100);
|
minY = Math.min(minY, task.position.y - 140);
|
||||||
maxX = Math.max(maxX, task.position.x + nodeWidth + 100);
|
maxX = Math.max(
|
||||||
|
maxX,
|
||||||
|
task.position.x + nodeWidth + SELF_LOOP_RIGHT_OFFSET + 40,
|
||||||
|
);
|
||||||
maxY = Math.max(maxY, task.position.y + nodeHeight + 100);
|
maxY = Math.max(maxY, task.position.y + nodeHeight + 100);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -909,56 +1040,20 @@ function WorkflowEdgesInner({
|
|||||||
height={svgBounds.height}
|
height={svgBounds.height}
|
||||||
style={{ zIndex: 1 }}
|
style={{ zIndex: 1 }}
|
||||||
>
|
>
|
||||||
<defs>
|
|
||||||
{/* Arrow markers for each edge type */}
|
|
||||||
{Object.entries(EDGE_COLORS).map(([type, color]) => (
|
|
||||||
<marker
|
|
||||||
key={`arrow-${type}`}
|
|
||||||
id={`arrow-${type}`}
|
|
||||||
viewBox="0 0 10 10"
|
|
||||||
refX={9}
|
|
||||||
refY={5}
|
|
||||||
markerWidth={8}
|
|
||||||
markerHeight={8}
|
|
||||||
orient="auto-start-reverse"
|
|
||||||
>
|
|
||||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={color} opacity={0.8} />
|
|
||||||
</marker>
|
|
||||||
))}
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<g className="pointer-events-auto">
|
<g className="pointer-events-auto">
|
||||||
{/* Dynamic arrow markers for custom-colored edges */}
|
|
||||||
{edges.map((edge, index) => {
|
|
||||||
if (!edge.color) return null;
|
|
||||||
return (
|
|
||||||
<marker
|
|
||||||
key={`arrow-custom-${index}`}
|
|
||||||
id={`arrow-custom-${index}`}
|
|
||||||
viewBox="0 0 10 10"
|
|
||||||
refX={9}
|
|
||||||
refY={5}
|
|
||||||
markerWidth={8}
|
|
||||||
markerHeight={8}
|
|
||||||
orient="auto-start-reverse"
|
|
||||||
>
|
|
||||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={edge.color} opacity={0.8} />
|
|
||||||
</marker>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Render edges */}
|
{/* Render edges */}
|
||||||
{edges.map((edge, index) => {
|
{edges.map((edge, index) => {
|
||||||
const fromTask = taskMap.get(edge.from);
|
const fromTask = taskMap.get(edge.from);
|
||||||
const toTask = taskMap.get(edge.to);
|
const toTask = taskMap.get(edge.to);
|
||||||
if (!fromTask || !toTask) return null;
|
if (!fromTask || !toTask) return null;
|
||||||
|
const isSelfLoopEdge = edge.from === edge.to;
|
||||||
|
|
||||||
// Build the current waypoints first so we can pass them into
|
// Build the current waypoints first so we can pass them into
|
||||||
// connection-point selection as an approach hint.
|
// connection-point selection as an approach hint.
|
||||||
let currentWaypoints: NodePosition[] = edge.waypoints
|
let currentWaypoints: NodePosition[] =
|
||||||
? [...edge.waypoints]
|
!isSelfLoopEdge && edge.waypoints ? [...edge.waypoints] : [];
|
||||||
: [];
|
|
||||||
if (
|
if (
|
||||||
|
!isSelfLoopEdge &&
|
||||||
activeDrag &&
|
activeDrag &&
|
||||||
activeDrag.edgeFrom === edge.from &&
|
activeDrag.edgeFrom === edge.from &&
|
||||||
activeDrag.edgeTo === edge.to &&
|
activeDrag.edgeTo === edge.to &&
|
||||||
@@ -985,26 +1080,21 @@ function WorkflowEdgesInner({
|
|||||||
|
|
||||||
const isSelected = edgeMatches(selectedEdge, edge);
|
const isSelected = edgeMatches(selectedEdge, edge);
|
||||||
|
|
||||||
// All points: start → waypoints → end
|
const selfLoopRoute =
|
||||||
const allPoints = [start, ...currentWaypoints, end];
|
|
||||||
|
|
||||||
const pathD =
|
|
||||||
selfLoop && currentWaypoints.length === 0
|
selfLoop && currentWaypoints.length === 0
|
||||||
? buildSelfLoopPath(start, end)
|
? buildSelfLoopRoute(fromTask, nodeWidth, nodeHeight)
|
||||||
: allPoints.length === 2
|
: null;
|
||||||
? buildCurvePath(start, end)
|
|
||||||
: buildSmoothPath(allPoints);
|
|
||||||
|
|
||||||
const color =
|
const color =
|
||||||
edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete;
|
edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete;
|
||||||
const dash = edge.lineStyle ? LINE_STYLE_DASH[edge.lineStyle] : "";
|
const dash = edge.lineStyle ? LINE_STYLE_DASH[edge.lineStyle] : "";
|
||||||
const arrowId = edge.color
|
const groupOpacity = isSelected ? 1 : 0.75;
|
||||||
? `arrow-custom-${index}`
|
|
||||||
: `arrow-${edge.type}`;
|
|
||||||
|
|
||||||
// Label position — evaluate t-parameter on the actual path
|
// Label position — evaluate t-parameter on the actual path
|
||||||
let labelPos: { x: number; y: number };
|
let labelPos: { x: number; y: number };
|
||||||
const isSelfLoopEdge = selfLoop && currentWaypoints.length === 0;
|
const usesDefaultSelfLoopRoute =
|
||||||
|
selfLoop && currentWaypoints.length === 0;
|
||||||
|
const allPoints = selfLoopRoute ?? [start, ...currentWaypoints, end];
|
||||||
if (
|
if (
|
||||||
activeDrag &&
|
activeDrag &&
|
||||||
activeDrag.type === "label" &&
|
activeDrag.type === "label" &&
|
||||||
@@ -1016,15 +1106,25 @@ function WorkflowEdgesInner({
|
|||||||
// During drag, dragPos is already snapped to the curve
|
// During drag, dragPos is already snapped to the curve
|
||||||
labelPos = dragPos;
|
labelPos = dragPos;
|
||||||
} else {
|
} else {
|
||||||
const t = edge.labelPosition ?? 0.5;
|
const t =
|
||||||
labelPos = evaluatePathAtT(allPoints, t, isSelfLoopEdge);
|
edge.labelPosition ?? (usesDefaultSelfLoopRoute ? 0.62 : 0.5);
|
||||||
|
labelPos = evaluatePathAtT(allPoints, t, usesDefaultSelfLoopRoute);
|
||||||
}
|
}
|
||||||
|
const arrowDirectionPoint = getArrowDirectionPoint(allPoints);
|
||||||
|
const arrowHead = buildArrowHeadPath(arrowDirectionPoint, end);
|
||||||
|
const pathD = buildTrimmedPath(
|
||||||
|
allPoints,
|
||||||
|
ARROW_LENGTH - ARROW_SHAFT_OVERLAP_PX,
|
||||||
|
);
|
||||||
|
|
||||||
const labelText = edge.label || "";
|
const labelText = edge.label || "";
|
||||||
const labelWidth = Math.max(labelText.length * 5.5 + 12, 48);
|
const labelWidth = Math.max(labelText.length * 5.5 + 12, 48);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={`edge-${index}-${edge.from}-${edge.to}`}>
|
<g
|
||||||
|
key={`edge-${index}-${edge.from}-${edge.to}`}
|
||||||
|
opacity={groupOpacity}
|
||||||
|
>
|
||||||
{/* Edge path */}
|
{/* Edge path */}
|
||||||
<path
|
<path
|
||||||
d={pathD}
|
d={pathD}
|
||||||
@@ -1032,9 +1132,12 @@ function WorkflowEdgesInner({
|
|||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={isSelected ? 2.5 : 2}
|
strokeWidth={isSelected ? 2.5 : 2}
|
||||||
strokeDasharray={dash}
|
strokeDasharray={dash}
|
||||||
markerEnd={`url(#${arrowId})`}
|
|
||||||
className="transition-opacity"
|
className="transition-opacity"
|
||||||
opacity={isSelected ? 1 : 0.75}
|
/>
|
||||||
|
<path
|
||||||
|
d={arrowHead.path}
|
||||||
|
fill={color}
|
||||||
|
className="pointer-events-none transition-opacity"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Selection glow for selected edge */}
|
{/* Selection glow for selected edge */}
|
||||||
@@ -1078,7 +1181,7 @@ function WorkflowEdgesInner({
|
|||||||
edge,
|
edge,
|
||||||
labelPos,
|
labelPos,
|
||||||
allPoints,
|
allPoints,
|
||||||
isSelfLoopEdge,
|
usesDefaultSelfLoopRoute,
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -1138,7 +1241,7 @@ function WorkflowEdgesInner({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* === Selected edge interactive elements === */}
|
{/* === Selected edge interactive elements === */}
|
||||||
{isSelected && (
|
{isSelected && !isSelfLoopEdge && (
|
||||||
<>
|
<>
|
||||||
{/* Waypoint handles */}
|
{/* Waypoint handles */}
|
||||||
{currentWaypoints.map((wp, wpIdx) => {
|
{currentWaypoints.map((wp, wpIdx) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user