diff --git a/docs/components/canvas-graph-component.md b/docs/components/canvas-graph-component.md index 7316b857..c16fcacd 100644 --- a/docs/components/canvas-graph-component.md +++ b/docs/components/canvas-graph-component.md @@ -14,6 +14,7 @@ classDiagram GraphComponent <|-- Connection GraphComponent -- HitBox GraphComponent -- Camera + GraphComponent -- DragService class EventedComponent { +props: TComponentProps @@ -29,11 +30,15 @@ classDiagram +getHitBox() +isVisible() +onDrag() + +isDraggable() + +handleDragStart() + +handleDrag() + +handleDragEnd() +subscribeSignal() } ``` -## The Four Core Capabilities +## The Core Capabilities ### 1. Spatial Awareness with HitBox and R-tree @@ -187,6 +192,110 @@ onDragUpdate: (diff: { - Use `diffX`/`diffY` when you need to calculate position relative to drag start (e.g., `initialPosition + diffX`) - Use `deltaX`/`deltaY` when you need frame-to-frame movement (e.g., `currentPosition + deltaX`) +### 3.1 DragService Integration + +For components that need to participate in the centralized drag system (multi-selection drag, autopanning, etc.), GraphComponent provides lifecycle methods that integrate with [DragService](../system/drag-system.md). + +> **Note:** The `onDrag()` method is for simple, self-contained drag behavior. For components that need to work with multi-selection and the centralized drag system, override the `isDraggable()` and `handleDrag*()` methods instead. + +**Key differences:** +| Feature | `onDrag()` method | DragService methods | +|---------|-------------------|---------------------| +| Multi-selection support | ❌ No | ✅ Yes | +| Autopanning | Manual setup | ✅ Automatic | +| Cursor management | Manual setup | ✅ Automatic | +| Drag state tracking | ❌ No | ✅ Via `$state` signal | +| Use case | Simple standalone drag | Blocks, groups, custom entities | + +**DragService lifecycle methods:** + +```typescript +import { GraphComponent, DragContext, DragDiff } from "@gravity-ui/graph"; + +class MyDraggableComponent extends GraphComponent { + private initialPosition: { x: number; y: number } | null = null; + + /** + * Return true to enable dragging for this component. + * Components returning true will participate in DragService-managed operations. + */ + public override isDraggable(): boolean { + return !this.props.locked; + } + + /** + * Called when drag operation starts. + * Use this to store initial state needed for drag calculations. + */ + public override handleDragStart(context: DragContext): void { + this.initialPosition = { + x: this.state.x, + y: this.state.y, + }; + } + + /** + * Called on each frame during drag. + * Update component position based on diff values. + */ + public override handleDrag(diff: DragDiff, context: DragContext): void { + if (!this.initialPosition) return; + + // Option 1: Use absolute diff (stable positioning) + const newX = this.initialPosition.x + diff.diffX; + const newY = this.initialPosition.y + diff.diffY; + + // Option 2: Use incremental delta (frame-to-frame) + // const newX = this.state.x + diff.deltaX; + // const newY = this.state.y + diff.deltaY; + + this.setState({ x: newX, y: newY }); + this.updateHitBox(); + } + + /** + * Called when drag operation ends. + * Use this to finalize state and clean up. + */ + public override handleDragEnd(context: DragContext): void { + this.initialPosition = null; + this.updateHitBox(); + } +} +``` + +**DragContext properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `sourceEvent` | `MouseEvent` | The native mouse event | +| `startCoords` | `[number, number]` | World coordinates when drag started | +| `prevCoords` | `[number, number]` | World coordinates from previous frame | +| `currentCoords` | `[number, number]` | Current world coordinates | +| `components` | `GraphComponent[]` | All components participating in this drag | + +**Checking drag state from anywhere:** + +```typescript +// Access drag state via DragService +const dragState = graph.dragService.$state.value; + +if (dragState.isDragging) { + console.log("Components being dragged:", dragState.components.length); + console.log("Is homogeneous drag:", dragState.isHomogeneous); + console.log("Component types:", [...dragState.componentTypes]); +} + +// Subscribe to drag state changes +graph.dragService.$state.subscribe((state) => { + if (state.isDragging && !state.isHomogeneous) { + // Disable snapping for heterogeneous multi-select drag + } +}); +``` + +For more details on the drag system, see [Drag System](../system/drag-system.md). + ### 4. Reactive Data with Signal Subscriptions GraphComponent enables reactive programming with a simple subscription system: diff --git a/docs/system/drag-system.md b/docs/system/drag-system.md new file mode 100644 index 00000000..f1448554 --- /dev/null +++ b/docs/system/drag-system.md @@ -0,0 +1,524 @@ +# Drag System + +The Drag System provides a centralized way to handle drag operations for all graph components. It automatically manages drag lifecycle, autopanning, cursor states, and coordinates movement across multiple selected components. + +## Overview + +When a user drags components on the graph: +1. **DragService** intercepts the mouse events +2. Collects all selected draggable components +3. Manages the drag lifecycle (start → update → end) +4. Handles autopanning near canvas edges +5. Synchronizes cursor state +6. Delegates actual movement to components via lifecycle methods + +### Key Benefits + +- **Unified Drag Behavior**: All draggable components use the same drag system +- **Multi-Selection Support**: Drag multiple selected components simultaneously +- **Automatic Autopanning**: Camera moves when dragging near canvas edges +- **Reactive State**: Track drag state for conditional behaviors (e.g., disable snapping) +- **Type Safety**: Full TypeScript support with typed contexts and diffs + +## Basic Usage + +### Accessing Drag State + +The `DragService` exposes a reactive `$state` signal that you can use to track the current drag operation: + +```typescript +import { Graph } from "@gravity-ui/graph"; + +const graph = new Graph(config, container); + +// Subscribe to drag state changes +graph.dragService.$state.subscribe((state) => { + if (state.isDragging) { + console.log("Dragging", state.components.length, "components"); + console.log("Component types:", [...state.componentTypes]); + console.log("Is homogeneous:", state.isHomogeneous); + } +}); +``` + +### Drag State Properties + +| Property | Type | Description | +|----------|------|-------------| +| `isDragging` | `boolean` | Whether a drag operation is in progress | +| `components` | `GraphComponent[]` | Components participating in the drag | +| `componentTypes` | `Set` | Set of component type names (constructor names) | +| `isMultiple` | `boolean` | Whether multiple components are being dragged | +| `isHomogeneous` | `boolean` | Whether all components are of the same type | + +### Example: Conditional Behavior Based on Drag State + +```typescript +// Disable snap-to-grid when dragging heterogeneous components +function shouldSnapToGrid(graph: Graph): boolean { + const { isDragging, isHomogeneous, componentTypes } = graph.dragService.$state.value; + + // Only snap when dragging blocks of the same type + if (isDragging && !isHomogeneous) { + return false; + } + + // Only snap for Block components + if (isDragging && !componentTypes.has("Block")) { + return false; + } + + return true; +} +``` + +## Creating Draggable Components + +To make a custom `GraphComponent` draggable, override the drag lifecycle methods: + +### Step 1: Enable Dragging + +Override `isDraggable()` to return `true` when the component should be draggable: + +```typescript +import { GraphComponent } from "@gravity-ui/graph"; + +class MyCustomComponent extends GraphComponent { + public override isDraggable(): boolean { + // Draggable when not locked and geometry changes are allowed + return !this.props.locked && this.canChangeGeometry(); + } +} +``` + +### Step 2: Handle Drag Start + +Override `handleDragStart()` to initialize drag state: + +```typescript +import { DragContext } from "@gravity-ui/graph"; + +class MyCustomComponent extends GraphComponent { + private initialPosition: { x: number; y: number } | null = null; + + public override handleDragStart(context: DragContext): void { + // Store initial position for absolute positioning + this.initialPosition = { + x: this.state.x, + y: this.state.y, + }; + + // Emit custom event + this.context.graph.emit("my-component-drag-start", { + component: this, + position: this.initialPosition, + }); + } +} +``` + +### Step 3: Handle Drag Updates + +Override `handleDrag()` to update component position during drag: + +```typescript +import { DragDiff, DragContext } from "@gravity-ui/graph"; + +class MyCustomComponent extends GraphComponent { + public override handleDrag(diff: DragDiff, context: DragContext): void { + if (!this.initialPosition) return; + + // Option 1: Use absolute diff (relative to drag start) + const newX = this.initialPosition.x + diff.diffX; + const newY = this.initialPosition.y + diff.diffY; + + // Option 2: Use incremental delta (frame-to-frame movement) + // const newX = this.state.x + diff.deltaX; + // const newY = this.state.y + diff.deltaY; + + this.updatePosition(newX, newY); + } +} +``` + +### Step 4: Handle Drag End + +Override `handleDragEnd()` to finalize the drag operation: + +```typescript +import { DragContext } from "@gravity-ui/graph"; + +class MyCustomComponent extends GraphComponent { + public override handleDragEnd(context: DragContext): void { + // Clean up drag state + this.initialPosition = null; + + // Update hit box for accurate hit testing + this.updateHitBox(this.state); + + // Emit custom event + this.context.graph.emit("my-component-drag-end", { + component: this, + position: { x: this.state.x, y: this.state.y }, + }); + } +} +``` + +## DragDiff Reference + +The `DragDiff` object provides coordinate information during drag: + +| Property | Type | Description | +|----------|------|-------------| +| `startCoords` | `[number, number]` | World coordinates when drag started | +| `prevCoords` | `[number, number]` | World coordinates from previous frame | +| `currentCoords` | `[number, number]` | Current world coordinates | +| `diffX` | `number` | Absolute X displacement from start (`currentCoords[0] - startCoords[0]`) | +| `diffY` | `number` | Absolute Y displacement from start (`currentCoords[1] - startCoords[1]`) | +| `deltaX` | `number` | Incremental X change since last frame (`currentCoords[0] - prevCoords[0]`) | +| `deltaY` | `number` | Incremental Y change since last frame (`currentCoords[1] - prevCoords[1]`) | + +### When to Use `diffX/diffY` vs `deltaX/deltaY` + +**Use `diffX/diffY` (absolute)** when you need position relative to drag start: +```typescript +// Good for: Restoring to initial position + offset +const newX = this.initialPosition.x + diff.diffX; +const newY = this.initialPosition.y + diff.diffY; +``` + +**Use `deltaX/deltaY` (incremental)** when you need frame-to-frame movement: +```typescript +// Good for: Accumulating position changes +const newX = this.state.x + diff.deltaX; +const newY = this.state.y + diff.deltaY; +``` + +## DragContext Reference + +The `DragContext` object provides additional information about the drag operation: + +| Property | Type | Description | +|----------|------|-------------| +| `sourceEvent` | `MouseEvent` | The native mouse event | +| `startCoords` | `[number, number]` | World coordinates when drag started | +| `prevCoords` | `[number, number]` | World coordinates from previous frame | +| `currentCoords` | `[number, number]` | Current world coordinates | +| `components` | `GraphComponent[]` | All components participating in this drag | + +### Example: Coordinated Multi-Component Behavior + +```typescript +public override handleDrag(diff: DragDiff, context: DragContext): void { + // Check if this is a multi-component drag + if (context.components.length > 1) { + // Skip individual snapping when dragging multiple items + this.moveWithoutSnapping(diff.deltaX, diff.deltaY); + } else { + // Apply snapping for single item drag + this.moveWithSnapping(diff.deltaX, diff.deltaY); + } +} +``` + +## React Integration + +### Reacting to Drag State Changes + +```tsx +import { useEffect, useState } from "react"; +import { useGraph, DragState } from "@gravity-ui/graph"; + +function DragIndicator() { + const { graph } = useGraph(); + const [dragState, setDragState] = useState(null); + + useEffect(() => { + if (!graph) return; + + const unsubscribe = graph.dragService.$state.subscribe((state) => { + setDragState(state); + }); + + return unsubscribe; + }, [graph]); + + if (!dragState?.isDragging) return null; + + return ( +
+ Dragging {dragState.components.length} item(s) + {!dragState.isHomogeneous && " (mixed types)"} +
+ ); +} +``` + +### Disabling UI During Drag + +```tsx +function Toolbar() { + const { graph } = useGraph(); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + if (!graph) return; + + const unsubscribe = graph.dragService.$state.subscribe((state) => { + setIsDragging(state.isDragging); + }); + + return unsubscribe; + }, [graph]); + + return ( +
+ + +
+ ); +} +``` + +## Block Drag Events + +The built-in `Block` component emits events during drag operations that you can listen to: + +| Event | Description | Payload | +|-------|-------------|---------| +| `block-drag-start` | Fires when block drag starts | `{ nativeEvent, block }` | +| `block-drag` | Fires on each drag frame | `{ nativeEvent, block, x, y }` | +| `block-drag-end` | Fires when block drag ends | `{ nativeEvent, block }` | + +### Example: Listening to Block Drag Events + +```typescript +graph.on("block-drag-start", (event) => { + console.log("Block drag started:", event.detail.block.id); +}); + +graph.on("block-drag", (event) => { + const { block, x, y } = event.detail; + console.log(`Block ${block.id} moving to (${x}, ${y})`); + + // Prevent default to cancel the drag + // event.preventDefault(); +}); + +graph.on("block-drag-end", (event) => { + console.log("Block drag ended:", event.detail.block.id); +}); +``` + +## Advanced Topics + +### Drag Behavior with Selection + +The DragService integrates with the SelectionService: + +1. **Target in selection**: All selected draggable components move together +2. **Target not in selection**: Only the target component moves + +```typescript +// Example: User has blocks A, B, C selected +// - Click and drag block A → A, B, C all move +// - Click and drag block D (not selected) → Only D moves +``` + +### Autopanning + +Autopanning is automatically enabled during drag operations. When the cursor moves near the canvas edges, the camera pans to follow: + +```typescript +// Autopanning is managed by DragService +// - Enabled on drag start +// - Disabled on drag end +// - Camera changes trigger position recalculation for smooth movement +``` + +### Cursor Locking + +During drag operations, the cursor is locked to "grabbing": + +```typescript +// Cursor management is automatic +// - Locks to "grabbing" on first move +// - Unlocks when drag ends +``` + +## Complete Example: Custom Draggable Node + +```typescript +import { + GraphComponent, + DragContext, + DragDiff, + TGraphComponentProps, +} from "@gravity-ui/graph"; + +interface NodeProps extends TGraphComponentProps { + locked?: boolean; +} + +interface NodeState { + x: number; + y: number; + width: number; + height: number; +} + +export class CustomNode extends GraphComponent { + private dragStartPosition: { x: number; y: number } | null = null; + + public getEntityId(): string { + return this.props.id; + } + + // Enable dragging when not locked + public override isDraggable(): boolean { + return !this.props.locked; + } + + // Store initial position on drag start + public override handleDragStart(context: DragContext): void { + this.dragStartPosition = { + x: this.state.x, + y: this.state.y, + }; + + this.context.graph.emit("custom-node-drag-start", { + nodeId: this.getEntityId(), + position: this.dragStartPosition, + }); + } + + // Update position during drag + public override handleDrag(diff: DragDiff, context: DragContext): void { + if (!this.dragStartPosition) return; + + // Use absolute diff for stable positioning + const newX = this.dragStartPosition.x + diff.diffX; + const newY = this.dragStartPosition.y + diff.diffY; + + // Apply snapping only for single-item drags + const shouldSnap = context.components.length === 1; + const [finalX, finalY] = shouldSnap + ? this.snapToGrid(newX, newY) + : [newX, newY]; + + this.setState({ x: finalX, y: finalY }); + this.updateHitBox({ ...this.state, x: finalX, y: finalY }); + } + + // Clean up on drag end + public override handleDragEnd(context: DragContext): void { + this.context.graph.emit("custom-node-drag-end", { + nodeId: this.getEntityId(), + position: { x: this.state.x, y: this.state.y }, + }); + + this.dragStartPosition = null; + this.updateHitBox(this.state); + } + + private snapToGrid(x: number, y: number): [number, number] { + const gridSize = 20; + return [ + Math.round(x / gridSize) * gridSize, + Math.round(y / gridSize) * gridSize, + ]; + } + + private updateHitBox(state: NodeState): void { + this.setHitBox( + state.x, + state.y, + state.x + state.width, + state.y + state.height + ); + } + + protected render(): void { + // Render the node... + } +} +``` + +## API Reference + +### DragService + +| Property/Method | Type | Description | +|----------------|------|-------------| +| `$state` | `Signal` | Reactive signal with current drag state | +| `destroy()` | `() => void` | Cleanup method (called automatically on graph unmount) | + +### DragState + +```typescript +type DragState = { + isDragging: boolean; + components: GraphComponent[]; + componentTypes: Set; + isMultiple: boolean; + isHomogeneous: boolean; +}; +``` + +### DragContext + +```typescript +type DragContext = { + sourceEvent: MouseEvent; + startCoords: [number, number]; + prevCoords: [number, number]; + currentCoords: [number, number]; + components: GraphComponent[]; +}; +``` + +### DragDiff + +```typescript +type DragDiff = { + startCoords: [number, number]; + prevCoords: [number, number]; + currentCoords: [number, number]; + diffX: number; + diffY: number; + deltaX: number; + deltaY: number; +}; +``` + +### GraphComponent Drag Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `isDraggable()` | `() => boolean` | Override to enable drag (default: `false`) | +| `handleDragStart()` | `(context: DragContext) => void` | Called when drag starts | +| `handleDrag()` | `(diff: DragDiff, context: DragContext) => void` | Called on each drag frame | +| `handleDragEnd()` | `(context: DragContext) => void` | Called when drag ends | + +### DragService.startDrag() + +For custom drag operations (like creating connections or duplicating blocks), use `startOperation`: + +```typescript +type DragOperationCallbacks = { + onStart?: (event: MouseEvent, coords: [number, number]) => void; + onUpdate?: (event: MouseEvent, coords: [number, number]) => void; + onEnd?: (event: MouseEvent, coords: [number, number]) => void; +}; + +type DragOperationOptions = { + document?: Document; // Defaults to graph canvas document + cursor?: CursorLayerCursorTypes; // Cursor during drag + autopanning?: boolean; // Enable edge autopanning (default: true) + stopOnMouseLeave?: boolean; // Stop drag on mouse leave +}; + +// Usage +graph.dragService.startDrag(callbacks, options); +``` \ No newline at end of file diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index fb707716..55747f00 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -5,10 +5,9 @@ import { GraphEventsDefinitions } from "../../../graphEvents"; import { Component } from "../../../lib"; import { TComponentContext, TComponentProps, TComponentState } from "../../../lib/Component"; import { HitBox, HitBoxData } from "../../../services/HitTest"; +import { DragContext, DragDiff } from "../../../services/drag"; import { PortState, TPortId } from "../../../store/connection/port/Port"; import { getXY } from "../../../utils/functions"; -import { dragListener } from "../../../utils/functions/dragListener"; -import { EVENTS } from "../../../utils/types/events"; import { EventedComponent } from "../EventedComponent/EventedComponent"; import { CursorLayerCursorTypes } from "../layers/cursorLayer"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; @@ -39,6 +38,48 @@ export class GraphComponent< throw new Error("GraphComponent.getEntityId() is not implemented"); } + /** + * Returns whether this component can be dragged. + * Override in subclasses to enable drag behavior. + * Components that return true will participate in drag operations managed by DragService. + * + * @returns true if the component is draggable, false otherwise + */ + public isDraggable(): boolean { + return false; + } + + /** + * Called when a drag operation starts on this component. + * Override in subclasses to handle drag start logic. + * + * @param _context - The drag context containing coordinates and participating components + */ + public handleDragStart(_context: DragContext): void { + // Default implementation does nothing + } + + /** + * Called on each frame during a drag operation. + * Override in subclasses to update component position. + * + * @param _diff - The diff containing coordinate changes (deltaX/deltaY for incremental, diffX/diffY for absolute) + * @param _context - The drag context containing coordinates and participating components + */ + public handleDrag(_diff: DragDiff, _context: DragContext): void { + // Default implementation does nothing + } + + /** + * Called when a drag operation ends. + * Override in subclasses to finalize drag state. + * + * @param _context - The drag context containing final coordinates and participating components + */ + public handleDragEnd(_context: DragContext): void { + // Default implementation does nothing + } + public get affectsUsableRect() { return this.props.affectsUsableRect ?? this.context.affectsUsableRect ?? true; } @@ -139,42 +180,45 @@ export class GraphComponent< return; } event.stopPropagation(); - dragListener(this.context.ownerDocument, { - graph: this.context.graph, - component: this, - autopanning: autopanning ?? true, - dragCursor: dragCursor ?? "grabbing", - }) - .on(EVENTS.DRAG_START, (event: MouseEvent) => { - if (onDragStart?.(event) === false) { - return; - } - const xy = getXY(this.context.canvas, event); - startCoords = this.context.camera.applyToPoint(xy[0], xy[1]); - prevCoords = startCoords; - }) - .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => { - if (!startCoords?.length) return; - - const [canvasX, canvasY] = getXY(this.context.canvas, event); - const currentCoords = this.context.camera.applyToPoint(canvasX, canvasY); - - // Absolute diff from drag start - const diffX = currentCoords[0] - startCoords[0]; - const diffY = currentCoords[1] - startCoords[1]; - - // Incremental diff from previous frame - const deltaX = currentCoords[0] - prevCoords[0]; - const deltaY = currentCoords[1] - prevCoords[1]; - - onDragUpdate?.({ startCoords, prevCoords, currentCoords, diffX, diffY, deltaX, deltaY }, event); - prevCoords = currentCoords; - }) - .on(EVENTS.DRAG_END, (_event: MouseEvent) => { - startCoords = undefined; - prevCoords = undefined; - onDrop?.(_event); - }); + this.context.graph.dragService.startDrag( + { + onStart: (event: MouseEvent) => { + if (onDragStart?.(event) === false) { + return; + } + const xy = getXY(this.context.canvas, event); + startCoords = this.context.camera.applyToPoint(xy[0], xy[1]); + prevCoords = startCoords; + }, + onUpdate: (event: MouseEvent) => { + if (!startCoords?.length) return; + + const [canvasX, canvasY] = getXY(this.context.canvas, event); + const currentCoords = this.context.camera.applyToPoint(canvasX, canvasY); + + // Absolute diff from drag start + const diffX = currentCoords[0] - startCoords[0]; + const diffY = currentCoords[1] - startCoords[1]; + + // Incremental diff from previous frame + const deltaX = currentCoords[0] - prevCoords[0]; + const deltaY = currentCoords[1] - prevCoords[1]; + + onDragUpdate?.({ startCoords, prevCoords, currentCoords, diffX, diffY, deltaX, deltaY }, event); + prevCoords = currentCoords; + }, + onEnd: (event: MouseEvent) => { + startCoords = undefined; + prevCoords = undefined; + onDrop?.(event); + }, + }, + { + component: this, + autopanning: autopanning ?? true, + cursor: dragCursor ?? "grabbing", + } + ); }); } diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index f5667d5d..6d27903e 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -4,16 +4,16 @@ import isObject from "lodash/isObject"; import { Component } from "../../../lib/Component"; import { ECameraScaleLevel } from "../../../services/camera/CameraService"; +import { DragContext, DragDiff } from "../../../services/drag"; import { TGraphSettingsConfig } from "../../../store"; import { EAnchorType } from "../../../store/anchor/Anchor"; import { BlockState, IS_BLOCK_TYPE, TBlockId } from "../../../store/block/Block"; import { selectBlockById } from "../../../store/block/selectors"; import { PortState } from "../../../store/connection/port/Port"; import { createAnchorPortId, createBlockPointPortId } from "../../../store/connection/port/utils"; -import { getXY } from "../../../utils/functions"; +import { isAllowDrag } from "../../../utils/functions"; import { TMeasureTextOptions } from "../../../utils/functions/text"; import { TTExtRect, renderText } from "../../../utils/renderers/text"; -import { EVENTS } from "../../../utils/types/events"; import { TPoint, TRect } from "../../../utils/types/shapes"; import { GraphComponent, TGraphComponentProps } from "../GraphComponent"; import { Anchor, TAnchor } from "../anchors"; @@ -130,10 +130,6 @@ export class Block { - 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.lastDragEvent = context.sourceEvent; + // Store start coords: [worldX, worldY, blockX, blockY] + this.startDragCoords = [...context.startCoords, this.state.x, this.state.y]; this.raiseBlock(); } ); } - protected onDragUpdate(event: MouseEvent) { - if (!this.startDragCoords) return; - - this.lastDragEvent = event; + /** + * Handle drag update - calculate new position and update block + */ + public override handleDrag(diff: DragDiff, context: DragContext): void { + if (!this.startDragCoords.length) return; - const [canvasX, canvasY] = getXY(this.context.canvas, event); - const [cameraSpaceX, cameraSpaceY] = this.context.camera.applyToPoint(canvasX, canvasY); + this.lastDragEvent = context.sourceEvent; - const [x, y] = this.calcNextDragPosition(cameraSpaceX, cameraSpaceY); + const [x, y] = this.calcNextDragPosition(context.currentCoords[0], context.currentCoords[1]); this.context.graph.executеDefaultEventAction( "block-drag", { - nativeEvent: event, + nativeEvent: context.sourceEvent, block: this.connectedState.asTBlock(), x, y, @@ -337,12 +327,30 @@ export class Block { this.setHitBox(geometry.x, geometry.y, geometry.x + geometry.width, geometry.y + geometry.height, force); }; diff --git a/src/components/canvas/blocks/controllers/BlockController.ts b/src/components/canvas/blocks/controllers/BlockController.ts index 66d10d29..79e26e11 100644 --- a/src/components/canvas/blocks/controllers/BlockController.ts +++ b/src/components/canvas/blocks/controllers/BlockController.ts @@ -1,16 +1,11 @@ import { ESelectionStrategy } from "../../../../services/selection/types"; -import { selectBlockById } from "../../../../store/block/selectors"; -import { ECanChangeBlockGeometry } from "../../../../store/settings"; -import { - createCustomDragEvent, - dispatchEvents, - isAllowChangeBlockGeometry, - isMetaKeyEvent, -} from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; -import { EVENTS } from "../../../../utils/types/events"; +import { isMetaKeyEvent } from "../../../../utils/functions"; import { Block } from "../Block"; +/** + * BlockController handles click events for block selection. + * Drag behavior is now managed by DragService. + */ export class BlockController { constructor(block: Block) { block.addEventListener("click", (event: MouseEvent) => { @@ -32,47 +27,5 @@ export class BlockController { !isMetaKeyEvent(event) ? ESelectionStrategy.REPLACE : ESelectionStrategy.APPEND ); }); - - block.addEventListener("mousedown", (event: MouseEvent) => { - const blockState = selectBlockById(block.context.graph, block.props.id); - const allowChangeBlockGeometry = isAllowChangeBlockGeometry( - block.getConfigFlag("canChangeBlockGeometry") as ECanChangeBlockGeometry, - blockState.$selected.value - ); - - if (!allowChangeBlockGeometry) return; - - event.stopPropagation(); - - const draggingElements = block.context.graph.rootStore.blocksList.$selectedBlocks.value.map((block) => - block.getViewComponent() - ); - - // Prevent drag if user selected multiple blocks but start drag from non-selected block - if (draggingElements.length && !draggingElements.includes(block)) { - return; - } - - // Add current block to list of dragging elements - if (!draggingElements.includes(block)) { - draggingElements.push(block); - } - - dragListener(block.context.ownerDocument, { - graph: block.context.graph, - dragCursor: "grabbing", - component: block, - autopanning: true, - }) - .on(EVENTS.DRAG_START, (_event: MouseEvent) => { - dispatchEvents(draggingElements, createCustomDragEvent(EVENTS.DRAG_START, _event)); - }) - .on(EVENTS.DRAG_UPDATE, (_event: MouseEvent) => { - dispatchEvents(draggingElements, createCustomDragEvent(EVENTS.DRAG_UPDATE, _event)); - }) - .on(EVENTS.DRAG_END, (_event: MouseEvent) => { - dispatchEvents(draggingElements, createCustomDragEvent(EVENTS.DRAG_END, _event)); - }); - }); } } diff --git a/src/components/canvas/groups/Group.ts b/src/components/canvas/groups/Group.ts index a04a9840..92f7ae05 100644 --- a/src/components/canvas/groups/Group.ts +++ b/src/components/canvas/groups/Group.ts @@ -1,9 +1,9 @@ import { TComponentState } from "../../../lib/Component"; +import { DragContext, DragDiff } from "../../../services/drag"; import { ESelectionStrategy } from "../../../services/selection/types"; import { BlockState } from "../../../store/block/Block"; import { GroupState, TGroup, TGroupId } from "../../../store/group/Group"; -import { ECanChangeBlockGeometry } from "../../../store/settings"; -import { isAllowChangeBlockGeometry, isMetaKeyEvent } from "../../../utils/functions"; +import { isAllowDrag, isMetaKeyEvent } from "../../../utils/functions"; import { TMeasureTextOptions } from "../../../utils/functions/text"; import { layoutText } from "../../../utils/renderers/text"; import { TRect } from "../../../utils/types/shapes"; @@ -107,45 +107,49 @@ export class Group extends GraphComponent this.isDraggable(), - onDragStart: () => { - this.context.graph.cameraService.enableAutoPanning(); - this.context.graph.lockCursor("grabbing"); - }, - onDragUpdate: ({ deltaX, deltaY }) => { - const rect = { - x: this.state.rect.x + deltaX, - y: this.state.rect.y + deltaY, - width: this.state.rect.width, - height: this.state.rect.height, - }; - this.setState({ - rect, - }); - this.updateHitBox(rect); - this.props.onDragUpdate(this.props.id, { deltaX, deltaY }); - }, - onDrop: () => { - this.context.graph.cameraService.disableAutoPanning(); - this.context.graph.unlockCursor(); - }, - }); } public getEntityId() { return this.props.id; } - protected isDraggable() { - return ( - this.props.draggable && - isAllowChangeBlockGeometry( - this.context.graph.rootStore.settings.getConfigFlag("canChangeBlockGeometry") as ECanChangeBlockGeometry, - this.state.selected - ) - ); + /** + * Check if group can be dragged based on props.draggable and canDrag setting + */ + public override isDraggable(): boolean { + const canDrag = this.context.graph.rootStore.settings.$canDrag.value; + return Boolean(this.props.draggable) && isAllowDrag(canDrag, this.state.selected); + } + + /** + * Handle drag start - nothing special needed, DragService handles autopanning and cursor + */ + public override handleDragStart(_context: DragContext): void { + // DragService handles autopanning and cursor locking + } + + /** + * Handle drag update - update group rect and notify via callback + */ + public override handleDrag(diff: DragDiff, _context: DragContext): void { + const rect = { + x: this.state.rect.x + diff.deltaX, + y: this.state.rect.y + diff.deltaY, + width: this.state.rect.width, + height: this.state.rect.height, + }; + this.setState({ + rect, + }); + this.updateHitBox(rect); + this.props.onDragUpdate(this.props.id, { deltaX: diff.deltaX, deltaY: diff.deltaY }); + } + + /** + * Handle drag end - nothing special needed, DragService handles cleanup + */ + public override handleDragEnd(_context: DragContext): void { + // DragService handles autopanning disable and cursor unlock } protected getRect(rect = this.state.rect) { diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index 54266f5c..bdce74b2 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -4,10 +4,8 @@ import { ESelectionStrategy } from "../../../../services/selection/types"; import { AnchorState } from "../../../../store/anchor/Anchor"; import { BlockState, TBlockId } from "../../../../store/block/Block"; import { isBlock, isShiftKeyEvent } from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; import { render } from "../../../../utils/renderers/render"; import { renderSVG } from "../../../../utils/renderers/svgPath"; -import { EVENTS } from "../../../../utils/types/events"; import { Point, TPoint } from "../../../../utils/types/shapes"; import { Anchor } from "../../../canvas/anchors"; import { Block } from "../../../canvas/blocks/Block"; @@ -196,20 +194,14 @@ export class ConnectionLayer extends Layer< nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - dragListener(this.root.ownerDocument, { - graph: this.context.graph, - dragCursor: "crosshair", - autopanning: true, - }) - .on(EVENTS.DRAG_START, (dStartEvent: MouseEvent) => { - this.onStartConnection(dStartEvent, this.context.graph.getPointInCameraSpace(dStartEvent)); - }) - .on(EVENTS.DRAG_UPDATE, (dUpdateEvent: MouseEvent) => - this.onMoveNewConnection(dUpdateEvent, this.context.graph.getPointInCameraSpace(dUpdateEvent)) - ) - .on(EVENTS.DRAG_END, (dEndEvent: MouseEvent) => { - this.onEndNewConnection(this.context.graph.getPointInCameraSpace(dEndEvent)); - }); + this.context.graph.dragService.startDrag( + { + onStart: (event, coords) => this.onStartConnection(event, new Point(coords[0], coords[1])), + onUpdate: (event, coords) => this.onMoveNewConnection(event, new Point(coords[0], coords[1])), + onEnd: (_event, coords) => this.onEndNewConnection(new Point(coords[0], coords[1])), + }, + { cursor: "crosshair" } + ); } }; diff --git a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts index cf84c7d9..8baed178 100644 --- a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts +++ b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts @@ -3,9 +3,7 @@ import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { ESelectionStrategy } from "../../../../services/selection/types"; import { BlockState } from "../../../../store/block/Block"; import { getXY, isAltKeyEvent, isBlock } from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; import { render } from "../../../../utils/renderers/render"; -import { EVENTS } from "../../../../utils/types/events"; import { TPoint } from "../../../../utils/types/shapes"; import { Block } from "../../../canvas/blocks/Block"; @@ -110,18 +108,16 @@ export class NewBlockLayer extends Layer< nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - dragListener(this.root.ownerDocument, { - graph: this.context.graph, - dragCursor: "copy", - autopanning: true, - }) - .on(EVENTS.DRAG_START, (event: MouseEvent) => { - this.onStartNewBlock(event, target); - }) - .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => this.onMoveNewBlock(event)) - .on(EVENTS.DRAG_END, (event: MouseEvent) => { - this.onEndNewBlock(event, this.context.graph.getPointInCameraSpace(event)); - }); + // Capture target in closure for onStart callback + const blockTarget = target; + this.context.graph.dragService.startDrag( + { + onStart: (event) => this.onStartNewBlock(event, blockTarget), + onUpdate: (event) => this.onMoveNewBlock(event), + onEnd: (event, coords) => this.onEndNewBlock(event, { x: coords[0], y: coords[1] }), + }, + { cursor: "copy" } + ); } }; diff --git a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts index 010081fc..933aea53 100644 --- a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts +++ b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts @@ -2,10 +2,8 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graph import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { Camera } from "../../../../services/camera/Camera"; import { ESelectionStrategy } from "../../../../services/selection"; -import { getXY, isMetaKeyEvent } from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; +import { isMetaKeyEvent } from "../../../../utils/functions"; import { render } from "../../../../utils/renderers/render"; -import { EVENTS } from "../../../../utils/types/events"; function getSelectionRect(sx: number, sy: number, ex: number, ey: number): number[] { if (sx > ex) [sx, ex] = [ex, sx]; @@ -103,32 +101,20 @@ export class SelectionLayer extends Layer< if (event && isMetaKeyEvent(event)) { nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - dragListener(this.root.ownerDocument, { - graph: this.context.graph, - autopanning: true, - }) - .on(EVENTS.DRAG_START, this.startSelectionRender) - .on(EVENTS.DRAG_UPDATE, this.updateSelectionRender) - .on(EVENTS.DRAG_END, this.endSelectionRender); + this.context.graph.dragService.startDrag({ + onStart: this.startSelectionRender, + onUpdate: this.updateSelectionRender, + onEnd: this.endSelectionRender, + }); } }; - private updateSelectionRender = (event: MouseEvent) => { - // Convert screen coordinates to world coordinates - const [screenX, screenY] = getXY(this.context.canvas, event); - const camera = this.context.graph.cameraService; - const [worldX, worldY] = camera.getRelativeXY(screenX, screenY); - + private updateSelectionRender = (event: MouseEvent, [worldX, worldY]: [number, number]) => { this.selectionEndWorld = { x: worldX, y: worldY }; this.performRender(); }; - private startSelectionRender = (_event: MouseEvent) => { - // Convert screen coordinates to world coordinates - const [screenX, screenY] = getXY(this.context.canvas, _event); - const camera = this.context.graph.cameraService; - const [worldX, worldY] = camera.getRelativeXY(screenX, screenY); - + private startSelectionRender = (_event: MouseEvent, [worldX, worldY]: [number, number]) => { this.selectionStartWorld = { x: worldX, y: worldY }; this.selectionEndWorld = { x: worldX, y: worldY }; }; diff --git a/src/graph.ts b/src/graph.ts index 2f972dc3..340e6b48 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -15,6 +15,7 @@ import { HitTest } from "./services/HitTest"; import { Layer, LayerPublicProps } from "./services/Layer"; import { Layers } from "./services/LayersService"; import { CameraService } from "./services/camera/CameraService"; +import { DragService } from "./services/drag"; import { RootStore } from "./store"; import { TBlockId } from "./store/block/Block"; import { TConnection } from "./store/connection/ConnectionState"; @@ -68,6 +69,12 @@ export class Graph { public hitTest = new HitTest(this); + /** + * Service that manages drag operations for all draggable GraphComponents. + * Handles autopanning, cursor locking, and coordinates drag lifecycle across selected components. + */ + public dragService: DragService; + protected graphLayer: GraphLayer; protected belowLayer: BelowLayer; @@ -121,6 +128,9 @@ export class Graph { this.selectionLayer = this.addLayer(SelectionLayer, {}); this.cursorLayer = this.addLayer(CursorLayer, {}); + // Initialize DragService for managing drag operations on GraphComponents + this.dragService = new DragService(this); + this.selectionLayer.hide(); this.graphLayer.hide(); this.belowLayer.hide(); @@ -470,6 +480,7 @@ export class Graph { clearTextCache(); this.rootStore.reset(); this.scheduler.stop(); + this.dragService.destroy(); } /** diff --git a/src/index.ts b/src/index.ts index 145a4460..4deba111 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ export { EAnchorType } from "./store/anchor/Anchor"; export type { BlockState, TBlockId } from "./store/block/Block"; export type { ConnectionState, TConnection, TConnectionId } from "./store/connection/ConnectionState"; export type { AnchorState } from "./store/anchor/Anchor"; -export { ECanChangeBlockGeometry } from "./store/settings"; +export { ECanChangeBlockGeometry, ECanDrag } from "./store/settings"; export { type TMeasureTextOptions, type TWrapText } from "./utils/functions/text"; export { ESchedulerPriority } from "./lib/Scheduler"; export { debounce, throttle, schedule } from "./utils/functions"; diff --git a/src/plugins/minimap/layer.ts b/src/plugins/minimap/layer.ts index a91f19a8..ba8f5857 100644 --- a/src/plugins/minimap/layer.ts +++ b/src/plugins/minimap/layer.ts @@ -1,8 +1,6 @@ import { TGraphLayerContext } from "../../components/canvas/layers/graphLayer/GraphLayer"; import { Layer, LayerContext, LayerProps } from "../../services/Layer"; import { computeCssVariable, noop } from "../../utils/functions"; -import { dragListener } from "../../utils/functions/dragListener"; -import { EVENTS } from "../../utils/types/events"; export type TMiniMapLocation = | "topLeft" @@ -324,8 +322,9 @@ export class MiniMapLayer extends Layer rootEvent.stopPropagation(); this.onCameraDrag(rootEvent); - dragListener(this.getCanvas(), { stopOnMouseLeave: true }).on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => - this.onCameraDrag(event) + this.context.graph.dragService.startDrag( + { onUpdate: (event: MouseEvent) => this.onCameraDrag(event) }, + { stopOnMouseLeave: true } ); }; } diff --git a/src/services/drag/DragService.ts b/src/services/drag/DragService.ts new file mode 100644 index 00000000..7124d74c --- /dev/null +++ b/src/services/drag/DragService.ts @@ -0,0 +1,330 @@ +import { signal } from "@preact/signals-core"; + +import type { GraphComponent } from "../../components/canvas/GraphComponent"; +import type { Graph } from "../../graph"; +import type { GraphMouseEvent } from "../../graphEvents"; +import { ECanDrag } from "../../store/settings"; +import { Emitter } from "../../utils/Emitter"; +import { getXY } from "../../utils/functions"; +import { dragListener } from "../../utils/functions/dragListener"; +import { EVENTS } from "../../utils/types/events"; + +import type { DragContext, DragDiff, DragOperationCallbacks, DragOperationOptions, DragState } from "./types"; + +/** + * 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 { + /** Components participating in the current drag operation */ + private dragComponents: GraphComponent[] = []; + + /** Starting coordinates in world space */ + private startCoords: [number, number] | null = null; + + /** Previous frame coordinates in world space */ + private prevCoords: [number, number] | null = null; + + /** Current drag listener emitter (null when not dragging) */ + private currentDragEmitter: Emitter | null = null; + + /** Unsubscribe function for graph mousedown event */ + private unsubscribeMouseDown: (() => void) | null = 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 + * } + * ``` + */ + public readonly $state = signal(this.createIdleState()); + + constructor(private graph: Graph) { + this.unsubscribeMouseDown = graph.on("mousedown", this.handleMouseDown, { + capture: true, + }); + } + + /** + * Create idle (not dragging) state + */ + private createIdleState(): DragState { + return { + isDragging: false, + components: [], + componentTypes: new Set(), + isMultiple: false, + isHomogeneous: true, + }; + } + + /** + * Create active drag state from components + */ + private createDragState(components: GraphComponent[]): DragState { + 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 + */ + public destroy(): void { + this.cleanup(); + if (this.unsubscribeMouseDown) { + this.unsubscribeMouseDown(); + this.unsubscribeMouseDown = null; + } + } + + /** + * Handle mousedown on graph - determine if drag should start + */ + private handleMouseDown = (event: GraphMouseEvent): void => { + // 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 as GraphComponent | undefined; + + 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; + } + + // Prevent camera drag when dragging components + event.preventDefault(); + + // 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); + }; + + /** + * 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. + */ + private collectDragComponents(target: GraphComponent, canDrag: ECanDrag): GraphComponent[] { + 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]; + } + + /** + * Handle drag start from dragListener + */ + private handleDragStart = (event: MouseEvent): void => { + // 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: DragContext = { + 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 + */ + private handleDragUpdate = (event: MouseEvent): void => { + if (!this.startCoords || !this.prevCoords) { + return; + } + + const currentCoords = this.getWorldCoords(event); + + const diff: DragDiff = { + 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: DragContext = { + 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 + */ + private handleDragEnd = (event: MouseEvent): void => { + if (this.startCoords && this.prevCoords) { + const currentCoords = this.getWorldCoords(event); + + const context: DragContext = { + 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(); + }; + + /** + * Convert screen coordinates to world coordinates + */ + private getWorldCoords(event: MouseEvent): [number, number] { + const canvas = this.graph.getGraphCanvas(); + const [screenX, screenY] = getXY(canvas, event); + return this.graph.cameraService.applyToPoint(screenX, screenY) as [number, number]; + } + + /** + * Cleanup after drag operation ends + */ + private cleanup(): void { + // 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 } + * ); + * ``` + */ + public startDrag(callbacks: DragOperationCallbacks, options: DragOperationOptions = {}): void { + const { document: doc, cursor, autopanning = true, stopOnMouseLeave } = options; + const { onStart, onUpdate, onEnd } = callbacks; + + const targetDocument = doc ?? this.graph.getGraphCanvas().ownerDocument; + + dragListener(targetDocument, { + graph: this.graph, + dragCursor: cursor, + autopanning, + stopOnMouseLeave, + }) + .on(EVENTS.DRAG_START, (event: MouseEvent) => { + const coords = this.getWorldCoords(event); + onStart?.(event, coords); + }) + .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => { + const coords = this.getWorldCoords(event); + onUpdate?.(event, coords); + }) + .on(EVENTS.DRAG_END, (event: MouseEvent) => { + const coords = this.getWorldCoords(event); + onEnd?.(event, coords); + }); + } +} diff --git a/src/services/drag/index.ts b/src/services/drag/index.ts new file mode 100644 index 00000000..3a969da8 --- /dev/null +++ b/src/services/drag/index.ts @@ -0,0 +1,2 @@ +export { DragService } from "./DragService"; +export type { DragContext, DragDiff, DragOperationCallbacks, DragOperationOptions, DragState } from "./types"; diff --git a/src/services/drag/types.ts b/src/services/drag/types.ts new file mode 100644 index 00000000..58f69689 --- /dev/null +++ b/src/services/drag/types.ts @@ -0,0 +1,81 @@ +import type { GraphComponent } from "../../components/canvas/GraphComponent"; +import type { CursorLayerCursorTypes } from "../../components/canvas/layers/cursorLayer/CursorLayer"; + +/** + * Options for creating a custom drag operation via DragService.startOperation() + */ +export type DragOperationOptions = { + /** Document to attach listeners to. Defaults to graph canvas document if not provided */ + document?: Document; + /** Cursor to show during drag */ + cursor?: CursorLayerCursorTypes; + component?: GraphComponent; + /** Enable autopanning when dragging near edges */ + autopanning?: boolean; + /** Stop drag when mouse leaves the document */ + stopOnMouseLeave?: boolean; +}; + +/** + * Callbacks for custom drag operation lifecycle + */ +export type DragOperationCallbacks = { + /** Called when drag starts (first mousemove after mousedown) */ + onStart?: (event: MouseEvent, coords: [number, number]) => void; + /** Called on each mousemove during drag */ + onUpdate?: (event: MouseEvent, coords: [number, number]) => void; + /** Called when drag ends (mouseup) */ + onEnd?: (event: MouseEvent, coords: [number, number]) => void; +}; + +/** + * Current state of drag operation, accessible via DragService.$state signal + */ +export type DragState = { + /** Whether a drag operation is currently in progress */ + isDragging: boolean; + /** Components participating in the current drag operation */ + components: GraphComponent[]; + /** Set of component type names (constructor names) participating in drag */ + componentTypes: Set; + /** Whether multiple components are being dragged */ + isMultiple: boolean; + /** Whether all dragged components are of the same type */ + isHomogeneous: boolean; +}; + +/** + * Context passed to drag lifecycle methods + */ +export type DragContext = { + /** The native mouse event */ + sourceEvent: MouseEvent; + /** Starting coordinates in world space when drag began */ + startCoords: [number, number]; + /** Previous coordinates in world space from last frame */ + prevCoords: [number, number]; + /** Current coordinates in world space */ + currentCoords: [number, number]; + /** All components participating in this drag operation */ + components: GraphComponent[]; +}; + +/** + * Diff values passed to handleDrag method + */ +export type DragDiff = { + /** Starting coordinates in world space when drag began */ + startCoords: [number, number]; + /** Previous coordinates in world space from last frame */ + prevCoords: [number, number]; + /** Current coordinates in world space */ + currentCoords: [number, number]; + /** Absolute X displacement from start (currentCoords.x - startCoords.x) */ + diffX: number; + /** Absolute Y displacement from start (currentCoords.y - startCoords.y) */ + diffY: number; + /** Incremental X change since last frame (currentCoords.x - prevCoords.x) */ + deltaX: number; + /** Incremental Y change since last frame (currentCoords.y - prevCoords.y) */ + deltaY: number; +}; diff --git a/src/store/settings.test.ts b/src/store/settings.test.ts index 41b58ddb..ea643289 100644 --- a/src/store/settings.test.ts +++ b/src/store/settings.test.ts @@ -1,6 +1,6 @@ import { Graph } from "../graph"; -import { ECanChangeBlockGeometry, GraphEditorSettings } from "./settings"; +import { ECanChangeBlockGeometry, ECanDrag, GraphEditorSettings } from "./settings"; describe("Settings store", () => { let graph: Graph; @@ -13,12 +13,12 @@ describe("Settings store", () => { expect(store).toBeDefined(); }); - it("Should init wit default settings", () => { + it("Should init with default settings", () => { expect(store.asConfig).toEqual({ canDragCamera: true, canZoomCamera: true, canDuplicateBlocks: false, - canChangeBlockGeometry: ECanChangeBlockGeometry.NONE, + canDrag: ECanDrag.ALL, canCreateNewConnections: false, showConnectionArrows: true, scaleFontSize: 1, @@ -30,12 +30,95 @@ describe("Settings store", () => { blockComponents: {}, }); }); + it("Should get config via key", () => { expect(store.getConfigFlag("canDuplicateBlocks")).toBe(false); }); + it("Should set config via key", () => { expect(store.getConfigFlag("canDuplicateBlocks")).toBe(false); store.setConfigFlag("canDuplicateBlocks", true); expect(store.getConfigFlag("canDuplicateBlocks")).toBe(true); }); + + describe("$canDrag computed with backward compatibility", () => { + describe("new setting (canDrag)", () => { + it("should default to ALL", () => { + expect(store.$canDrag.value).toBe(ECanDrag.ALL); + }); + + it("should use ALL when explicitly set", () => { + store.setupSettings({ canDrag: ECanDrag.ALL }); + expect(store.$canDrag.value).toBe(ECanDrag.ALL); + }); + + it("should use ONLY_SELECTED when explicitly set", () => { + store.setupSettings({ canDrag: ECanDrag.ONLY_SELECTED }); + expect(store.$canDrag.value).toBe(ECanDrag.ONLY_SELECTED); + }); + + it("should use NONE when explicitly set", () => { + store.setupSettings({ canDrag: ECanDrag.NONE }); + expect(store.$canDrag.value).toBe(ECanDrag.NONE); + }); + }); + + describe("deprecated setting (canChangeBlockGeometry)", () => { + it("should use ALL when set via deprecated setting", () => { + store.setupSettings({ canChangeBlockGeometry: ECanChangeBlockGeometry.ALL }); + expect(store.$canDrag.value).toBe(ECanDrag.ALL); + }); + + it("should use ONLY_SELECTED when set via deprecated setting", () => { + store.setupSettings({ canChangeBlockGeometry: ECanChangeBlockGeometry.ONLY_SELECTED }); + expect(store.$canDrag.value).toBe(ECanDrag.ONLY_SELECTED); + }); + + it("should use NONE when set via deprecated setting", () => { + store.setupSettings({ canChangeBlockGeometry: ECanChangeBlockGeometry.NONE }); + expect(store.$canDrag.value).toBe(ECanDrag.NONE); + }); + }); + + describe("conflict resolution (both settings provided)", () => { + it("should prioritize deprecated canChangeBlockGeometry over canDrag to not break existing users", () => { + store.setupSettings({ + canChangeBlockGeometry: ECanChangeBlockGeometry.NONE, + canDrag: ECanDrag.ALL, + }); + expect(store.$canDrag.value).toBe(ECanDrag.NONE); + }); + + it("should use canChangeBlockGeometry=ALL even when canDrag=NONE", () => { + store.setupSettings({ + canChangeBlockGeometry: ECanChangeBlockGeometry.ALL, + canDrag: ECanDrag.NONE, + }); + expect(store.$canDrag.value).toBe(ECanDrag.ALL); + }); + + it("should use canChangeBlockGeometry=ONLY_SELECTED even when canDrag=ALL", () => { + store.setupSettings({ + canChangeBlockGeometry: ECanChangeBlockGeometry.ONLY_SELECTED, + canDrag: ECanDrag.ALL, + }); + expect(store.$canDrag.value).toBe(ECanDrag.ONLY_SELECTED); + }); + }); + + describe("migration path", () => { + it("should allow migration by removing deprecated setting and using new one", () => { + // User starts with deprecated setting + store.setupSettings({ canChangeBlockGeometry: ECanChangeBlockGeometry.NONE }); + expect(store.$canDrag.value).toBe(ECanDrag.NONE); + + // User migrates: removes deprecated setting, adds new one + store.setupSettings({ + canChangeBlockGeometry: undefined, + canDrag: ECanDrag.ALL, + }); + expect(store.$canDrag.value).toBe(ECanDrag.ALL); + }); + }); + }); }); diff --git a/src/store/settings.ts b/src/store/settings.ts index c49fc8cc..3394d054 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -9,18 +9,31 @@ import { TConnection } from "./connection/ConnectionState"; import { RootStore } from "./index"; +/** @deprecated Use ECanDrag and setting canDrag instead */ export enum ECanChangeBlockGeometry { ALL = "all", ONLY_SELECTED = "onlySelected", NONE = "none", } +export enum ECanDrag { + /** Any component can be dragged. If component is in selection, all selected draggable components move together */ + ALL = "all", + /** Only selected components can be dragged */ + ONLY_SELECTED = "onlySelected", + /** Drag is disabled for all components (except manual drag via startDrag) */ + NONE = "none", +} + export type TGraphSettingsConfig = { canDragCamera: boolean; canZoomCamera: boolean; /** @deprecated Use NewBlockLayer parameters instead */ canDuplicateBlocks?: boolean; - canChangeBlockGeometry: ECanChangeBlockGeometry; + /** @deprecated Use canDrag instead */ + canChangeBlockGeometry?: ECanChangeBlockGeometry; + /** Controls which components can be dragged */ + canDrag?: ECanDrag; canCreateNewConnections: boolean; scaleFontSize: number; showConnectionArrows: boolean; @@ -38,7 +51,7 @@ const getInitState: TGraphSettingsConfig = { canDragCamera: true, canZoomCamera: true, canDuplicateBlocks: false, - canChangeBlockGeometry: ECanChangeBlockGeometry.NONE, + canDrag: ECanDrag.ALL, canCreateNewConnections: false, showConnectionArrows: true, scaleFontSize: 1, @@ -91,6 +104,28 @@ export class GraphEditorSettings { }; }); + /** + * Computed canDrag setting with backward compatibility. + * Priority: canChangeBlockGeometry (deprecated, for existing users) > canDrag > default ALL + */ + public $canDrag = computed((): ECanDrag => { + const settings = this.$settings.value; + + // 1. If deprecated canChangeBlockGeometry is set, use it (don't break existing users) + // Both enums have the same string values, so we can cast directly + if (settings.canChangeBlockGeometry !== undefined) { + return settings.canChangeBlockGeometry as unknown as ECanDrag; + } + + // 2. Use canDrag if explicitly set (new users) + if (settings.canDrag !== undefined) { + return settings.canDrag; + } + + // 3. Default to ALL if neither is set + return ECanDrag.ALL; + }); + public toJSON() { return cloneDeep(this.$settings.toJSON()); } diff --git a/src/stories/Playground/GraphPlayground.tsx b/src/stories/Playground/GraphPlayground.tsx index 41cf8305..d4def137 100644 --- a/src/stories/Playground/GraphPlayground.tsx +++ b/src/stories/Playground/GraphPlayground.tsx @@ -16,7 +16,7 @@ import { ConnectionLayer } from "../../components/canvas/layers/connectionLayer/ import { Graph, GraphState, TGraphConfig } from "../../graph"; import { GraphBlock, GraphCanvas, HookGraphParams, useGraph, useGraphEvent, useLayer } from "../../react-components"; import { useFn } from "../../react-components/utils/hooks/useFn"; -import { ECanChangeBlockGeometry } from "../../store/settings"; +import { ECanDrag } from "../../store/settings"; import { EAnchorType } from "../configurations/definitions"; import { ActionBlock } from "./ActionBlock"; @@ -80,7 +80,7 @@ const config: HookGraphParams = { canDragCamera: true, canZoomCamera: true, canDuplicateBlocks: false, - canChangeBlockGeometry: ECanChangeBlockGeometry.ALL, + canDrag: ECanDrag.ALL, canCreateNewConnections: true, showConnectionArrows: false, scaleFontSize: 1, diff --git a/src/stories/canvas/groups/default.stories.tsx b/src/stories/canvas/groups/default.stories.tsx index c2f97e43..5862ac08 100644 --- a/src/stories/canvas/groups/default.stories.tsx +++ b/src/stories/canvas/groups/default.stories.tsx @@ -4,7 +4,7 @@ import type { Meta, StoryFn } from "@storybook/react-webpack5"; import groupBy from "lodash/groupBy"; import { BlockGroups, Group } from "../../../components/canvas/groups"; -import { BlockState, ECanChangeBlockGeometry, Graph, GraphState, TBlock } from "../../../index"; +import { BlockState, ECanDrag, Graph, GraphState, TBlock } from "../../../index"; import { GraphCanvas, useGraph, useGraphEvent } from "../../../react-components"; import { useFn } from "../../../react-components/utils/hooks/useFn"; import { BlockStory } from "../../main/Block"; @@ -102,7 +102,7 @@ const GroupsLayer = BlockGroups.withBlockGrouping({ const GraphApp = () => { const { graph, setEntities, start } = useGraph({ settings: { - canChangeBlockGeometry: ECanChangeBlockGeometry.ALL, + canDrag: ECanDrag.ALL, }, }); const config = createConfig(); diff --git a/src/stories/configurations/definitions.ts b/src/stories/configurations/definitions.ts index c5fe48b3..9c558222 100644 --- a/src/stories/configurations/definitions.ts +++ b/src/stories/configurations/definitions.ts @@ -1,4 +1,4 @@ -import { ECanChangeBlockGeometry } from "../../store/settings"; +import { ECanDrag } from "../../store/settings"; export enum EAnchorType { IN = "IN", @@ -8,8 +8,8 @@ export enum EAnchorType { export const storiesSettings = { canDragCamera: true, canZoomCamera: true, + canDrag: ECanDrag.ALL, canDuplicateBlocks: false, - canChangeBlockGeometry: ECanChangeBlockGeometry.ALL, canCreateNewConnections: true, showConnectionArrows: true, scaleFontSize: 1, diff --git a/src/stories/examples/mouseWheelBehaviorScroll/mouseWheelBehaviorScroll.stories.tsx b/src/stories/examples/mouseWheelBehaviorScroll/mouseWheelBehaviorScroll.stories.tsx index 5e5a5bcd..cf1e080e 100644 --- a/src/stories/examples/mouseWheelBehaviorScroll/mouseWheelBehaviorScroll.stories.tsx +++ b/src/stories/examples/mouseWheelBehaviorScroll/mouseWheelBehaviorScroll.stories.tsx @@ -6,7 +6,7 @@ import { TBlock } from "../../../components/canvas/blocks/Block"; import { Graph, GraphState } from "../../../graph"; import { GraphBlock, GraphCanvas, HookGraphParams, useGraph, useGraphEvent } from "../../../react-components"; import { useFn } from "../../../react-components/utils/hooks/useFn"; -import { ECanChangeBlockGeometry } from "../../../store/settings"; +import { ECanDrag } from "../../../store/settings"; const config: HookGraphParams = { viewConfiguration: { @@ -20,7 +20,7 @@ const config: HookGraphParams = { canDragCamera: true, canZoomCamera: true, canDuplicateBlocks: false, - canChangeBlockGeometry: ECanChangeBlockGeometry.ALL, + canDrag: ECanDrag.ALL, canCreateNewConnections: true, showConnectionArrows: false, scaleFontSize: 1, diff --git a/src/stories/main/GraphEditor.stories.tsx b/src/stories/main/GraphEditor.stories.tsx index 2f53549f..30a14ce5 100644 --- a/src/stories/main/GraphEditor.stories.tsx +++ b/src/stories/main/GraphEditor.stories.tsx @@ -7,7 +7,7 @@ import merge from "lodash/merge"; import { Graph, TGraphConfig } from "../../graph"; import { TGraphConstants, initGraphConstants } from "../../graphConfig"; import { TGraphSettingsConfig } from "../../store"; -import { ECanChangeBlockGeometry } from "../../store/settings"; +import { ECanDrag } from "../../store/settings"; import { RecursivePartial } from "../../utils/types/helpers"; import { CustomLayerConfig } from "../configurations/CustomLayerConfig"; import { oneBezierConnectionConfig } from "../configurations/bezierConnection"; @@ -62,9 +62,9 @@ const meta: Meta = { control: "boolean", description: "Allow zoom camera", }, - canChangeBlockGeometry: { + canDrag: { control: "select", - options: [ECanChangeBlockGeometry.ALL, ECanChangeBlockGeometry.ONLY_SELECTED, ECanChangeBlockGeometry.NONE], + options: [ECanDrag.ALL, ECanDrag.ONLY_SELECTED, ECanDrag.NONE], description: "allow drag blocks", }, canCreateNewConnections: { @@ -89,7 +89,7 @@ const meta: Meta = { showConnectionLabels: false, canDragCamera: true, canZoomCamera: true, - canChangeBlockGeometry: ECanChangeBlockGeometry.ALL, + canDrag: ECanDrag.ALL, canCreateNewConnections: true, scaleFontSize: 1, showConnectionArrows: true, diff --git a/src/stories/plugins/minimap.stories.tsx b/src/stories/plugins/minimap.stories.tsx index a078812c..eb0d2a75 100644 --- a/src/stories/plugins/minimap.stories.tsx +++ b/src/stories/plugins/minimap.stories.tsx @@ -3,7 +3,7 @@ import React, { useRef } from "react"; import { ThemeProvider } from "@gravity-ui/uikit"; import type { Meta, StoryFn } from "@storybook/react-webpack5"; -import { ECanChangeBlockGeometry, Graph, LayerConfig, MiniMapLayer } from "../../"; +import { ECanDrag, Graph, LayerConfig, MiniMapLayer } from "../../"; import { generatePrettyBlocks } from "../configurations/generatePretty"; import { GraphComponentStory } from "../main/GraphEditor"; @@ -32,7 +32,7 @@ const GraphApp = () => { config={{ ...generatePrettyBlocks({ layersCount: 10, connectionsPerLayer: 10 }), layers, - settings: { canChangeBlockGeometry: ECanChangeBlockGeometry.ALL }, + settings: { canDrag: ECanDrag.ALL }, }} /> diff --git a/src/utils/functions/index.ts b/src/utils/functions/index.ts index e2954961..562970b8 100644 --- a/src/utils/functions/index.ts +++ b/src/utils/functions/index.ts @@ -1,7 +1,7 @@ import { GraphComponent } from "../../components/canvas/GraphComponent"; import { Block, TBlock } from "../../components/canvas/blocks/Block"; -import { ECanChangeBlockGeometry } from "../../store/settings"; -import { EVENTS_DETAIL, SELECTION_EVENT_TYPES } from "../types/events"; +import { ECanDrag } from "../../store/settings"; +import { SELECTION_EVENT_TYPES } from "../types/events"; import { Rect, TRect } from "../types/shapes"; export { parseClassNames } from "./classNames"; @@ -55,15 +55,6 @@ export function isBlock(component: unknown): component is Block { return (component as Block)?.isBlock; } -export function createCustomDragEvent(eventType: string, e): CustomEvent { - return new CustomEvent(eventType, { - detail: { - ...EVENTS_DETAIL[eventType](e.pageX, e.pageY), - sourceEvent: e, - }, - }); -} - export function createObject(simpleObject: object, forDefineProperties: PropertyDescriptorMap): object { const defaultProperties = { configurable: true, @@ -81,14 +72,6 @@ export function createObject(simpleObject: object, forDefineProperties: Property return simpleObject; } -export function dispatchEvents(comps, e) { - for (let i = 0; i < comps.length; i += 1) { - if (comps[i] !== this && comps[i].dispatchEvent) { - comps[i].dispatchEvent(e); - } - } -} - export function addEventListeners( instance: EventTarget, mapEventsToFn?: Record void> @@ -106,10 +89,16 @@ export function addEventListeners( return () => subs.forEach((f) => f()); } -export function isAllowChangeBlockGeometry(globalCanChangeGeometry: ECanChangeBlockGeometry, blockSelected: boolean) { - if (globalCanChangeGeometry === ECanChangeBlockGeometry.ALL) return true; +/** + * Check if drag is allowed based on canDrag setting and component selection state. + * @param canDrag - The canDrag setting value + * @param isSelected - Whether the component is currently selected + * @returns true if the component can be dragged + */ +export function isAllowDrag(canDrag: ECanDrag, isSelected: boolean): boolean { + if (canDrag === ECanDrag.ALL) return true; - return globalCanChangeGeometry === ECanChangeBlockGeometry.ONLY_SELECTED && blockSelected; + return canDrag === ECanDrag.ONLY_SELECTED && isSelected; } /** diff --git a/src/utils/types/events.ts b/src/utils/types/events.ts index acdf32e6..862ff37c 100644 --- a/src/utils/types/events.ts +++ b/src/utils/types/events.ts @@ -25,15 +25,3 @@ export const SELECTION_EVENT_TYPES = { DELETE: 2, TOGGLE: 3, }; - -export const EVENTS_DETAIL = { - [EVENTS.DRAG_START]: (x: number, y: number) => { - return { x, y }; - }, - [EVENTS.DRAG_UPDATE]: (x: number, y: number) => { - return { x, y }; - }, - [EVENTS.DRAG_END]: (x: number, y: number) => { - return { x, y }; - }, -};