@gravity-ui/graph
Version:
Modern graph editor component
512 lines (511 loc) • 20.7 kB
JavaScript
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");