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, BaseEdge,
EdgeLabelRenderer, EdgeLabelRenderer,
getBezierPath, getBezierPath,
Position,
type EdgeProps, type EdgeProps,
type InternalNode,
useInternalNode,
} from "@xyflow/react"; } from "@xyflow/react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { useContext } from "react"; import { useContext } from "react";
@@ -16,8 +19,77 @@ import { useContext } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { EdgeActionContext } from "./context"; 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({ export function ConditionEdge({
id, id,
source,
target,
sourceX, sourceX,
sourceY, sourceY,
targetX, targetX,
@@ -26,16 +98,42 @@ export function ConditionEdge({
targetPosition, targetPosition,
data, data,
selected, selected,
markerEnd,
}: EdgeProps) { }: EdgeProps) {
const actions = useContext(EdgeActionContext); 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({ const [path, labelX, labelY] = getBezierPath({
sourceX, sourceX: sourceEndpoint?.x ?? sourceX,
sourceY, sourceY: sourceEndpoint?.y ?? sourceY,
sourcePosition, sourcePosition: sourceEndpoint?.position ?? sourcePosition,
targetX, targetX: targetEndpoint?.x ?? targetX,
targetY, targetY: targetEndpoint?.y ?? targetY,
targetPosition, targetPosition: targetEndpoint?.position ?? targetPosition,
curvature: 0.28, curvature: 0.28,
}); });
@@ -55,6 +153,7 @@ export function ConditionEdge({
<BaseEdge <BaseEdge
id={id} id={id}
path={path} path={path}
markerEnd={markerEnd}
style={{ style={{
stroke: selected ? "var(--primary)" : "var(--muted-soft)", stroke: selected ? "var(--primary)" : "var(--muted-soft)",
strokeWidth: selected ? 2.5 : 1.5, strokeWidth: selected ? 2.5 : 1.5,

View File

@@ -7,6 +7,7 @@ import {
type Connection, type Connection,
Controls, Controls,
type Edge, type Edge,
MarkerType,
type Node, type Node,
type NodeChange, type NodeChange,
type OnConnectEnd, type OnConnectEnd,
@@ -588,7 +589,16 @@ export function WorkflowCanvas({
}} }}
fitView fitView
proOptions={{ hideAttribution: true }} 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 <Background
variant={BackgroundVariant.Dots} variant={BackgroundVariant.Dots}