@ichigo_san/graphing
Version:
A lightweight UML-style diagram editor built with React Flow and Tailwind CSS
439 lines (409 loc) • 16.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _react = _interopRequireWildcard(require("react"));
var _reactflow = require("reactflow");
var _routingPipeline = require("../../utils/routingPipeline.js");
var _edgeHandlers = require("../../utils/edgeHandlers.js");
var _edgeTypes = require("../../utils/edgeTypes.js");
var _gridSnapping = require("../../utils/gridSnapping.js");
var _coordinateTransforms = require("../../utils/coordinateTransforms.js");
var _anchorPoint = require("../../utils/anchorPoint.js");
var _jetty = require("../../utils/jetty.js");
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
/**
* Optimized Orthogonal Edge - Draw.io Style Implementation
*
* Features:
* ✅ Uses our new routing infrastructure (Modules A, B, C)
* ✅ Handler-based editing system (Module D)
* ✅ Performance optimized with caching and debouncing
* ✅ Draw.io-style connection points and jetty system
* ✅ Virtual handles for segment dragging
* ✅ Collision avoidance and route optimization
* ✅ Grid snapping and orthogonal constraints
* ✅ Live preview during editing
*/
// Performance optimization constants
const DEBOUNCE_DELAY = 100;
const CACHE_EXPIRY = 30000; // 30 seconds
const MIN_SEGMENT_LENGTH = 30;
const OptimizedOrthogonalEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerStart,
markerEnd,
data,
selected
}) => {
const {
screenToFlowPosition,
setEdges,
getEdges,
getNodes,
getViewport
} = (0, _reactflow.useReactFlow)();
// State management
const [hoveredElement, setHoveredElement] = (0, _react.useState)(null);
const [isProcessing, setIsProcessing] = (0, _react.useState)(false);
const [routeCache, setRouteCache] = (0, _react.useState)(new Map());
const [lastRouteUpdate, setLastRouteUpdate] = (0, _react.useState)(0);
// Refs for performance optimization
const debounceTimeoutRef = (0, _react.useRef)(null);
const handlerRef = (0, _react.useRef)(null);
const lastNodesRef = (0, _react.useRef)(null);
// Get current nodes and viewport
const nodes = getNodes();
const viewport = getViewport();
const transform = {
x: viewport.x,
y: viewport.y,
k: viewport.zoom
};
// Grid configuration
const gridConfig = (0, _react.useMemo)(() => ({
..._gridSnapping.DEFAULT_GRID_CONFIG,
tolerance: _gridSnapping.DEFAULT_GRID_CONFIG.tolerance * viewport.zoom
}), [viewport.zoom]);
// Get source and target nodes
const sourceNode = (0, _react.useMemo)(() => nodes.find(n => n.id === (data === null || data === void 0 ? void 0 : data.source)), [nodes, data === null || data === void 0 ? void 0 : data.source]);
const targetNode = (0, _react.useMemo)(() => nodes.find(n => n.id === (data === null || data === void 0 ? void 0 : data.target)), [nodes, data === null || data === void 0 ? void 0 : data.target]);
// Determine edge style
const edgeStyle = (0, _react.useMemo)(() => (data === null || data === void 0 ? void 0 : data.style) || _edgeTypes.EDGE_STYLES.ORTHOGONAL, [data === null || data === void 0 ? void 0 : data.style]);
// Create handler context
const handlerContext = (0, _react.useMemo)(() => (0, _edgeHandlers.createHandlerContext)({
id,
sourceX,
sourceY,
targetX,
targetY,
data,
selected
}, nodes, transform, gridConfig, () => {},
// updateEdge function
setEdges), [id, sourceX, sourceY, targetX, targetY, data, selected, nodes, transform, gridConfig, setEdges]);
// Initialize handler
(0, _react.useEffect)(() => {
handlerRef.current = (0, _edgeHandlers.getEdgeHandler)(edgeStyle, handlerContext);
}, [edgeStyle, handlerContext]);
// Calculate route using our optimized infrastructure
const calculateRoute = (0, _react.useCallback)(async () => {
if (!sourceNode || !targetNode) return null;
// Check cache first
const cacheKey = `${sourceNode.id}-${targetNode.id}-${sourceNode.position.x}-${sourceNode.position.y}-${targetNode.position.x}-${targetNode.position.y}`;
const cached = routeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_EXPIRY) {
return cached.route;
}
setIsProcessing(true);
try {
// Use our routing pipeline
const routingContext = {
sourceNode,
targetNode,
sourcePort: data === null || data === void 0 ? void 0 : data.sourcePort,
targetPort: data === null || data === void 0 ? void 0 : data.targetPort,
transform,
gridConfig,
obstacles: nodes.filter(n => n.id !== sourceNode.id && n.id !== targetNode.id),
avoidObstacles: true
};
const routeResult = (0, _routingPipeline.routeOrthogonalEdge)(routingContext);
if (routeResult.success) {
// Cache the result
setRouteCache(prev => new Map(prev).set(cacheKey, {
route: routeResult,
timestamp: Date.now()
}));
return routeResult;
}
} catch (error) {
console.warn('Route calculation failed:', error);
} finally {
setIsProcessing(false);
}
return null;
}, [sourceNode, targetNode, data === null || data === void 0 ? void 0 : data.sourcePort, data === null || data === void 0 ? void 0 : data.targetPort, transform, gridConfig, nodes, routeCache]);
// Debounced route update
const updateRoute = (0, _react.useCallback)(() => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(async () => {
const routeResult = await calculateRoute();
if (routeResult) {
setLastRouteUpdate(Date.now());
// Update edge data with new route
setEdges(edges => edges.map(edge => {
if (edge.id === id) {
return {
...edge,
data: {
...edge.data,
waypoints: routeResult.modelPoints,
lastRouteUpdate: Date.now(),
routeMetadata: routeResult.metadata
}
};
}
return edge;
}));
}
}, DEBOUNCE_DELAY);
}, [calculateRoute, id, setEdges]);
// Check if nodes have moved significantly
const checkNodeMovement = (0, _react.useCallback)(() => {
if (!lastNodesRef.current) {
lastNodesRef.current = nodes;
return false;
}
const hasMoved = nodes.some(node => {
const lastNode = lastNodesRef.current.find(n => n.id === node.id);
if (!lastNode) return true;
const dx = Math.abs(node.position.x - lastNode.position.x);
const dy = Math.abs(node.position.y - lastNode.position.y);
return dx > 5 || dy > 5;
});
lastNodesRef.current = nodes;
return hasMoved;
}, [nodes]);
// Update route when nodes move
(0, _react.useEffect)(() => {
if (checkNodeMovement()) {
updateRoute();
}
}, [nodes, checkNodeMovement, updateRoute]);
// Get current waypoints (from data or calculated)
const waypoints = (0, _react.useMemo)(() => {
return (data === null || data === void 0 ? void 0 : data.waypoints) || [];
}, [data === null || data === void 0 ? void 0 : data.waypoints]);
// Calculate connection points with jetty
const connectionPoints = (0, _react.useMemo)(() => {
if (!sourceNode || !targetNode) {
return {
sourcePoint: {
x: sourceX,
y: sourceY
},
targetPoint: {
x: targetX,
y: targetY
}
};
}
// Get anchor points
const sourceAnchor = (0, _anchorPoint.anchorPoint)(sourceNode, data === null || data === void 0 ? void 0 : data.sourcePort, {
x: targetNode.position.x,
y: targetNode.position.y
});
const targetAnchor = (0, _anchorPoint.anchorPoint)(targetNode, data === null || data === void 0 ? void 0 : data.targetPort, {
x: sourceNode.position.x,
y: sourceNode.position.y
});
if (!sourceAnchor || !targetAnchor) {
return {
sourcePoint: {
x: sourceX,
y: sourceY
},
targetPoint: {
x: targetX,
y: targetY
}
};
}
// Apply jetty system
const jettyResult = (0, _jetty.jettyEdgeEndpoints)(sourceAnchor.point, sourceAnchor.side, targetAnchor.point, targetAnchor.side);
return {
sourcePoint: jettyResult.sourcePoint,
targetPoint: jettyResult.targetPoint,
sourceSide: sourceAnchor.side,
targetSide: targetAnchor.side
};
}, [sourceNode, targetNode, sourceX, sourceY, targetX, targetY, data === null || data === void 0 ? void 0 : data.sourcePort, data === null || data === void 0 ? void 0 : data.targetPort]);
// Generate all points for the path
const allPoints = (0, _react.useMemo)(() => {
return [connectionPoints.sourcePoint, ...waypoints, connectionPoints.targetPoint];
}, [connectionPoints.sourcePoint, waypoints, connectionPoints.targetPoint]);
// Generate SVG path
const path = (0, _react.useMemo)(() => {
if (allPoints.length < 2) return '';
let pathString = `M ${allPoints[0].x},${allPoints[0].y}`;
for (let i = 1; i < allPoints.length; i++) {
const prev = allPoints[i - 1];
const curr = allPoints[i];
// Check if segment is orthogonal
const isOrthogonal = Math.abs(prev.x - curr.x) < 5 || Math.abs(prev.y - curr.y) < 5;
if (isOrthogonal) {
pathString += ` L ${curr.x},${curr.y}`;
} else {
// Smooth curve for non-orthogonal segments
const midX = (prev.x + curr.x) / 2;
const midY = (prev.y + curr.y) / 2;
pathString += ` Q ${midX},${midY} ${curr.x},${curr.y}`;
}
}
return pathString;
}, [allPoints]);
// Calculate label position
const labelPosition = (0, _react.useMemo)(() => {
if (allPoints.length < 2) return {
x: sourceX,
y: sourceY
};
// Find the longest segment for label placement
let maxLength = 0;
let bestSegment = 0;
for (let i = 0; i < allPoints.length - 1; i++) {
const p1 = allPoints[i];
const p2 = allPoints[i + 1];
const length = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
if (length > maxLength) {
maxLength = length;
bestSegment = i;
}
}
const p1 = allPoints[bestSegment];
const p2 = allPoints[bestSegment + 1];
return {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
}, [allPoints, sourceX, sourceY]);
// Event handlers
const handleMouseDown = (0, _react.useCallback)((event, elementType, index) => {
if (handlerRef.current) {
handlerRef.current.onMouseDown(event, index);
}
}, []);
const handleMouseMove = (0, _react.useCallback)(event => {
if (handlerRef.current) {
handlerRef.current.onMouseMove(event);
}
}, []);
const handleMouseUp = (0, _react.useCallback)(() => {
if (handlerRef.current) {
handlerRef.current.onMouseUp();
}
}, []);
const handleDoubleClick = (0, _react.useCallback)((event, index) => {
if (handlerRef.current) {
handlerRef.current.onDoubleClick(event, index);
}
}, []);
// Global mouse event listeners
(0, _react.useEffect)(() => {
const handleGlobalMouseMove = event => {
handleMouseMove(event);
};
const handleGlobalMouseUp = () => {
handleMouseUp();
};
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => {
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
};
}, [handleMouseMove, handleMouseUp]);
// Render interactive handles
const renderHandles = (0, _react.useCallback)(() => {
if (!handlerRef.current) return null;
return handlerRef.current.renderHandles(waypoints, selected, hoveredElement === 'edge');
}, [waypoints, selected, hoveredElement]);
// Render virtual segment handles
const renderVirtualHandles = (0, _react.useCallback)(() => {
if (!selected && hoveredElement !== 'edge') return null;
return allPoints.slice(0, -1).map((p1, segmentIndex) => {
const p2 = allPoints[segmentIndex + 1];
const segmentLength = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
// Only show handles for meaningful segments
if (segmentLength < MIN_SEGMENT_LENGTH) return null;
const isHorizontal = Math.abs(p1.y - p2.y) < 5;
const isVertical = Math.abs(p1.x - p2.x) < 5;
return /*#__PURE__*/_react.default.createElement("g", {
key: `virtual-handle-${segmentIndex}`
}, /*#__PURE__*/_react.default.createElement("line", {
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y,
stroke: "transparent",
strokeWidth: 20,
style: {
cursor: isHorizontal ? 'ns-resize' : isVertical ? 'ew-resize' : 'move'
},
onMouseDown: e => handleMouseDown(e, 'segment', segmentIndex),
onMouseEnter: () => setHoveredElement('segment'),
onMouseLeave: () => setHoveredElement(null)
}), /*#__PURE__*/_react.default.createElement("line", {
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y,
stroke: hoveredElement === 'segment' ? "rgba(59, 130, 246, 0.6)" : "transparent",
strokeWidth: 4,
strokeDasharray: "8,4",
style: {
pointerEvents: 'none'
}
}));
});
}, [allPoints, selected, hoveredElement, handleMouseDown]);
return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement(_reactflow.BaseEdge, {
id: id,
path: path,
style: {
...style,
opacity: isProcessing ? 0.7 : 1,
transition: 'opacity 0.2s ease'
},
markerStart: markerStart,
markerEnd: markerEnd,
onMouseEnter: () => setHoveredElement('edge'),
onMouseLeave: () => setHoveredElement(null)
}), isProcessing && /*#__PURE__*/_react.default.createElement("g", null, /*#__PURE__*/_react.default.createElement("circle", {
cx: labelPosition.x,
cy: labelPosition.y,
r: 8,
fill: "rgba(59, 130, 246, 0.1)",
stroke: "rgb(59, 130, 246)",
strokeWidth: 2,
opacity: 0.8
}, /*#__PURE__*/_react.default.createElement("animateTransform", {
attributeName: "transform",
type: "rotate",
values: `0 ${labelPosition.x} ${labelPosition.y};360 ${labelPosition.x} ${labelPosition.y}`,
dur: "1s",
repeatCount: "indefinite"
}))), (data === null || data === void 0 ? void 0 : data.label) && /*#__PURE__*/_react.default.createElement(_reactflow.EdgeLabelRenderer, null, /*#__PURE__*/_react.default.createElement("div", {
style: {
position: 'absolute',
left: labelPosition.x,
top: labelPosition.y,
transform: 'translate(-50%, -50%)',
fontSize: 12,
fontWeight: 500,
color: '#333',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
padding: '2px 6px',
borderRadius: '4px',
border: '1px solid rgba(0, 0, 0, 0.1)',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
pointerEvents: 'all',
whiteSpace: 'nowrap',
zIndex: 10,
backdropFilter: 'blur(4px)'
},
className: "nodrag nopan edge-label"
}, data.label)), renderVirtualHandles(), renderHandles());
};
var _default = exports.default = OptimizedOrthogonalEdge;