feat(workflow): add floating directed edges
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user