UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

220 lines (219 loc) 8.02 kB
import { ESchedulerPriority } from "../../../lib"; import { selectConnectionById } from "../../../store/connection/selectors"; import { debounce } from "../../../utils/functions"; import { GraphComponent } from "../GraphComponent"; /** * BaseConnection - Foundation class for all connection types in @gravity-ui/graph * * Provides core functionality for connection components including: * - Integration with the Port System for reliable connection points * - Automatic state synchronization with ConnectionState * - Reactive geometry updates when ports change * - HitBox management for user interaction * * ## Key Features: * * ### Port System Integration * Uses the Port System to resolve connection endpoints, which solves the initialization * order problem where connections can be created before blocks/anchors are ready. * * ### Reactive Updates * Automatically subscribes to port changes and updates connection geometry when * source or target positions change. * * ### Event Handling * Provides hover state management and can be extended for custom interaction handling. * * ## Usage: * * BaseConnection is typically used as a base class for more specific connection types. * For most use cases, prefer BlockConnection which extends this with optimized rendering. * * @example * ```typescript * class SimpleConnection extends BaseConnection { * protected render() { * if (!this.connectionPoints) return; * * const [source, target] = this.connectionPoints; * const ctx = this.context.ctx; * * ctx.beginPath(); * ctx.moveTo(source.x, source.y); * ctx.lineTo(target.x, target.y); * ctx.stroke(); * } * } * ``` * * @see {@link BlockConnection} For production-ready connection implementation * @see {@link ConnectionState} For connection data management * @see {@link PortState} For port system details */ export class BaseConnection extends GraphComponent { /** * @deprecated use port system instead */ get sourceBlock() { return this.connectedState.$sourcePortState.value.component; } /** * @deprecated use port system instead */ get targetBlock() { return this.connectedState.$targetPortState.value.component; } /** * @deprecated use port system instead */ get sourceAnchor() { return this.sourceBlock.connectedState.getAnchorById(this.connectedState.sourceAnchorId)?.asTAnchor(); } /** * @deprecated use port system instead */ get targetAnchor() { return this.targetBlock.connectedState.getAnchorById(this.connectedState.targetAnchorId)?.asTAnchor(); } constructor(props, parent) { super(props, parent); this.debounceHover = debounce((hovered) => { this.onHoverChange(hovered); }, { priority: ESchedulerPriority.LOWEST, frameInterval: 3, frameTimeout: 0, }); /** * Updates the hit box for user interaction * Adds threshold padding around the connection line to make it easier to click * * @returns {void} */ this.updateHitBox = () => { const { x, y, width, height } = this.getHitBoxRect(); const threshold = this.context.constants.connection.THRESHOLD_LINE_HIT; this.setHitBox(x - threshold, y - threshold, x + width + threshold, y + height + threshold); }; // Get reactive connection state from the store this.connectedState = selectConnectionById(this.context.graph, this.props.id); this.connectedState.setViewComponent(this); // Subscribe to port changes for automatic geometry updates this.connectedState.$sourcePortState.value.addObserver(this); this.connectedState.$targetPortState.value.addObserver(this); // Initialize component state with connection data this.setState({ ...this.connectedState.$state.value, hovered: false }); } getEntityId() { return this.props.id; } willMount() { // Subscribe to connection state changes for automatic updates this.subscribeSignal(this.connectedState.$selected, (selected) => { this.setState({ selected }); }); this.subscribeSignal(this.connectedState.$state, (state) => { this.setState({ ...state }); }); // Subscribe to geometry changes to update connection points this.subscribeSignal(this.connectedState.$geometry, () => { this.updatePoints(); }); // When both endpoints are hidden (e.g. both blocks inside a collapsed group) // schedule a re-render so willIterate() picks up the new isVisible() result. this.subscribeSignal(this.connectedState.$hidden, () => { this.performRender(); }); // Enable hover interaction this.listenEvents(["mouseenter", "mouseleave"]); } isVisible() { if (this.connectedState.$hidden.value) return false; return super.isVisible(); } handleEvent(event) { event.stopPropagation(); super.handleEvent(event); switch (event.type) { case "mouseenter": this.debounceHover(true); break; case "mouseleave": this.debounceHover(false); break; } } onHoverChange(hoverState) { if (hoverState === this.state.hovered) { return; } this.setState({ hovered: hoverState }); } unmount() { this.connectedState.$sourcePortState.value.removeObserver(this); this.connectedState.$targetPortState.value.removeObserver(this); super.unmount(); } /** * Updates connection points based on current port positions * Called automatically when port geometry changes * * This method: * 1. Retrieves current port positions from the Port System * 2. Updates connectionPoints for rendering * 3. Recalculates bounding box for optimization * 4. Updates hit box for interaction * * @returns {void} */ updatePoints(additionalPoints) { // Initialize with default points this.connectionPoints = [ { x: 0, y: 0 }, { x: 0, y: 0 }, ]; // Update with actual port positions if available if (this.connectedState.$geometry.value) { const [source, target] = this.connectedState.$geometry.value; this.connectionPoints = [ { x: source.x, y: source.y }, { x: target.x, y: target.y }, ]; } // Calculate bounding box from connection points, additional points, and subclass points const points = this.collectBBoxPoints(); if (additionalPoints) { points.push(...additionalPoints); } const x = points.map((p) => p.x).filter(Number.isFinite); const y = points.map((p) => p.y).filter(Number.isFinite); this.bBox = [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)]; // Update interaction area this.updateHitBox(); } /** * Collects points that define the bounding box of the connection. * Override in subclasses to include additional points (e.g., bezier control points, labels). */ collectBBoxPoints() { if (!this.connectionPoints) { return []; } return [this.connectionPoints[0], this.connectionPoints[1]]; } /** * Get the current bounding box of the connection * @returns Readonly tuple of [sourceX, sourceY, targetX, targetY] */ getBBox() { return this.bBox; } /** * Get the current bounding box of the connection as a TRect. */ getHitBoxRect() { const [minX, minY, maxX, maxY] = this.getBBox(); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } }