UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

512 lines (511 loc) 20.7 kB
import RBush from "rbush"; import { extractNativeGraphMouseEvent, isGraphEvent } from "../../../../graphEvents"; import { Layer } from "../../../../services/Layer"; import { ESelectionStrategy } from "../../../../services/selection"; import { EAnchorType } from "../../../../store/anchor/Anchor"; import { getXY, vectorDistance } from "../../../../utils/functions"; import { stopDragListening } from "../../../../utils/functions/dragListener"; import { render } from "../../../../utils/renderers/render"; import { renderSVG } from "../../../../utils/renderers/svgPath"; import { Point } from "../../../../utils/types/shapes"; import { Anchor } from "../../../canvas/anchors"; import { Block } from "../../../canvas/blocks/Block"; import { GraphComponent } from "../../GraphComponent"; /** * Default search radius for port detection and snapping in pixels */ const PORT_SEARCH_RADIUS = 20; /** * PortConnectionLayer - new layer for creating connections, working only with ports * * Key differences from ConnectionLayer: * - Works only with ports, (use Block and Anchor components only to pass more info about ports in Event) * - Uses findPortAtPoint to detect ports under cursor * - Metadata is stored under unique PortConnectionLayer.PortMetaKey key * - More efficient search through spatial index * - Events extended with sourcePort and targetPort parameters * * @example * ```typescript * // Configure port for snapping * port.updatePort({ * meta: { * [PortConnectionLayer.PortMetaKey]: { * snappable: true, * snapCondition: (ctx) => { * // Custom validation logic * return true; * } * } * } * }); * ``` */ export class PortConnectionLayer extends Layer { constructor(props) { super({ canvas: { zIndex: 4, classNames: ["no-pointer-events"], transformByCameraPosition: true, ...props.canvas, }, ...props, }); this.startState = null; this.endState = null; this.snappingPortsTree = null; this.isSnappingTreeOutdated = true; this.enable = () => { this.enabled = true; }; this.disable = () => { this.enabled = false; }; this.currentListener = null; this.handleMouseDown = (nativeEvent) => { if (!this.enabled) { return; } const initEvent = extractNativeGraphMouseEvent(nativeEvent); const initialComponent = nativeEvent.detail.target; if (!initEvent || !this.root?.ownerDocument || !initialComponent) { return; } if (initEvent.button !== 0) { return; } if (!(initialComponent instanceof GraphComponent) || initialComponent.getPorts().length === 0) { return; } const canvas = this.context.graph.getGraphCanvas(); const [screenX, screenY] = getXY(canvas, initEvent); const [worldX, worldY] = this.context.graph.cameraService.applyToPoint(screenX, screenY); const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; const port = this.context.graph.rootStore.connectionsList.ports.findPortAtPointByComponent(initialComponent, new Point(worldX, worldY), searchRadius, (candidate) => this.isSnappablePort(candidate)); if (!port) { return; } if (isGraphEvent(nativeEvent)) { nativeEvent.preventGraphEventDefault(); nativeEvent.stopGraphEventPropagation(); } // DragService will provide world coordinates in callbacks this.currentListener = this.context.graph.dragService.startDrag({ onStart: (_event, coords) => { this.onStartConnection(port, new Point(coords[0], coords[1])); }, onUpdate: (event, coords) => this.onMoveNewConnection(event, new Point(coords[0], coords[1])), onEnd: (_event, coords) => this.onEndNewConnection(new Point(coords[0], coords[1])), }, { cursor: "crosshair", initialEvent: initEvent }); }; this.setContext({ canvas: this.getCanvas(), graphCanvas: props.graph.getGraphCanvas(), ctx: this.getCanvas().getContext("2d"), camera: props.camera, constants: this.props.graph.graphConstants, colors: this.props.graph.graphColors, graph: this.props.graph, }); this.enabled = Boolean(this.props.graph.rootStore.settings.getConfigFlag("canCreateNewConnections")); this.onSignal(this.props.graph.rootStore.settings.$settings, (value) => { this.enabled = Boolean(value.canCreateNewConnections); }); } afterInit() { this.onGraphEvent("mousedown", this.handleMouseDown, { capture: true }); // Subscribe to ports changes const checkPortsChanged = () => { this.isSnappingTreeOutdated = true; }; this.portsUnsubscribe = this.onSignal(this.context.graph.rootStore.connectionsList.ports.$ports, checkPortsChanged); this.context.graph.keyboardService.onPress("Escape", () => { if (this.currentListener) { this.cancelNewConnection(); } }, { signal: this.eventAbortController.signal, }); super.afterInit(); } onCameraChange(_camera) { this.isSnappingTreeOutdated = true; } isSnappablePort(port) { const meta = port.meta?.[PortConnectionLayer.PortMetaKey]; return Boolean(meta?.snappable); } renderEndpoint(ctx) { ctx.beginPath(); const scale = this.context.camera.getCameraScale(); const iconSize = 24 / scale; const iconOffset = 12 / scale; if (!this.targetPort && this.props.createIcon && this.endState) { renderSVG({ path: this.props.createIcon.path, width: this.props.createIcon.width, height: this.props.createIcon.height, iniatialWidth: this.props.createIcon.viewWidth, initialHeight: this.props.createIcon.viewHeight, }, ctx, { x: this.endState.x, y: this.endState.y - iconOffset, width: iconSize, height: iconSize }); } else if (this.props.point) { ctx.fillStyle = this.props.point.fill || this.context.colors.canvas.belowLayerBackground; if (this.props.point.stroke) { ctx.strokeStyle = this.props.point.stroke; } renderSVG({ path: this.props.point.path, width: this.props.point.width, height: this.props.point.height, iniatialWidth: this.props.point.viewWidth, initialHeight: this.props.point.viewHeight, }, ctx, { x: this.endState.x, y: this.endState.y - iconOffset, width: iconSize, height: iconSize }); } ctx.closePath(); } render() { this.resetTransform(); if (!this.startState || !this.endState) { return; } if (this.props.drawLine) { const { path, style } = this.props.drawLine(this.startState, this.endState); this.context.ctx.lineWidth = this.context.camera.limitScaleEffect(style.width || 3); this.context.ctx.strokeStyle = style.color; this.context.ctx.setLineDash(style.dash); this.context.ctx.stroke(path); } else { this.context.ctx.beginPath(); this.context.ctx.lineWidth = this.context.camera.limitScaleEffect(3); this.context.ctx.strokeStyle = this.context.colors.connection.selectedBackground; this.context.ctx.moveTo(this.startState.x, this.startState.y); this.context.ctx.lineTo(this.endState.x, this.endState.y); this.context.ctx.stroke(); this.context.ctx.closePath(); } render(this.context.ctx, (ctx) => { this.renderEndpoint(ctx); }); } onStartConnection(port, _worldCoords) { if (!port) { return; } const params = this.getEventParams(port); this.sourcePort = port; this.startState = new Point(port.x, port.y); this.context.graph.executеDefaultEventAction("port-connection-create-start", { blockId: params.blockId, anchorId: params.anchorId, sourcePort: port, }, () => { this.selectPort(port, true); }); this.performRender(); } onMoveNewConnection(event, point) { if (!this.startState || !this.sourcePort) { return; } // Try to snap to nearby port first const snapResult = this.findNearestSnappingPort(point, this.sourcePort); let actualEndPoint = point; let newTargetPort; if (snapResult) { // Snap to port actualEndPoint = new Point(snapResult.snapPoint.x, snapResult.snapPoint.y); newTargetPort = snapResult.port; } else { // Try to find port at cursor without snapping const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; newTargetPort = this.context.graph.rootStore.connectionsList.ports.findPortAtPoint(point, searchRadius, (p) => { return this.isSnappablePort(p) && Boolean(p.owner) && p.id !== this.sourcePort?.id; }); } this.endState = new Point(actualEndPoint.x, actualEndPoint.y); this.performRender(); // Handle target port change if (newTargetPort !== this.targetPort) { this.selectPort(this.targetPort, false); this.targetPort = newTargetPort; const sourceParams = this.getEventParams(this.sourcePort); const targetParams = this.getEventParams(newTargetPort); this.context.graph.executеDefaultEventAction("port-connection-create-hover", { sourceBlockId: sourceParams.blockId, sourceAnchorId: sourceParams.anchorId, targetBlockId: targetParams?.blockId, targetAnchorId: targetParams?.anchorId, sourcePort: this.sourcePort, targetPort: newTargetPort || undefined, }, () => { if (this.targetPort) { this.selectPort(this.targetPort, true); } }); } } selectPort(port, select) { if (!port) return; const component = port.owner; if (component instanceof GraphComponent) { const bucket = this.context.graph.rootStore.selectionService.getBucketByElement(component); if (!bucket) { return; } if (select) { bucket.select([component.getEntityId()], ESelectionStrategy.REPLACE); } else { bucket.deselect([component.getEntityId()]); } } } cancelNewConnection() { if (this.currentListener) { stopDragListening(this.currentListener); } this.startState = null; this.endState = null; this.performRender(); this.context.graph.executеDefaultEventAction("port-connection-cancel", { sourcePort: this.sourcePort, targetPort: this.targetPort, }, () => { }); // Cleanup ports to prevent stale state this.sourcePort = undefined; this.targetPort = undefined; } onEndNewConnection(point) { if (!this.sourcePort || !this.startState || !this.endState) { return; } // Try to find target port at drop point using same logic as onMove // First try snapping, then fallback to direct search let targetPort; // Try snapping first const snapResult = this.findNearestSnappingPort(point, this.sourcePort); if (snapResult) { targetPort = snapResult.port; } else { // Fallback: try to find port at drop point const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; targetPort = this.context.graph.rootStore.connectionsList.ports.findPortAtPoint(point, searchRadius, (p) => { return this.isSnappablePort(p) && Boolean(p.owner) && p.id !== this.sourcePort?.id; }); } this.startState = null; this.endState = null; this.performRender(); const sourceParams = this.getEventParams(this.sourcePort); if (!targetPort) { // Drop without target this.context.graph.executеDefaultEventAction("port-connection-create-drop", { sourceBlockId: sourceParams.blockId, sourceAnchorId: sourceParams.anchorId, point, sourcePort: this.sourcePort, }, () => { }); // Cleanup this.sourcePort = undefined; this.targetPort = undefined; return; } // Determine port types to ensure correct connection direction (OUT -> IN) const sourceType = this.getPortType(this.sourcePort); const targetType = this.getPortType(targetPort); // Determine actual source and target based on port types let actualSourcePort = this.sourcePort; let actualTargetPort = targetPort; // If source is IN and target is OUT, swap them if (sourceType === EAnchorType.IN && targetType === EAnchorType.OUT) { actualSourcePort = targetPort; actualTargetPort = this.sourcePort; } const actualSourceParams = this.getEventParams(actualSourcePort); const actualTargetParams = this.getEventParams(actualTargetPort); // Create connection this.context.graph.executеDefaultEventAction("port-connection-created", { sourceBlockId: actualSourceParams.blockId, sourceAnchorId: actualSourceParams.anchorId, targetBlockId: actualTargetParams.blockId, targetAnchorId: actualTargetParams.anchorId, sourcePort: actualSourcePort, targetPort: actualTargetPort, }, () => { this.context.graph.rootStore.connectionsList.addConnection({ sourceBlockId: actualSourceParams.blockId, sourceAnchorId: actualSourceParams.anchorId, targetBlockId: actualTargetParams.blockId, targetAnchorId: actualTargetParams.anchorId, }); }); this.selectPort(this.sourcePort, false); this.selectPort(targetPort, false); const targetParams = this.getEventParams(targetPort); // Drop event this.context.graph.executеDefaultEventAction("port-connection-create-drop", { sourceBlockId: sourceParams.blockId, sourceAnchorId: sourceParams.anchorId, targetBlockId: targetParams.blockId, targetAnchorId: targetParams.anchorId, point, sourcePort: this.sourcePort, targetPort: targetPort, }, () => { }); // Cleanup this.sourcePort = undefined; this.targetPort = undefined; } findNearestSnappingPort(point, sourcePort) { this.rebuildSnappingTree(); if (!this.snappingPortsTree) { return null; } const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; const candidates = this.snappingPortsTree.search({ minX: point.x - searchRadius, minY: point.y - searchRadius, maxX: point.x + searchRadius, maxY: point.y + searchRadius, }); if (candidates.length === 0) { return null; } let nearestPort = null; let nearestDistance = Infinity; for (const candidate of candidates) { const port = candidate.port; // Skip source port if (sourcePort && port.id === sourcePort.id) { continue; } // Calculate vector distance const distance = vectorDistance(point, port); // Check custom condition if provided const meta = port.meta?.[PortConnectionLayer.PortMetaKey]; if (meta?.snapCondition && sourcePort) { const canSnap = meta.snapCondition({ sourcePort: sourcePort, targetPort: port, cursorPosition: point, distance, }); if (!canSnap) { continue; } } // Update nearest port if (distance < nearestDistance) { nearestDistance = distance; nearestPort = port; } } if (!nearestPort) { return null; } return { port: nearestPort, snapPoint: { x: nearestPort.x, y: nearestPort.y }, }; } /** * Rebuild the RBush spatial index for snapping ports * Optimization: Only includes ports from components visible in viewport + padding */ rebuildSnappingTree() { if (!this.isSnappingTreeOutdated) { return; } const snappingBoxes = []; const searchRadius = this.props.searchRadius || PORT_SEARCH_RADIUS; // Get only visible components in viewport (with padding already applied) const visibleComponents = this.context.graph.getElementsInViewport([GraphComponent]); // Collect ports from visible components only for (const component of visibleComponents) { const ports = component.getPorts(); for (const port of ports) { // Skip ports in lookup state (no valid coordinates) if (port.lookup) continue; if (this.isSnappablePort(port)) { snappingBoxes.push({ minX: port.x - searchRadius, minY: port.y - searchRadius, maxX: port.x + searchRadius, maxY: port.y + searchRadius, port: port, }); } } } this.snappingPortsTree = new RBush(9); if (snappingBoxes.length > 0) { this.snappingPortsTree.load(snappingBoxes); } this.isSnappingTreeOutdated = false; } /** * Determine the port type (IN or OUT) * @param port Port to check * @returns EAnchorType.IN, EAnchorType.OUT, or null if the port is a block point (no specific direction) */ getPortType(port) { const component = port.owner; if (!component) { return null; } // For Anchor components, get the anchor type if (component instanceof Anchor) { const anchorType = component.connectedState.state.type; if (anchorType === EAnchorType.IN || anchorType === EAnchorType.OUT) { return anchorType; } } // For Block points, return null (no specific direction) return null; } /** * Get full event parameters from a port * Includes both legacy parameters (blockId, anchorId) and new port reference */ getEventParams(port) { if (!port) { return {}; } const component = port.owner; if (!component) { throw new Error("Port has no owner component"); } if (component instanceof Anchor) { return { blockId: component.connectedState.blockId, anchorId: component.connectedState.id, }; } if (component instanceof Block) { return { blockId: component.connectedState.id, }; } return {}; } unmount() { if (this.portsUnsubscribe) { this.portsUnsubscribe(); this.portsUnsubscribe = undefined; } this.snappingPortsTree = null; super.unmount(); } } /** * Unique key for port metadata * Using a symbol prevents conflicts with other layers */ PortConnectionLayer.PortMetaKey = Symbol.for("PortConnectionLayer.PortMeta");