UNPKG

@ichigo_san/graphing

Version:

A lightweight UML-style diagram editor built with React Flow and Tailwind CSS

505 lines (479 loc) 15.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createHandlerContext = createHandlerContext; exports.getEdgeHandler = getEdgeHandler; exports.getHitTestPriority = getHitTestPriority; var _edgeTypes = require("./edgeTypes.js"); var _routingPipeline = require("./routingPipeline.js"); var _gridSnapping = require("./gridSnapping.js"); var _coordinateTransforms = require("./coordinateTransforms.js"); // Module D - Edge Handlers & Editing /** * @typedef {Object} EdgeHandlerContext * @property {string} edgeId - Edge identifier * @property {Object} edge - Edge data * @property {Array<Object>} nodes - All nodes in the diagram * @property {Object} transform - Current view transform * @property {Object} gridConfig - Grid configuration * @property {Function} updateEdge - Function to update edge data * @property {Function} setEdges - Function to update all edges */ /** * @typedef {Object} EdgeHandler * @property {Function} onMouseDown - Handle mouse down events * @property {Function} onMouseMove - Handle mouse move events * @property {Function} onMouseUp - Handle mouse up events * @property {Function} onDoubleClick - Handle double click events * @property {Function} getHitTestPriority - Get hit test priority * @property {Function} renderHandles - Render interactive handles */ /** * Base handler class with common functionality */ class BaseEdgeHandler { constructor(context) { this.context = context; this.isDragging = false; this.dragStartPoint = null; this.originalEdgeData = null; this.livePreview = null; } /** * Get hit test priority for this handler */ getHitTestPriority() { return 1; // Default priority } /** * Validate orthogonality constraint */ validateOrthogonality(points) { if (points.length < 2) return true; for (let i = 1; i < points.length; i++) { const prev = points[i - 1]; const curr = points[i]; const isHorizontal = Math.abs(prev.y - curr.y) < 5; const isVertical = Math.abs(prev.x - curr.x) < 5; if (!isHorizontal && !isVertical) { return false; } } return true; } /** * Validate minimum segment length */ validateMinSegmentLength(points, minLength = 30) { if (points.length < 2) return true; for (let i = 1; i < points.length; i++) { const prev = points[i - 1]; const curr = points[i]; const length = Math.sqrt(Math.pow(curr.x - prev.x, 2) + Math.pow(curr.y - prev.y, 2)); if (length < minLength) { return false; } } return true; } /** * Apply grid snapping to points */ snapPointsToGrid(points, gridConfig) { return points.map(point => (0, _gridSnapping.snapPoint)(point, gridConfig)); } /** * Create routing context for edge updates */ createRoutingContext(sourceNode, targetNode, transform, gridConfig) { var _this$context$edge$da, _this$context$edge$da2; return { sourceNode, targetNode, sourcePort: (_this$context$edge$da = this.context.edge.data) === null || _this$context$edge$da === void 0 ? void 0 : _this$context$edge$da.sourcePort, targetPort: (_this$context$edge$da2 = this.context.edge.data) === null || _this$context$edge$da2 === void 0 ? void 0 : _this$context$edge$da2.targetPort, transform, gridConfig, obstacles: this.context.nodes.filter(n => n.id !== sourceNode.id && n.id !== targetNode.id), avoidObstacles: true }; } /** * Update edge with new route */ updateEdgeRoute(newPoints, metadata = {}) { const { edgeId, updateEdge, setEdges } = this.context; setEdges(edges => edges.map(edge => { if (edge.id === edgeId) { return { ...edge, data: { ...edge.data, waypoints: newPoints, lastUpdated: Date.now(), ...metadata } }; } return edge; })); } } /** * Segment Handler - For orthogonal edges with multiple segments * Allows dragging individual segments while maintaining orthogonality */ class SegmentHandler extends BaseEdgeHandler { constructor(context) { super(context); this.draggedSegmentIndex = null; this.axisLock = null; // 'horizontal' or 'vertical' } getHitTestPriority() { return 3; // High priority for segment handles } onMouseDown(event, segmentIndex) { var _this$context$edge$da3; this.isDragging = true; this.draggedSegmentIndex = segmentIndex; this.dragStartPoint = { x: event.clientX, y: event.clientY }; // Store original edge data for undo this.originalEdgeData = { ...this.context.edge.data }; // Determine axis lock based on segment orientation const waypoints = ((_this$context$edge$da3 = this.context.edge.data) === null || _this$context$edge$da3 === void 0 ? void 0 : _this$context$edge$da3.waypoints) || []; if (segmentIndex < waypoints.length - 1) { const p1 = waypoints[segmentIndex]; const p2 = waypoints[segmentIndex + 1]; this.axisLock = Math.abs(p1.y - p2.y) < 5 ? 'horizontal' : 'vertical'; } event.preventDefault(); event.stopPropagation(); } onMouseMove(event) { var _this$context$edge$da4; if (!this.isDragging || this.draggedSegmentIndex === null) return; const deltaX = event.clientX - this.dragStartPoint.x; const deltaY = event.clientY - this.dragStartPoint.y; // Apply axis lock let adjustedDeltaX = deltaX; let adjustedDeltaY = deltaY; if (this.axisLock === 'horizontal') { adjustedDeltaY = 0; } else if (this.axisLock === 'vertical') { adjustedDeltaX = 0; } // Transform screen coordinates to model coordinates const transform = this.context.transform; const modelDelta = (0, _coordinateTransforms.viewToModel)({ x: adjustedDeltaX, y: adjustedDeltaY }, transform); // Update waypoints with live preview const waypoints = [...(((_this$context$edge$da4 = this.context.edge.data) === null || _this$context$edge$da4 === void 0 ? void 0 : _this$context$edge$da4.waypoints) || [])]; const segmentIndex = this.draggedSegmentIndex; if (segmentIndex < waypoints.length) { waypoints[segmentIndex] = { x: waypoints[segmentIndex].x + modelDelta.x, y: waypoints[segmentIndex].y + modelDelta.y }; } // Apply constraints if (this.validateOrthogonality(waypoints) && this.validateMinSegmentLength(waypoints)) { const snappedWaypoints = this.snapPointsToGrid(waypoints, this.context.gridConfig); this.livePreview = snappedWaypoints; // Update edge with live preview this.updateEdgeRoute(snappedWaypoints, { isLivePreview: true, draggedSegment: segmentIndex }); } } onMouseUp() { if (!this.isDragging) return; this.isDragging = false; this.draggedSegmentIndex = null; this.axisLock = null; // Commit the final route if (this.livePreview) { this.updateEdgeRoute(this.livePreview, { isLivePreview: false, lastEdit: 'segment_drag' }); this.livePreview = null; } this.dragStartPoint = null; this.originalEdgeData = null; } onDoubleClick(event, segmentIndex) { var _this$context$edge$da5; // Remove waypoint on double click const waypoints = [...(((_this$context$edge$da5 = this.context.edge.data) === null || _this$context$edge$da5 === void 0 ? void 0 : _this$context$edge$da5.waypoints) || [])]; if (segmentIndex >= 0 && segmentIndex < waypoints.length) { waypoints.splice(segmentIndex, 1); // Validate and update if (this.validateOrthogonality(waypoints) && this.validateMinSegmentLength(waypoints)) { const snappedWaypoints = this.snapPointsToGrid(waypoints, this.context.gridConfig); this.updateEdgeRoute(snappedWaypoints, { lastEdit: 'waypoint_removed', removedIndex: segmentIndex }); } } } renderHandles(waypoints, isSelected, isHovered) { if (!isSelected && !isHovered) return null; return waypoints.map((waypoint, index) => /*#__PURE__*/React.createElement("g", { key: `segment-handle-${index}` }, /*#__PURE__*/React.createElement("circle", { cx: waypoint.x, cy: waypoint.y, r: 8, fill: "transparent", style: { cursor: 'move' }, onMouseDown: e => this.onMouseDown(e, index), onDoubleClick: e => this.onDoubleClick(e, index) }), /*#__PURE__*/React.createElement("circle", { cx: waypoint.x, cy: waypoint.y, r: isHovered ? 6 : 4, fill: "rgb(59, 130, 246)", stroke: "white", strokeWidth: 2, style: { pointerEvents: 'none' } }), /*#__PURE__*/React.createElement("circle", { cx: waypoint.x, cy: waypoint.y, r: 2, fill: "white", style: { pointerEvents: 'none' } }))); } } /** * Elbow Handler - For single-bend orthogonal edges * Maintains L-shape routing with single waypoint */ class ElbowHandler extends BaseEdgeHandler { constructor(context) { super(context); this.draggedWaypointIndex = null; } getHitTestPriority() { return 2; // Medium priority } onMouseDown(event, waypointIndex) { this.isDragging = true; this.draggedWaypointIndex = waypointIndex; this.dragStartPoint = { x: event.clientX, y: event.clientY }; this.originalEdgeData = { ...this.context.edge.data }; event.preventDefault(); event.stopPropagation(); } onMouseMove(event) { var _this$context$edge$da6; if (!this.isDragging || this.draggedWaypointIndex === null) return; const deltaX = event.clientX - this.dragStartPoint.x; const deltaY = event.clientY - this.dragStartPoint.y; const transform = this.context.transform; const modelDelta = (0, _coordinateTransforms.viewToModel)({ x: deltaX, y: deltaY }, transform); const waypoints = [...(((_this$context$edge$da6 = this.context.edge.data) === null || _this$context$edge$da6 === void 0 ? void 0 : _this$context$edge$da6.waypoints) || [])]; const waypointIndex = this.draggedWaypointIndex; if (waypointIndex < waypoints.length) { waypoints[waypointIndex] = { x: waypoints[waypointIndex].x + modelDelta.x, y: waypoints[waypointIndex].y + modelDelta.y }; // Ensure L-shape constraint const sourceNode = this.context.nodes.find(n => n.id === this.context.edge.source); const targetNode = this.context.nodes.find(n => n.id === this.context.edge.target); if (sourceNode && targetNode) { const routingContext = this.createRoutingContext(sourceNode, targetNode, transform, this.context.gridConfig); const routeResult = (0, _routingPipeline.routeOrthogonalEdge)(routingContext); if (routeResult.success) { this.livePreview = routeResult.modelPoints; this.updateEdgeRoute(routeResult.modelPoints, { isLivePreview: true, draggedWaypoint: waypointIndex }); } } } } onMouseUp() { if (!this.isDragging) return; this.isDragging = false; this.draggedWaypointIndex = null; if (this.livePreview) { this.updateEdgeRoute(this.livePreview, { isLivePreview: false, lastEdit: 'elbow_drag' }); this.livePreview = null; } this.dragStartPoint = null; this.originalEdgeData = null; } onDoubleClick(event, waypointIndex) { // Remove waypoint and recalculate route const sourceNode = this.context.nodes.find(n => n.id === this.context.edge.source); const targetNode = this.context.nodes.find(n => n.id === this.context.edge.target); if (sourceNode && targetNode) { const routingContext = this.createRoutingContext(sourceNode, targetNode, this.context.transform, this.context.gridConfig); const routeResult = (0, _routingPipeline.routeOrthogonalEdge)(routingContext); if (routeResult.success) { this.updateEdgeRoute(routeResult.modelPoints, { lastEdit: 'waypoint_removed', removedIndex: waypointIndex }); } } } renderHandles(waypoints, isSelected, isHovered) { if (!isSelected && !isHovered) return null; return waypoints.map((waypoint, index) => /*#__PURE__*/React.createElement("g", { key: `elbow-handle-${index}` }, /*#__PURE__*/React.createElement("circle", { cx: waypoint.x, cy: waypoint.y, r: 10, fill: "transparent", style: { cursor: 'move' }, onMouseDown: e => this.onMouseDown(e, index), onDoubleClick: e => this.onDoubleClick(e, index) }), /*#__PURE__*/React.createElement("circle", { cx: waypoint.x, cy: waypoint.y, r: isHovered ? 7 : 5, fill: "rgb(34, 197, 94)", stroke: "white", strokeWidth: 2, style: { pointerEvents: 'none' } }), /*#__PURE__*/React.createElement("circle", { cx: waypoint.x, cy: waypoint.y, r: 3, fill: "white", style: { pointerEvents: 'none' } }))); } } /** * Edge Handler - For straight edges * Simple point-to-point routing */ class EdgeHandler extends BaseEdgeHandler { constructor(context) { super(context); } getHitTestPriority() { return 1; // Lowest priority } onMouseDown(event) { // Straight edges don't have waypoints to drag return; } onMouseMove(event) { // No dragging for straight edges return; } onMouseUp() { // No dragging for straight edges return; } onDoubleClick(event) { // Convert to orthogonal edge on double click const sourceNode = this.context.nodes.find(n => n.id === this.context.edge.source); const targetNode = this.context.nodes.find(n => n.id === this.context.edge.target); if (sourceNode && targetNode) { const routingContext = this.createRoutingContext(sourceNode, targetNode, this.context.transform, this.context.gridConfig); const routeResult = (0, _routingPipeline.routeOrthogonalEdge)(routingContext); if (routeResult.success) { this.updateEdgeRoute(routeResult.modelPoints, { lastEdit: 'converted_to_orthogonal', style: _edgeTypes.EDGE_STYLES.ORTHOGONAL }); } } } renderHandles(waypoints, isSelected, isHovered) { // Straight edges don't have handles return null; } } /** * Handler factory - creates appropriate handler based on edge style */ function getEdgeHandler(edgeStyle, context) { switch (edgeStyle) { case _edgeTypes.EDGE_STYLES.ORTHOGONAL: case _edgeTypes.EDGE_STYLES.SEGMENT: return new SegmentHandler(context); case _edgeTypes.EDGE_STYLES.ELBOW: return new ElbowHandler(context); case _edgeTypes.EDGE_STYLES.STRAIGHT: default: return new EdgeHandler(context); } } /** * Get hit test priority for edge elements * Higher numbers = higher priority */ function getHitTestPriority(elementType) { const priorities = { 'terminal': 5, // Connection points 'waypoint': 4, // Waypoint handles 'segment': 3, // Segment handles 'virtual': 2, // Virtual handles 'label': 1 // Edge labels }; return priorities[elementType] || 0; } /** * Create handler context from React Flow props */ function createHandlerContext(edge, nodes, transform, gridConfig, updateEdge, setEdges) { return { edgeId: edge.id, edge, nodes, transform, gridConfig, updateEdge, setEdges }; }