@gravity-ui/graph
Version:
Modern graph editor component
192 lines (191 loc) • 6.06 kB
JavaScript
import { computed, signal } from "@preact/signals-core";
import cloneDeep from "lodash/cloneDeep";
import { ESelectionStrategy } from "../../services/selection/types";
import { AnchorState } from "../anchor/Anchor";
export const IS_BLOCK_TYPE = "Block";
export class BlockState {
static fromTBlock(store, block) {
return new BlockState(store, block, store.blockSelectionBucket);
}
/**
* Block id
*/
get id() {
return this.$state.value.id;
}
/**
* Block x position
*/
get x() {
return this.$state.value.x;
}
/**
* Block y position
*/
get y() {
return this.$state.value.y;
}
/**
* Block width
*/
get width() {
return this.$state.value.width;
}
/**
* Block height
*/
get height() {
return this.$state.value.height;
}
/**
* Block selected
*/
get selected() {
return this.$selected.value;
}
constructor(store, block, blockSelectionBucket) {
this.store = store;
this.blockSelectionBucket = blockSelectionBucket;
this.$rawState = signal(undefined);
/**
* Block state signal
*
* @returns {ReadonlySignal<T>} Block state
*/
this.$state = computed(() => ({
...this.$rawState.value,
selected: this.$selected.value,
}));
/**
* Computed signal that reactively determines if this block is selected
* by checking if its ID exists in the selection bucket
*/
this.$selected = computed(() => this.blockSelectionBucket.isSelected(this.$rawState.value.id));
this.$anchorStates = signal([]);
/**
* Block geometry signal
*
* Pay attention!! x and y are rounded to integer
*
* @returns {ReadonlySignal<{x: number, y: number, width: number, height: number}>} Block geometry
*/
this.$geometry = computed(() => {
const state = this.$state.value;
return {
x: state.x | 0,
y: state.y | 0,
width: state.width,
height: state.height,
};
});
/**
* Block anchor indexes signal
*
* @returns {ReadonlySignal<Map<string, number>>} Block anchor indexes
*/
this.$anchorIndexs = computed(() => {
const typeIndex = {};
return new Map(this.$anchorStates.value
?.sort((a, b) => (a.state.index || 0) - (b.state.index || 0))
.map((anchorState) => {
if (!typeIndex[anchorState.state.type]) {
typeIndex[anchorState.state.type] = 0;
}
return [anchorState.id, typeIndex[anchorState.state.type]++];
}) || []);
});
/**
* Block anchors signal
*
* @returns {ReadonlySignal<TAnchor[]>} Block anchors
*/
this.$anchors = computed(() => {
return this.$anchorStates.value?.map((anchorState) => anchorState.asTAnchor()) || [];
});
/**
* Block selected anchors signal
*
* @returns {TAnchor[]} Block selected anchors
*/
this.$selectedAnchors = computed(() => {
return (this.$anchorStates.value?.filter((anchorState) => this.store.anchorSelectionBucket.$selected.value.has(anchorState.id)) || []);
});
this.$rawState.value = block;
this.$anchorStates.value = block.anchors?.map((anchor) => new AnchorState(this, anchor)) ?? [];
}
onAnchorSelected(anchorId, selected) {
this.store.setAnchorSelection(this.id, anchorId, selected);
}
setSelection(selected, strategy = ESelectionStrategy.REPLACE) {
this.store.updateBlocksSelection([this.id], selected, strategy);
}
getSelectedAnchor() {
return this.$selectedAnchors.value[0];
}
getAnchorState(id) {
return this.$anchorStates.value.find((state) => state.id === id);
}
updateXY(x, y, forceUpdate = false) {
this.store.updatePosition(this.id, { x, y });
if (forceUpdate) {
this.blockView.updatePosition(x, y, true);
}
}
setViewComponent(blockComponent) {
this.blockView = blockComponent;
}
getViewComponent() {
return this.blockView;
}
getConnections() {
return this.store.getBlockConnections(this.id);
}
clearAnchorsSelection() {
this.store.anchorSelectionBucket.reset();
}
setName(newName) {
this.$rawState.value = {
...this.$rawState.value,
name: newName,
};
}
updateAnchors(anchors) {
const anchorsMap = new Map(this.$anchorStates.value.map((a) => [a.id, a]));
this.$anchorStates.value = anchors.map((anchor) => {
if (anchorsMap.has(anchor.id)) {
const anchorState = anchorsMap.get(anchor.id);
anchorState.update(anchor);
return anchorState;
}
return new AnchorState(this, anchor);
});
}
/**
* Updates block state
*
* @param block {Partial<TBlock>} Block to update
* @returns void
*/
updateBlock(block) {
// Update anchors first to ensure they have correct state when geometry changes
if (block.anchors) {
this.updateAnchors(block.anchors);
}
this.$rawState.value = Object.assign({}, this.$rawState.value, block);
this.getViewComponent()?.updateHitBox(this.$geometry.value, true);
}
getAnchorById(anchorId) {
return this.$anchorStates.value.find((anchor) => anchor.id === anchorId);
}
/**
* Converts the block state to a TBlock
*
* @returns {TBlock} TBlock
*/
asTBlock() {
return cloneDeep({
...this.$rawState.toJSON(),
selected: this.$selected.value,
});
}
}