@gravity-ui/graph
Version:
Modern graph editor component
285 lines (284 loc) • 13.6 kB
JavaScript
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,
}, () => { });
}
}