UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

235 lines (234 loc) 10.9 kB
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); } }