@gravity-ui/graph
Version:
Modern graph editor component
194 lines (193 loc) • 6.05 kB
JavaScript
import { computed, signal } from "@preact/signals-core";
export const IS_PORT_TYPE = "Port";
/**
* PortState - Reactive state container for a connection port
*
* Manages the lifecycle and state of a port, including position updates,
* component ownership, and listener management for connections that use this port.
*
* ## Key Concepts:
*
* ### Lazy Creation
* Ports are created on-demand when connections need them, even if the target
* component doesn't exist yet. This solves initialization order problems.
*
* ### Lookup State
* When `lookup: true`, the port is waiting for its component to provide coordinates.
* When `lookup: false`, the port has valid coordinates and can be used for rendering.
*
* ### Listener Management
* Tracks which components are listening to this port's changes. When no listeners
* remain and no component owns the port, it can be safely garbage collected.
*/
export class PortState {
/**
* Get the port's unique identifier
*
* @returns {TPortId} The port's ID
*/
get id() {
return this.$state.value.id;
}
/**
* Get the port's effective X coordinate (respects delegation)
*
* @returns {number} The X coordinate
*/
get x() {
return this.$point.value.x;
}
/**
* Get the port's effective Y coordinate (respects delegation)
*
* @returns {number} The Y coordinate
*/
get y() {
return this.$point.value.y;
}
/**
* Get the component that owns this port
*
* @returns {Component | undefined} The owning component, if any
*/
get component() {
return this.owner || this.$state.value.component;
}
/**
* Get whether the port is in lookup state (waiting for coordinates)
*
* @returns {boolean | undefined} True if waiting for coordinates, false if resolved
*/
get lookup() {
return this.$state.value.lookup;
}
/**
* Get the port's metadata
*
* @returns {T | undefined} The metadata attached to this port
*/
get meta() {
return this.$state.value.meta;
}
constructor(port) {
this.$state = signal(undefined);
this.$delegate = signal(undefined);
/**
* Set of references observing this port's changes
*
* Used for reference counting to determine when the port can be safely deleted.
* Stores actual object references to ensure accurate counting and prevent duplicates.
*/
this.observers = new Set();
this.$point = computed(() => {
const delegate = this.$delegate.value;
if (delegate) {
return delegate.$point.value;
}
return { x: this.$state.value.x, y: this.$state.value.y };
});
this.$state.value = { ...port };
// Initialize owner if component was provided in the constructor
if (port.component) {
this.owner = port.component;
}
}
/**
* Set the component that owns this port
* @param owner Component that will own this port (block, anchor, etc.)
* @returns void
*/
setOwner(owner) {
this.owner = owner;
this.updatePort({ component: owner, lookup: false });
}
/**
* Remove the current owner from this port
*/
removeOwner() {
this.owner = undefined;
this.updatePort({ component: undefined, lookup: true });
}
/**
* Add an observer reference to this port
* Stores the actual reference for accurate counting
* @param observer The object observing this port
*/
addObserver(observer) {
this.observers.add(observer);
}
/**
* Remove an observer reference from this port
* Removes the actual reference from the set
* @param observer The object to stop observing this port
*/
removeObserver(observer) {
this.observers.delete(observer);
}
/**
* Update the port's position coordinates.
* When delegated, the position is saved but does not affect getPoint() —
* the effective position comes from the delegate port.
* @param x New X coordinate
* @param y New Y coordinate
*/
setPoint(x, y) {
if (this.$delegate.value) {
this.savedPoint = { x, y };
return;
}
this.updatePort({ x, y });
}
getPoint() {
return this.$point.value;
}
/**
* Delegate this port to mirror another port's position.
* While delegated, getPoint() returns the target port's position.
* Any setPoint() calls are saved and restored on undelegate().
* @param target The port to mirror
*/
delegate(target) {
this.savedPoint = { x: this.$state.value.x, y: this.$state.value.y };
this.$delegate.value = target;
}
/**
* Remove delegation and restore the last saved position.
* If setPoint() was called during delegation, the last value is used.
* Otherwise, the position from before delegation is restored.
*/
undelegate() {
this.$delegate.value = undefined;
if (this.savedPoint) {
this.updatePort({ x: this.savedPoint.x, y: this.savedPoint.y });
this.savedPoint = undefined;
}
}
/**
* Whether this port is currently delegated to another port
*/
get isDelegated() {
return this.$delegate.value !== undefined;
}
/**
* Update port state with partial data
* @param port Partial port data to merge with current state
*/
updatePort(port) {
this.$state.value = {
...this.$state.value,
...port,
meta: {
...this.$state.value.meta,
...port.meta,
},
};
}
/**
* Check if this port can be safely deleted
* @returns true if port has no owner and no observers
*/
canBeDeleted() {
return !this.owner && this.observers.size === 0;
}
}