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