@gravity-ui/graph
Version:
Modern graph editor component
220 lines (219 loc) • 8.02 kB
JavaScript
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 };
}
}