UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

348 lines (347 loc) 12.1 kB
import { effect } from "@preact/signals-core"; import { Point } from "../../../utils/types/shapes"; import { Block } from "../blocks/Block"; import { BlockGroups } from "./BlockGroups"; import { Group } from "./Group"; /** * BlockGroups layer with block-to-group transfer functionality. * * ## Features * - Hold Shift during drag to activate transfer mode * - Release Shift to deactivate transfer mode and return to normal drag * - Groups highlight when blocks are dragged over them * - Multi-block transfer: all selected blocks are transferred together * - Source groups lock their size during transfer * - Callbacks for state synchronization with external stores (Redux, MobX, etc.) * * ## Basic Usage * ```typescript * const layer = graph.addLayer(BlockGroupsTransferLayer, { * transferEnabled: true, * draggable: true, * }); * ``` * * ## With Automatic Grouping * ```typescript * const GroupsLayer = BlockGroupsTransferLayer.withBlockGrouping({ * groupingFn: (blocks) => groupBy(blocks, (b) => b.$state.value.group), * mapToGroups: (groupId, { rect }) => ({ id: groupId, rect }), * }); * * graph.addLayer(GroupsLayer, { * transferEnabled: true, * updateBlocksOnDrag: true, // Blocks move with the group * }); * ``` * * ## With Redux Integration * ```typescript * graph.addLayer(GroupsLayer, { * onTransferStart: (blockIds, sourceGroupIds) => { * console.log('Transfer started:', blockIds); * }, * onBlockGroupChange: (changes) => { * // Sync with Redux * changes.forEach(({ blockId, targetGroup }) => { * store.dispatch(updateBlockGroup({ blockId, groupId: targetGroup })); * }); * }, * onTransferEnd: (blockIds, targetGroupId) => { * console.log('Transfer completed:', blockIds, 'to group:', targetGroupId); * }, * }); * ``` * * Uses DragService.$state.currentEvent.shiftKey to track Shift state in real-time. */ export class BlockGroupsTransferLayer extends BlockGroups { constructor() { super(...arguments); /** Current transfer state */ this.transferState = this.createIdleState(); /** Cleanup function for the drag state subscription */ this.disposeSubscription = null; } get isTransferEnabled() { return this.props.transferEnabled !== false; } afterInit() { super.afterInit(); if (this.isTransferEnabled) { this.subscribeToDragState(); } } /** * Subscribe to DragService state changes */ subscribeToDragState() { const dragService = this.props.graph.dragService; if (!dragService) return; this.disposeSubscription = effect(() => { const isShiftPressed = this.props.graph.keyboardService.isShiftPressed(); this.handleDragStateChange(dragService.$state.value, isShiftPressed ?? false); }); } /** * Handle drag state changes - react to Shift key in real-time */ handleDragStateChange(dragState, isShiftPressed) { // Drag ended if (!dragState.isDragging) { if (this.transferState.isTransferring) { this.endTransfer(); } return; } // During drag: check if Shift state changed if (isShiftPressed && !this.transferState.isTransferring) { // Shift pressed - activate transfer this.activateTransfer(dragState); } else if (!isShiftPressed && this.transferState.isTransferring) { // Shift released - deactivate transfer this.deactivateTransfer(); } // Update highlighting if in transfer mode if (this.transferState.isTransferring && dragState.currentCoords) { this.updateHighlight(dragState.currentCoords); } } /** * Activate transfer mode for currently dragged blocks */ activateTransfer(dragState) { // Find all blocks among the dragged components const blocks = dragState.components .filter((c) => c instanceof Block) .map((block) => block.state); if (blocks.length === 0) return; // Collect unique source group IDs (for tracking which groups blocks came from) const sourceGroupIds = new Set(); for (const block of blocks) { const groupId = this.$blockGroupsMap.value.get(block.id) ?? null; if (groupId) { sourceGroupIds.add(groupId); } } this.transferState = { isTransferring: true, blocks, sourceGroupIds, targetGroupId: null, highlightedGroupId: null, }; // Lock ALL groups' sizes during transfer mode this.lockAllGroups(); // Call onTransferStart callback this.props.onTransferStart?.(blocks.map((b) => b.id.toString()), sourceGroupIds); // Update highlight immediately if we have coordinates if (dragState.currentCoords) { this.updateHighlight(dragState.currentCoords); } } /** * Deactivate transfer mode - apply transfer and unlock groups * Called when Shift is released during drag */ deactivateTransfer() { const { highlightedGroupId, targetGroupId, blocks } = this.transferState; // Unhighlight current group if (highlightedGroupId) { this.setGroupHighlight(highlightedGroupId, false); } // Apply the group change for all blocks (transfer happens on Shift release) const changes = []; for (const block of blocks) { const oldGroupId = this.$blockGroupsMap.value.get(block.id) ?? null; // Only change if target is different from current if (oldGroupId !== targetGroupId) { changes.push({ blockId: block.id, sourceGroup: oldGroupId, targetGroup: targetGroupId, }); } } this.applyGroupChange(changes); // Unlock all groups this.unlockAllGroups(); // Call onTransferEnd callback this.props.onTransferEnd?.(blocks.map((b) => b.id.toString()), targetGroupId); this.transferState = this.createIdleState(); } /** * Lock all groups' sizes */ lockAllGroups() { const groups = this.props.graph.rootStore.groupsList.$groups.value; for (const groupState of groups) { groupState.lockSize(); } } /** * Unlock all groups' sizes */ unlockAllGroups() { const groups = this.props.graph.rootStore.groupsList.$groups.value; for (const groupState of groups) { groupState.unlockSize(); } } createIdleState() { return { isTransferring: false, blocks: [], sourceGroupIds: new Set(), targetGroupId: null, highlightedGroupId: null, }; } /** * Update highlighting based on cursor position */ updateHighlight(point) { if (!this.transferState.isTransferring) return; const targetGroup = this.findGroupAtPoint(point); const targetGroupId = targetGroup?.getEntityId() ?? null; // Update highlight visual if changed if (this.transferState.highlightedGroupId !== targetGroupId) { // Unhighlight previous group if (this.transferState.highlightedGroupId) { this.setGroupHighlight(this.transferState.highlightedGroupId, false); } // Highlight new group if (targetGroupId) { this.setGroupHighlight(targetGroupId, true); } } // Always store the actual target group (even if it's a source group) // The decision about what to do is made at drop time this.transferState = { ...this.transferState, targetGroupId, // Real target group under cursor highlightedGroupId: targetGroupId, }; } /** * End transfer on drag end (mouseup) - apply transfer if in transfer mode */ endTransfer() { const { highlightedGroupId, targetGroupId, blocks } = this.transferState; // Unhighlight the group if (highlightedGroupId) { this.setGroupHighlight(highlightedGroupId, false); } // Apply the group change for all blocks const changes = []; for (const block of blocks) { const currentGroupId = this.$blockGroupsMap.value.get(block.id) ?? null; // Only change if target is different from current if (currentGroupId !== targetGroupId) { changes.push({ blockId: block.id, sourceGroup: currentGroupId, targetGroup: targetGroupId, }); } } this.applyGroupChange(changes); // Unlock all groups this.unlockAllGroups(); // Call onTransferEnd callback this.props.onTransferEnd?.(blocks.map((b) => b.id.toString()), targetGroupId); this.transferState = this.createIdleState(); } /** * Cancel the transfer operation without applying changes. * * This method can be called to abort an ongoing transfer without moving blocks to a new group. * It will unhighlight groups, unlock sizes, and reset the transfer state. * * @example * ```typescript * // Cancel transfer on Escape key * document.addEventListener('keydown', (e) => { * if (e.key === 'Escape' && layer.isTransferring()) { * layer.cancelTransfer(); * } * }); * ``` */ cancelTransfer() { const { highlightedGroupId } = this.transferState; if (highlightedGroupId) { this.setGroupHighlight(highlightedGroupId, false); } this.unlockAllGroups(); this.transferState = this.createIdleState(); } /** * Find a group at the given point */ findGroupAtPoint(point) { const [x, y] = point; return this.props.graph.getElementOverPoint(new Point(x, y), [Group]) ?? null; } /** * Set highlight state for a group directly on the component */ setGroupHighlight(groupId, highlighted) { const groupComponent = this.getGroupById(groupId); groupComponent?.setHighlighted(highlighted); } /** * Apply the group change to the block */ applyGroupChange(changes) { if (changes.length > 0) { this.props.onBlockGroupChange?.(changes); } } /** * Check if a block transfer is currently in progress. * * @returns `true` if transfer mode is active (Shift is pressed during drag), `false` otherwise * * @example * ```typescript * if (layer.isTransferring()) { * console.log('Transferring', layer.getTransferringBlocksCount(), 'blocks'); * } * ``` */ isTransferring() { return this.transferState.isTransferring; } /** * Get the number of blocks being transferred in the current operation. * * @returns Number of blocks currently being transferred, or 0 if no transfer is in progress * * @example * ```typescript * const count = layer.getTransferringBlocksCount(); * console.log(`Transferring ${count} block${count !== 1 ? 's' : ''}`); * ``` */ getTransferringBlocksCount() { return this.transferState.blocks.length; } unmountLayer() { // Cleanup drag subscription if (this.disposeSubscription) { this.disposeSubscription(); this.disposeSubscription = null; } // Cleanup transfer state if (this.transferState.highlightedGroupId) { this.setGroupHighlight(this.transferState.highlightedGroupId, false); } this.transferState = this.createIdleState(); super.unmountLayer(); } }