feat(workflow): add floating directed edges

This commit is contained in:
Xin Wang
2026-08-04 09:22:36 +08:00
parent c3cb410c17
commit d068927b53
2 changed files with 116 additions and 7 deletions

View File

@@ -8,7 +8,10 @@ import {
BaseEdge,
EdgeLabelRenderer,
getBezierPath,
Position,
type EdgeProps,
type InternalNode,
useInternalNode,
} from "@xyflow/react";
import { Trash2 } from "lucide-react";
import { useContext } from "react";
@@ -16,8 +19,77 @@ import { useContext } from "react";
import { cn } from "@/lib/utils";
import { EdgeActionContext } from "./context";
type EdgeEndpoint = {
x: number;
y: number;
position: Position;
};
function getNodeCenter(node: InternalNode) {
const width = node.measured.width ?? node.width ?? 0;
const height = node.measured.height ?? node.height ?? 0;
return {
x: node.internals.positionAbsolute.x + width / 2,
y: node.internals.positionAbsolute.y + height / 2,
width,
height,
};
}
/**
* 计算一条从节点中心朝外的射线与节点矩形边界的交点。
* 这样边只依赖节点的位置和尺寸,不会被创建连接时使用的底部 Handle 限制。
*/
function getNodeBoundaryPoint(
node: InternalNode,
direction: { x: number; y: number },
fallbackPosition: Position,
): EdgeEndpoint | null {
const center = getNodeCenter(node);
if (!center.width || !center.height) return null;
const { x: dx } = direction;
let { y: dy } = direction;
if (dx === 0 && dy === 0) {
dy = fallbackPosition === Position.Top ? -1 : 1;
}
const halfWidth = center.width / 2;
const halfHeight = center.height / 2;
const normalizedX = dx / halfWidth;
const normalizedY = dy / halfHeight;
const scale = 1 / Math.max(Math.abs(normalizedX), Math.abs(normalizedY));
const hitsVerticalSide = Math.abs(normalizedX) > Math.abs(normalizedY);
if (hitsVerticalSide) {
// 避开圆角,让吸附点始终落在可见的直边上。
const cornerInset = Math.min(16, halfHeight);
return {
x: center.x + dx * scale,
y: Math.min(
center.y + halfHeight - cornerInset,
Math.max(center.y - halfHeight + cornerInset, center.y + dy * scale),
),
position: dx > 0 ? Position.Right : Position.Left,
};
}
const cornerInset = Math.min(16, halfWidth);
return {
x: Math.min(
center.x + halfWidth - cornerInset,
Math.max(center.x - halfWidth + cornerInset, center.x + dx * scale),
),
y: center.y + dy * scale,
position: dy > 0 ? Position.Bottom : Position.Top,
};
}
export function ConditionEdge({
id,
source,
target,
sourceX,
sourceY,
targetX,
@@ -26,16 +98,42 @@ export function ConditionEdge({
targetPosition,
data,
selected,
markerEnd,
}: EdgeProps) {
const actions = useContext(EdgeActionContext);
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
const sourceCenter = sourceNode ? getNodeCenter(sourceNode) : null;
const targetCenter = targetNode ? getNodeCenter(targetNode) : null;
const sourceEndpoint = sourceNode && sourceCenter && targetCenter
? getNodeBoundaryPoint(
sourceNode,
{
x: targetCenter.x - sourceCenter.x,
y: targetCenter.y - sourceCenter.y,
},
Position.Bottom,
)
: null;
const targetEndpoint = targetNode && targetCenter && sourceCenter
? getNodeBoundaryPoint(
targetNode,
{
x: sourceCenter.x - targetCenter.x,
y: sourceCenter.y - targetCenter.y,
},
Position.Top,
)
: null;
const [path, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
sourceX: sourceEndpoint?.x ?? sourceX,
sourceY: sourceEndpoint?.y ?? sourceY,
sourcePosition: sourceEndpoint?.position ?? sourcePosition,
targetX: targetEndpoint?.x ?? targetX,
targetY: targetEndpoint?.y ?? targetY,
targetPosition: targetEndpoint?.position ?? targetPosition,
curvature: 0.28,
});
@@ -55,6 +153,7 @@ export function ConditionEdge({
<BaseEdge
id={id}
path={path}
markerEnd={markerEnd}
style={{
stroke: selected ? "var(--primary)" : "var(--muted-soft)",
strokeWidth: selected ? 2.5 : 1.5,

View File

@@ -7,6 +7,7 @@ import {
type Connection,
Controls,
type Edge,
MarkerType,
type Node,
type NodeChange,
type OnConnectEnd,
@@ -588,7 +589,16 @@ export function WorkflowCanvas({
}}
fitView
proOptions={{ hideAttribution: true }}
defaultEdgeOptions={{ type: "condition", animated: true }}
defaultEdgeOptions={{
type: "condition",
animated: true,
markerEnd: {
type: MarkerType.ArrowClosed,
color: "var(--muted-soft)",
width: 18,
height: 18,
},
}}
>
<Background
variant={BackgroundVariant.Dots}