@gravity-ui/graph
Version:
Modern graph editor component
220 lines (219 loc) • 8.26 kB
JavaScript
import { computed, signal } from "@preact/signals-core";
import { ESelectionStrategy, } from "./types";
/**
* @abstract
* @template IDType The type of the unique identifier for the entities managed by this bucket (e.g., `string`, `number`).
* @template TEntity The type of the entity that IDs resolve to. Must extend TSelectionEntity (GraphComponent or IEntityWithComponent).
*
* Base class for selection buckets.
*
* Selection buckets are fundamental components of the {@link SelectionService}.
* Each bucket is responsible for managing the selection state (the set of selected IDs)
* for a specific entity type within the graph (e.g., blocks, connections, anchors, or custom entities).
*
* This abstract class provides a common structure and basic functionality for all selection buckets,
* including managing the internal reactive state of selected IDs using `@preact/signals-core`
* and handling interaction with the central {@link SelectionService}.
*
* When creating a custom selection bucket, you should extend this class (or its concrete implementations
* like {@link MultipleSelectionBucket} or {@link SingleSelectionBucket}) and define the specific
* logic for selecting and deselecting entities of its type.
*
* @example
* ```typescript
* class MySelectionBucket extends BaseSelectionBucket<string, MyEntity> {
* public updateSelection(ids: string[], select: boolean, strategy: ESelectionStrategy, silent?: boolean): void {
* // implementation
* }
* }
* ```
*
* @implements {ISelectionBucket<IDType, TEntity>}
* @see {@link SelectionService}
* @see {@link ISelectionBucket}
* @see {@link MultipleSelectionBucket}
* @see {@link SingleSelectionBucket}
* @see {@linkplain ../../docs/system/selection-manager.md SelectionManager Documentation} for more details on selection architecture.
*/
export class BaseSelectionBucket {
/**
* Check if an entity is a GraphComponent
*/
isGraphComponent(entity) {
return (typeof entity === "object" &&
entity !== null &&
"getEntityId" in entity &&
typeof entity.getEntityId === "function");
}
/**
* Check if an entity has getViewComponent method
*/
hasViewComponent(entity) {
return (typeof entity === "object" &&
entity !== null &&
"getViewComponent" in entity &&
typeof entity.getViewComponent === "function");
}
constructor(entityType, onSelectionChange = (_payload, defaultAction) => {
const result = defaultAction();
return result ?? true;
}, isRelatedElement, resolver) {
this.entityType = entityType;
this.onSelectionChange = onSelectionChange;
this.isRelatedElement = isRelatedElement;
this.resolver = resolver;
this.$selectedIds = signal(new Set());
this.$selected = computed(() => new Set(this.$selectedIds.value));
/**
* Computed signal that resolves selected IDs to their corresponding entities.
* Returns an empty array if no resolver function is provided.
*/
this.$selectedEntities = computed(() => {
if (!this.resolver) {
return [];
}
const ids = Array.from(this.$selectedIds.value);
return this.resolver(ids);
});
/**
* Computed signal that resolves selected entities to their GraphComponent views.
* Works with entities that:
* - Are GraphComponent instances themselves
* - Implement IEntityWithComponent interface (have getViewComponent() method)
* Returns an empty array if entities cannot be resolved to components.
*/
this.$selectedComponents = computed(() => {
const entities = this.$selectedEntities.value;
if (entities.length === 0) {
return [];
}
return entities
.map((entity) => {
// Check if entity is already a GraphComponent
if (this.isGraphComponent(entity)) {
return entity;
}
// Check if entity has getViewComponent method
if (this.hasViewComponent(entity)) {
return entity.getViewComponent();
}
return undefined;
})
.filter((component) => component !== undefined);
});
}
/**
* Attaches the bucket to the manager
*
* @param manager {SelectionService} - The manager to attach to
* @returns void
*/
attachToManager(manager) {
manager.registerBucket(this);
this.manager = manager;
}
/**
* Detaches the bucket from the manager
* @param manager {SelectionService} - The manager to detach from
* @returns void
*/
detachFromManager(manager) {
manager.unregisterBucket(this);
this.manager = undefined;
}
/**
* Selects the given ids
*
* @param ids {IDType[]} - The ids to select
* @param strategy {ESelectionStrategy} - The strategy to use
* @param silent {boolean} - Whether to suppress the selection change event
* @returns void
*/
select(ids, strategy = ESelectionStrategy.REPLACE, silent) {
if (this.manager) {
this.manager.select(this.entityType, ids, strategy);
}
else {
this.updateSelection(ids, true, strategy, silent);
}
}
/**
* Deselects the given ids
* Passed ids will be deselected with strategy SUBTRACT
*
* @param ids {IDType[]} - The ids to deselect
* @param silent {boolean} - Whether to suppress the selection change event
* @returns void
*/
deselect(ids, silent) {
if (this.manager) {
this.manager.deselect(this.entityType, ids);
}
else {
this.updateSelection(ids, false, ESelectionStrategy.SUBTRACT, silent);
}
}
/**
* Resets the selection
* All selected ids will be deselected with strategy SUBTRACT
*
* @returns void
*/
reset() {
const currentSelectedIds = Array.from(this.$selectedIds.value);
if (currentSelectedIds.length > 0) {
this.updateSelection(currentSelectedIds, false, ESelectionStrategy.SUBTRACT);
}
}
/**
* Checks if the given id is selected
*
* @param id {IDType} - The id to check
* @returns boolean
*/
isSelected(id) {
return this.$selectedIds.value.has(id);
}
/**
* Applies the selection
* Generate diff between new and current selected ids and run onSelectionChange callback
* If silent is true, the nextSelection state will be applied immediately, otherwise it will be applied after the callback is executed and
*
* @param newSelectedIds {Set<IDType>} - The new selected ids
* @param currentSelectedIds {Set<IDType>} - The current selected ids
* @param silent {boolean} - Whether to suppress the selection change event
* @returns void
*/
applySelection(newSelectedIds, currentSelectedIds, silent) {
const addedIds = [];
const removedIds = [];
for (const id of newSelectedIds) {
if (!currentSelectedIds.has(id)) {
addedIds.push(id);
}
}
for (const id of currentSelectedIds) {
if (!newSelectedIds.has(id)) {
removedIds.push(id);
}
}
if (addedIds.length > 0 || removedIds.length > 0) {
const payload = {
list: Array.from(newSelectedIds),
changes: {
add: addedIds,
removed: removedIds,
},
};
let callbackUpdated = false;
const updateSelection = (rewritenIds) => {
this.$selectedIds.value = rewritenIds ?? newSelectedIds;
callbackUpdated = true;
};
const shouldUpdate = silent || this.onSelectionChange(payload, updateSelection);
if (shouldUpdate && !callbackUpdated) {
updateSelection();
}
}
}
}