@gravity-ui/graph
Version:
Modern graph editor component
273 lines (272 loc) • 10.2 kB
JavaScript
import { ESelectionStrategy } from "../../../services/selection/types";
import { isAllowDrag, isMetaKeyEvent } from "../../../utils/functions";
import { layoutText } from "../../../utils/renderers/text";
import { GraphComponent } from "../GraphComponent";
const defaultStyle = {
background: "rgba(100, 100, 100, 0.1)",
border: "rgba(100, 100, 100, 0.3)",
borderWidth: 2,
selectedBackground: "rgba(100, 100, 100, 1)",
selectedBorder: "rgba(100, 100, 100, 1)",
highlightedBackground: "rgba(100, 200, 100, 0.3)",
highlightedBorder: "rgba(100, 200, 100, 0.8)",
};
const defaultGeometry = {
padding: [20, 20, 20, 20],
};
export class Group extends GraphComponent {
static define(config) {
return class SpecificGroup extends Group {
constructor(props, parent) {
super({
...props,
style: {
...defaultStyle,
...config.style,
...props.style,
},
geometry: {
...defaultGeometry,
...config.geometry,
...props.geometry,
},
}, parent);
}
};
}
get zIndex() {
return 0;
}
constructor(props, parent) {
super(props, parent);
this.blocks = [];
this.cursor = "pointer";
/** Whether the group is highlighted (block is being dragged over it) */
this.highlighted = false;
/** Whether the group is currently being dragged */
this.isDragging = false;
this.dragStartRect = null;
this.lastSnappedPos = null;
this.handleClick = (event) => {
event.stopPropagation();
const isMeta = isMetaKeyEvent(event);
this.groupState.setSelection(!isMeta ? true : !this.groupState.$selected.value, !isMeta ? ESelectionStrategy.REPLACE : ESelectionStrategy.APPEND);
};
this.style = {
...defaultStyle,
...props.style,
};
this.geometry = {
...defaultGeometry,
...props.geometry,
};
this.subscribeToGroup();
this.addEventListener("click", this.handleClick);
}
/**
* Set the highlighted state of the group
*/
setHighlighted(highlighted) {
if (this.highlighted !== highlighted) {
this.highlighted = highlighted;
this.performRender();
}
}
/**
* Check if the group is currently highlighted (block is being dragged over it).
*
* This method is useful for custom group components that override the `render()` method
* and need to apply different styling when the group is highlighted during transfer mode.
*
* @returns `true` if the group is highlighted, `false` otherwise
*
* @example
* ```typescript
* class CustomGroup extends Group {
* protected override render() {
* const ctx = this.context.ctx;
* const rect = this.getRect();
*
* // Apply different styles based on state
* if (this.isHighlighted()) {
* ctx.strokeStyle = 'rgba(100, 200, 100, 1)';
* ctx.lineWidth = 3;
* } else if (this.state.selected) {
* ctx.strokeStyle = 'rgba(100, 100, 100, 1)';
* ctx.lineWidth = 2;
* } else {
* ctx.strokeStyle = 'rgba(100, 100, 100, 0.4)';
* ctx.lineWidth = 1;
* }
*
* ctx.beginPath();
* ctx.roundRect(rect.x, rect.y, rect.width, rect.height, 8);
* ctx.stroke();
* }
* }
* ```
*/
isHighlighted() {
return this.highlighted;
}
getEntityId() {
return this.props.id;
}
/**
* Check if group can be dragged based on props.draggable and canDrag setting
*/
isDraggable() {
const canDrag = this.context.graph.rootStore.settings.$canDrag.value;
return Boolean(this.props.draggable) && isAllowDrag(canDrag, Boolean(this.state.selected));
}
/**
* Override to apply snapping or other position transforms during drag.
* Called with the raw (pre-snap) target position computed from the drag start + cumulative diff.
* The default implementation returns the position unchanged (no snapping).
*
* @example
* ```typescript
* protected override snapPosition(x: number, y: number) {
* const grid = 16;
* return { x: Math.round(x / grid) * grid, y: Math.round(y / grid) * grid };
* }
* ```
*/
snapPosition(x, y) {
const gridSize = this.context.constants.block.SNAPPING_GRID_SIZE;
if (gridSize <= 1) {
return { x, y };
}
return {
x: Math.round(x / gridSize) * gridSize,
y: Math.round(y / gridSize) * gridSize,
};
}
/**
* Handle drag start - stores the initial rect position and sets isDragging flag.
* Subclasses that override this method should call super.handleDragStart() to preserve this behavior.
*/
handleDragStart(_context) {
this.isDragging = true;
this.dragStartRect = { x: this.state.rect.x, y: this.state.rect.y };
this.lastSnappedPos = { x: this.state.rect.x, y: this.state.rect.y };
}
/**
* Handle drag update - moves the group rect and notifies via onDragUpdate.
* Uses the cumulative diff from drag start so that snapPosition always operates
* on the absolute target position (avoids error accumulation from per-frame deltas).
* onDragUpdate is called only when the position actually changes.
*/
handleDrag(diff, _context) {
if (!this.dragStartRect || !this.lastSnappedPos) {
return;
}
const { x: newX, y: newY } = this.snapPosition(this.dragStartRect.x + diff.diffX, this.dragStartRect.y + diff.diffY);
const deltaX = newX - this.lastSnappedPos.x;
const deltaY = newY - this.lastSnappedPos.y;
this.lastSnappedPos = { x: newX, y: newY };
const rect = {
x: newX,
y: newY,
width: this.state.rect.width,
height: this.state.rect.height,
};
this.setState({ rect });
this.updateHitBox(rect);
if (deltaX !== 0 || deltaY !== 0) {
this.props.onDragUpdate(this.props.id, { deltaX, deltaY });
}
}
/**
* Handle drag end - clears isDragging flag and drag tracking state.
* Subclasses that override this method should call super.handleDragEnd() to preserve this behavior.
*/
handleDragEnd(_context) {
this.isDragging = false;
this.dragStartRect = null;
this.lastSnappedPos = null;
}
getRect(rect = this.state.rect) {
const [paddingTop, paddingRight, paddingBottom, paddingLeft] = this.geometry.padding;
return {
x: rect.x - paddingLeft,
y: rect.y - paddingTop,
width: rect.width + paddingLeft + paddingRight,
height: rect.height + paddingTop + paddingBottom,
};
}
subscribeToGroup() {
this.groupState = this.context.graph.rootStore.groupsList.getGroupState(this.props.id);
this.subscribeSignal(this.groupState.$selected, (selected) => {
this.setState({
selected,
});
});
this.groupState.setViewComponent(this);
return this.subscribeSignal(this.groupState.$state, (group) => {
if (!group) {
return;
}
if (this.isDragging) {
// Suppress rect update during drag to prevent the block bounding-box signal chain
// from overwriting the position set by handleDrag / subclass snapping logic.
// Use getState() instead of this.state to get the pending nextState (which includes
// the rect set by handleDrag in the same frame), preventing it from being overwritten
// with the stale this.state.rect.
const { rect: _rect, ...groupWithoutRect } = group;
this.setState({
...this.getState(),
...groupWithoutRect,
});
}
else {
this.setState({
...this.state,
...group,
});
// Inner blocks-area rect; updateHitBox applies geometry padding via getRect().
this.updateHitBox(group.rect);
}
});
}
unmount() {
this.groupState.setViewComponent(undefined);
super.unmount();
}
updateHitBox(rect) {
const hitArea = this.getRect(rect);
this.setHitBox(hitArea.x, hitArea.y, hitArea.x + hitArea.width, hitArea.y + hitArea.height);
}
layoutText(text, textParams) {
const currentRect = this.getRect();
return layoutText(text, this.context.ctx, currentRect, {
maxWidth: currentRect.width,
maxHeight: currentRect.height,
...textParams,
});
}
renderBody(ctx, rect = this.getRect()) {
// Determine colors based on state priority: highlighted > selected > default
if (this.highlighted) {
ctx.strokeStyle = this.style.highlightedBorder;
ctx.fillStyle = this.style.highlightedBackground;
}
else if (this.state.selected) {
ctx.strokeStyle = this.style.selectedBorder;
ctx.fillStyle = this.style.selectedBackground;
}
else {
ctx.strokeStyle = this.style.border;
ctx.fillStyle = this.style.background;
}
ctx.lineWidth = this.highlighted ? this.style.borderWidth + 1 : this.style.borderWidth;
// Draw group rectangle
ctx.beginPath();
ctx.roundRect(rect.x, rect.y, rect.width, rect.height, 8);
ctx.fill();
ctx.stroke();
}
render() {
this.renderBody(this.context.ctx, this.getRect());
}
}