260 lines
7.1 KiB
TypeScript
260 lines
7.1 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
type PointerEvent as ReactPointerEvent,
|
|
} from "react";
|
|
import {
|
|
BaseEdge,
|
|
useReactFlow,
|
|
type EdgeProps,
|
|
type EdgeTypes,
|
|
} from "@xyflow/react";
|
|
import {
|
|
createOrthogonalSegmentDetour,
|
|
edgeSegmentAxis,
|
|
moveOrthogonalCorner,
|
|
moveOrthogonalSegment,
|
|
orthogonalEdgePoints,
|
|
orthogonalPolylinePath,
|
|
type EdgeRouteData,
|
|
type EdgeRoutePoint,
|
|
} from "./edgeRouting";
|
|
|
|
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
|
|
|
|
type RouteDrag = {
|
|
basePoints: EdgeRoutePoint[];
|
|
cleanup: () => void;
|
|
kind: "corner" | "detour" | "segment";
|
|
pointerId: number;
|
|
routeIndex: number;
|
|
startPointer: EdgeRoutePoint;
|
|
};
|
|
|
|
function longestSegmentIndex(points: EdgeRoutePoint[]) {
|
|
let bestIndex = 0;
|
|
let bestLength = -1;
|
|
for (let index = 0; index < points.length - 1; index += 1) {
|
|
const length = Math.hypot(
|
|
points[index + 1].x - points[index].x,
|
|
points[index + 1].y - points[index].y,
|
|
);
|
|
if (length > bestLength) {
|
|
bestIndex = index;
|
|
bestLength = length;
|
|
}
|
|
}
|
|
return bestIndex;
|
|
}
|
|
|
|
function RoutedEdge({
|
|
data,
|
|
id,
|
|
interactionWidth,
|
|
markerEnd,
|
|
markerStart,
|
|
selected,
|
|
sourcePosition,
|
|
sourceX,
|
|
sourceY,
|
|
style,
|
|
targetPosition,
|
|
targetX,
|
|
targetY,
|
|
}: EdgeProps) {
|
|
const reactFlow = useReactFlow();
|
|
const dragRef = useRef<RouteDrag | null>(null);
|
|
const edgeData = (data ?? {}) as EdgeRouteData;
|
|
useEffect(
|
|
() => () => {
|
|
dragRef.current?.cleanup();
|
|
dragRef.current = null;
|
|
},
|
|
[],
|
|
);
|
|
const points = orthogonalEdgePoints({
|
|
routePoints: edgeData.routePoints,
|
|
sourcePosition,
|
|
sourceX,
|
|
sourceY,
|
|
targetPosition,
|
|
targetX,
|
|
targetY,
|
|
});
|
|
const edgePath = orthogonalPolylinePath(points, edgeData.crossingJumps);
|
|
|
|
const beginRouteDrag = (
|
|
event: ReactPointerEvent<SVGElement>,
|
|
basePoints: EdgeRoutePoint[],
|
|
kind: RouteDrag["kind"],
|
|
routeIndex: number,
|
|
) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
dragRef.current?.cleanup();
|
|
|
|
const drag: RouteDrag = {
|
|
basePoints: basePoints.map((point) => ({ ...point })),
|
|
cleanup: () => undefined,
|
|
kind,
|
|
pointerId: event.pointerId,
|
|
routeIndex,
|
|
startPointer: reactFlow.screenToFlowPosition(
|
|
{ x: event.clientX, y: event.clientY },
|
|
{ snapToGrid: false },
|
|
),
|
|
};
|
|
const moveRoute = (pointerEvent: PointerEvent) => {
|
|
if (
|
|
dragRef.current !== drag ||
|
|
pointerEvent.pointerId !== drag.pointerId
|
|
) {
|
|
return;
|
|
}
|
|
pointerEvent.preventDefault();
|
|
pointerEvent.stopPropagation();
|
|
const pointer = reactFlow.screenToFlowPosition(
|
|
{ x: pointerEvent.clientX, y: pointerEvent.clientY },
|
|
{ snapToGrid: false },
|
|
);
|
|
const delta = {
|
|
x: pointer.x - drag.startPointer.x,
|
|
y: pointer.y - drag.startPointer.y,
|
|
};
|
|
const nextPoints =
|
|
drag.kind === "corner"
|
|
? moveOrthogonalCorner(drag.basePoints, drag.routeIndex, delta)
|
|
: drag.kind === "detour"
|
|
? createOrthogonalSegmentDetour(
|
|
drag.basePoints,
|
|
drag.routeIndex,
|
|
delta,
|
|
)
|
|
: moveOrthogonalSegment(
|
|
drag.basePoints,
|
|
drag.routeIndex,
|
|
delta,
|
|
);
|
|
edgeData.onRoutePointsChange?.(id, nextPoints.slice(1, -1));
|
|
};
|
|
const finishRoute = (pointerEvent: PointerEvent) => {
|
|
if (
|
|
dragRef.current !== drag ||
|
|
pointerEvent.pointerId !== drag.pointerId
|
|
) {
|
|
return;
|
|
}
|
|
pointerEvent.preventDefault();
|
|
pointerEvent.stopPropagation();
|
|
drag.cleanup();
|
|
dragRef.current = null;
|
|
};
|
|
drag.cleanup = () => {
|
|
window.removeEventListener("pointermove", moveRoute);
|
|
window.removeEventListener("pointerup", finishRoute);
|
|
window.removeEventListener("pointercancel", finishRoute);
|
|
};
|
|
dragRef.current = drag;
|
|
window.addEventListener("pointermove", moveRoute, { passive: false });
|
|
window.addEventListener("pointerup", finishRoute);
|
|
window.addEventListener("pointercancel", finishRoute);
|
|
edgeData.onRouteEditStart?.(id);
|
|
};
|
|
|
|
const editable = selected && edgeData.editable === true;
|
|
const internalSegments = points
|
|
.slice(0, -1)
|
|
.map((start, index) => ({ end: points[index + 1], index, start }))
|
|
.filter(({ index }) => index > 0 && index < points.length - 2);
|
|
const internalCorners = points
|
|
.map((point, index) => ({ index, point }))
|
|
.filter(({ index }) => index > 0 && index < points.length - 1);
|
|
const fallbackIndex = longestSegmentIndex(points);
|
|
const fallbackStart = points[fallbackIndex];
|
|
const fallbackEnd = points[fallbackIndex + 1];
|
|
|
|
return (
|
|
<>
|
|
<BaseEdge
|
|
id={id}
|
|
interactionWidth={interactionWidth}
|
|
markerEnd={markerEnd}
|
|
markerStart={markerStart}
|
|
path={edgePath}
|
|
style={style}
|
|
/>
|
|
{editable
|
|
? internalSegments.map(({ end, index, start }) => (
|
|
<g
|
|
className="manual-edge-segment-control"
|
|
key={`${id}-segment-${index}`}
|
|
>
|
|
<line
|
|
aria-label={`调整连接线段 ${index}`}
|
|
className={`manual-edge-segment-handle ${edgeSegmentAxis(start, end)}`}
|
|
data-edge-id={id}
|
|
data-segment-index={index}
|
|
onPointerDown={(event) =>
|
|
beginRouteDrag(event, points, "segment", index)
|
|
}
|
|
x1={start.x}
|
|
x2={end.x}
|
|
y1={start.y}
|
|
y2={end.y}
|
|
/>
|
|
<line
|
|
aria-hidden="true"
|
|
className="manual-edge-segment-guide"
|
|
x1={start.x}
|
|
x2={end.x}
|
|
y1={start.y}
|
|
y2={end.y}
|
|
/>
|
|
</g>
|
|
))
|
|
: null}
|
|
{editable
|
|
? internalCorners.map(({ index, point }) => (
|
|
<circle
|
|
aria-label={`双向调整连接线拐点 ${index}`}
|
|
className="manual-edge-corner-handle"
|
|
cx={point.x}
|
|
cy={point.y}
|
|
data-corner-index={index}
|
|
data-edge-id={id}
|
|
key={`${id}-corner-${index}`}
|
|
onPointerDown={(event) =>
|
|
beginRouteDrag(event, points, "corner", index)
|
|
}
|
|
r={4.5}
|
|
/>
|
|
))
|
|
: null}
|
|
{editable && internalCorners.length === 0 && fallbackEnd ? (
|
|
<circle
|
|
aria-label="双向调整连接线路由"
|
|
className="manual-edge-route-handle"
|
|
cx={(fallbackStart.x + fallbackEnd.x) / 2}
|
|
cy={(fallbackStart.y + fallbackEnd.y) / 2}
|
|
data-edge-id={id}
|
|
onPointerDown={(event) =>
|
|
beginRouteDrag(event, points, "detour", fallbackIndex)
|
|
}
|
|
r={5}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function ContactAwareEdge(props: EdgeProps) {
|
|
if (props.data?.isContactEdge === true) {
|
|
return null;
|
|
}
|
|
return <RoutedEdge {...props} />;
|
|
}
|
|
|
|
export const contactAwareEdgeTypes: EdgeTypes = {
|
|
[CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge,
|
|
};
|