UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

273 lines (272 loc) 10.2 kB
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()); } }