@gravity-ui/graph
Version:
Modern graph editor component
319 lines (318 loc) • 11.6 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 { 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;