UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

358 lines (357 loc) 13.4 kB
import { signal } from "@preact/signals-core"; import cloneDeep from "lodash/cloneDeep"; import isObject from "lodash/isObject"; import { ECameraScaleLevel } from "../../../services/camera/CameraService"; import { EAnchorType } from "../../../store/anchor/Anchor"; import { IS_BLOCK_TYPE } from "../../../store/block/Block"; import { selectBlockById } from "../../../store/block/selectors"; import { createAnchorPortId, createBlockPointPortId } from "../../../store/connection/port/utils"; import { isAllowDrag } from "../../../utils/functions"; import { renderText } from "../../../utils/renderers/text"; import { GraphComponent } from "../GraphComponent"; import { Anchor } from "../anchors"; import { BlockController } from "./controllers/BlockController"; export function isTBlock(block) { return isObject(block); } export class Block extends GraphComponent { currentState() { return this.connectedState.$state.value; } constructor(props, parent) { super(props, parent); this.cursor = "pointer"; // from controller mixin this.isBlock = true; this.startDragCoords = []; this.blockController = new BlockController(this); this.$viewState = signal({ zIndex: 0, order: 0 }); this.updateHitBox = (geometry, force) => { this.setHitBox(geometry.x, geometry.y, geometry.x + geometry.width, geometry.y + geometry.height, force); }; this.binderGetAnchorPosition = (anchor) => { return this.getConnectionAnchorPosition(anchor); }; this.subscribe(props.id); } getEntityId() { return this.props.id; } isRendered() { return this.shouldRender; } updateViewState(params) { let hasChanges = false; for (const [key, value] of Object.entries(params)) { if (this.$viewState.value[key] !== value) { hasChanges = true; break; } } if (!hasChanges) { return; } this.$viewState.value = { ...this.$viewState.value, ...params, }; } getGeometry() { return { x: this.state.x, y: this.state.y, width: this.state.width, height: this.state.height, }; } getConfigFlag(flagPath) { return this.context.graph.rootStore.settings.getConfigFlag(flagPath); } subscribe(id) { this.connectedState = selectBlockById(this.context.graph, id); this.state = cloneDeep(this.connectedState.$state.value); this.connectedState.setViewComponent(this); this.setState({ ...this.connectedState.$state.value, anchors: this.connectedState.$anchors.value, }); this.updateViewState({ zIndex: this.zIndex, order: this.renderOrder, }); // Initialize ports return [ this.subscribeSignal(this.connectedState.$anchors, () => { this.setState({ anchors: this.connectedState.$anchors.value, }); this.shouldUpdateChildren = true; }), this.subscribeSignal(this.connectedState.$state, () => { this.setState({ ...this.connectedState.$state.value, anchors: this.connectedState.$anchors.value, }); this.updateHitBox(this.connectedState.$geometry.value); this.updatePortPositions(); }), ]; } getNextState() { // @ts-ignore return this.__data.nextState || this.state; } didIterate() { if (this.$viewState.value.zIndex !== this.zIndex || this.$viewState.value.order !== this.renderOrder) { this.updateViewState({ zIndex: this.zIndex, order: this.renderOrder, }); } } calcZIndex() { const raised = this.connectedState.$selected.value || this.lastDragEvent ? 1 : 0; return this.context.constants.block.DEFAULT_Z_INDEX + raised; } raiseBlock() { this.zIndex = this.calcZIndex(); this.performRender(); } stateChanged(nextState) { if (!this.firstRender && nextState.selected !== this.state.selected) { this.raiseBlock(); } return super.stateChanged(nextState); } getRenderIndex() { return this.renderOrder; } updatePosition(x, y, silent = false) { if (!silent) { this.connectedState.updateXY(x, y); } this.setState({ x, y }); this.updatePortPositions(); } updatePortPositions() { // Update input port position const inputPoint = this.getConnectionPoint("in"); this.getInputPort().setPoint(inputPoint.x, inputPoint.y); // Update output port position const outputPoint = this.getConnectionPoint("out"); this.getOutputPort().setPoint(outputPoint.x, outputPoint.y); // Update anchor ports positions this.connectedState.$anchors.value.forEach((anchor) => { const port = this.getAnchorPort(anchor.id); if (port) { const anchorPoint = this.getConnectionAnchorPosition(anchor); port.setPoint(anchorPoint.x, anchorPoint.y); } }); } getInputPort() { return this.getPort(createBlockPointPortId(this.state.id, true)); } getOutputPort() { return this.getPort(createBlockPointPortId(this.state.id, false)); } getAnchorPort(anchorId) { return this.getPort(createAnchorPortId(this.state.id, anchorId)); } /** * Check if block can be dragged based on canDrag setting */ isDraggable() { const canDrag = this.context.graph.rootStore.settings.$canDrag.value; return isAllowDrag(canDrag, this.connectedState.$selected.value); } /** * Handle drag start - emit event and initialize drag state */ handleDragStart(context) { this.context.graph.executеDefaultEventAction("block-drag-start", { nativeEvent: context.sourceEvent, block: this.connectedState.asTBlock(), }, () => { this.lastDragEvent = context.sourceEvent; // Store start coords: [worldX, worldY, blockX, blockY] this.startDragCoords = [...context.startCoords, this.state.x, this.state.y]; this.raiseBlock(); }); } /** * Handle drag update - calculate new position and update block */ handleDrag(diff, context) { if (!this.startDragCoords.length) return; this.lastDragEvent = context.sourceEvent; const [x, y] = this.calcNextDragPosition(context.currentCoords[0], context.currentCoords[1]); this.context.graph.executеDefaultEventAction("block-drag", { nativeEvent: context.sourceEvent, block: this.connectedState.asTBlock(), x, y, }, () => this.applyNextPosition(x, y)); } /** * Handle drag end - finalize drag state */ handleDragEnd(context) { if (!this.startDragCoords.length) return; this.context.graph.emit("block-drag-end", { nativeEvent: context.sourceEvent, block: this.connectedState.asTBlock(), }); this.lastDragEvent = undefined; this.startDragCoords = []; this.updateHitBox(this.state); } calcNextDragPosition(x, y) { // Calculate displacement from drag start (consistent with DragDiff API) const diffX = (x - this.startDragCoords[0]) | 0; const diffY = (y - this.startDragCoords[1]) | 0; // Apply displacement to initial block position let nextX = this.startDragCoords[2] + diffX; let nextY = this.startDragCoords[3] + diffY; const spanGridSize = this.context.constants.block.SNAPPING_GRID_SIZE; if (spanGridSize > 1) { nextX = Math.round(nextX / spanGridSize) * spanGridSize; nextY = Math.round(nextY / spanGridSize) * spanGridSize; } return [nextX, nextY]; } applyNextPosition(x, y) { this.updatePosition(x, y); } /* Calculate the position of the anchor based on the absolute position of the block. */ getConnectionAnchorPosition(anchor) { const { x, y } = this.getAnchorPosition(anchor); return { x: x + this.connectedState.x, y: y + this.connectedState.y, }; } /* Calculate the position of the anchors relative to the block container. */ getAnchorPosition(anchor) { const index = this.connectedState.$anchorIndexs.value?.get(anchor.id) || 0; const offset = this.context.constants.block.HEAD_HEIGHT + this.context.constants.block.BODY_PADDING; return { x: anchor.type === EAnchorType.OUT ? this.state.width : 0, y: offset + index * this.context.constants.system.GRID_SIZE * 2, }; } getConnectionPoint(direction) { return { x: this.connectedState.x + (direction === "out" ? this.connectedState.width : 0), y: (this.connectedState.y + this.connectedState.height / 2) | 0, }; } renderAnchor(anchor, getPosition) { return Anchor.create({ ...anchor, zIndex: this.zIndex, size: 18, lineWidth: 2, getPosition, }, { key: anchor.id, }); } isAnchorsAllowed() { return Array.isArray(this.state.anchors) && this.state.anchors.length && this.getConfigFlag("useBlocksAnchors"); } updateChildren() { if (!this.isAnchorsAllowed()) { return undefined; } return this.state.anchors.map((anchor) => { return this.renderAnchor(anchor, this.binderGetAnchorPosition); }); } willRender() { super.willRender(); const scale = this.context.camera.getCameraScale(); this.shouldRenderText = scale > this.context.constants.block.SCALES[0]; } renderStroke(color) { this.context.ctx.lineWidth = Math.round(3 / this.context.camera.getCameraScale()); this.context.ctx.strokeStyle = color; this.context.ctx.strokeRect(this.state.x, this.state.y, this.state.width, this.state.height); } /* Returns rect of block size with padding */ getContentRect() { return { x: this.state.x + this.context.constants.text.PADDING, y: this.state.y + this.context.constants.text.PADDING, height: this.state.height - this.context.constants.text.PADDING * 2, width: this.state.width - this.context.constants.text.PADDING * 2, }; } renderText(text, ctx = this.context.ctx, { rect, renderParams } = { rect: this.getContentRect(), renderParams: { font: this.props.font }, }) { renderText(text, ctx, rect, renderParams); } renderMinimalisticBlock(ctx) { this.renderSchematicView(ctx); } renderBody(ctx) { ctx.fillStyle = this.context.colors.block.background; ctx.strokeStyle = this.context.colors.block.border; ctx.fillRect(this.state.x, this.state.y, this.state.width, this.state.height); this.renderStroke(this.state.selected ? this.context.colors.block.selectedBorder : this.context.colors.block.border); } renderSchematicView(ctx) { this.renderBody(ctx); if (this.shouldRenderText) { ctx.fillStyle = this.context.colors.block.text; ctx.textAlign = "center"; this.renderText(this.state.name, ctx); } } setHiddenBlock(hidden) { if (this.hidden !== hidden) { this.hidden = hidden; this.shouldRender = !hidden; this.performRender(); } } // eslint-disable-next-line @typescript-eslint/no-unused-vars renderDetailedView(ctx) { return this.renderBody(ctx); } render() { if (this.hidden) { return; } const scaleLevel = this.context.graph.cameraService.getCameraBlockScaleLevel(); switch (scaleLevel) { case ECameraScaleLevel.Minimalistic: { this.renderMinimalisticBlock(this.context.ctx); break; } case ECameraScaleLevel.Schematic: { this.renderSchematicView(this.context.ctx); break; } case ECameraScaleLevel.Detailed: { this.renderDetailedView(this.context.ctx); break; } } } unmount() { // Release ownership of all ports owned by this block const connectionsList = this.context.graph.rootStore.connectionsList; connectionsList.releasePort(createBlockPointPortId(this.state.id, true), this); connectionsList.releasePort(createBlockPointPortId(this.state.id, false), this); this.state.anchors.forEach((anchor) => { connectionsList.releasePort(createAnchorPortId(this.state.id, anchor.id), this); }); super.unmount(); } } Block.IS = IS_BLOCK_TYPE;