UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

278 lines (277 loc) 13.1 kB
import { extractNativeGraphMouseEvent } from "../../../../graphEvents"; import { Layer } from "../../../../services/Layer"; import { AnchorState } from "../../../../store/anchor/Anchor"; import { BlockState } from "../../../../store/block/Block"; import { getXY, isBlock, isShiftKeyEvent } from "../../../../utils/functions"; import { dragListener } from "../../../../utils/functions/dragListener"; import { render } from "../../../../utils/renderers/render"; import { renderSVG } from "../../../../utils/renderers/svgPath"; import { EVENTS } from "../../../../utils/types/events"; import { ESelectionStrategy } from "../../../../utils/types/types"; 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"], ...props.canvas, }, ...props, }); this.connectionState = { sx: 0, sy: 0, tx: 0, ty: 0, }; this.enable = () => { this.enabled = true; }; this.disable = () => { this.enabled = false; }; this.handleMouseDown = (nativeEvent) => { const target = nativeEvent.detail.target; const event = extractNativeGraphMouseEvent(nativeEvent); if (!event || !target || !this.root?.ownerDocument) { return; } if (this.enabled && ((this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors") && target instanceof Anchor) || (isShiftKeyEvent(event) && isBlock(target)))) { // Get the source component state const sourceComponent = target.connectedState; // Check if connection is allowed using the validation function if provided if (this.props.isConnectionAllowed && !this.props.isConnectionAllowed(sourceComponent)) { return; } nativeEvent.preventDefault(); nativeEvent.stopPropagation(); dragListener(this.root.ownerDocument) .on(EVENTS.DRAG_START, (dStartEvent) => { this.onStartConnection(dStartEvent, this.context.graph.getPointInCameraSpace(dStartEvent)); }) .on(EVENTS.DRAG_UPDATE, (dUpdateEvent) => this.onMoveNewConnection(dUpdateEvent, this.context.graph.getPointInCameraSpace(dUpdateEvent))) .on(EVENTS.DRAG_END, (dEndEvent) => this.onEndNewConnection(this.context.graph.getPointInCameraSpace(dEndEvent))); } }; 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(); } renderEndpoint(ctx) { ctx.beginPath(); if (!this.target && this.props.createIcon) { 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.connectionState.tx, y: this.connectionState.ty - 12, width: 24, height: 24 }); } 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.connectionState.tx, y: this.connectionState.ty - 12, width: 24, height: 24 }); } ctx.closePath(); } render() { this.resetTransform(); if (!this.connectionState.sx && !this.connectionState.sy && !this.connectionState.tx && !this.connectionState.ty) { return; } if (this.props.drawLine) { const { path, style } = this.props.drawLine({ x: this.connectionState.sx, y: this.connectionState.sy }, { x: this.connectionState.tx, y: this.connectionState.ty }); 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.connectionState.sx, this.connectionState.sy); this.context.ctx.lineTo(this.connectionState.tx, this.connectionState.ty); 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(event, point) { const sourceComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); if (!sourceComponent) { return; } this.sourceComponent = sourceComponent.connectedState; const xy = getXY(this.context.graphCanvas, event); this.connectionState = { ...this.connectionState, sx: xy[0], sy: xy[1], }; 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) { const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); const xy = getXY(this.context.graphCanvas, event); this.connectionState = { ...this.connectionState, tx: xy[0], ty: xy[1], }; 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) { return; } const targetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); this.connectionState = { sx: 0, sy: 0, tx: 0, ty: 0, }; 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, }, () => { }); } }