@gravity-ui/graph
Version:
Modern graph editor component
235 lines (234 loc) • 10.9 kB
JavaScript
import intersects from "intersects";
import { isMetaKeyEvent } from "../../../utils/functions";
import { getFontSize } from "../../../utils/functions/text";
import { cachedMeasureText } from "../../../utils/renderers/text";
import { ESelectionStrategy } from "../../../utils/types/types";
import { ConnectionArrow } from "./Arrow";
import { BaseConnection } from "./BaseConnection";
import { bezierCurveLine, getArrowCoords, isPointInStroke } from "./bezierHelpers";
import { getLabelCoords } from "./labelHelper";
export class BlockConnection extends BaseConnection {
/**
* Creates a new BlockConnection instance.
*
* @param props - The connection properties including showConnectionArrows setting
* @param parent - The parent BlockConnections component
*/
constructor(props, parent) {
super(props, parent);
this.cursor = "pointer";
this.labelGeometry = { x: 0, y: 0, width: 0, height: 0 };
this.geometry = { x1: 0, x2: 0, y1: 0, y2: 0 };
/**
* The arrow shape component that renders the arrow in the middle of the connection.
* This is conditionally added to the batch renderer based on the showConnectionArrows setting.
*/
this.arrowShape = new ConnectionArrow(this);
this.addEventListener("click", this);
// Add the connection line to the batch renderer
this.context.batch.add(this, { zIndex: this.zIndex, group: this.getClassName() });
// We'll handle arrow addition in applyShape based on showConnectionArrows setting
this.applyShape(this.state, props);
}
/**
* Updates the visual appearance of the connection and manages arrow visibility.
* This method centralizes all arrow rendering logic to ensure consistency.
*
* IMPORTANT: We must use the props parameter instead of this.props because this.props
* may contain outdated values during re-renders, which was the source of the original bug.
* Always pass the most current props to this method when calling it from propsChanged.
*
* @param state - The current state of the connection (selected, hovered, etc.)
* @param props - The connection properties, used to check showConnectionArrows setting
*/
applyShape(state = this.state, props = this.props) {
const zIndex = state.selected || state.hovered ? this.zIndex + 10 : this.zIndex;
this.context.batch.update(this, { zIndex: zIndex, group: this.getClassName(state) });
// Handle arrow visibility based on the provided props
if (props.showConnectionArrows) {
// Update will handle adding if not already in batch or updating if it is
this.context.batch.update(this.arrowShape, { zIndex: zIndex, group: `arrow/${this.getClassName(state)}` });
}
else {
// Remove arrow from batch if showConnectionArrows is false
this.context.batch.delete(this.arrowShape);
}
}
getPath() {
return this.generatePath();
}
/**
* Creates the Path2D object for the arrow in the middle of the connection.
* This is used by the ConnectionArrow component to render the arrow.
*
* @returns A Path2D object representing the arrow shape
*/
createArrowPath() {
const coords = getArrowCoords(this.props.useBezier, this.geometry.x1, this.geometry.y1, this.geometry.x2, this.geometry.y2, this.props.bezierDirection);
const path = new Path2D();
path.moveTo(coords[0], coords[1]);
path.lineTo(coords[2], coords[3]);
path.lineTo(coords[4], coords[5]);
return path;
}
styleArrow(ctx) {
ctx.lineWidth = this.state.hovered || this.state.selected ? 4 : 2;
ctx.strokeStyle = this.getStrokeColor(this.state);
return { type: "stroke" };
}
generatePath() {
/* Setting this.path2D is important, as hotbox checking uses the isPointInStroke method. */
this.path2d = this.createPath();
return this.path2d;
}
createPath() {
if (!this.geometry) {
return new Path2D();
}
if (this.props.useBezier) {
return bezierCurveLine({
x: this.geometry.x1,
y: this.geometry.y1,
}, {
x: this.geometry.x2,
y: this.geometry.y2,
}, this.props.bezierDirection);
}
const path2d = new Path2D();
path2d.moveTo(this.geometry.x1, this.geometry.y1);
path2d.lineTo(this.geometry.x2, this.geometry.y2);
return path2d;
}
getClassName(state = this.state) {
const hovered = state.hovered ? "hovered" : "none";
const selected = state.selected ? "selected" : "none";
const stroke = this.getStrokeColor(state);
const dash = state.dashed ? (state.styles?.dashes || [6, 4]).join(",") : "";
return `connection/${hovered}/${selected}/${stroke}/${dash}`;
}
style(ctx) {
this.setRenderStyles(ctx, this.state);
return { type: "stroke" };
}
setRenderStyles(ctx, state = this.state, withDashed = true) {
ctx.lineWidth = state.hovered || state.selected ? 4 : 2;
ctx.strokeStyle = this.getStrokeColor(state);
if (withDashed && state.dashed) {
ctx.setLineDash(state.styles?.dashes || [6, 4]);
}
}
afterRender(ctx) {
const cameraClose = this.context.camera.getCameraScale() >= this.context.constants.connection.MIN_ZOOM_FOR_CONNECTION_ARROW_AND_LABEL;
if (this.state.label && this.props.showConnectionLabels && cameraClose) {
this.renderLabelText(ctx);
}
}
propsChanged(nextProps) {
super.propsChanged(nextProps);
this.applyShape(this.state, nextProps);
}
stateChanged(nextState) {
super.stateChanged(nextState);
this.applyShape(nextState);
}
get zIndex() {
return this.context.constants.connection.DEFAULT_Z_INDEX;
}
updatePoints() {
super.updatePoints();
if (!this.connectedState) {
this.geometry = {
x1: 0,
y1: 0,
x2: 0,
y2: 0,
};
return;
}
const useAnchors = this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors");
const source = useAnchors ? this.anchorsPoints?.[0] || this.connectionPoints[0] : this.connectionPoints[0];
const target = useAnchors ? this.anchorsPoints?.[1] || this.connectionPoints[1] : this.connectionPoints[1];
if (!source || !target) {
this.applyShape();
return;
}
this.geometry = {
x1: source.x,
y1: source.y,
x2: target.x,
y2: target.y,
};
this.applyShape();
}
handleEvent(event) {
event.stopPropagation();
super.handleEvent(event);
switch (event.type) {
case "click": {
const { blocksList } = this.context.graph.rootStore;
const isAnyBlockSelected = blocksList.$selectedBlocks.value.length !== 0;
const isAnyAnchorSelected = Boolean(blocksList.$selectedAnchor.value);
if (!isMetaKeyEvent(event) && isAnyBlockSelected) {
blocksList.resetSelection();
}
if (!isMetaKeyEvent(event) && isAnyAnchorSelected) {
blocksList.resetSelection();
}
this.context.graph.api.selectConnections([this.props.id], !isMetaKeyEvent(event) ? true : !this.state.selected, !isMetaKeyEvent(event) ? ESelectionStrategy.REPLACE : ESelectionStrategy.APPEND);
break;
}
}
}
onHitBox(shape) {
const THRESHOLD_LINE_HIT = this.context.constants.connection.THRESHOLD_LINE_HIT;
if (isPointInStroke(this.context.ctx, this.path2d, shape.x, shape.y, THRESHOLD_LINE_HIT * 2)) {
return true;
}
// Or if pointer over label
if (this.labelGeometry !== undefined) {
const x = (shape.minX + shape.maxX) / 2;
const y = (shape.minY + shape.maxY) / 2;
const relativeTreshold = THRESHOLD_LINE_HIT / this.context.camera.getCameraScale();
return intersects.boxBox(x - relativeTreshold / 2, y - relativeTreshold / 2, relativeTreshold, relativeTreshold, this.labelGeometry.x, this.labelGeometry.y, this.labelGeometry.width, this.labelGeometry.height);
}
return false;
}
renderLabelText(ctx) {
const [labelInnerTopPadding, labelInnerRightPadding, labelInnerBottomPadding, labelInnerLeftPadding] = this.context.constants.connection.LABEL.INNER_PADDINGS;
const padding = this.context.constants.system.GRID_SIZE / 8;
const fontSize = Math.max(14, getFontSize(9, this.context.camera.getCameraScale()));
const font = `${fontSize}px sans-serif`;
const measure = cachedMeasureText(this.state.label, {
font,
});
const height = measure.height;
const width = measure.width;
const { x, y, aligment } = getLabelCoords(this.geometry.x1, this.geometry.y1, this.geometry.x2, this.geometry.y2, measure.width + padding * 2 + labelInnerLeftPadding + labelInnerRightPadding, measure.height + labelInnerTopPadding + labelInnerBottomPadding, this.context.constants.system.GRID_SIZE);
this.labelGeometry = { x, y, width, height };
ctx.fillStyle = this.context.colors.connectionLabel.text;
if (this.state.hovered)
ctx.fillStyle = this.context.colors.connectionLabel.hoverText;
if (this.state.selected)
ctx.fillStyle = this.context.colors.connectionLabel.selectedText;
ctx.textBaseline = "top";
ctx.textAlign = aligment;
ctx.font = font;
ctx.fillText(this.state.label, x + padding, y + padding);
ctx.fillStyle = this.context.colors.connectionLabel.background;
if (this.state.hovered)
ctx.fillStyle = this.context.colors.connectionLabel.hoverBackground;
if (this.state.selected)
ctx.fillStyle = this.context.colors.connectionLabel.selectedBackground;
ctx.fillRect(x - labelInnerLeftPadding, y - labelInnerTopPadding, measure.width + labelInnerLeftPadding + labelInnerRightPadding, measure.height + labelInnerTopPadding + labelInnerBottomPadding);
}
getStrokeColor(state) {
if (state.selected)
return state.styles?.selectedBackground || this.context.colors.connection.selectedBackground;
return state.styles?.background || this.context.colors.connection.background;
}
unmount() {
super.unmount();
this.context.batch.delete(this);
this.context.batch.delete(this.arrowShape);
}
}