From b85853569f742c699245ba85a3fda73dffc82fb9 Mon Sep 17 00:00:00 2001 From: draedful Date: Mon, 8 Dec 2025 00:23:55 +0300 Subject: [PATCH 1/2] feat: introduce DragSystem --- docs/components/canvas-graph-component.md | 111 +++- docs/system/drag-system.md | 502 ++++++++++++++++++ .../canvas/GraphComponent/index.tsx | 43 ++ src/components/canvas/blocks/Block.ts | 103 ++-- .../blocks/controllers/BlockController.ts | 57 +- src/components/canvas/groups/Group.ts | 64 ++- src/graph.ts | 11 + src/services/drag/DragService.ts | 318 +++++++++++ src/services/drag/index.ts | 2 + src/services/drag/types.ts | 53 ++ src/utils/functions/index.ts | 19 +- src/utils/types/events.ts | 12 - 12 files changed, 1133 insertions(+), 162 deletions(-) create mode 100644 docs/system/drag-system.md create mode 100644 src/services/drag/DragService.ts create mode 100644 src/services/drag/index.ts create mode 100644 src/services/drag/types.ts 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..a4a6dc27 --- /dev/null +++ b/docs/system/drag-system.md @@ -0,0 +1,502 @@ +# 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 | diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index da335a0e..b3f735ff 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -5,6 +5,7 @@ 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"; @@ -39,6 +40,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; } diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index f5667d5d..199adf27 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -4,16 +4,17 @@ 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 { ECanChangeBlockGeometry } from "../../../store/settings"; +import { isAllowChangeBlockGeometry } 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 +131,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 +330,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..ee6cfe13 100644 --- a/src/components/canvas/groups/Group.ts +++ b/src/components/canvas/groups/Group.ts @@ -1,4 +1,5 @@ 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"; @@ -107,40 +108,18 @@ 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() { + /** + * Check if group can be dragged based on props.draggable and canChangeBlockGeometry setting + */ + public override isDraggable(): boolean { return ( - this.props.draggable && + Boolean(this.props.draggable) && isAllowChangeBlockGeometry( this.context.graph.rootStore.settings.getConfigFlag("canChangeBlockGeometry") as ECanChangeBlockGeometry, this.state.selected @@ -148,6 +127,37 @@ export class Group extends GraphComponent 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 or pending + // this.doc being set indicates we're waiting for first mousemove + if (this.dragging || this.doc) { + return; + } + + const target = event.detail.target as GraphComponent | undefined; + + if (!target || typeof target.isDraggable !== "function" || !target.isDraggable()) { + return; + } + + // Prevent camera drag when dragging components + event.preventDefault(); + + // Collect all draggable components that should participate + this.dragComponents = this.collectDragComponents(target); + + if (this.dragComponents.length === 0) { + return; + } + + // Setup document listeners for drag tracking + this.doc = this.graph.getGraphCanvas().ownerDocument; + this.doc.addEventListener("mousemove", this.handleFirstMove, { once: true, capture: true }); + this.doc.addEventListener("mouseup", this.handleMouseUp, { once: true, capture: true }); + }; + + /** + * Collect all components that should participate in drag operation. + * If target is in selection, drag all selected draggable components. + * If target is not in selection, drag only the target. + */ + private collectDragComponents(target: GraphComponent): GraphComponent[] { + const selectedComponents = this.graph.selectionService.$selectedComponents.value; + + // Check if target is among selected components + const targetInSelection = selectedComponents.some((c) => c === target); + + if (targetInSelection && selectedComponents.length > 0) { + // Drag all selected draggable components + return selectedComponents.filter((c) => typeof c.isDraggable === "function" && c.isDraggable()); + } + + // Target is not in selection - drag only target + return [target]; + } + + /** + * Handle first mousemove - this initiates the actual drag + */ + private handleFirstMove = (event: MouseEvent): void => { + this.dragging = true; + this.lastMouseEvent = event; + + // Update reactive state + this.$state.value = this.createDragState(this.dragComponents); + + // Enable autopanning + this.graph.cameraService.enableAutoPanning(); + + // Lock cursor to grabbing + this.graph.lockCursor("grabbing"); + + // Subscribe to camera-change for autopanning synchronization + this.graph.on("camera-change", this.handleCameraChange); + + // 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); + }); + + // Continue listening for mousemove + if (this.doc) { + this.doc.addEventListener("mousemove", this.handleMouseMove, { capture: true }); + } + }; + + /** + * Handle mousemove during drag + */ + private handleMouseMove = (event: MouseEvent): void => { + if (!this.dragging || !this.startCoords || !this.prevCoords) { + return; + } + + this.lastMouseEvent = event; + this.emitDragUpdate(event); + }; + + /** + * Emit drag update to all components + */ + private emitDragUpdate(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 mouseup - end drag operation + */ + private handleMouseUp = (event: MouseEvent): void => { + if (this.dragging && 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(); + }; + + /** + * Handle camera-change during drag for autopanning synchronization + */ + private handleCameraChange = (): void => { + if (this.dragging && this.lastMouseEvent) { + // Re-emit drag update with last known mouse position + // This ensures components update their positions when camera moves during autopanning + this.emitDragUpdate(this.lastMouseEvent); + } + }; + + /** + * 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 { + // Remove event listeners + if (this.doc) { + this.doc.removeEventListener("mousemove", this.handleFirstMove, { capture: true }); + this.doc.removeEventListener("mousemove", this.handleMouseMove, { capture: true }); + this.doc.removeEventListener("mouseup", this.handleMouseUp, { capture: true }); + } + + // Unsubscribe from camera-change + this.graph.off("camera-change", this.handleCameraChange); + + // Disable autopanning + if (this.dragging) { + this.graph.cameraService.disableAutoPanning(); + this.graph.unlockCursor(); + } + + // Reset state + this.dragging = false; + this.dragComponents = []; + this.startCoords = null; + this.prevCoords = null; + this.lastMouseEvent = null; + this.doc = null; + + // Update reactive state + this.$state.value = this.createIdleState(); + } +} diff --git a/src/services/drag/index.ts b/src/services/drag/index.ts new file mode 100644 index 00000000..b5118c7a --- /dev/null +++ b/src/services/drag/index.ts @@ -0,0 +1,2 @@ +export { DragService } from "./DragService"; +export type { DragContext, DragDiff, DragState } from "./types"; diff --git a/src/services/drag/types.ts b/src/services/drag/types.ts new file mode 100644 index 00000000..58196f65 --- /dev/null +++ b/src/services/drag/types.ts @@ -0,0 +1,53 @@ +import type { GraphComponent } from "../../components/canvas/GraphComponent"; + +/** + * 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/utils/functions/index.ts b/src/utils/functions/index.ts index e2954961..aa2ad72d 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 { 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> 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 }; - }, -}; From 81fe5cffd9f20c06cc6f97fb094a7ec7cd29ee24 Mon Sep 17 00:00:00 2001 From: draedful Date: Mon, 8 Dec 2025 14:11:07 +0300 Subject: [PATCH 2/2] feat: Add DragService.startDrag method to creating dragging for any component --- docs/system/drag-system.md | 22 + .../canvas/GraphComponent/index.tsx | 75 +- .../layers/connectionLayer/ConnectionLayer.ts | 24 +- .../layers/newBlockLayer/NewBlockLayer.ts | 24 +- .../layers/selectionLayer/SelectionLayer.ts | 30 +- src/plugins/minimap/layer.ts | 7 +- src/services/drag/DragService.test.ts | 679 ++++++++++++++++++ src/services/drag/DragService.ts | 158 ++-- src/services/drag/index.ts | 2 +- src/services/drag/types.ts | 28 + 10 files changed, 872 insertions(+), 177 deletions(-) create mode 100644 src/services/drag/DragService.test.ts diff --git a/docs/system/drag-system.md b/docs/system/drag-system.md index a4a6dc27..f1448554 100644 --- a/docs/system/drag-system.md +++ b/docs/system/drag-system.md @@ -500,3 +500,25 @@ type DragDiff = { | `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 b3f735ff..3711c1d7 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -180,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/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/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.test.ts b/src/services/drag/DragService.test.ts new file mode 100644 index 00000000..b28175c3 --- /dev/null +++ b/src/services/drag/DragService.test.ts @@ -0,0 +1,679 @@ +import { signal } from "@preact/signals-core"; + +import type { GraphComponent } from "../../components/canvas/GraphComponent"; +import type { Graph } from "../../graph"; + +import { DragService } from "./DragService"; +import { DragContext, DragDiff } from "./types"; + +// Mock component interface for testing (simulates GraphComponent without inheritance) +interface MockComponent { + isDraggable: () => boolean; + handleDragStart: (context: DragContext) => void; + handleDrag: (diff: DragDiff, context: DragContext) => void; + handleDragEnd: (context: DragContext) => void; + constructor: { name: string }; +} + +// Factory for creating mock draggable components +function createMockDraggableComponent(name = "MockDraggableComponent"): MockComponent & { + dragStartCalls: DragContext[]; + dragCalls: Array<{ diff: DragDiff; context: DragContext }>; + dragEndCalls: DragContext[]; + setIsDraggable: (value: boolean) => void; +} { + let _isDraggable = true; + const component = { + dragStartCalls: [] as DragContext[], + dragCalls: [] as Array<{ diff: DragDiff; context: DragContext }>, + dragEndCalls: [] as DragContext[], + constructor: { name }, + isDraggable: () => _isDraggable, + setIsDraggable: (value: boolean) => { + _isDraggable = value; + }, + handleDragStart: (context: DragContext) => { + component.dragStartCalls.push(context); + }, + handleDrag: (diff: DragDiff, context: DragContext) => { + component.dragCalls.push({ diff, context }); + }, + handleDragEnd: (context: DragContext) => { + component.dragEndCalls.push(context); + }, + }; + return component; +} + +// Factory for creating mock non-draggable components +function createMockNonDraggableComponent(): MockComponent { + return { + constructor: { name: "MockNonDraggableComponent" }, + isDraggable: () => false, + handleDragStart: () => {}, + handleDrag: () => {}, + handleDragEnd: () => {}, + }; +} + +// Create minimal mock Graph +function createMockGraph() { + const mousedownHandlers: Array<(event: unknown) => void> = []; + const cameraChangeHandlers: Array<(event?: unknown) => void> = []; + + const mockCanvas = document.createElement("canvas"); + mockCanvas.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 800, + height: 600, + right: 800, + bottom: 600, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + const mockGraph = { + on: jest.fn((eventName: string, handler: (event: unknown) => void, _options?: unknown) => { + if (eventName === "mousedown") { + mousedownHandlers.push(handler); + } else if (eventName === "camera-change") { + cameraChangeHandlers.push(handler); + } + return () => { + if (eventName === "mousedown") { + const idx = mousedownHandlers.indexOf(handler); + if (idx >= 0) mousedownHandlers.splice(idx, 1); + } else if (eventName === "camera-change") { + const idx = cameraChangeHandlers.indexOf(handler); + if (idx >= 0) cameraChangeHandlers.splice(idx, 1); + } + }; + }), + off: jest.fn((eventName: string, handler: (event?: unknown) => void) => { + if (eventName === "camera-change") { + const idx = cameraChangeHandlers.indexOf(handler); + if (idx >= 0) cameraChangeHandlers.splice(idx, 1); + } + }), + getGraphCanvas: jest.fn(() => mockCanvas), + cameraService: { + enableAutoPanning: jest.fn(), + disableAutoPanning: jest.fn(), + applyToPoint: jest.fn((x: number, y: number) => [x, y] as [number, number]), + }, + selectionService: { + $selectedComponents: signal([]), + }, + lockCursor: jest.fn(), + unlockCursor: jest.fn(), + // Helper methods for tests + _emitMouseDown: (event: unknown) => { + mousedownHandlers.forEach((h) => h(event)); + }, + _emitCameraChange: () => { + cameraChangeHandlers.forEach((h) => h()); + }, + _mousedownHandlers: mousedownHandlers, + _cameraChangeHandlers: cameraChangeHandlers, + }; + + return mockGraph; +} + +// Create mock mouse event +function createMockMouseEvent(type: string, x: number, y: number): MouseEvent { + return new MouseEvent(type, { + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + }); +} + +// Create mock GraphMouseEvent +function createMockGraphMouseEvent(target?: MockComponent) { + let defaultPrevented = false; + return { + detail: { + target, + }, + preventDefault: jest.fn(() => { + defaultPrevented = true; + }), + isDefaultPrevented: () => defaultPrevented, + }; +} + +describe("DragService", () => { + let service: DragService; + let mockGraph: ReturnType; + + beforeEach(() => { + mockGraph = createMockGraph(); + service = new DragService(mockGraph as unknown as Graph); + }); + + afterEach(() => { + service.destroy(); + }); + + describe("initialization", () => { + it("should initialize with idle state", () => { + const state = service.$state.value; + expect(state.isDragging).toBe(false); + expect(state.components).toEqual([]); + expect(state.componentTypes.size).toBe(0); + expect(state.isMultiple).toBe(false); + expect(state.isHomogeneous).toBe(true); + }); + + it("should subscribe to graph mousedown event", () => { + expect(mockGraph.on).toHaveBeenCalledWith("mousedown", expect.any(Function), { capture: true }); + }); + }); + + describe("destroy", () => { + it("should unsubscribe from mousedown event", () => { + expect(mockGraph._mousedownHandlers.length).toBe(1); + service.destroy(); + expect(mockGraph._mousedownHandlers.length).toBe(0); + }); + + it("should reset state to idle on destroy", () => { + // Simulate an active drag first + const component = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + // Simulate first move to start drag + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.isDragging).toBe(true); + + service.destroy(); + + expect(service.$state.value.isDragging).toBe(false); + }); + }); + + describe("mousedown handling", () => { + it("should ignore mousedown if target is not provided", () => { + const event = createMockGraphMouseEvent(undefined); + mockGraph._emitMouseDown(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(service.$state.value.isDragging).toBe(false); + }); + + it("should ignore mousedown if target is not draggable", () => { + const component = createMockNonDraggableComponent(); + const event = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(service.$state.value.isDragging).toBe(false); + }); + + it("should prevent default for draggable target", () => { + const component = createMockDraggableComponent(); + const event = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe("drag lifecycle", () => { + let component: ReturnType; + + beforeEach(() => { + component = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + }); + + it("should start drag on first mousemove after mousedown", () => { + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + expect(service.$state.value.isDragging).toBe(false); + + // First mousemove initiates drag + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.isDragging).toBe(true); + expect(component.dragStartCalls.length).toBe(1); + }); + + it("should call handleDrag on subsequent mousemove", () => { + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + // First move - drag start (also triggers first handleDrag with delta=0) + const moveEvent1 = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent1); + + // dragListener emits DRAG_UPDATE on first move too (with delta 0) + const initialCallsCount = component.dragCalls.length; + + // Second move - drag update with actual movement + const moveEvent2 = createMockMouseEvent("mousemove", 110, 120); + document.dispatchEvent(moveEvent2); + + expect(component.dragCalls.length).toBe(initialCallsCount + 1); + const lastCall = component.dragCalls[component.dragCalls.length - 1]; + expect(lastCall.diff.deltaX).toBe(10); + expect(lastCall.diff.deltaY).toBe(20); + }); + + it("should call handleDragEnd on mouseup", () => { + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + // First move - drag start + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(component.dragEndCalls.length).toBe(0); + + // Mouse up - drag end + const upEvent = createMockMouseEvent("mouseup", 150, 150); + document.dispatchEvent(upEvent); + + expect(component.dragEndCalls.length).toBe(1); + expect(service.$state.value.isDragging).toBe(false); + }); + + it("should enable autopanning on drag start", () => { + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(mockGraph.cameraService.enableAutoPanning).toHaveBeenCalled(); + }); + + it("should lock cursor on drag start", () => { + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(mockGraph.lockCursor).toHaveBeenCalledWith("grabbing"); + }); + + it("should disable autopanning on drag end", () => { + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + const upEvent = createMockMouseEvent("mouseup", 150, 150); + document.dispatchEvent(upEvent); + + expect(mockGraph.cameraService.disableAutoPanning).toHaveBeenCalled(); + }); + + it("should unlock cursor on drag end", () => { + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + const upEvent = createMockMouseEvent("mouseup", 150, 150); + document.dispatchEvent(upEvent); + + expect(mockGraph.unlockCursor).toHaveBeenCalled(); + }); + }); + + describe("$state signal", () => { + it("should update componentTypes based on component constructor names", () => { + const component = createMockDraggableComponent("CustomBlock"); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.componentTypes.has("CustomBlock")).toBe(true); + }); + + it("should set isMultiple to true when dragging multiple components", () => { + const component1 = createMockDraggableComponent(); + const component2 = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [ + component1 as unknown as GraphComponent, + component2 as unknown as GraphComponent, + ]; + + const mousedownEvent = createMockGraphMouseEvent(component1); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.isMultiple).toBe(true); + expect(service.$state.value.components.length).toBe(2); + }); + + it("should set isHomogeneous to true when all components are same type", () => { + const component1 = createMockDraggableComponent("Block"); + const component2 = createMockDraggableComponent("Block"); + mockGraph.selectionService.$selectedComponents.value = [ + component1 as unknown as GraphComponent, + component2 as unknown as GraphComponent, + ]; + + const mousedownEvent = createMockGraphMouseEvent(component1); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.isHomogeneous).toBe(true); + }); + + it("should set isHomogeneous to false when components are different types", () => { + const component1 = createMockDraggableComponent("Block"); + const component2 = createMockDraggableComponent("Group"); + mockGraph.selectionService.$selectedComponents.value = [ + component1 as unknown as GraphComponent, + component2 as unknown as GraphComponent, + ]; + + const mousedownEvent = createMockGraphMouseEvent(component1); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.isHomogeneous).toBe(false); + expect(service.$state.value.componentTypes.size).toBe(2); + expect(service.$state.value.componentTypes.has("Block")).toBe(true); + expect(service.$state.value.componentTypes.has("Group")).toBe(true); + }); + }); + + describe("component collection", () => { + it("should drag only target if target is not in selection", () => { + const selectedComponent = createMockDraggableComponent(); + const targetComponent = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [selectedComponent as unknown as GraphComponent]; + + const mousedownEvent = createMockGraphMouseEvent(targetComponent); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.components).toEqual([targetComponent]); + expect(targetComponent.dragStartCalls.length).toBe(1); + expect(selectedComponent.dragStartCalls.length).toBe(0); + }); + + it("should drag all selected draggable components if target is in selection", () => { + const component1 = createMockDraggableComponent(); + const component2 = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [ + component1 as unknown as GraphComponent, + component2 as unknown as GraphComponent, + ]; + + const mousedownEvent = createMockGraphMouseEvent(component1); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.components.length).toBe(2); + expect(component1.dragStartCalls.length).toBe(1); + expect(component2.dragStartCalls.length).toBe(1); + }); + + it("should filter out non-draggable components from selection", () => { + const draggable = createMockDraggableComponent(); + const nonDraggable = createMockNonDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [ + draggable as unknown as GraphComponent, + nonDraggable as unknown as GraphComponent, + ]; + + const mousedownEvent = createMockGraphMouseEvent(draggable); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.components).toEqual([draggable]); + }); + }); + + describe("diff calculation", () => { + it("should calculate correct diffX/diffY (absolute displacement from start)", () => { + const component = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + // Start at (100, 100) + const moveEvent1 = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent1); + + // Move to (130, 150) - diff from start should be (30, 50) + const moveEvent2 = createMockMouseEvent("mousemove", 130, 150); + document.dispatchEvent(moveEvent2); + + const lastDrag = component.dragCalls[component.dragCalls.length - 1]; + expect(lastDrag.diff.diffX).toBe(30); + expect(lastDrag.diff.diffY).toBe(50); + }); + + it("should calculate correct deltaX/deltaY (incremental change)", () => { + const component = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + // Start at (100, 100) - first move also triggers DRAG_UPDATE with delta 0 + const moveEvent1 = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent1); + + const callsAfterStart = component.dragCalls.length; + + // Move to (110, 115) - delta should be (10, 15) + const moveEvent2 = createMockMouseEvent("mousemove", 110, 115); + document.dispatchEvent(moveEvent2); + + // Move to (125, 130) - delta should be (15, 15) + const moveEvent3 = createMockMouseEvent("mousemove", 125, 130); + document.dispatchEvent(moveEvent3); + + // Check the last two drag calls (after initial start) + expect(component.dragCalls[callsAfterStart].diff.deltaX).toBe(10); + expect(component.dragCalls[callsAfterStart].diff.deltaY).toBe(15); + expect(component.dragCalls[callsAfterStart + 1].diff.deltaX).toBe(15); + expect(component.dragCalls[callsAfterStart + 1].diff.deltaY).toBe(15); + }); + + it("should provide startCoords in diff", () => { + const component = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + // Start at (100, 100) + const moveEvent1 = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent1); + + // Move to (130, 150) + const moveEvent2 = createMockMouseEvent("mousemove", 130, 150); + document.dispatchEvent(moveEvent2); + + const lastDrag = component.dragCalls[component.dragCalls.length - 1]; + expect(lastDrag.diff.startCoords).toEqual([100, 100]); + }); + }); + + describe("camera change handling", () => { + it("should re-emit drag update on camera change during drag", () => { + const component = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + const callsAfterStart = component.dragCalls.length; + + // Emit camera change - should trigger additional drag update + mockGraph._emitCameraChange(); + + expect(component.dragCalls.length).toBe(callsAfterStart + 1); + }); + + it("should not emit drag update on camera change if not dragging", () => { + const component = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + + // No mousedown, just emit camera change + mockGraph._emitCameraChange(); + + expect(component.dragCalls.length).toBe(0); + }); + }); + + describe("abort on mouseup without move", () => { + it("should not call handleDragStart if mouseup happens before mousemove", () => { + const component = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + // Mouse up immediately without moving + const upEvent = createMockMouseEvent("mouseup", 100, 100); + document.dispatchEvent(upEvent); + + expect(component.dragStartCalls.length).toBe(0); + expect(component.dragEndCalls.length).toBe(0); + expect(service.$state.value.isDragging).toBe(false); + }); + }); + + describe("context passed to handlers", () => { + it("should pass all participating components in context", () => { + const component1 = createMockDraggableComponent(); + const component2 = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [ + component1 as unknown as GraphComponent, + component2 as unknown as GraphComponent, + ]; + + const mousedownEvent = createMockGraphMouseEvent(component1); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(component1.dragStartCalls[0].components.length).toBe(2); + expect(component2.dragStartCalls[0].components.length).toBe(2); + }); + + it("should pass sourceEvent in context", () => { + const component = createMockDraggableComponent(); + mockGraph.selectionService.$selectedComponents.value = [component as unknown as GraphComponent]; + + const mousedownEvent = createMockGraphMouseEvent(component); + mockGraph._emitMouseDown(mousedownEvent); + + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(component.dragStartCalls[0].sourceEvent).toBe(moveEvent); + }); + }); + + describe("concurrent drag prevention", () => { + it("should ignore second mousedown while drag is in progress", () => { + const component1 = createMockDraggableComponent("Component1"); + const component2 = createMockDraggableComponent("Component2"); + mockGraph.selectionService.$selectedComponents.value = [component1 as unknown as GraphComponent]; + + // Start first drag + const mousedownEvent1 = createMockGraphMouseEvent(component1); + mockGraph._emitMouseDown(mousedownEvent1); + + // First move to initiate drag + const moveEvent1 = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent1); + + expect(service.$state.value.isDragging).toBe(true); + expect(component1.dragStartCalls.length).toBe(1); + + // Try to start second drag while first is in progress + mockGraph.selectionService.$selectedComponents.value = [component2 as unknown as GraphComponent]; + const mousedownEvent2 = createMockGraphMouseEvent(component2); + mockGraph._emitMouseDown(mousedownEvent2); + + // Second component should NOT have started dragging + expect(component2.dragStartCalls.length).toBe(0); + + // Original drag should still be tracked + expect(service.$state.value.components).toEqual([component1]); + + // End drag - should end for component1, not component2 + const upEvent = createMockMouseEvent("mouseup", 150, 150); + document.dispatchEvent(upEvent); + + expect(component1.dragEndCalls.length).toBe(1); + expect(component2.dragEndCalls.length).toBe(0); + }); + + it("should ignore second mousedown while waiting for first mousemove", () => { + const component1 = createMockDraggableComponent("Component1"); + const component2 = createMockDraggableComponent("Component2"); + mockGraph.selectionService.$selectedComponents.value = [component1 as unknown as GraphComponent]; + + // Start first drag (mousedown but no mousemove yet) + const mousedownEvent1 = createMockGraphMouseEvent(component1); + mockGraph._emitMouseDown(mousedownEvent1); + + // Drag has not started yet (waiting for first move) + expect(service.$state.value.isDragging).toBe(false); + + // Try to start second drag before first mousemove + mockGraph.selectionService.$selectedComponents.value = [component2 as unknown as GraphComponent]; + const mousedownEvent2 = createMockGraphMouseEvent(component2); + mockGraph._emitMouseDown(mousedownEvent2); + + // Now trigger first move - should still be for component1 + const moveEvent = createMockMouseEvent("mousemove", 100, 100); + document.dispatchEvent(moveEvent); + + expect(service.$state.value.isDragging).toBe(true); + expect(component1.dragStartCalls.length).toBe(1); + expect(component2.dragStartCalls.length).toBe(0); + + // Cleanup + const upEvent = createMockMouseEvent("mouseup", 100, 100); + document.dispatchEvent(upEvent); + }); + }); +}); diff --git a/src/services/drag/DragService.ts b/src/services/drag/DragService.ts index 953ff854..8e233f03 100644 --- a/src/services/drag/DragService.ts +++ b/src/services/drag/DragService.ts @@ -3,23 +3,23 @@ import { signal } from "@preact/signals-core"; import type { GraphComponent } from "../../components/canvas/GraphComponent"; import type { Graph } from "../../graph"; import type { GraphMouseEvent } from "../../graphEvents"; +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, DragState } from "./types"; +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) + * 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 { - /** Whether a drag operation is currently in progress */ - private dragging = false; - /** Components participating in the current drag operation */ private dragComponents: GraphComponent[] = []; @@ -29,11 +29,8 @@ export class DragService { /** Previous frame coordinates in world space */ private prevCoords: [number, number] | null = null; - /** Last mouse event for camera-change re-emission */ - private lastMouseEvent: MouseEvent | null = null; - - /** Reference to document for event listeners */ - private doc: Document | 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; @@ -100,9 +97,8 @@ export class DragService { * Handle mousedown on graph - determine if drag should start */ private handleMouseDown = (event: GraphMouseEvent): void => { - // Prevent initiating new drag while one is already in progress or pending - // this.doc being set indicates we're waiting for first mousemove - if (this.dragging || this.doc) { + // Prevent initiating new drag while one is already in progress + if (this.currentDragEmitter) { return; } @@ -122,10 +118,16 @@ export class DragService { return; } - // Setup document listeners for drag tracking - this.doc = this.graph.getGraphCanvas().ownerDocument; - this.doc.addEventListener("mousemove", this.handleFirstMove, { once: true, capture: true }); - this.doc.addEventListener("mouseup", this.handleMouseUp, { once: true, capture: true }); + // 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); }; /** @@ -149,24 +151,12 @@ export class DragService { } /** - * Handle first mousemove - this initiates the actual drag + * Handle drag start from dragListener */ - private handleFirstMove = (event: MouseEvent): void => { - this.dragging = true; - this.lastMouseEvent = event; - + private handleDragStart = (event: MouseEvent): void => { // Update reactive state this.$state.value = this.createDragState(this.dragComponents); - // Enable autopanning - this.graph.cameraService.enableAutoPanning(); - - // Lock cursor to grabbing - this.graph.lockCursor("grabbing"); - - // Subscribe to camera-change for autopanning synchronization - this.graph.on("camera-change", this.handleCameraChange); - // Calculate starting coordinates in world space const coords = this.getWorldCoords(event); this.startCoords = coords; @@ -185,29 +175,12 @@ export class DragService { this.dragComponents.forEach((component) => { component.handleDragStart(context); }); - - // Continue listening for mousemove - if (this.doc) { - this.doc.addEventListener("mousemove", this.handleMouseMove, { capture: true }); - } }; /** - * Handle mousemove during drag + * Handle drag update from dragListener */ - private handleMouseMove = (event: MouseEvent): void => { - if (!this.dragging || !this.startCoords || !this.prevCoords) { - return; - } - - this.lastMouseEvent = event; - this.emitDragUpdate(event); - }; - - /** - * Emit drag update to all components - */ - private emitDragUpdate(event: MouseEvent): void { + private handleDragUpdate = (event: MouseEvent): void => { if (!this.startCoords || !this.prevCoords) { return; } @@ -238,13 +211,13 @@ export class DragService { }); this.prevCoords = currentCoords; - } + }; /** - * Handle mouseup - end drag operation + * Handle drag end from dragListener */ - private handleMouseUp = (event: MouseEvent): void => { - if (this.dragging && this.startCoords && this.prevCoords) { + private handleDragEnd = (event: MouseEvent): void => { + if (this.startCoords && this.prevCoords) { const currentCoords = this.getWorldCoords(event); const context: DragContext = { @@ -264,17 +237,6 @@ export class DragService { this.cleanup(); }; - /** - * Handle camera-change during drag for autopanning synchronization - */ - private handleCameraChange = (): void => { - if (this.dragging && this.lastMouseEvent) { - // Re-emit drag update with last known mouse position - // This ensures components update their positions when camera moves during autopanning - this.emitDragUpdate(this.lastMouseEvent); - } - }; - /** * Convert screen coordinates to world coordinates */ @@ -288,31 +250,59 @@ export class DragService { * Cleanup after drag operation ends */ private cleanup(): void { - // Remove event listeners - if (this.doc) { - this.doc.removeEventListener("mousemove", this.handleFirstMove, { capture: true }); - this.doc.removeEventListener("mousemove", this.handleMouseMove, { capture: true }); - this.doc.removeEventListener("mouseup", this.handleMouseUp, { capture: true }); - } - - // Unsubscribe from camera-change - this.graph.off("camera-change", this.handleCameraChange); - - // Disable autopanning - if (this.dragging) { - this.graph.cameraService.disableAutoPanning(); - this.graph.unlockCursor(); - } - // Reset state - this.dragging = false; + this.currentDragEmitter = null; this.dragComponents = []; this.startCoords = null; this.prevCoords = null; - this.lastMouseEvent = null; - this.doc = 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 index b5118c7a..3a969da8 100644 --- a/src/services/drag/index.ts +++ b/src/services/drag/index.ts @@ -1,2 +1,2 @@ export { DragService } from "./DragService"; -export type { DragContext, DragDiff, DragState } from "./types"; +export type { DragContext, DragDiff, DragOperationCallbacks, DragOperationOptions, DragState } from "./types"; diff --git a/src/services/drag/types.ts b/src/services/drag/types.ts index 58196f65..58f69689 100644 --- a/src/services/drag/types.ts +++ b/src/services/drag/types.ts @@ -1,4 +1,32 @@ 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