UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

251 lines (250 loc) 11.2 kB
import intersects from "intersects"; import { ESelectionStrategy } from "../../../services/selection/types"; import { isMetaKeyEvent } from "../../../utils/functions"; import { getFontSize } from "../../../utils/functions/text"; import { cachedMeasureText } from "../../../utils/renderers/text"; import { ConnectionArrow } from "./Arrow"; import { BaseConnection } from "./BaseConnection"; import { bezierCurveLine, generateBezierParams, 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.path2d = new Path2D(); this.labelGeometry = undefined; 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; const group = this.getClassName(state); this.context.batch.update(this, { zIndex, group }); if (props.showConnectionArrows) { this.context.batch.update(this.arrowShape, { zIndex: zIndex - 1, group: `arrow/${group}` }); } else { 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; const strokeColor = this.getStrokeColor(this.state); if (strokeColor) { ctx.strokeStyle = strokeColor; } 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; const strokeColor = this.getStrokeColor(state); if (strokeColor) { ctx.strokeStyle = strokeColor; } 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; } collectBBoxPoints() { const points = super.collectBBoxPoints(); if (this.labelGeometry) { points.push({ x: this.labelGeometry.x, y: this.labelGeometry.y }, { x: this.labelGeometry.x + this.labelGeometry.width, y: this.labelGeometry.y + this.labelGeometry.height, }); } if (this.props.useBezier && this.connectionPoints) { const bezierParams = generateBezierParams(this.connectionPoints[0], this.connectionPoints[1], this.props.bezierDirection); points.push(bezierParams[1], bezierParams[2]); } return points; } updatePoints() { super.updatePoints(); if (!this.connectionPoints) { return; } this.geometry.x1 = this.connectionPoints[0].x; this.geometry.y1 = this.connectionPoints[0].y; this.geometry.x2 = this.connectionPoints[1].x; this.geometry.y2 = this.connectionPoints[1].y; this.generatePath(); this.applyShape(); } handleEvent(event) { event.stopPropagation(); super.handleEvent(event); switch (event.type) { case "click": { 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) { if (!this.isVisible() || !this.state.label) { return; } const [labelInnerTopPadding, labelInnerRightPadding, labelInnerBottomPadding, labelInnerLeftPadding] = this.context.constants.connection.LABEL.INNER_PADDINGS; const fontSize = Math.max(14, getFontSize(9, this.context.camera.getCameraScale())); const font = `${fontSize}px sans-serif`; const measure = cachedMeasureText(this.state.label, { font, }); if (!measure) { return; } const { x, y } = getLabelCoords(this.geometry.x1, this.geometry.y1, this.geometry.x2, this.geometry.y2, measure.width + labelInnerLeftPadding + labelInnerRightPadding, measure.height + labelInnerTopPadding + labelInnerBottomPadding, this.context.constants.system.GRID_SIZE); if (this.context.colors.connectionLabel?.background) { ctx.fillStyle = this.context.colors.connectionLabel.background; } if (this.state.hovered && this.context.colors.connectionLabel?.hoverBackground) { ctx.fillStyle = this.context.colors.connectionLabel.hoverBackground; } if (this.state.selected && this.context.colors.connectionLabel?.selectedBackground) { ctx.fillStyle = this.context.colors.connectionLabel.selectedBackground; } const rectX = x; const rectY = y; const rectWidth = measure.width + labelInnerLeftPadding + labelInnerRightPadding; const rectHeight = measure.height + labelInnerTopPadding + labelInnerBottomPadding; this.labelGeometry = { x: rectX, y: rectY, width: rectWidth, height: rectHeight, }; ctx.fillRect(rectX, rectY, rectWidth, rectHeight); if (this.context.colors.connectionLabel?.text) { ctx.fillStyle = this.context.colors.connectionLabel.text; } if (this.state.hovered && this.context.colors.connectionLabel?.hoverText) { ctx.fillStyle = this.context.colors.connectionLabel.hoverText; } if (this.state.selected && this.context.colors.connectionLabel?.selectedText) { ctx.fillStyle = this.context.colors.connectionLabel.selectedText; } ctx.textBaseline = "top"; ctx.font = font; ctx.textAlign = "left"; ctx.fillText(this.state.label, rectX + labelInnerLeftPadding, rectY + labelInnerTopPadding); } 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); } }