@gravity-ui/graph
Version:
Modern graph editor component
358 lines (357 loc) • 13.4 kB
JavaScript
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;