UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

283 lines (282 loc) 11.3 kB
import { signal } from "@preact/signals-core"; import { isGraphEvent } from "../../graphEvents"; import { ECanDrag } from "../../store/settings"; import { getXY } from "../../utils/functions"; import { dragListener } from "../../utils/functions/dragListener"; import { EVENTS } from "../../utils/types/events"; /** * DragService manages drag operations for all draggable GraphComponents. * * When a user starts dragging a component: * 1. Collects all selected draggable components from SelectionService * 2. Manages the drag lifecycle (start, update, end) via dragListener * 3. Handles autopanning, cursor locking, and camera-change synchronization * 4. Delegates actual movement logic to components via handleDragStart/handleDrag/handleDragEnd */ export class DragService { constructor(graph) { this.graph = graph; /** Components participating in the current drag operation */ this.dragComponents = []; /** Starting coordinates in world space */ this.startCoords = null; /** Previous frame coordinates in world space */ this.prevCoords = null; /** Current drag listener emitter (null when not dragging) */ this.currentDragEmitter = null; /** Unsubscribe function for graph mousedown event */ this.unsubscribeMouseDown = null; /** * Reactive signal with current drag state. * Use this to react to drag state changes, e.g. to disable certain behaviors during multi-component drag. * * @example * ```typescript * // Check if drag is happening with multiple heterogeneous components * if (graph.dragService.$state.value.isDragging && !graph.dragService.$state.value.isHomogeneous) { * // Disable snap to grid * } * ``` */ this.$state = signal(this.createIdleState()); /** * Handle mousedown on graph - determine if drag should start */ this.handleMouseDown = (event) => { // Prevent initiating new drag while one is already in progress // Check actual drag state, not just emitter presence (emitter may exist but drag not started yet) if (this.currentDragEmitter && this.$state.value.isDragging) { return; } const canDrag = this.graph.rootStore.settings.$canDrag.value; // If drag is disabled, don't start drag operation if (canDrag === ECanDrag.NONE) { return; } const target = event.detail.target; if (!target || typeof target.isDraggable !== "function" || !target.isDraggable()) { return; } // Collect all draggable components that should participate this.dragComponents = this.collectDragComponents(target, canDrag); if (this.dragComponents.length === 0) { return; } if (isGraphEvent(event)) { event.stopGraphEventPropagation(); } // Reset stale emitter from previous mousedown that didn't result in drag this.currentDragEmitter = null; // Use dragListener for consistent drag behavior const doc = this.graph.getGraphCanvas().ownerDocument; this.currentDragEmitter = dragListener(doc, { graph: this.graph, dragCursor: "grabbing", autopanning: true, }) .on(EVENTS.DRAG_START, this.handleDragStart) .on(EVENTS.DRAG_UPDATE, this.handleDragUpdate) .on(EVENTS.DRAG_END, this.handleDragEnd); }; /** * Handle drag start from dragListener */ this.handleDragStart = (event) => { // Update reactive state this.$state.value = this.createDragState(this.dragComponents); // Calculate starting coordinates in world space const coords = this.getWorldCoords(event); this.startCoords = coords; this.prevCoords = coords; // Create context for drag start const context = { sourceEvent: event, startCoords: coords, prevCoords: coords, currentCoords: coords, components: this.dragComponents, }; // Notify all components about drag start this.dragComponents.forEach((component) => { component.handleDragStart(context); }); }; /** * Handle drag update from dragListener */ this.handleDragUpdate = (event) => { if (!this.startCoords || !this.prevCoords) { return; } const currentCoords = this.getWorldCoords(event); const diff = { startCoords: this.startCoords, prevCoords: this.prevCoords, currentCoords, diffX: currentCoords[0] - this.startCoords[0], diffY: currentCoords[1] - this.startCoords[1], deltaX: currentCoords[0] - this.prevCoords[0], deltaY: currentCoords[1] - this.prevCoords[1], }; const context = { sourceEvent: event, startCoords: this.startCoords, prevCoords: this.prevCoords, currentCoords, components: this.dragComponents, }; // Notify all components about drag update this.dragComponents.forEach((component) => { component.handleDrag(diff, context); }); this.prevCoords = currentCoords; }; /** * Handle drag end from dragListener */ this.handleDragEnd = (event) => { if (this.startCoords && this.prevCoords) { const currentCoords = this.getWorldCoords(event); const context = { sourceEvent: event, startCoords: this.startCoords, prevCoords: this.prevCoords, currentCoords, components: this.dragComponents, }; // Notify all components about drag end this.dragComponents.forEach((component) => { component.handleDragEnd(context); }); } this.cleanup(); }; this.unsubscribeMouseDown = graph.on("mousedown", this.handleMouseDown, { capture: true, }); } /** * Create idle (not dragging) state */ createIdleState() { return { isDragging: false, components: [], componentTypes: new Set(), isMultiple: false, isHomogeneous: true, }; } /** * Create active drag state from components */ createDragState(components) { const componentTypes = new Set(components.map((c) => c.constructor.name)); return { isDragging: true, components, componentTypes, isMultiple: components.length > 1, isHomogeneous: componentTypes.size <= 1, }; } /** * Cleanup when service is destroyed */ destroy() { this.cleanup(); if (this.unsubscribeMouseDown) { this.unsubscribeMouseDown(); this.unsubscribeMouseDown = null; } } /** * Collect all components that should participate in drag operation. * Behavior depends on canDrag setting: * - ALL: If target is in selection, drag all selected draggable components. Otherwise drag only target. * - ONLY_SELECTED: Only selected components can be dragged. If target is not selected, returns empty array. */ collectDragComponents(target, canDrag) { const selectedComponents = this.graph.selectionService.$selectedComponents.value; // Check if target is among selected components const targetInSelection = selectedComponents.some((c) => c === target); if (canDrag === ECanDrag.ONLY_SELECTED) { // In ONLY_SELECTED mode, target must be in selection to start drag if (!targetInSelection) { return []; } // Drag all selected draggable components return selectedComponents.filter((c) => typeof c.isDraggable === "function" && c.isDraggable()); } // ALL mode: if target is in selection, drag all selected draggable components if (targetInSelection && selectedComponents.length > 0) { return selectedComponents.filter((c) => typeof c.isDraggable === "function" && c.isDraggable()); } // Target is not in selection - drag only target return [target]; } /** * Convert screen coordinates to world coordinates */ getWorldCoords(event) { const canvas = this.graph.getGraphCanvas(); const [screenX, screenY] = getXY(canvas, event); return this.graph.cameraService.applyToPoint(screenX, screenY); } /** * Cleanup after drag operation ends */ cleanup() { // Reset state this.currentDragEmitter = null; this.dragComponents = []; this.startCoords = null; this.prevCoords = null; // Update reactive state this.$state.value = this.createIdleState(); } /** * Start a custom drag operation for specialized use cases like creating connections or new blocks. * This provides a unified API for drag operations without exposing dragListener directly. * * @param callbacks - Lifecycle callbacks (onStart, onUpdate, onEnd) * @param options - Drag options (document, cursor, autopanning, etc.) * * @example * ```typescript * // In ConnectionLayer * graph.dragService.startOperation( * { * onStart: (event, coords) => this.onStartConnection(event, coords), * onUpdate: (event, coords) => this.onMoveConnection(event, coords), * onEnd: (event, coords) => this.onEndConnection(coords), * }, * { cursor: "crosshair", autopanning: true } * ); * ``` */ startDrag(callbacks, options = {}) { const { document: doc, cursor, autopanning = true, stopOnMouseLeave, threshold } = options; const { onStart, onUpdate, onEnd } = callbacks; const targetDocument = doc ?? this.graph.getGraphCanvas().ownerDocument; dragListener(targetDocument, { graph: this.graph, dragCursor: cursor, autopanning, stopOnMouseLeave, threshold, }) .on(EVENTS.DRAG_START, (event) => { const coords = this.getWorldCoords(event); onStart?.(event, coords); }) .on(EVENTS.DRAG_UPDATE, (event) => { const coords = this.getWorldCoords(event); onUpdate?.(event, coords); }) .on(EVENTS.DRAG_END, (event) => { const coords = this.getWorldCoords(event); onEnd?.(event, coords); }); } }