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