UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

224 lines (223 loc) 7.28 kB
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.$viewComponent = signal(undefined); /** Reactive flag that mirrors the canvas component's hidden state. */ this.$hidden = signal(false); 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.$viewComponent.value?.updatePosition(x, y, true); } } setViewComponent(blockComponent) { this.$viewComponent.value = blockComponent; if (this.pendingHidden !== undefined) { blockComponent.setHiddenBlock(this.pendingHidden); this.pendingHidden = undefined; } } /** * Request to hide or show this block's canvas component. * Safe to call before the canvas component is mounted — the request is * deferred and applied automatically in setViewComponent(). */ requestHidden(hidden) { this.$hidden.value = hidden; const canvasBlock = this.getViewComponent(); if (canvasBlock) { canvasBlock.setHiddenBlock(hidden); } else { this.pendingHidden = hidden; } } getViewComponent() { return this.$viewComponent.value; } 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, }); } /** * Cheap snapshot for graph events (e.g. `block-change`): one object spread, no deep clone. * Nested values (`anchors`, `meta`, `settings`, …) are shared with the live store — treat as read-only. */ asTBlockShallow() { return { ...this.$rawState.value, selected: this.$selected.value, }; } }