@gravity-ui/graph
Version:
Modern graph editor component
337 lines (336 loc) • 11.8 kB
JavaScript
import { batch, computed, signal } from "@preact/signals-core";
import { Block, isTBlock } from "../../components/canvas/blocks/Block";
import { generateRandomId } from "../../components/canvas/blocks/generate";
import { MultipleSelectionBucket } from "../../services/selection/MultipleSelectionBucket";
import { SingleSelectionBucket } from "../../services/selection/SingleSelectionBucket";
import { ESelectionStrategy } from "../../services/selection/types";
import { BlockState } from "./Block";
/**
* Storage for managing blocks state
*/
export class BlockListStore {
constructor(rootStore, graph) {
this.rootStore = rootStore;
this.graph = graph;
this.$blocksMap = signal(new Map());
this.$blocks = signal([]);
/**
* This signal is used to store blocks in reactive state.
* this signal fired for each change of the block state.
*
* NOTE: Please do not use it before you know what you are doing.
*/
this.$blocksReactiveState = computed(() => {
return Array.from(this.$blocksMap.value.values());
});
/**
* Computed signal that returns selected blocks as Block GraphComponent instances
* Automatically resolves BlockState to Block components via getViewComponent()
* Use this when you need to work with rendered Block components
*/
this.$selectedBlockComponents = computed(() => {
// Use the built-in $selectedComponents from BaseSelectionBucket
return this.blockSelectionBucket.$selectedComponents.value;
});
/**
* @deprecated Use blockSelectionBucket.$selectedEntities instead
* Computed signal that returns the currently selected blocks
*/
this.$selectedBlocks = computed(() => {
return this.blockSelectionBucket.$selectedEntities.value;
});
/**
* @deprecated Use anchorSelectionBucket.$selectedEntities instead
* Computed signal that returns the currently selected anchor
*/
this.$selectedAnchor = computed(() => {
const entities = this.anchorSelectionBucket.$selectedEntities.value;
return entities.length > 0 ? entities[0] : undefined;
});
this.blockSelectionBucket = new MultipleSelectionBucket("block", (payload, defaultAction) => {
return this.graph.executеDefaultEventAction("blocks-selection-change", payload, defaultAction);
}, (element) => element instanceof Block, (ids) => ids.map((id) => this.getBlockState(id)).filter((block) => block !== undefined));
this.anchorSelectionBucket = new SingleSelectionBucket("anchor", (diff, defaultAction) => {
if (diff.changes.add.length > 0) {
const anchorId = diff.changes.add[0];
const anchor = this.$blocks.value
.flatMap((block) => block.$anchorStates.value)
.find((a) => a.id === anchorId);
if (anchor) {
return this.graph.executеDefaultEventAction("block-anchor-selection-change", { anchor: anchor.asTAnchor(), selected: true }, defaultAction);
}
}
if (diff.changes.removed.length > 0) {
const anchorId = diff.changes.removed[0];
const anchor = this.$blocks.value
.flatMap((block) => block.$anchorStates.value)
.find((a) => a.id === anchorId);
if (anchor) {
return this.graph.executеDefaultEventAction("block-anchor-selection-change", { anchor: anchor.asTAnchor(), selected: false }, defaultAction);
}
}
return defaultAction();
}, undefined, (ids) => {
if (!this.rootStore.settings.getConfigFlag("useBlocksAnchors"))
return [];
const result = [];
for (const block of this.$blocks.value) {
for (const anchor of block.$anchorStates.value) {
if (ids.includes(anchor.id)) {
result.push(anchor);
}
}
}
return result;
});
this.blockSelectionBucket.attachToManager(this.rootStore.selectionService);
this.anchorSelectionBucket.attachToManager(this.rootStore.selectionService);
}
/**
* Sets anchor selection
*
* @param blockId {BlockState["id"]} Block id
* @param anchorId {AnchorState["id"]} Anchor id
* @param selected {boolean} Selected
* @returns void
*/
setAnchorSelection(blockId, anchorId, selected) {
const blockState = this.$blocksMap.value.get(blockId);
if (!blockState) {
return;
}
const anchor = blockState.getAnchorById(anchorId);
if (!anchor) {
return;
}
if (selected) {
this.anchorSelectionBucket.select([anchorId], ESelectionStrategy.REPLACE);
}
else {
this.anchorSelectionBucket.deselect([anchorId]);
}
}
/**
* Checks if a block is selected
*
* @param blockId {BlockState["id"]} Block id
* @returns {boolean} Is selected
*/
isSelectedBlock(blockId) {
return this.blockSelectionBucket.$selected.value.has(blockId);
}
unsetAnchorsSelection() {
this.anchorSelectionBucket.reset();
}
/**
* Updates block position
*
* @event block-change
* @param id {BlockState["id"]} Block id
* @param nextState {{x: number, y: number}} Next state
* @returns void
*/
updatePosition(id, nextState) {
const blockState = this.$blocksMap.value.get(id);
if (!blockState) {
return;
}
this.graph.executеDefaultEventAction("block-change", { block: blockState.asTBlock() }, () => {
blockState.updateBlock(nextState);
});
}
updateBlocksMap(blocks) {
this.$blocksMap.value = new Map(blocks);
this.$blocks.value = Array.from(this.$blocksMap.value.values());
}
/**
* Adds block
*
* If a block with this id already exists, it will be updated.
* If id is not provided, a random id will be generated.
*
* @param block {Omit<TBlock, "id"> & { id?: TBlockId }} Block to add
* @returns void
*/
addBlock(block) {
const id = block.id || generateRandomId("block");
this.$blocksMap.value.set(id, this.getOrCraeateBlockState({
id,
...block,
}));
this.updateBlocksMap(this.$blocksMap.value);
return id;
}
/**
* Deletes blocks
*
* @param blocks {TBlock["id"] | TBlock} Blocks to delete
* @returns void
*/
deleteBlocks(blocks) {
const map = new Map(this.$blocksMap.value);
blocks.forEach((bId) => {
const id = isTBlock(bId) ? bId.id : bId;
const block = map.get(id);
if (!block) {
return;
}
map.delete(id);
});
this.updateBlocksMap(map);
}
/**
* Updates blocks state
*
* If block with this id already exists, it will be updated.
* Otherwise, a new block will be created.
*
* @param blocks {TBlock[]} Blocks to update
* @returns void
*/
updateBlocks(blocks) {
this.updateBlocksMap(blocks.reduce((acc, block) => {
const state = this.getOrCraeateBlockState(block);
acc.set(block.id, state);
return acc;
}, this.$blocksMap.value));
}
/**
* Sets blocks state
*
* @param blocks {TBlock[]} Blocks to set
* @returns void
*/
setBlocks(blocks) {
const blockStates = blocks.map((block) => this.getOrCraeateBlockState(block));
this.applyBlocksState(blockStates);
}
getOrCraeateBlockState(block) {
const blockState = this.$blocksMap.value.get(block.id);
if (blockState) {
blockState.updateBlock(block);
return blockState;
}
return BlockState.fromTBlock(this, block);
}
applyBlocksState(blocks) {
this.updateBlocksMap(blocks.map((block) => [block.id, block]));
}
/**
* Updates block selection using the SelectionService
*
* @param ids Block IDs to update selection for
* @param selected Whether to select or deselect
* @param strategy The selection strategy to apply
*
* @returns void
*/
updateBlocksSelection(ids, selected, strategy = ESelectionStrategy.REPLACE) {
if (selected) {
this.blockSelectionBucket.select(ids, strategy);
}
else {
this.blockSelectionBucket.deselect(ids);
}
}
/**
* Gets connections of a block
*
* Method search connection with source/target block id.
* If you connect blocks via custom ports, this method will not work.
*
* @param blockId {TBlockId} Block id
* @returns {ConnectionState[]} Connections
*/
getBlockConnections(blockId) {
return this.rootStore.connectionsList.$connections.value.filter((connection) => [connection.targetBlockId, connection.sourceBlockId].includes(blockId));
}
/**
* Resets block selection
*
* @returns void
*/
resetSelection() {
batch(() => {
this.unsetAnchorsSelection();
this.blockSelectionBucket.reset();
});
}
/**
* Deletes selected blocks
*
* @returns void
*/
deleteSelectedBlocks() {
const selectedBlocks = this.$selectedBlocks.value;
selectedBlocks.forEach((block) => {
this.deleteAllBlockConnections(block.id);
});
const newBlocks = this.$blocks.value.filter((block) => !selectedBlocks.includes(block));
this.applyBlocksState(newBlocks);
}
/**
* Deletes all connections of a block
*
* Method search connection with source/target block id.
* If you connect blocks via custom ports, this method will not work.
*
* @param blockId {TBlockId} Block id
* @returns void
*/
deleteAllBlockConnections(blockId) {
const connections = this.getBlockConnections(blockId);
this.rootStore.connectionsList.deleteConnections(connections);
}
reset() {
this.applyBlocksState([]);
}
/**
* Gets blocks as JSON
*
* @returns {TBlock[]} Blocks
*/
toJSON() {
return this.$blocks.value.map((block) => block.asTBlock());
}
/**
* Gets block state by id
*
* @param id {TBlockId} Block id
* @returns {BlockState | undefined} Block state
*/
getBlockState(id) {
return this.$blocksMap.value.get(id);
}
/**
* Gets block by id
*
* @param id {TBlockId} Block id
* @returns {TBlock | undefined} Block
*/
getBlock(id) {
return this.getBlockState(id)?.asTBlock();
}
/**
* Gets blocks by ids
*
* If block with this id does not exist, it will filtered out.
*
* @param ids {BlockState["id"][]} Block ids
* @returns {TBlock[]} Blocks
*/
getBlocks(ids) {
return this.getBlockStates(ids).map((block) => block.asTBlock());
}
/**
* Gets block states by ids
*
* If block with this id does not exist, it will filtered out.
*
* @param ids {BlockState["id"][]} Block ids
* @returns {BlockState[]} Block states
*/
getBlockStates(ids) {
return ids.map((id) => this.getBlockState(id)).filter((block) => block !== undefined);
}
}