UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

285 lines (284 loc) 13.6 kB
import { extractNativeGraphMouseEvent, isGraphEvent } from "../../../../graphEvents"; import { Layer } from "../../../../services/Layer"; import { ESelectionStrategy } from "../../../../services/selection/types"; import { AnchorState } from "../../../../store/anchor/Anchor"; import { BlockState } from "../../../../store/block/Block"; import { isBlock, isShiftKeyEvent } from "../../../../utils/functions"; 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"; /** * ConnectionLayer manages the creation process of connections between blocks and anchors in the graph. * It handles the temporary visualization during connection creation but does not render existing connections. * * Features: * - Interactive connection creation through drag and drop * - Temporary visualization during connection creation with configurable icons and line styles * - Automatic selection handling of source and target elements * - Comprehensive event system for the connection creation lifecycle * - Optional connection validation through isConnectionAllowed prop * * Connection types: * - Block-to-Block: Hold Shift key and drag from one block to another * - Anchor-to-Anchor: Drag from one anchor to another (must be on different blocks) * * The layer renders on a separate canvas with a higher z-index and handles * all mouse interactions for connection creation. */ export class ConnectionLayer extends Layer { constructor(props) { super({ canvas: { zIndex: 4, classNames: ["no-pointer-events"], transformByCameraPosition: true, // Automatically apply camera transformation ...props.canvas, }, ...props, }); this.startState = null; this.endState = null; this.enable = () => { this.enabled = true; }; this.disable = () => { this.enabled = false; }; this.handleMouseDown = (nativeEvent) => { const target = nativeEvent.detail.target; const initEvent = extractNativeGraphMouseEvent(nativeEvent); if (!initEvent || !target || !this.root?.ownerDocument) { return; } if (initEvent.button !== 0) { return; } if (this.checkIsShouldStartCreationConnection(target, initEvent) && (isBlock(target) || target instanceof Anchor)) { if (isGraphEvent(nativeEvent)) { nativeEvent.preventGraphEventDefault(); nativeEvent.stopGraphEventPropagation(); } this.context.graph.dragService.startDrag({ onStart: (_event, coords) => this.onStartConnection(target, 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" }); } }; 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); }); } /** * Called after initialization and when the layer is reattached. * This is where we set up event subscriptions to ensure they work properly * after the layer is unmounted and reattached. */ afterInit() { // Register event listeners with the graphOn wrapper method for automatic cleanup when unmounted this.onGraphEvent("mousedown", this.handleMouseDown, { capture: true }); // Call parent afterInit to ensure proper initialization super.afterInit(); } checkIsShouldStartCreationConnection(target, initEvent) { if (!this.enabled) { return false; } const isTargetAllowed = (target instanceof Anchor && this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors")) || (isShiftKeyEvent(initEvent) && isBlock(target)); if (!isTargetAllowed) { return false; } if (this.props.isConnectionAllowed && !this.props.isConnectionAllowed(target.connectedState)) { return false; } return true; } renderEndpoint(ctx) { ctx.beginPath(); // Icons should remain constant size on screen, so scale them inversely to camera scale const scale = this.context.camera.getCameraScale(); const iconSize = 24 / scale; const iconOffset = 12 / scale; if (!this.target && 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; } // Line width should remain constant on screen regardless of camera scale const scale = this.context.camera.getCameraScale(); this.context.ctx.lineWidth = Math.round(2 / scale); if (this.props.drawLine) { const { path, style } = this.props.drawLine(this.startState, this.endState); 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.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); }); } getBlockId(component) { if (component instanceof AnchorState) { return component.blockId; } return component.id; } getAnchorId(component) { if (component instanceof AnchorState) { return component.id; } return undefined; } onStartConnection(sourceComponent, worldCoords) { if (!sourceComponent) { return; } this.sourceComponent = sourceComponent.connectedState; if (sourceComponent instanceof Block) { this.startState = new Point(worldCoords.x, worldCoords.y); } else if (sourceComponent instanceof Anchor) { const point = sourceComponent.getPosition(); this.startState = new Point(point.x, point.y); } this.context.graph.executеDefaultEventAction("connection-create-start", { blockId: sourceComponent instanceof Anchor ? sourceComponent.connectedState.blockId : sourceComponent.connectedState.id, anchorId: sourceComponent instanceof Anchor ? sourceComponent.connectedState.id : undefined, }, () => { if (sourceComponent instanceof Block) { this.context.graph.api.selectBlocks([this.sourceComponent.id], true, ESelectionStrategy.REPLACE); } else if (sourceComponent instanceof Anchor) { this.context.graph.api.setAnchorSelection(sourceComponent.props.blockId, sourceComponent.props.id, true); } }); this.performRender(); } onMoveNewConnection(event, point) { if (!this.startState || !this.sourceComponent) { return; } const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); // Use world coordinates from point instead of screen coordinates this.endState = new Point(point.x, point.y); this.performRender(); if (!newTargetComponent || !newTargetComponent.connectedState) { this.target?.connectedState?.setSelection(false); this.target = undefined; return; } // Only process if the target has changed or if there was no previous target if ((!this.target || this.target.connectedState !== newTargetComponent.connectedState) && newTargetComponent.connectedState !== this.sourceComponent) { this.target?.connectedState?.setSelection(false); const target = newTargetComponent.connectedState; this.target = newTargetComponent; this.context.graph.executеDefaultEventAction("connection-create-hover", { sourceBlockId: this.sourceComponent instanceof AnchorState ? this.sourceComponent.blockId : this.sourceComponent.id, sourceAnchorId: this.sourceComponent instanceof AnchorState ? this.sourceComponent.id : undefined, targetAnchorId: target instanceof AnchorState ? target.id : undefined, targetBlockId: target instanceof AnchorState ? target.blockId : target.id, }, () => { this.target.connectedState.setSelection(true); }); } } onEndNewConnection(point) { if (!this.sourceComponent || !this.startState || !this.endState) { return; } const targetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); this.startState = null; this.endState = null; this.performRender(); if (!(targetComponent instanceof Block) && !(targetComponent instanceof Anchor)) { this.context.graph.executеDefaultEventAction("connection-create-drop", { sourceBlockId: this.getBlockId(this.sourceComponent), sourceAnchorId: this.getAnchorId(this.sourceComponent), point, }, () => { }); return; } if (targetComponent && targetComponent.connectedState && this.sourceComponent !== targetComponent.connectedState) { if (this.sourceComponent instanceof AnchorState && targetComponent.connectedState instanceof AnchorState && this.sourceComponent.blockId !== targetComponent.connectedState.blockId) { const params = { sourceBlockId: this.sourceComponent.blockId, sourceAnchorId: this.sourceComponent.id, targetAnchorId: targetComponent.connectedState.id, targetBlockId: targetComponent.connectedState.blockId, }; this.context.graph.executеDefaultEventAction("connection-created", params, () => { this.context.graph.rootStore.connectionsList.addConnection(params); }); } else if (this.sourceComponent instanceof BlockState && targetComponent.connectedState instanceof BlockState) { const params = { sourceBlockId: this.sourceComponent.id, targetBlockId: targetComponent.connectedState.id, }; this.context.graph.executеDefaultEventAction("connection-created", params, () => { this.context.graph.rootStore.connectionsList.addConnection(params); }); } this.sourceComponent.setSelection(false); targetComponent.connectedState.setSelection(false); } this.context.graph.executеDefaultEventAction("connection-create-drop", { sourceBlockId: this.getBlockId(this.sourceComponent), sourceAnchorId: this.getAnchorId(this.sourceComponent), targetBlockId: this.getBlockId(targetComponent.connectedState), targetAnchorId: this.getAnchorId(targetComponent.connectedState), point, }, () => { }); } }