UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

319 lines (318 loc) 11.6 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 { getXY } from "../../../utils/functions"; import { renderText } from "../../../utils/renderers/text"; import { EVENTS } from "../../../utils/types/events"; 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); // 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); this.addEventListener(EVENTS.DRAG_START, this); this.addEventListener(EVENTS.DRAG_UPDATE, this); this.addEventListener(EVENTS.DRAG_END, this); } 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, }); 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); }), ]; } 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 || 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 }); } handleEvent(event) { switch (event.type) { case EVENTS.DRAG_START: { this.onDragStart(event.detail.sourceEvent); break; } case EVENTS.DRAG_UPDATE: { this.onDragUpdate(event.detail.sourceEvent); break; } case EVENTS.DRAG_END: { this.onDragEnd(event.detail.sourceEvent); break; } } } onDragStart(event) { this.context.graph.executеDefaultEventAction("block-drag-start", { nativeEvent: event, block: this.connectedState.asTBlock(), }, () => { this.lastDragEvent = event; const xy = getXY(this.context.canvas, event); this.startDragCoords = this.context.camera.applyToPoint(xy[0], xy[1]).concat([this.state.x, this.state.y]); this.raiseBlock(); }); } onDragUpdate(event) { if (!this.startDragCoords) return; this.lastDragEvent = event; const [canvasX, canvasY] = getXY(this.context.canvas, event); const [cameraSpaceX, cameraSpaceY] = this.context.camera.applyToPoint(canvasX, canvasY); const [x, y] = this.calcNextDragPosition(cameraSpaceX, cameraSpaceY); this.context.graph.executеDefaultEventAction("block-drag", { nativeEvent: event, block: this.connectedState.asTBlock(), x, y, }, () => this.applyNextPosition(x, y)); } calcNextDragPosition(x, y) { const diffX = (this.startDragCoords[0] - x) | 0; const diffY = (this.startDragCoords[1] - y) | 0; 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); } onDragEnd(event) { if (!this.startDragCoords) return; this.context.graph.emit("block-drag-end", { nativeEvent: event, block: this.connectedState.asTBlock(), }); this.lastDragEvent = undefined; this.startDragCoords = []; this.updateHitBox(this.state); } /* 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; } } } } Block.IS = IS_BLOCK_TYPE;