diff --git a/README-ru.md b/README-ru.md index aeaf5f23..390d0161 100644 --- a/README-ru.md +++ b/README-ru.md @@ -204,6 +204,7 @@ graph.zoomTo("center", { padding: 100 }); | Раздел | Описание | Документация | |--------|-----------|--------------| | Жизненный цикл компонентов | Инициализация, обновление, отрисовка и удаление компонентов | [Подробнее](docs/system/component-lifecycle.md) | +| Система перетаскивания | Управление drag-операциями, модификаторы позиций, поддержка множественного выбора | [Подробнее](docs/system/drag-system.md) | | Механизм отрисовки | Процесс отрисовки и оптимизации | [Подробнее](docs/rendering/rendering-mechanism.md) | | Система событий | Обработка и распространение событий | [Подробнее](docs/system/events.md) | diff --git a/README.md b/README.md index 709448f9..079fb638 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ graph.zoomTo("center", { padding: 100 }); 1. System - [Component Lifecycle](docs/system/component-lifecycle.md) + - [Drag System](docs/system/drag-system.md) - [Events](docs/system/events.md) - [Graph Settings](docs/system/graph-settings.md) - [Public API](docs/system/public_api.md) diff --git a/docs/system/drag-system.md b/docs/system/drag-system.md new file mode 100644 index 00000000..b2d0668e --- /dev/null +++ b/docs/system/drag-system.md @@ -0,0 +1,1290 @@ +# Drag System: Smart Dragging with Position Modifiers + +## Introduction + +The drag system in @gravity-ui/graph is designed to solve a fundamental problem: how to make dragging intuitive, precise, and extensible while working with complex graph structures that can be zoomed, panned, and contain multiple selected elements. + +Traditional drag implementations often struggle with: +- **Coordinate space confusion** - mixing screen pixels with world coordinates +- **Inconsistent snapping** - grid alignment that doesn't work across zoom levels +- **Multiple selection complexity** - dragging several blocks while maintaining their relative positions +- **Extensibility limitations** - hard to add new behaviors like anchor magnetism or alignment guides + +Our solution introduces a two-component architecture: +- **DragController** - The orchestrator that manages the drag lifecycle +- **DragInfo** - The smart state container that handles coordinate transformations and position modifications + +This system automatically handles coordinate space conversions, provides extensible position modification through modifiers, and seamlessly supports both single and multiple entity dragging. + +## How DragController Works + +DragController acts as the central command center for all drag operations. Think of it as the conductor of an orchestra - it doesn't make the music itself, but coordinates all the participants to create a harmonious experience. + +### The Drag Lifecycle + +When you start dragging a block, here's what happens behind the scenes: + +1. **Initialization Phase**: DragController receives the initial mouse event and configuration +2. **Setup Phase**: It creates a DragInfo instance with all the necessary state and modifiers +3. **Update Phase**: For each mouse movement, it updates DragInfo and calls your drag handlers +4. **Cleanup Phase**: When dragging ends, it cleans up resources and calls final handlers + +The key insight is that DragController doesn't just pass raw mouse events to your components. Instead, it enriches the events with computed information about position modifications, coordinate transformations, and movement deltas. + +### Configuration: Telling DragController What You Need + +DragController is highly configurable through the `DragControllerConfig` object. Let's break down each option: + +**positionModifiers** - This is where the magic happens. You can provide an array of functions that can modify the drag position in real-time. For example: +```typescript +positionModifiers: [ + DragModifiers.gridSnap(20), // Snap to 20px grid + anchorMagnetism, // Custom anchor snapping + alignmentGuides // Block-to-block alignment +] +``` + +**context** - This is your way to pass dynamic configuration and data to modifiers. It's like a shared context that all modifiers can read from: +```typescript +context: { + enableGridSnap: user.preferences.snapToGrid, + gridSize: layout.cellSize, + selectedBlocks: selectionManager.getSelectedBlocks(), + nearbyAnchors: anchorDetector.findNearby(mousePosition, 50) +} +``` + +**initialEntityPosition** - This is crucial for accurate positioning. It tells DragController where the dragged entity actually starts, not where the mouse cursor is. This distinction is important because you might click anywhere on a block, but the block's position is defined by its top-left corner. + +### A Real Example: Setting Up Block Dragging + +Here's how you would set up dragging for a block with grid snapping enabled: + +```typescript +// In your Blocks component +const startDrag = (event, mainBlock) => { + dragController.start({ + // Your drag event handlers + onDragStart: (event, dragInfo) => { + // Highlight selected blocks, show drag indicators + }, + onDragUpdate: (event, dragInfo) => { + // Update block positions using dragInfo + selectedBlocks.forEach(block => { + const newPos = dragInfo.applyAdjustedDelta(block.startX, block.startY); + block.updatePosition(newPos.x, newPos.y); + }); + }, + beforeUpdate: (dragInfo) => { + // Choose which position modifier to apply + if (shiftPressed) { + dragInfo.selectModifier('gridSnap'); + } else { + dragInfo.selectByDistance(); // Pick the closest suggestion + } + }, + onDragEnd: (event, dragInfo) => { + // Finalize positions, update state + } + }, event, { + positionModifiers: [DragModifiers.gridSnap(gridSize)], + context: { + enableGridSnap: !event.ctrlKey, // Disable with Ctrl + selectedBlocks: getSelectedBlocks() + }, + initialEntityPosition: { + x: mainBlock.state.x, + y: mainBlock.state.y + } + }); +}; +``` + +## Understanding DragInfo: The Smart State Container + +DragInfo is where all the computational intelligence of the drag system lives. While DragController orchestrates the process, DragInfo does the heavy lifting of coordinate calculations, position modifications, and state management. + +### Why DragInfo Exists + +In a complex graph editor, a simple drag operation involves many coordinate systems and calculations: +- Converting between screen pixels and world coordinates +- Handling zoom and pan transformations +- Applying snapping and alignment rules +- Managing multiple selected entities +- Computing movement deltas and velocities + +Instead of scattering this logic across different components, DragInfo centralizes all this intelligence in one place, providing a clean API for drag handlers to use. + +### The Coordinate Systems Problem + +One of the biggest challenges in implementing drag operations is managing different coordinate systems. DragInfo automatically tracks and converts between: + +**Screen Coordinates** (`startX`, `lastX`, `startY`, `lastY`): +These are raw pixel coordinates relative to the browser window. This is what you get directly from mouse events. However, these coordinates become useless when the user zooms or pans the graph. + +**Camera Coordinates** (`startCameraX`, `lastCameraX`, `startCameraY`, `lastCameraY`): +These are coordinates in the "world space" of your graph. They account for zoom level and pan offset. When you zoom in 2x, a 10-pixel mouse movement translates to a 5-unit movement in camera space. DragInfo automatically performs these calculations using the graph's camera service. + +**Entity Coordinates** (`entityStartX`, `entityStartY`, `currentEntityPosition`): +These represent the actual position of the dragged object. This is crucial because the mouse cursor might be anywhere on a block, but the block's position is typically defined by its top-left corner or center point. + +### How Position Modification Works + +The position modification system is the heart of DragInfo's intelligence. Here's how it works step by step: + +**Step 1: Collecting Modifier Suggestions** +When the mouse moves, DragInfo doesn't immediately update the entity position. Instead, it asks all registered position modifiers: "Given the current mouse position, what position would you suggest for the entity?" + +Each modifier that is `applicable()` provides a suggestion. For example: +- GridSnap modifier suggests the nearest grid intersection +- Anchor magnetism suggests the position that would align with nearby anchors +- Alignment guides suggest positions that would align with other blocks + +**Step 2: Lazy Suggestion Calculation** +Position modification can be computationally expensive (imagine calculating distances to hundreds of anchors). DragInfo uses lazy evaluation - suggestions are only calculated when actually needed, and results are cached. + +**Step 3: Modifier Selection** +Multiple modifiers might provide suggestions simultaneously. DragInfo provides several strategies for choosing which one to apply: + +- **selectByPriority()**: Choose the modifier with the highest priority value +- **selectByDistance()**: Choose the suggestion closest to the raw mouse position +- **selectByCustom()**: Let your code implement custom selection logic +- **selectModifier(name)**: Directly select a specific modifier + +**Step 4: Position Application** +Once a modifier is selected, DragInfo calculates the final entity position and makes it available through `adjustedEntityPosition`. + +### Entity vs Mouse Position: A Critical Distinction + +Traditional drag implementations often confuse mouse position with entity position. Here's why the distinction matters: + +Imagine you have a 100x50 pixel block, and the user clicks at the center of the block to start dragging. The mouse is at the block's center, but the block's coordinate system defines its position by its top-left corner. + +**Without this distinction:** +- Mouse moves to (150, 100) +- GridSnap snaps to (160, 100) +- Block position becomes (160, 100) +- But now the block's center is at (210, 125) - the mouse is no longer at the center! + +**With DragInfo's approach:** +- DragInfo calculates the offset between mouse click and entity origin +- Mouse moves to (150, 100) +- Entity position (accounting for offset) would be at (100, 75) +- GridSnap snaps entity to (100, 80) +- Block's center remains properly aligned with the adjusted mouse position + +### Delta-based Movement for Multiple Entities + +When multiple blocks are selected, DragInfo uses a sophisticated delta-based approach: + +1. **Primary Entity**: One block (usually the first clicked) serves as the "primary" entity +2. **Delta Calculation**: DragInfo calculates how much the primary entity has moved: `adjustedEntityPosition - entityStartPosition` +3. **Delta Application**: Each secondary entity applies this same delta to its own starting position + +This ensures that: +- All selected blocks move together as a group +- Position modifications (like grid snapping) affect the entire group consistently +- Relative positioning between blocks is preserved + +Example: +```typescript +// Primary block starts at (100, 100), moves to (120, 100) due to grid snap +// Delta = (20, 0) + +// Secondary block starts at (200, 150) +// Its new position = (200, 150) + (20, 0) = (220, 150) + +selectedBlocks.forEach(block => { + const newPos = dragInfo.applyAdjustedDelta(block.startX, block.startY); + block.updatePosition(newPos.x, newPos.y); +}); +``` + +## Building Position Modifiers: Making Dragging Intelligent + +Position modifiers are the secret sauce that transforms basic mouse movements into intelligent, context-aware positioning. They're small, focused functions that can suggest alternative positions for your dragged entities. + +### The Philosophy Behind Modifiers + +The key insight behind position modifiers is separation of concerns. Instead of hardcoding snapping logic into your drag handlers, you define modifiers as independent, composable functions. This approach offers several benefits: + +1. **Reusability**: A grid snapping modifier works for blocks, connections, and any other draggable entity +2. **Composability**: You can combine multiple modifiers (grid + anchor + alignment) and let the system choose the best suggestion +3. **Testability**: Each modifier is a pure function that's easy to test in isolation +4. **Extensibility**: Adding new behaviors doesn't require changing existing code + +### Anatomy of a Position Modifier + +Every position modifier implements the `PositionModifier` interface with four key properties: + +**name** - A unique string identifier. This is used for debugging, logging, and direct modifier selection. Choose descriptive names like "gridSnap", "anchorMagnetism", or "blockAlignment". + +**priority** - A numeric value that determines which modifier wins when multiple modifiers compete. Higher numbers = higher priority. This is useful for scenarios like "anchor snapping should always override grid snapping". + +**applicable(position, dragInfo, context)** - A function that determines whether this modifier should even be considered. This is your chance to implement conditional logic: +- Only apply grid snapping when the user isn't holding Ctrl +- Only suggest anchor magnetism when there are anchors within 50 pixels +- Only activate alignment guides when multiple blocks are visible + +**suggest(position, dragInfo, context)** - The core function that calculates the modified position. It receives the current entity position and returns a new position suggestion. + +### How the Built-in GridSnap Works + +Let's examine the gridSnap modifier to understand how modifiers work in practice: + +```typescript +gridSnap: (gridSize = 20) => ({ + name: 'gridSnap', + priority: 5, + + applicable: (pos, dragInfo, ctx) => { + // Don't snap during micro-movements (prevents jitter) + if (dragInfo.isMicroDrag()) return false; + + // Check if grid snapping is enabled in context + if (ctx.enableGridSnap === false) return false; + + return true; + }, + + suggest: (pos, dragInfo, ctx) => { + // Allow context to override grid size + const effectiveGridSize = (ctx.gridSize as number) || gridSize; + + // Calculate nearest grid intersection + const snappedX = Math.round(pos.x / effectiveGridSize) * effectiveGridSize; + const snappedY = Math.round(pos.y / effectiveGridSize) * effectiveGridSize; + + return new Point(snappedX, snappedY); + } +}) +``` + +Notice how the modifier is implemented as a factory function. This allows you to create multiple grid snap modifiers with different grid sizes, while still maintaining the same interface. + +### Creating Your Own Modifiers + +Let's walk through creating a more complex modifier - anchor magnetism. This modifier snaps blocks to nearby connection anchors: + +```typescript +const anchorMagnetism: PositionModifier = { + name: 'anchorMagnetism', + priority: 10, // Higher priority than grid snapping + + applicable: (pos, dragInfo, ctx) => { + // Only apply if we're not doing micro-movements + if (dragInfo.isMicroDrag()) return false; + + // Only apply if there are anchors in the context + const anchors = ctx.nearbyAnchors as Anchor[]; + return anchors && anchors.length > 0; + }, + + suggest: (pos, dragInfo, ctx) => { + const anchors = ctx.nearbyAnchors as Anchor[]; + const magnetDistance = ctx.magnetDistance as number || 30; + + // Find the closest anchor within magnet distance + let closestAnchor = null; + let closestDistance = magnetDistance; + + for (const anchor of anchors) { + const distance = Math.sqrt( + Math.pow(pos.x - anchor.x, 2) + + Math.pow(pos.y - anchor.y, 2) + ); + + if (distance < closestDistance) { + closestAnchor = anchor; + closestDistance = distance; + } + } + + // If we found a nearby anchor, snap to it + if (closestAnchor) { + return new Point(closestAnchor.x, closestAnchor.y); + } + + // No snapping suggestion + return pos; + } +}; +``` + +### Context: Sharing Data Between Components and Modifiers + +The context system is how you pass dynamic data and configuration to your modifiers. It's a simple object that gets passed to all modifier functions, allowing you to: + +**Pass Configuration Data:** +```typescript +context: { + enableGridSnap: userPreferences.snapToGrid, + gridSize: 25, + magnetDistance: 40 +} +``` + +**Share Runtime Data:** +```typescript +context: { + selectedBlocks: selectionManager.getSelected(), + nearbyAnchors: anchorDetector.findNearby(mousePos, 100), + allBlocks: graph.blocks.getAll(), + cameraZoom: graph.camera.getZoom() +} +``` + +**Provide Component References:** +```typescript +context: { + graph: this.context.graph, + layer: this.layer, + hitTester: this.hitTester +} +``` + +The beauty of the context system is that it's completely flexible. Different modifiers can look for different properties in the context, and you can add new data without breaking existing modifiers. + +### Advanced Modifier Patterns + +**Conditional Modifiers:** +Sometimes you want modifiers that only activate under specific conditions: + +```typescript +const shiftGridSnap = { + name: 'shiftGridSnap', + priority: 8, + applicable: (pos, dragInfo, ctx) => { + // Only active when Shift is pressed + return ctx.shiftPressed && !dragInfo.isMicroDrag(); + }, + suggest: (pos, dragInfo, ctx) => { + // Snap to larger grid when shift is pressed + const largeGridSize = 100; + return new Point( + Math.round(pos.x / largeGridSize) * largeGridSize, + Math.round(pos.y / largeGridSize) * largeGridSize + ); + } +}; +``` + +**Multi-axis Modifiers:** +Some modifiers might want to snap only horizontally or vertically: + +```typescript +const horizontalAlignment = { + name: 'horizontalAlignment', + priority: 7, + applicable: (pos, dragInfo, ctx) => { + const nearbyBlocks = ctx.nearbyBlocks as Block[]; + return nearbyBlocks && nearbyBlocks.length > 0; + }, + suggest: (pos, dragInfo, ctx) => { + const nearbyBlocks = ctx.nearbyBlocks as Block[]; + + // Find blocks with similar Y coordinates + const alignmentCandidates = nearbyBlocks.filter(block => + Math.abs(block.y - pos.y) < 10 + ); + + if (alignmentCandidates.length > 0) { + // Suggest aligning Y coordinate with the first candidate + return new Point(pos.x, alignmentCandidates[0].y); + } + + return pos; + } +}; +``` + +## Handling Multiple Selected Entities + +One of the most challenging aspects of implementing a drag system is handling multiple selected entities. Users expect to be able to select several blocks and drag them as a cohesive group, while maintaining their relative positions and applying consistent modifications. + +### The Challenge of Group Dragging + +When dragging a single block, the solution is straightforward: calculate the new position and apply it. But with multiple blocks, several questions arise: + +1. **Which block determines the snapping behavior?** If you have 5 selected blocks, and grid snapping is enabled, which block's position gets snapped to the grid? + +2. **How do you maintain relative positioning?** If blocks were initially 50 pixels apart, they should remain 50 pixels apart after dragging, even with position modifications. + +3. **How do you handle conflicts?** What if one block would snap to a grid intersection, but doing so would cause another block to move outside the visible area? + +### Our Solution: Primary Entity + Delta Propagation + +The drag system solves these challenges using a "primary entity" approach: + +**Step 1: Designate a Primary Entity** +When dragging begins, one of the selected entities (typically the one that was clicked) becomes the "primary entity". This entity's position is used for all position modification calculations. + +**Step 2: Calculate the Primary Entity's Movement** +The system applies all position modifiers to the primary entity, calculating where it should move to. This gives us both: +- The raw movement delta (how far the mouse moved) +- The adjusted movement delta (after applying snapping, alignment, etc.) + +**Step 3: Propagate the Delta to Secondary Entities** +All other selected entities apply the same adjusted delta to their own starting positions. This ensures they move together as a cohesive group. + +### API: Single vs Multiple Entity Patterns + +The drag system provides two different API patterns depending on your use case: + +**For Single Entity Dragging:** +```typescript +onDragUpdate: (event, dragInfo) => { + // Get the final adjusted position directly + const newPos = dragInfo.adjustedEntityPosition; + entity.updatePosition(newPos.x, newPos.y); +} +``` + +This is perfect when you're only dragging one entity. The `adjustedEntityPosition` gives you the final position after all modifications have been applied. + +**For Multiple Entity Dragging:** +```typescript +onDragUpdate: (event, dragInfo) => { + selectedEntities.forEach(entity => { + // Apply the adjusted delta to each entity's starting position + const newPos = dragInfo.applyAdjustedDelta(entity.startX, entity.startY); + entity.updatePosition(newPos.x, newPos.y); + }); +} +``` + +Here, `applyAdjustedDelta()` takes the starting position of each entity and applies the same movement delta that was calculated for the primary entity. + +### A Detailed Example + +Let's trace through a real example to see how this works: + +**Initial Setup:** +- Block A (primary): starts at (100, 100) +- Block B: starts at (250, 150) +- Block C: starts at (200, 50) +- Grid snapping is enabled with 20px grid size + +**User Drags 15 pixels to the right:** +1. Raw mouse movement: +15 pixels horizontally +2. Primary entity (Block A) would move to (115, 100) +3. Grid snapping modifies this to (120, 100) - nearest grid intersection +4. Adjusted delta = (120, 100) - (100, 100) = (20, 0) + +**Delta Applied to All Blocks:** +- Block A: (100, 100) + (20, 0) = (120, 100) +- Block B: (250, 150) + (20, 0) = (270, 150) +- Block C: (200, 50) + (20, 0) = (220, 50) + +**Result:** +All blocks moved together, maintaining their relative positions, and the entire group was snapped to the grid based on the primary entity's final position. + +### Advanced Scenarios + +**Modifier Selection for Groups:** +When multiple modifiers are available, the selection logic (`selectByPriority`, `selectByDistance`, etc.) operates on the primary entity's position. The chosen modifier then affects the entire group. + +**Context Sharing:** +The context object can include information about all selected entities, allowing modifiers to make group-aware decisions: + +```typescript +context: { + selectedBlocks: [blockA, blockB, blockC], + groupBounds: calculateBounds(selectedBlocks), + groupCenter: calculateCenter(selectedBlocks) +} +``` + +**Boundary Checking:** +You might want to implement modifiers that prevent the group from moving outside certain boundaries: + +```typescript +const boundaryModifier = { + name: 'boundaryCheck', + priority: 15, // High priority + applicable: (pos, dragInfo, ctx) => true, + suggest: (pos, dragInfo, ctx) => { + const groupBounds = ctx.groupBounds; + const adjustedBounds = calculateGroupBoundsAtPosition(pos, groupBounds); + + if (adjustedBounds.right > canvasWidth) { + pos.x -= (adjustedBounds.right - canvasWidth); + } + if (adjustedBounds.bottom > canvasHeight) { + pos.y -= (adjustedBounds.bottom - canvasHeight); + } + + return pos; + } +}; +``` + +## Understanding the Context System + +The context system is the communication bridge between your application and the position modifiers. It allows you to pass dynamic data, configuration, and component references to modifiers without tightly coupling them to your specific implementation. + +### Why Context Matters + +Without a context system, position modifiers would be isolated functions with no knowledge of your application's state. They wouldn't know: +- Whether grid snapping is currently enabled in your UI +- What other blocks exist that could be used for alignment +- What the current zoom level is +- Whether certain keyboard modifiers are pressed +- What the user's preferences are + +Context solves this by providing a generic, extensible way to share this information. + +### Built-in Context Properties + +DragInfo automatically includes several useful properties in the context: + +**graph** - Reference to the main Graph instance, giving modifiers access to camera, layers, and services + +**currentPosition** - The current mouse position in camera space (accounts for zoom/pan) + +**currentEntityPosition** - The current position of the dragged entity (after offset calculations) + +**entityStartPosition** - The initial position of the dragged entity + +**Custom Context Properties** - Any additional data you provide when starting the drag + +### Designing Context for Your Use Cases + +The key to effective context design is thinking about what data your modifiers need to make intelligent decisions. Here are some common patterns: + +**User Preferences:** +```typescript +context: { + snapToGrid: userSettings.snapToGrid, + gridSize: userSettings.gridSize, + magnetism: userSettings.enableMagnetism, + showAlignmentGuides: userSettings.showGuides +} +``` + +**Keyboard Modifiers:** +```typescript +context: { + shiftPressed: event.shiftKey, + ctrlPressed: event.ctrlKey, + altPressed: event.altKey +} +``` + +**Spatial Data:** +```typescript +context: { + allBlocks: graph.blocks.getAll(), + visibleBlocks: viewport.getVisibleBlocks(), + nearbyAnchors: anchorService.findNearby(mousePos, 100), + canvasBounds: { width: canvas.width, height: canvas.height } +} +``` + +**Selection State:** +```typescript +context: { + selectedBlocks: selectionManager.getSelected(), + isMultiSelect: selectionManager.count() > 1, + primaryBlock: selectionManager.getPrimary() +} +``` + +### Dynamic Context Updates + +Context can be recalculated on each mouse movement if needed. This is useful for data that changes during the drag operation: + +```typescript +onDragUpdate: (event, dragInfo) => { + // Update context with fresh data + const updatedContext = { + nearbyAnchors: anchorService.findNearby(dragInfo.currentPosition, 50), + visibleBlocks: viewport.getVisibleBlocks() + }; + + // The modifier system will use the updated context + dragInfo.updateContext(updatedContext); +} +``` + +## Real-world Use Cases and Implementation Patterns + +The drag system's flexibility allows it to handle a wide variety of interaction patterns. Here are some common use cases and how to implement them: + +### Precise Grid Snapping + +Grid snapping is essential for creating clean, aligned layouts. The built-in gridSnap modifier handles this, but you can customize its behavior: + +```typescript +// Basic grid snapping +positionModifiers: [DragModifiers.gridSnap(20)] + +// Dynamic grid size based on zoom level +context: { + gridSize: Math.max(10, 20 / camera.zoom) // Larger grid when zoomed out +} + +// Conditional grid snapping +context: { + enableGridSnap: !event.ctrlKey // Disable when Ctrl is held +} +``` + +### Magnetic Anchor Snapping + +When creating connections, you want blocks to snap to nearby anchor points: + +```typescript +const anchorMagnetism = { + name: 'anchorMagnetism', + priority: 10, + applicable: (pos, dragInfo, ctx) => { + return ctx.nearbyAnchors && ctx.nearbyAnchors.length > 0; + }, + suggest: (pos, dragInfo, ctx) => { + const magnetDistance = 25; + const closest = findClosestAnchor(pos, ctx.nearbyAnchors, magnetDistance); + return closest ? new Point(closest.x, closest.y) : pos; + } +}; + +// Update nearby anchors during drag +context: { + nearbyAnchors: anchorService.findWithinRadius(currentPos, 50) +} +``` + +### Visual Alignment Guides + +Alignment guides help users line up blocks with each other: + +```typescript +const alignmentGuides = { + name: 'alignment', + priority: 8, + applicable: (pos, dragInfo, ctx) => { + return ctx.otherBlocks && ctx.otherBlocks.length > 0; + }, + suggest: (pos, dragInfo, ctx) => { + const tolerance = 5; + + // Check for horizontal alignment + for (const block of ctx.otherBlocks) { + if (Math.abs(pos.y - block.y) < tolerance) { + return new Point(pos.x, block.y); // Snap to same Y + } + if (Math.abs(pos.y - (block.y + block.height)) < tolerance) { + return new Point(pos.x, block.y + block.height); // Snap to bottom edge + } + } + + // Check for vertical alignment + for (const block of ctx.otherBlocks) { + if (Math.abs(pos.x - block.x) < tolerance) { + return new Point(block.x, pos.y); // Snap to same X + } + } + + return pos; + } +}; +``` + +### Boundary Constraints + +Prevent blocks from being dragged outside the canvas or into restricted areas: + +```typescript +const boundaryConstraint = { + name: 'boundary', + priority: 20, // High priority - always enforce + applicable: () => true, + suggest: (pos, dragInfo, ctx) => { + const bounds = ctx.canvasBounds; + const blockSize = ctx.blockSize || { width: 100, height: 50 }; + + return new Point( + Math.max(0, Math.min(pos.x, bounds.width - blockSize.width)), + Math.max(0, Math.min(pos.y, bounds.height - blockSize.height)) + ); + } +}; +``` + +### Keyboard-modified Behavior + +Different modifier keys can change snapping behavior: + +```typescript +// In beforeUpdate handler +beforeUpdate: (dragInfo) => { + if (dragInfo.context.shiftPressed) { + // Shift = constrain to single axis + dragInfo.selectModifier('axisConstraint'); + } else if (dragInfo.context.ctrlPressed) { + // Ctrl = disable all snapping + dragInfo.selectDefault(); + } else { + // Normal mode = use closest suggestion + dragInfo.selectByDistance(); + } +} +``` + +### Velocity-based Interactions + +Use movement speed to trigger different behaviors: + +```typescript +const velocityModifier = { + name: 'velocity', + priority: 5, + applicable: (pos, dragInfo, ctx) => { + return dragInfo.velocity > 500; // Fast movement + }, + suggest: (pos, dragInfo, ctx) => { + // During fast movement, disable precise snapping + // to allow fluid motion + return pos; + } +}; +``` + +## Integration with Graph Components + +The drag system is designed to integrate seamlessly with existing graph components. Here's how different parts of the system work together: + +### Block Component Integration + +Individual block components participate in the drag system by implementing drag handlers. Each block receives enriched dragInfo and can respond appropriately: + +```typescript +class Block extends Component { + onDragUpdate(event: MouseEvent, dragInfo: DragInfo) { + // For multiple selection, apply delta to this block's start position + const newPos = dragInfo.applyAdjustedDelta( + this.startDragCoords[0], + this.startDragCoords[1] + ); + + // Update block position with snapping/alignment applied + this.updatePosition(newPos.x, newPos.y); + + // Trigger any visual feedback + this.showPositionPreview(newPos); + } + + onDragEnd(event: MouseEvent, dragInfo: DragInfo) { + // Finalize position and clear any temporary visual indicators + this.finalizePosition(); + this.hidePositionPreview(); + } +} +``` + +The key insight is that individual blocks don't need to know about snapping logic, coordinate transformations, or multiple selection. They simply apply the computed delta to their starting position. + +### Layer Integration for Visual Feedback + +Rendering layers can access drag state to provide visual feedback like grid overlays, alignment guides, or drop zones: + +```typescript +class AlignmentGuidesLayer extends Layer { + render(ctx: CanvasRenderingContext2D) { + if (!this.graph.dragController.isDragging) return; + + const dragInfo = this.graph.dragController.dragInfo; + + // Show alignment lines when alignment modifier is active + if (dragInfo.isModified('alignment')) { + this.renderAlignmentGuides(ctx, dragInfo); + } + + // Show grid when grid snapping is active + if (dragInfo.isModified('gridSnap')) { + this.renderGrid(ctx, dragInfo.context.gridSize); + } + } + + renderAlignmentGuides(ctx: CanvasRenderingContext2D, dragInfo: DragInfo) { + // Draw helper lines showing alignment with other blocks + const guides = this.calculateAlignmentGuides(dragInfo); + guides.forEach(guide => this.drawGuideLine(ctx, guide)); + } +} +``` + +### Service Integration + +Camera and other services provide essential coordinate transformation capabilities: + +```typescript +// DragInfo automatically uses camera service for coordinate transformations +class DragInfo { + get startCameraX(): number { + if (!this._startCameraPoint) { + // Convert screen coordinates to camera space + this._startCameraPoint = this.graph.getPointInCameraSpace(this.initialEvent); + } + return this._startCameraPoint.x; + } +} +``` + +This ensures that drag operations work correctly regardless of zoom level or pan position. + +## Performance Optimization and Best Practices + +The drag system is designed for high performance during interactive dragging. Here are the key optimizations and how to use them effectively: + +### Lazy Evaluation Strategy + +Many drag calculations are expensive and might not be needed every frame. The system uses lazy evaluation extensively: + +**Coordinate Transformations:** +```typescript +// Camera space coordinates are only calculated when first accessed +get lastCameraX(): number { + if (!this._currentCameraPoint) { + this._currentCameraPoint = this.graph.getPointInCameraSpace(this.lastEvent); + } + return this._currentCameraPoint.x; +} +``` + +**Modifier Suggestions:** +```typescript +// Position suggestions are only calculated when the modifier is applicable +const suggestion = { + name: modifier.name, + priority: modifier.priority, + getSuggestedPosition: () => { + // Lazy evaluation - only calculated when accessed + if (!this.cachedPosition) { + this.cachedPosition = modifier.suggest(pos, dragInfo, context); + } + return this.cachedPosition; + } +}; +``` + +### Micro-drag Detection + +Small mouse movements (micro-drags) are filtered out to prevent jittery behavior: + +```typescript +// Built into DragInfo +isMicroDrag(): boolean { + const threshold = 3; // pixels + return this.distance < threshold; +} + +// Used by modifiers to avoid unnecessary snapping +applicable: (pos, dragInfo, ctx) => { + if (dragInfo.isMicroDrag()) return false; // Skip snapping for tiny movements + return true; +} +``` + +### Efficient Context Updates + +Context updates can be expensive if done inefficiently. Best practices: + +```typescript +// Good: Update only changed properties +const updatedContext = { + ...existingContext, + nearbyAnchors: newAnchors // Only update what changed +}; + +// Avoid: Recalculating everything every frame +const expensiveContext = { + allBlocks: graph.blocks.getAll(), // Expensive! + allConnections: graph.connections.getAll(), // Expensive! + // ... computed every mouse move +}; +``` + +### Memory Management + +The drag system minimizes object creation during drag operations: + +```typescript +// Reuse Point objects where possible +const reusablePoint = new Point(0, 0); + +suggest: (pos, dragInfo, ctx) => { + // Modify existing object instead of creating new one + reusablePoint.x = Math.round(pos.x / gridSize) * gridSize; + reusablePoint.y = Math.round(pos.y / gridSize) * gridSize; + return reusablePoint; +} +``` + +## Complete API Reference + +### DragController + +**Methods:** +```typescript +start(handler: DragHandler, event: MouseEvent, config?: DragControllerConfig): void +// Begins a drag operation with the given handler and configuration + +update(event: MouseEvent): void +// Processes a mouse move event during dragging + +end(event: MouseEvent): void +// Completes the drag operation and cleans up resources + +get isDragging(): boolean +// Returns true if a drag operation is currently active + +get dragInfo(): DragInfo | null +// Returns the current DragInfo instance, or null if not dragging +``` + +**Configuration (DragControllerConfig):** +```typescript +interface DragControllerConfig { + positionModifiers?: PositionModifier[]; // Array of position modification functions + context?: Record; // Custom data passed to modifiers + initialEntityPosition?: { x: number; y: number }; // Starting position of dragged entity +} +``` + +### DragInfo + +**Position Properties:** +```typescript +// Screen coordinates (raw pixel values) +readonly startX: number; // Initial mouse X position +readonly startY: number; // Initial mouse Y position +readonly lastX: number; // Current mouse X position +readonly lastY: number; // Current mouse Y position + +// Camera coordinates (world space, accounting for zoom/pan) +readonly startCameraX: number; // Initial mouse position in camera space +readonly startCameraY: number; +readonly lastCameraX: number; // Current mouse position in camera space +readonly lastCameraY: number; + +// Entity coordinates (position of dragged object) +readonly entityStartX: number; // Initial entity X position +readonly entityStartY: number; // Initial entity Y position +readonly currentEntityPosition: Point; // Current entity position (before modifications) +readonly adjustedEntityPosition: Point; // Final entity position (after modifications) +``` + +**Movement Calculations:** +```typescript +readonly worldDelta: { x: number; y: number }; // Raw movement delta +readonly adjustedWorldDelta: { x: number; y: number }; // Movement delta with modifications +readonly distance: number; // Total distance moved +readonly velocity: number; // Current movement velocity +readonly duration: number; // Time since drag started +``` + +**Modifier Management:** +```typescript +analyzeSuggestions(): void; // Generate suggestions from all applicable modifiers +selectByPriority(): void; // Choose modifier with highest priority +selectByDistance(): void; // Choose modifier with closest suggestion +selectByCustom(fn: SelectionFunction): void; // Use custom selection logic +selectModifier(name: string): void; // Directly select a specific modifier +selectDefault(): void; // Use no modifications (raw position) + +isApplicable(modifierName: string): boolean; // Check if a modifier is currently applicable +isModified(modifierName?: string): boolean; // Check if any/specific modifier is active +``` + +**Position Application:** +```typescript +applyAdjustedDelta(startX: number, startY: number): { x: number; y: number }; +// Apply the adjusted movement delta to any starting position +// Essential for multiple entity dragging +``` + +**Context Management:** +```typescript +updateContext(newContext: Record): void; +// Update the custom context during drag operation +// Merges new context with existing data and invalidates cache + +readonly context: DragContext; // Access to context data +``` + +**Utility Methods:** +```typescript +isMicroDrag(): boolean; // True if movement is below threshold +``` + +### Position Modifiers + +**Interface:** +```typescript +interface PositionModifier { + name: string; // Unique identifier + priority: number; // Conflict resolution priority + applicable(pos: Point, dragInfo: DragInfo, ctx: DragContext): boolean; // Availability check + suggest(pos: Point, dragInfo: DragInfo, ctx: DragContext): Point; // Position calculation +} +``` + +**Built-in Factory:** +```typescript +DragModifiers.gridSnap(gridSize?: number): PositionModifier; +// Creates a grid snapping modifier with the specified grid size +``` + +**Context Interface:** +```typescript +interface DragContext { + graph: Graph; // Graph instance reference + currentPosition: Point; // Current mouse position in camera space + currentEntityPosition: Point; // Current entity position + entityStartPosition: Point | null; // Initial entity position + [key: string]: unknown; // Custom context properties +} +``` + +This comprehensive API allows for flexible, performant drag implementations that can handle everything from simple block movement to complex multi-entity operations with sophisticated snapping and alignment behaviors. + +## Drag Lifecycle and State Transitions + +The drag system operates through a well-defined lifecycle with three distinct stages. Understanding these stages is crucial for implementing effective position modifiers and drag behaviors. + +### Drag Stage State Machine + +```mermaid +stateDiagram-v2 + [*] --> start: dragController.start() + start --> dragging: dragInfo.update() + dragging --> dragging: dragInfo.update() + dragging --> drop: dragInfo.end() + drop --> [*]: dragInfo.reset() + + state start { + [*] --> onDragStart + onDragStart --> [*] + } + + state dragging { + [*] --> analyzeSuggestions + analyzeSuggestions --> beforeUpdate + beforeUpdate --> onDragUpdate + onDragUpdate --> [*] + } + + state drop { + [*] --> analyzeSuggestions_final + analyzeSuggestions_final --> beforeUpdate_final + beforeUpdate_final --> onDragUpdate_final + onDragUpdate_final --> onDragEnd + onDragEnd --> [*] + } +``` + +### Stage Descriptions + +**Start Stage (`"start"`):** +- **Trigger**: User initiates drag (mouse down + initial movement) +- **Purpose**: Initialize drag state, show visual feedback, prepare for movement +- **Modifiers**: Generally inactive to avoid interfering with drag initiation +- **Duration**: Single event cycle + +**Dragging Stage (`"dragging"`):** +- **Trigger**: Mouse movement during active drag +- **Purpose**: Continuous position updates, real-time visual feedback +- **Modifiers**: Can apply for real-time effects (previews, smooth snapping) +- **Duration**: Multiple event cycles until mouse release + +**Drop Stage (`"drop"`):** +- **Trigger**: User releases mouse button +- **Purpose**: Final position calculation, snap to final positions +- **Modifiers**: Ideal for snap behaviors (grid, alignment, magnetism) +- **Duration**: Single event cycle before cleanup + +### Method Call Sequence + +The following sequence diagram shows the precise order of method calls during a complete drag operation: + +```mermaid +sequenceDiagram + participant User + participant DragController + participant DragInfo + participant DragHandler + participant Modifiers + + Note over User,Modifiers: START PHASE + User->>DragController: start(handler, event, config) + DragController->>DragInfo: new DragInfo(modifiers, context) + DragController->>DragInfo: init(event) [stage = "start"] + DragController->>DragHandler: onDragStart(event, dragInfo) + + Note over User,Modifiers: DRAGGING PHASE (multiple times) + User->>DragController: update(event) + DragController->>DragInfo: update(event) [stage = "dragging"] + DragController->>DragInfo: analyzeSuggestions() + DragInfo->>Modifiers: applicable(pos, dragInfo, ctx) + Modifiers-->>DragInfo: true/false based on stage + DragInfo->>Modifiers: suggest(pos, dragInfo, ctx) + Modifiers-->>DragInfo: modified position + DragController->>DragHandler: beforeUpdate(dragInfo) + DragHandler->>DragInfo: selectModifier() or selectDefault() + DragController->>DragHandler: onDragUpdate(event, dragInfo) + + Note over User,Modifiers: DROP PHASE + User->>DragController: end(event) + DragController->>DragInfo: end(event) [stage = "drop"] + DragController->>DragInfo: analyzeSuggestions() + DragInfo->>Modifiers: applicable(pos, dragInfo, ctx) + Note right of Modifiers: gridSnapOnDrop returns true
for stage === "drop" + DragInfo->>Modifiers: suggest(pos, dragInfo, ctx) + Modifiers-->>DragInfo: snapped position + DragController->>DragHandler: beforeUpdate(dragInfo) + DragController->>DragHandler: onDragUpdate(event, dragInfo) + Note right of DragHandler: Gets final snapped positions + DragController->>DragHandler: onDragEnd(event, dragInfo) + DragController->>DragInfo: reset() +``` + +### Position Modifier Flow by Stage + +This flowchart illustrates how position modifiers are evaluated and applied at different stages: + +```mermaid +flowchart TD + A[Mouse Event] --> B{Stage?} + + B -->|start| C[Stage: START] + B -->|dragging| D[Stage: DRAGGING] + B -->|drop| E[Stage: DROP] + + C --> C1[No modifiers applied] + C1 --> C2[onDragStart called] + + D --> D1[Check modifiers] + D1 --> D2{gridSnap applicable?} + D2 -->|No for dragging| D3[Skip gridSnap] + D2 -->|Yes for dragging| D4[Apply gridSnap] + D3 --> D5[Smooth movement] + D4 --> D5 + D5 --> D6[onDragUpdate called] + + E --> E1[Check modifiers] + E1 --> E2{gridSnapOnDrop applicable?} + E2 -->|Yes for drop| E3[Apply snap to grid] + E2 -->|No for drop| E4[No snap] + E3 --> E5[Final snapped position] + E4 --> E5 + E5 --> E6[onDragUpdate with final pos] + E6 --> E7[onDragEnd called] + + subgraph Examples ["Modifier Examples"] + M1["gridSnap(20, 'dragging')
applies during movement"] + M2["gridSnapOnDrop(20)
applies only at end"] + M3["previewSnap
shows preview during drag"] + end +``` + +## Stage-based Modifier Patterns + +### Pattern 1: Snap Only on Drop +Perfect for clean final positioning without interference during movement: + +```typescript +const snapOnDrop = DragModifiers.gridSnapOnDrop(20); +// Only activates when ctx.stage === "drop" +``` + +### Pattern 2: Real-time Snapping +For immediate visual feedback during movement: + +```typescript +const realtimeSnap = DragModifiers.gridSnap(20, "dragging"); +// Activates when ctx.stage === "dragging" +``` + +### Pattern 3: Progressive Behavior +Different behaviors for different stages: + +```typescript +const progressiveModifier = { + name: "progressive", + priority: 10, + applicable: (pos, dragInfo, ctx) => { + if (ctx.stage === "start") return false; // No modification at start + if (ctx.stage === "dragging") return true; // Loose snapping during drag + if (ctx.stage === "drop") return true; // Precise snapping on drop + }, + suggest: (pos, dragInfo, ctx) => { + const gridSize = ctx.stage === "dragging" ? 40 : 20; // Larger grid during drag + return new Point( + Math.round(pos.x / gridSize) * gridSize, + Math.round(pos.y / gridSize) * gridSize + ); + } +}; +``` + +### Pattern 4: Preview + Final +Show preview during drag, apply on drop: + +```typescript +const previewModifier = { + name: "preview", + applicable: (pos, dragInfo, ctx) => ctx.stage === "dragging", + suggest: (pos, dragInfo, ctx) => { + // Calculate snap position but don't apply + ctx.previewPosition = calculateSnapPosition(pos); + return pos; // Return original position + } +}; + +const finalModifier = { + name: "final", + applicable: (pos, dragInfo, ctx) => ctx.stage === "drop", + suggest: (pos, dragInfo, ctx) => { + // Apply the previewed position + return ctx.previewPosition || pos; + } +}; +``` + +## Implementation Guidelines + +### Best Practices for Stage-aware Modifiers + +1. **Start Stage**: Avoid position modifications to prevent interfering with drag initiation +2. **Dragging Stage**: Use for real-time feedback, loose snapping, or preview calculations +3. **Drop Stage**: Ideal for precise final positioning, grid snapping, alignment + +### Performance Considerations + +- **Dragging stage modifiers** run on every mouse move - keep them lightweight +- **Drop stage modifiers** run once - can be more computationally expensive +- Use `ctx.stage` checks early in `applicable()` for optimal performance + +### Multi-stage Modifier Design + +```typescript +const smartModifier = { + name: "smart", + applicable: (pos, dragInfo, ctx) => { + // Quick stage filtering + if (ctx.stage === "start") return false; + + // Stage-specific logic + return ctx.stage === "dragging" ? + ctx.showPreview : + ctx.enableSnapping; + }, + suggest: (pos, dragInfo, ctx) => { + const intensity = ctx.stage === "dragging" ? 0.5 : 1.0; + return applySnapping(pos, intensity); + } +}; +``` + +This stage-based architecture provides a clean, extensible foundation for implementing sophisticated drag behaviors while maintaining optimal performance and user experience. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 599c9b0a..54cb6a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,8 @@ "monaco-editor": "^0.52.0", "prettier": "^3.0.0", "process": "^0.11.10", + "react": "^18.2.0", + "react-dom": "^18.2.0", "sass": "^1.77.1", "size-limit": "^10.0.1", "storybook": "^8.1.11", @@ -76,10 +78,6 @@ "engines": { "pnpm": "Please use npm instead of pnpm to install dependencies", "yarn": "Please use npm instead of yarn to install dependencies" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, "node_modules/@adobe/css-tools": { @@ -14086,7 +14084,8 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "node_modules/js-yaml": { "version": "4.1.0", @@ -14541,6 +14540,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -16298,6 +16298,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -16396,6 +16397,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -17063,6 +17065,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0" } diff --git a/src/components/canvas/EventedComponent/EventedComponent.ts b/src/components/canvas/EventedComponent/EventedComponent.ts index 23e63816..d9dfbcfe 100644 --- a/src/components/canvas/EventedComponent/EventedComponent.ts +++ b/src/components/canvas/EventedComponent/EventedComponent.ts @@ -4,6 +4,8 @@ type TEventedComponentListener = Component | ((e: Event) => void); const listeners = new WeakMap>>(); +const parents = new WeakMap(); + export class EventedComponent< Props extends TComponentProps = TComponentProps, State extends TComponentState = TComponentState, @@ -50,7 +52,14 @@ export class EventedComponent< } } + protected getTargetComponent(event: Event): EventedComponent { + return parents.get(event); + } + public _fireEvent(cmp: Component, event: Event) { + if (!parents.has(event)) { + parents.set(event, this); + } const handlers = listeners.get(cmp)?.get?.(event.type); handlers?.forEach((cb) => { diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index a8a0c096..cafdd95e 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -5,8 +5,6 @@ import { Component } from "../../../lib"; import { TComponentContext, TComponentProps, TComponentState } from "../../../lib/Component"; import { HitBox, HitBoxData } from "../../../services/HitTest"; import { getXY } from "../../../utils/functions"; -import { dragListener } from "../../../utils/functions/dragListener"; -import { EVENTS } from "../../../utils/types/events"; import { EventedComponent } from "../EventedComponent/EventedComponent"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; @@ -22,7 +20,7 @@ export class GraphComponent< > extends EventedComponent { public hitBox: HitBox; - private unsubscribe: (() => void)[] = []; + protected unsubscribe: (() => void)[] = []; constructor(props: Props, parent: Component) { super(props, parent); @@ -54,32 +52,36 @@ export class GraphComponent< return; } event.stopPropagation(); - dragListener(this.context.ownerDocument) - .on(EVENTS.DRAG_START, (event: MouseEvent) => { - if (onDragStart?.(event) === false) { - return; - } - this.context.graph.getGraphLayer().captureEvents(this); - const xy = getXY(this.context.canvas, event); - startDragCoords = this.context.camera.applyToPoint(xy[0], xy[1]); - }) - .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => { - if (!startDragCoords.length) return; - - const [canvasX, canvasY] = getXY(this.context.canvas, event); - const currentCoords = this.context.camera.applyToPoint(canvasX, canvasY); - - const diffX = (startDragCoords[0] - currentCoords[0]) | 0; - const diffY = (startDragCoords[1] - currentCoords[1]) | 0; - - onDragUpdate?.({ prevCoords: startDragCoords, currentCoords, diffX, diffY }, event); - startDragCoords = currentCoords; - }) - .on(EVENTS.DRAG_END, (_event: MouseEvent) => { - this.context.graph.getGraphLayer().releaseCapture(); - startDragCoords = undefined; - onDrop?.(_event); - }); + this.context.graph.dragController.start( + { + onDragStart: (event) => { + if (onDragStart?.(event) === false) { + return; + } + this.context.graph.getGraphLayer().captureEvents(this); + const xy = getXY(this.context.canvas, event); + startDragCoords = this.context.camera.applyToPoint(xy[0], xy[1]); + }, + onDragUpdate: (event) => { + if (!startDragCoords.length) return; + + const [canvasX, canvasY] = getXY(this.context.canvas, event); + const currentCoords = this.context.camera.applyToPoint(canvasX, canvasY); + + const diffX = (startDragCoords[0] - currentCoords[0]) | 0; + const diffY = (startDragCoords[1] - currentCoords[1]) | 0; + + onDragUpdate?.({ prevCoords: startDragCoords, currentCoords, diffX, diffY }, event); + startDragCoords = currentCoords; + }, + onDragEnd: (event) => { + this.context.graph.getGraphLayer().releaseCapture(); + startDragCoords = undefined; + onDrop?.(event); + }, + }, + event + ); }); } diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index 08e8be1a..9ed99e62 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -2,22 +2,21 @@ import { signal } from "@preact/signals-core"; import cloneDeep from "lodash/cloneDeep"; import isObject from "lodash/isObject"; +import { DragInfo } from "../../../services/Drag/DragInfo"; import { ECameraScaleLevel } from "../../../services/camera/CameraService"; 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 { 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 } from "../GraphComponent"; import { Anchor, TAnchor } from "../anchors"; import { GraphLayer, TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; -import { BlockController } from "./controllers/BlockController"; - export type TBlockSettings = { /** Phantom blocks are blocks whose dimensions and position * are not taken into account when calculating the usable rect. */ @@ -98,9 +97,7 @@ export class Block; - protected lastDragEvent?: MouseEvent; - - protected startDragCoords: number[] = []; + protected startDragCoords?: [number, number]; protected shouldRenderText: boolean; @@ -114,18 +111,12 @@ export class Block({ zIndex: 0, order: 0 }); constructor(props: Props, parent: GraphLayer) { super(props, parent); this.subscribe(props.id); - - this.addEventListener(EVENTS.DRAG_START, this); - this.addEventListener(EVENTS.DRAG_UPDATE, this); - this.addEventListener(EVENTS.DRAG_END, this); } public isRendered() { @@ -151,6 +142,13 @@ 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.startDragCoords = [this.state.x, this.state.y]; this.raiseBlock(); } ); } - protected onDragUpdate(event: MouseEvent) { + public onDragUpdate(event: MouseEvent, dragInfo: DragInfo) { if (!this.startDragCoords) return; - this.lastDragEvent = event; - - const [canvasX, canvasY] = getXY(this.context.canvas, event); - const [cameraSpaceX, cameraSpaceY] = this.context.camera.applyToPoint(canvasX, canvasY); - - const [x, y] = this.calcNextDragPosition(cameraSpaceX, cameraSpaceY); + // Применяем скорректированную дельту к начальной позиции этого блока + const newPos = dragInfo.applyAdjustedDelta(this.startDragCoords[0], this.startDragCoords[1]); this.context.graph.executеDefaultEventAction( "block-drag", { nativeEvent: event, block: this.connectedState.asTBlock(), - x, - y, + x: newPos.x, + y: newPos.y, }, - () => this.applyNextPosition(x, y) + () => this.applyNextPosition(newPos.x, newPos.y) ); } - protected calcNextDragPosition(x: number, y: number) { - const diffX = (this.startDragCoords[0] - x) | 0; - const diffY = (this.startDragCoords[1] - y) | 0; - - let nextX = this.startDragCoords[2] - diffX; - let nextY = this.startDragCoords[3] - diffY; + protected calcNextDragPosition(dragInfo: DragInfo) { + const diff = dragInfo.worldDelta; - const spanGridSize = this.context.constants.block.SNAPPING_GRID_SIZE; - - if (spanGridSize > 1) { - nextX = Math.round(nextX / spanGridSize) * spanGridSize; - nextY = Math.round(nextY / spanGridSize) * spanGridSize; - } + const nextX = this.startDragCoords[0] + diff.x; + const nextY = this.startDragCoords[1] + diff.y; return [nextX, nextY]; } @@ -312,15 +279,14 @@ export class Block void)[]; - private font: string; constructor(props: {}, context: any) { super(props, context); - this.unsubscribe = this.subscribe(); + this.subscribe(); this.prepareFont(this.getFontScale()); } + protected handleEvent(_: Event): void {} + + protected willMount(): void { + this.addEventListener("click", (event) => { + const blockInstance = this.getTargetComponent(event); + + if (!(blockInstance instanceof Block)) { + return; + } + event.stopPropagation(); + + const { connectionsList } = this.context.graph.rootStore; + const isAnyConnectionSelected = connectionsList.$selectedConnections.value.size !== 0; + + if (!isMetaKeyEvent(event as MouseEvent) && isAnyConnectionSelected) { + connectionsList.resetSelection(); + } + + this.context.graph.api.selectBlocks( + [blockInstance.props.id], + /** + * On click with meta key we want to select only one block, otherwise we want to toggle selection + */ + !isMetaKeyEvent(event as MouseEvent) ? true : !blockInstance.state.selected, + /** + * On click with meta key we want to append selection, otherwise we want to replace selection + */ + !isMetaKeyEvent(event as MouseEvent) ? ESelectionStrategy.REPLACE : ESelectionStrategy.APPEND + ); + }); + + this.addEventListener("mousedown", (event) => { + const blockInstance = this.getTargetComponent(event); + + if (!(blockInstance instanceof Block) || !blockInstance.isDraggable()) { + return; + } + + event.stopPropagation(); + + const blockState = blockInstance.connectedState; + + const selectedBlocksStates = getSelectedBlocks(blockState, this.context.graph.rootStore.blocksList); + const selectedBlocksComponents: Block[] = selectedBlocksStates.map((block) => block.getViewComponent()); + + this.context.graph.getGraphLayer().captureEvents(blockInstance); + + // Получаем начальную позицию основного блока (который инициировал драг) + const mainBlockState = blockInstance.connectedState; + const initialEntityPosition = { x: mainBlockState.x, y: mainBlockState.y }; + + this.context.graph.dragController.start( + { + onDragStart: (dragEvent, dragInfo) => { + const blocks = dragInfo.context.selectedBlocks as Block[]; + for (const block of blocks) { + block.onDragStart(dragEvent); + } + }, + beforeUpdate: (dragInfo) => { + dragInfo.selectByPriority(); + }, + onDragUpdate: (dragEvent, dragInfo) => { + const blocks = dragInfo.context.selectedBlocks as Block[]; + for (const block of blocks) { + block.onDragUpdate(dragEvent, dragInfo); + } + }, + onDragEnd: (dragEvent, dragInfo) => { + this.context.graph.getGraphLayer().releaseCapture(); + const blocks = dragInfo.context.selectedBlocks as Block[]; + for (const block of blocks) { + block.onDragEnd(dragEvent); + } + }, + }, + event as MouseEvent, + { + positionModifiers: [ + createGridSnapModifier({ gridSize: this.context.constants.block.SNAPPING_GRID_SIZE, stage: "drop" }), + ], + initialEntityPosition: initialEntityPosition, + context: { + dragEntity: blockInstance, + enableGridSnap: true, + selectedBlocks: selectedBlocksComponents, + }, + } + ); + }); + } + protected getFontScale() { return this.context.graph.rootStore.settings.getConfigFlag("scaleFontSize"); } @@ -35,16 +130,14 @@ export class Blocks extends Component { protected subscribe() { this.blocks = this.context.graph.rootStore.blocksList.$blocks.value; this.blocksView = this.context.graph.rootStore.settings.getConfigFlag("blockComponents"); - return [ - this.context.graph.rootStore.blocksList.$blocks.subscribe((blocks) => { - this.blocks = blocks; - this.rerender(); - }), - this.context.graph.rootStore.settings.$blockComponents.subscribe((blockComponents) => { - this.blocksView = blockComponents; - this.rerender(); - }), - ]; + this.subscribeSignal(this.context.graph.rootStore.blocksList.$blocks, (blocks) => { + this.blocks = blocks; + this.rerender(); + }); + this.subscribeSignal(this.context.graph.rootStore.settings.$blockComponents, (blockComponents) => { + this.blocksView = blockComponents; + this.rerender(); + }); } private prepareFont(scaleFontSize) { @@ -71,3 +164,13 @@ export class Blocks extends Component { }); } } + +export function getSelectedBlocks(currentBlockState: BlockState, blocksState: BlockListStore) { + let selected; + if (currentBlockState.selected) { + selected = blocksState.$selectedBlocks.value; + } else { + selected = [currentBlockState]; + } + return selected; +} diff --git a/src/components/canvas/blocks/controllers/BlockController.ts b/src/components/canvas/blocks/controllers/BlockController.ts deleted file mode 100644 index 92096a31..00000000 --- a/src/components/canvas/blocks/controllers/BlockController.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { BlockState } from "../../../../store/block/Block"; -import { BlockListStore } from "../../../../store/block/BlocksList"; -import { selectBlockById } from "../../../../store/block/selectors"; -import { ECanChangeBlockGeometry } from "../../../../store/settings"; -import { - addEventListeners, - createCustomDragEvent, - dispatchEvents, - isAllowChangeBlockGeometry, - isMetaKeyEvent, -} from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; -import { EVENTS } from "../../../../utils/types/events"; -import { ESelectionStrategy } from "../../../../utils/types/types"; -import { Block } from "../Block"; - -export class BlockController { - constructor(block: Block) { - addEventListeners(block as EventTarget, { - click(event: MouseEvent) { - event.stopPropagation(); - - const { connectionsList } = block.context.graph.rootStore; - const isAnyConnectionSelected = connectionsList.$selectedConnections.value.size !== 0; - - if (!isMetaKeyEvent(event) && isAnyConnectionSelected) { - connectionsList.resetSelection(); - } - - block.context.graph.api.selectBlocks( - [block.props.id], - /** - * On click with meta key we want to select only one block, otherwise we want to toggle selection - */ - !isMetaKeyEvent(event) ? true : !block.state.selected, - /** - * On click with meta key we want to append selection, otherwise we want to replace selection - */ - !isMetaKeyEvent(event) ? ESelectionStrategy.REPLACE : ESelectionStrategy.APPEND - ); - }, - - mousedown(event: MouseEvent) { - const blockState = selectBlockById(block.context.graph, block.props.id); - const allowChangeBlockGeometry = isAllowChangeBlockGeometry( - block.getConfigFlag("canChangeBlockGeometry") as ECanChangeBlockGeometry, - blockState.selected - ); - - if (!allowChangeBlockGeometry) return; - - event.stopPropagation(); - - const blocksListState = this.context.graph.rootStore.blocksList; - const selectedBlocksStates = getSelectedBlocks(blockState, blocksListState); - const selectedBlocksComponents = selectedBlocksStates.map((block) => block.getViewComponent()); - - dragListener(block.context.ownerDocument) - .on(EVENTS.DRAG_START, (_event: MouseEvent) => { - block.context.graph.getGraphLayer().captureEvents(this); - dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_START, _event)); - }) - .on(EVENTS.DRAG_UPDATE, (_event: MouseEvent) => { - dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_UPDATE, _event)); - }) - .on(EVENTS.DRAG_END, (_event: MouseEvent) => { - block.context.graph.getGraphLayer().releaseCapture(); - dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_END, _event)); - }); - }, - }); - } -} - -export function getSelectedBlocks(currentBlockState: BlockState, blocksState: BlockListStore) { - let selected; - if (currentBlockState.selected) { - selected = blocksState.$selectedBlocks.value; - } else { - selected = [currentBlockState]; - } - return selected; -} diff --git a/src/components/canvas/layers/alignmentLayer/AlignmentLinesLayer.ts b/src/components/canvas/layers/alignmentLayer/AlignmentLinesLayer.ts new file mode 100644 index 00000000..f6f1464f --- /dev/null +++ b/src/components/canvas/layers/alignmentLayer/AlignmentLinesLayer.ts @@ -0,0 +1,435 @@ +import { TComponentState } from "../../../../lib/Component"; +import { DragContext, DragInfo, DragModifier, IDragMiddleware } from "../../../../services/Drag/DragInfo"; +import { + MagneticBorderModifier, + MagneticBorderModifierConfig, +} from "../../../../services/Drag/modifiers/MagneticBorderModifier"; +import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; +import { GraphComponent } from "../../GraphComponent"; +import { Block } from "../../blocks/Block"; + +/** Border information from magnetic modifier context */ +interface BorderInfo { + element: GraphComponent; + border: "top" | "right" | "bottom" | "left"; + point: { x: number; y: number }; + distance: number; +} + +/** Extended drag context with magnetic border information */ +interface MagneticDragContext extends DragContext { + allBorderLines?: BorderInfo[]; + selectedBorders?: BorderInfo[]; + dragEntity?: GraphComponent; +} + +/** + * Configuration for the alignment lines layer + */ +export interface AlignmentLinesLayerProps extends LayerProps { + /** Configuration for the magnetic border modifier */ + magneticBorderConfig?: Partial; + /** Style configuration for alignment lines */ + lineStyle?: { + /** Color for snap lines (lines that trigger snapping) */ + snapColor?: string; + /** Color for guide lines (lines that don't trigger snapping) */ + guideColor?: string; + /** Line width */ + width?: number; + /** Dash pattern for snap lines */ + snapDashPattern?: number[]; + /** Dash pattern for guide lines */ + guideDashPattern?: number[]; + }; +} + +/** + * State for storing alignment line information + */ +interface AlignmentLinesState extends TComponentState { + /** Array of snap lines (lines that trigger snapping) */ + snapLines: Array<{ + /** Line type - horizontal or vertical */ + type: "horizontal" | "vertical"; + /** Position coordinate (y for horizontal, x for vertical) */ + position: number; + /** Visual bounds for the line */ + bounds: { + start: number; + end: number; + }; + }>; + /** Array of guide lines (lines that show potential alignment but don't snap) */ + guideLines: Array<{ + /** Line type - horizontal or vertical */ + type: "horizontal" | "vertical"; + /** Position coordinate (y for horizontal, x for vertical) */ + position: number; + /** Visual bounds for the line */ + bounds: { + start: number; + end: number; + }; + }>; + /** Whether alignment lines are currently visible */ + visible: boolean; +} + +/** + * Layer that displays alignment lines when dragging blocks with magnetic border snapping + */ +export class AlignmentLinesLayer + extends Layer< + AlignmentLinesLayerProps, + LayerContext & { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D }, + AlignmentLinesState + > + implements IDragMiddleware +{ + /** Current drag modifier instance */ + private magneticModifier: DragModifier | null = null; + + /** Configuration for magnetic border behavior */ + private readonly magneticConfig: MagneticBorderModifierConfig; + + constructor(props: AlignmentLinesLayerProps) { + super({ + canvas: { + zIndex: 15, // Above selection layer but below new block layer + classNames: ["alignment-lines-layer", "no-pointer-events"], + transformByCameraPosition: true, + ...props.canvas, + }, + ...props, + }); + + // Default magnetic border configuration + this.magneticConfig = { + magnetismDistance: 50, // Show guide lines up to 50px + snapThreshold: 15, // Snap only within 15px + enabledBorders: ["top", "right", "bottom", "left"], + allowMultipleSnap: true, // Allow snapping to both horizontal and vertical lines + targets: [Block], + resolveBounds: (element: GraphComponent) => { + if (element instanceof Block) { + const state = element.state; + return { + x: state.x, + y: state.y, + width: state.width, + height: state.height, + }; + } + return null; + }, + filter: (element: GraphComponent, dragInfo: DragInfo, ctx: DragContext) => { + // Don't snap to self and filter non-block elements + return element instanceof Block && element !== ctx.dragEntity; + }, + ...props.magneticBorderConfig, + }; + + // Initialize state + this.setState({ + snapLines: [], + guideLines: [], + visible: false, + }); + + const canvas = this.getCanvas(); + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Unable to get 2D rendering context"); + } + + this.setContext({ + canvas, + ctx, + camera: props.camera, + constants: this.props.graph.graphConstants, + colors: this.props.graph.graphColors, + graph: this.props.graph, + }); + } + + /** + * Provides drag modifier for magnetic border snapping with line visualization + * @returns Drag modifier for border snapping with alignment lines + */ + public dragModifier(): DragModifier { + if (!this.magneticModifier) { + // Create the magnetic border modifier + // eslint-disable-next-line new-cap + const baseMagneticModifier = MagneticBorderModifier(this.magneticConfig); + + // Extend it with line visualization + this.magneticModifier = { + ...baseMagneticModifier, + name: "alignmentLinesLayer", + onApply: (dragInfo: DragInfo, ctx: DragContext) => { + // Update alignment lines based on closest border + this.updateAlignmentLines(dragInfo, ctx); + }, + }; + } + + return this.magneticModifier; + } + + /** + * Called after layer initialization + * Sets up event listeners for drag events + * @returns void + */ + protected afterInit(): void { + super.afterInit(); + + // Subscribe to drag events to manage modifier and visualization + this.context.graph.on("drag-start", (event) => { + const { dragInfo } = event.detail; + + // Add our modifier if we're dragging a Block + if (this.isDraggingBlock(dragInfo)) { + dragInfo.addModifier(this.dragModifier()); + this.setState({ visible: true }); + } + }); + + this.context.graph.on("drag-update", (event) => { + const { dragInfo } = event.detail; + + // Update visualization if our modifier is applied + if (dragInfo.isApplied(this.dragModifier())) { + this.updateAlignmentLines(dragInfo, dragInfo.context as DragContext); + this.performRender(); + } + }); + + this.context.graph.on("drag-end", (event) => { + const { dragInfo } = event.detail; + + // Clean up: remove modifier and hide lines + dragInfo.removeModifier(this.dragModifier().name); + this.setState({ + snapLines: [], + guideLines: [], + visible: false, + }); + this.performRender(); + }); + } + + /** + * Checks if we're dragging a block + * @param dragInfo - Current drag information + * @returns true if dragging a Block component + */ + private isDraggingBlock(dragInfo: DragInfo): boolean { + return "dragEntity" in dragInfo.context && dragInfo.context.dragEntity instanceof Block; + } + + /** + * Updates alignment lines based on current drag state + * @param dragInfo - Current drag information + * @param ctx - Drag context containing border information + * @returns void + */ + private updateAlignmentLines(dragInfo: DragInfo, ctx: DragContext): void { + const snapLines: AlignmentLinesState["snapLines"] = []; + const guideLines: AlignmentLinesState["guideLines"] = []; + + // Get border information from context + const magneticCtx = ctx as MagneticDragContext; + const allBorderLines = magneticCtx.allBorderLines || []; + const selectedBorders = magneticCtx.selectedBorders || []; + + // Get current dragged block bounds for line extension + const draggedEntity = magneticCtx.dragEntity; + let draggedBounds = null; + if (draggedEntity instanceof Block) { + const state = draggedEntity.state; + draggedBounds = { + x: state.x, + y: state.y, + width: state.width, + height: state.height, + }; + } + + // Convert selected borders to snap lines + selectedBorders.forEach((borderInfo) => { + const { border, point, element } = borderInfo; + + // Get bounds of the target element for line extension + const targetBounds = this.magneticConfig.resolveBounds?.(element); + if (!targetBounds) return; + + const line = this.createLineFromBorder(border, point, targetBounds, draggedBounds); + if (line) { + snapLines.push(line); + } + }); + + // Convert all other borders to guide lines (excluding those already used for snapping) + const selectedBorderIds = new Set(selectedBorders.map((b) => `${this.getElementId(b.element)}-${b.border}`)); + + allBorderLines.forEach((borderInfo) => { + const { border, point, element } = borderInfo; + const borderId = `${this.getElementId(element)}-${border}`; + + // Skip if this border is already used for snapping + if (selectedBorderIds.has(borderId)) return; + + // Get bounds of the target element for line extension + const targetBounds = this.magneticConfig.resolveBounds?.(element); + if (!targetBounds) return; + + const line = this.createLineFromBorder(border, point, targetBounds, draggedBounds); + if (line) { + guideLines.push(line); + } + }); + + // Update state with new lines + this.setState({ + snapLines, + guideLines, + visible: snapLines.length > 0 || guideLines.length > 0, + }); + } + + /** + * Gets a unique identifier for a GraphComponent + * @param element - The graph component + * @returns Unique string identifier for the element + */ + private getElementId(element: GraphComponent): string { + // Use a combination of constructor name and unique string as identifier + // Since we don't have a reliable id property, we use the object's string representation + return `${element.constructor.name}_${Object.prototype.toString.call(element)}`; + } + + /** + * Creates a line object from border information + * @param border - Border type (top, right, bottom, left) + * @param point - Point on the border line + * @param targetBounds - Bounds of the target element + * @param draggedBounds - Bounds of the dragged element (can be null) + * @returns Line object or null if border type is unsupported + */ + private createLineFromBorder( + border: "top" | "right" | "bottom" | "left", + point: { x: number; y: number }, + targetBounds: { x: number; y: number; width: number; height: number }, + draggedBounds: { x: number; y: number; width: number; height: number } | null + ) { + const padding = 20; + + if (border === "top" || border === "bottom") { + // Horizontal line + const lineY = point.y; + + // Calculate line bounds - extend beyond both elements + let startX = Math.min(targetBounds.x, draggedBounds?.x ?? point.x); + let endX = Math.max( + targetBounds.x + targetBounds.width, + (draggedBounds?.x ?? point.x) + (draggedBounds?.width ?? 0) + ); + + // Add padding + startX -= padding; + endX += padding; + + return { + type: "horizontal" as const, + position: lineY, + bounds: { start: startX, end: endX }, + }; + } else if (border === "left" || border === "right") { + // Vertical line + const lineX = point.x; + + // Calculate line bounds - extend beyond both elements + let startY = Math.min(targetBounds.y, draggedBounds?.y ?? point.y); + let endY = Math.max( + targetBounds.y + targetBounds.height, + (draggedBounds?.y ?? point.y) + (draggedBounds?.height ?? 0) + ); + + // Add padding + startY -= padding; + endY += padding; + + return { + type: "vertical" as const, + position: lineX, + bounds: { start: startY, end: endY }, + }; + } + + return null; + } + + /** + * Renders alignment lines on canvas + * @returns void + */ + protected render(): void { + super.render(); + + const state = this.state; + if (!state.visible || (state.snapLines.length === 0 && state.guideLines.length === 0)) { + return; + } + + const ctx = this.context.ctx; + const lineStyle = this.props.lineStyle || {}; + + // Configure common line properties + ctx.lineWidth = lineStyle.width || 1; + + // Draw guide lines first (less prominent) + if (state.guideLines.length > 0) { + ctx.strokeStyle = lineStyle.guideColor || "#E0E0E0"; // Light gray + ctx.setLineDash(lineStyle.guideDashPattern || [3, 3]); // Subtle dashes + + state.guideLines.forEach((line) => { + ctx.beginPath(); + + if (line.type === "horizontal") { + ctx.moveTo(line.bounds.start, line.position); + ctx.lineTo(line.bounds.end, line.position); + } else { + ctx.moveTo(line.position, line.bounds.start); + ctx.lineTo(line.position, line.bounds.end); + } + + ctx.stroke(); + }); + } + + // Draw snap lines (more prominent) + if (state.snapLines.length > 0) { + ctx.strokeStyle = lineStyle.snapColor || "#007AFF"; // Bright blue + ctx.setLineDash(lineStyle.snapDashPattern || [5, 5]); // More prominent dashes + + state.snapLines.forEach((line) => { + ctx.beginPath(); + + if (line.type === "horizontal") { + ctx.moveTo(line.bounds.start, line.position); + ctx.lineTo(line.bounds.end, line.position); + } else { + ctx.moveTo(line.position, line.bounds.start); + ctx.lineTo(line.position, line.bounds.end); + } + + ctx.stroke(); + }); + } + + // Reset line dash + ctx.setLineDash([]); + } +} diff --git a/src/components/canvas/layers/alignmentLayer/README.md b/src/components/canvas/layers/alignmentLayer/README.md new file mode 100644 index 00000000..29e17ef1 --- /dev/null +++ b/src/components/canvas/layers/alignmentLayer/README.md @@ -0,0 +1,161 @@ +# Alignment Lines Layer + +Слой для отображения линий выравнивания при перетаскивании блоков с использованием магнитного прилипания к границам. + +## Описание + +`AlignmentLinesLayer` - это визуальный слой, который показывает пунктирные линии выравнивания когда пользователь перетаскивает блоки. Линии появляются когда блок приближается к границам других блоков и показывают точки выравнивания. + +## Особенности + +- ✅ Интеграция с `MagneticBorderModifier` +- ✅ Автоматическое обнаружение блоков в области видимости +- ✅ Настраиваемый стиль линий (цвет, толщина, пунктир) +- ✅ Поддержка всех четырех направлений выравнивания +- ✅ Динамическое добавление/удаление модификаторов через события +- ✅ Оптимизированная отрисовка только во время перетаскивания + +## Использование + +### Базовое использование + +```typescript +import { Graph } from "@gravity-ui/graph"; +import { AlignmentLinesLayer } from "@gravity-ui/graph"; + +const graph = new Graph(container, { + layers: [ + // ... другие слои + [AlignmentLinesLayer, {}], // Базовая конфигурация + ], +}); +``` + +### Расширенная конфигурация + +```typescript +[ + AlignmentLinesLayer, + { + magneticBorderConfig: { + magnetismDistance: "auto", // или число в пикселях + enabledBorders: ["top", "right", "bottom", "left"], + targets: [Block], // типы компонентов для выравнивания + }, + lineStyle: { + color: "#007AFF", + width: 1, + dashPattern: [5, 5], + }, + }, +] +``` + +## Конфигурация + +### AlignmentLinesLayerProps + +| Свойство | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `magneticBorderConfig` | `Partial` | - | Конфигурация магнитного прилипания | +| `lineStyle` | `LineStyleConfig` | - | Стиль отображения линий | + +### MagneticBorderModifierConfig + +| Свойство | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `magnetismDistance` | `number \| "auto"` | `"auto"` | Расстояние магнетизма в пикселях или "auto" для всего viewport | +| `enabledBorders` | `Array<"top" \| "right" \| "bottom" \| "left">` | `["top", "right", "bottom", "left"]` | Включенные границы для выравнивания | +| `targets` | `Constructor[]` | `[Block]` | Типы компонентов для поиска | +| `resolveBounds` | `(element) => Bounds \| null` | - | Функция получения границ элемента | +| `filter` | `(element) => boolean` | - | Фильтр элементов для выравнивания | + +### LineStyleConfig + +| Свойство | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `color` | `string` | `"#007AFF"` | Цвет линий | +| `width` | `number` | `1` | Толщина линий в пикселях | +| `dashPattern` | `number[]` | `[5, 5]` | Паттерн пунктирной линии | + +## Примеры + +### Только горизонтальное выравнивание + +```typescript +[ + AlignmentLinesLayer, + { + magneticBorderConfig: { + enabledBorders: ["top", "bottom"], + }, + lineStyle: { + color: "#FF3B30", + width: 2, + }, + }, +] +``` + +### Ограниченное расстояние магнетизма + +```typescript +[ + AlignmentLinesLayer, + { + magneticBorderConfig: { + magnetismDistance: 50, // 50 пикселей + }, + lineStyle: { + color: "#34C759", + dashPattern: [3, 3], + }, + }, +] +``` + +### Кастомная фильтрация элементов + +```typescript +[ + AlignmentLinesLayer, + { + magneticBorderConfig: { + filter: (element) => { + // Выравнивание только с видимыми блоками определенного типа + return element instanceof Block && + element.isVisible() && + element.getState().type === "process"; + }, + }, + }, +] +``` + +## Архитектура + +Слой реализует интерфейс `IDragMiddleware` и использует систему событий: + +1. **drag-start** - добавляет модификатор и активирует визуализацию +2. **drag-update** - обновляет линии выравнивания на основе текущей позиции +3. **drag-end** - убирает модификатор и скрывает линии + +### Алгоритм работы + +1. При начале перетаскивания блока добавляется `MagneticBorderModifier` +2. Модификатор анализирует ближайшие элементы и находит точки выравнивания +3. Данные о выравнивании передаются через контекст drag события +4. Слой извлекает информацию о границах и строит линии выравнивания +5. Линии отрисовываются на canvas с настроенным стилем +6. При завершении перетаскивания все очищается + +## Производительность + +- Отрисовка происходит только во время активного перетаскивания +- Используется кеширование вычислений в `MagneticBorderModifier` +- Линии рисуются с учетом текущего масштаба камеры +- Автоматическая очистка при завершении операции + +## Интеграция + +Слой полностью интегрирован с системой событий графа и может использоваться совместно с другими слоями и модификаторами без конфликтов. \ No newline at end of file diff --git a/src/components/canvas/layers/alignmentLayer/index.ts b/src/components/canvas/layers/alignmentLayer/index.ts new file mode 100644 index 00000000..c5499efd --- /dev/null +++ b/src/components/canvas/layers/alignmentLayer/index.ts @@ -0,0 +1 @@ +export { AlignmentLinesLayer, type AlignmentLinesLayerProps } from "./AlignmentLinesLayer"; \ No newline at end of file diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index 9c0013b1..7822e763 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -1,16 +1,18 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; +import { DragHandler } from "../../../../services/Drag/DragController"; +import { DragInfo } from "../../../../services/Drag/DragInfo"; +import { MagneticModifier } from "../../../../services/Drag/modifiers/MagneticModifier"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; -import { AnchorState } from "../../../../store/anchor/Anchor"; +import { AnchorState, EAnchorType } from "../../../../store/anchor/Anchor"; import { BlockState, TBlockId } from "../../../../store/block/Block"; -import { getXY, isBlock, isShiftKeyEvent } from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; +import { isBlock, isShiftKeyEvent } from "../../../../utils/functions"; 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 { ESelectionStrategy } from "../../../../utils/types/types"; import { Anchor } from "../../../canvas/anchors"; import { Block } from "../../../canvas/blocks/Block"; +import { GraphComponent } from "../../GraphComponent"; type TIcon = { path: string; @@ -34,6 +36,10 @@ type ConnectionLayerProps = LayerProps & { point?: TIcon; drawLine?: DrawLineFunction; isConnectionAllowed?: (sourceComponent: BlockState | AnchorState) => boolean; + /** + * Distance threshold for block magnetism. Defaults to 50 pixels. + */ + magnetismDistance?: number; }; declare module "../../../../graphEvents" { @@ -127,6 +133,20 @@ export class ConnectionLayer extends Layer< protected enabled: boolean; private declare eventAborter: AbortController; + // eslint-disable-next-line new-cap + protected magneticModifier = MagneticModifier({ + magnetismDistance: 200, + resolvePosition: (element: GraphComponent) => { + if (element instanceof Block) { + return element.getConnectionPoint("in"); + } + if (element instanceof Anchor) { + return element.getPosition(); + } + return null; + }, + }); + constructor(props: ConnectionLayerProps) { super({ canvas: { @@ -158,6 +178,7 @@ export class ConnectionLayer extends Layer< * Called after initialization and when the layer is reattached. * This is where we set up event subscriptions to ensure they work properly * after the layer is unmounted and reattached. + * @returns {void} */ protected afterInit(): void { // Register event listeners with the graphOn wrapper method for automatic cleanup when unmounted @@ -169,24 +190,37 @@ export class ConnectionLayer extends Layer< super.afterInit(); } + /** + * Enables connection creation functionality + * @returns {void} + */ public enable = () => { this.enabled = true; }; + /** + * Disables connection creation functionality + * @returns {void} + */ public disable = () => { this.enabled = false; }; + /** + * Handles mousedown events to initiate connection creation + * @param {GraphMouseEvent} nativeEvent - The graph mouse event + * @returns {void} + */ protected handleMouseDown = (nativeEvent: GraphMouseEvent) => { const target = nativeEvent.detail.target; const event = extractNativeGraphMouseEvent(nativeEvent); if (!event || !target || !this.root?.ownerDocument) { return; } + const useBlocksAnchors = this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors"); if ( this.enabled && - ((this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors") && target instanceof Anchor) || - (isShiftKeyEvent(event) && isBlock(target))) + ((useBlocksAnchors && target instanceof Anchor) || (isShiftKeyEvent(event) && isBlock(target))) ) { // Get the source component state const sourceComponent = target.connectedState; @@ -198,20 +232,46 @@ export class ConnectionLayer extends Layer< nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - dragListener(this.root.ownerDocument) - .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)) - ); + + const connectionHandler: DragHandler = { + onDragStart: (dStartEvent: MouseEvent, dragInfo: DragInfo) => { + this.onStartConnection(dStartEvent, new Point(dragInfo.startCameraX, dragInfo.startCameraY)); + }, + onDragUpdate: (dUpdateEvent: MouseEvent, dragInfo: DragInfo) => { + this.onMoveNewConnection( + dUpdateEvent, + dragInfo.adjustedEntityPosition, + dragInfo.context?.closestTarget as Block | Anchor + ); + }, + onDragEnd: (dEndEvent: MouseEvent, dragInfo: DragInfo) => { + this.onEndNewConnection(dragInfo.adjustedEntityPosition, dragInfo.context?.closestTarget as Block | Anchor); + }, + }; + + this.magneticModifier.setParams({ + magnetismDistance: this.props.magnetismDistance || 80, + targets: useBlocksAnchors ? [Anchor] : [Block], + filter: (element: GraphComponent) => { + if (element === target) { + return false; + } + if (useBlocksAnchors) { + if (target instanceof Anchor && element instanceof Anchor) { + // Anchors with same type can't be connected (IN and IN or OUT and OUT) + return target.connectedState.state.type !== element.connectedState.state.type; + } + } + }, + }); + + this.context.graph.dragController.start(connectionHandler, event, { + positionModifiers: [this.magneticModifier], + }); } }; - protected renderEndpoint(ctx: CanvasRenderingContext2D) { + protected renderEndpoint(ctx: CanvasRenderingContext2D, endCanvasX: number, endCanvasY: number) { ctx.beginPath(); if (!this.target && this.props.createIcon) { renderSVG( @@ -223,7 +283,7 @@ export class ConnectionLayer extends Layer< initialHeight: this.props.createIcon.viewHeight, }, ctx, - { x: this.connectionState.tx, y: this.connectionState.ty - 12, width: 24, height: 24 } + { x: endCanvasX, y: endCanvasY - 12, width: 24, height: 24 } ); } else if (this.props.point) { ctx.fillStyle = this.props.point.fill || this.context.colors.canvas.belowLayerBackground; @@ -240,7 +300,7 @@ export class ConnectionLayer extends Layer< initialHeight: this.props.point.viewHeight, }, ctx, - { x: this.connectionState.tx, y: this.connectionState.ty - 12, width: 24, height: 24 } + { x: endCanvasX, y: endCanvasY - 12, width: 24, height: 24 } ); } ctx.closePath(); @@ -252,10 +312,18 @@ export class ConnectionLayer extends Layer< return; } + const scale = this.context.camera.getCameraScale(); + const cameraRect = this.context.camera.getCameraRect(); + + const startCanvasX = this.connectionState.sx * scale + cameraRect.x; + const startCanvasY = this.connectionState.sy * scale + cameraRect.y; + const endCanvasX = this.connectionState.tx * scale + cameraRect.x; + const endCanvasY = this.connectionState.ty * scale + cameraRect.y; + if (this.props.drawLine) { const { path, style } = this.props.drawLine( - { x: this.connectionState.sx, y: this.connectionState.sy }, - { x: this.connectionState.tx, y: this.connectionState.ty } + { x: startCanvasX, y: startCanvasY }, + { x: endCanvasX, y: endCanvasY } ); this.context.ctx.strokeStyle = style.color; @@ -264,14 +332,14 @@ export class ConnectionLayer extends Layer< } else { this.context.ctx.beginPath(); this.context.ctx.strokeStyle = this.context.colors.connection.selectedBackground; - this.context.ctx.moveTo(this.connectionState.sx, this.connectionState.sy); - this.context.ctx.lineTo(this.connectionState.tx, this.connectionState.ty); + this.context.ctx.moveTo(startCanvasX, startCanvasY); + this.context.ctx.lineTo(endCanvasX, endCanvasY); this.context.ctx.stroke(); this.context.ctx.closePath(); } render(this.context.ctx, (ctx) => { - this.renderEndpoint(ctx); + this.renderEndpoint(ctx, endCanvasX, endCanvasY); }); } @@ -298,11 +366,10 @@ export class ConnectionLayer extends Layer< this.sourceComponent = sourceComponent.connectedState; - const xy = getXY(this.context.graphCanvas, event); this.connectionState = { ...this.connectionState, - sx: xy[0], - sy: xy[1], + sx: point.x, + sy: point.y, }; this.context.graph.executеDefaultEventAction( @@ -326,14 +393,12 @@ export class ConnectionLayer extends Layer< this.performRender(); } - private onMoveNewConnection(event: MouseEvent, point: Point) { - const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); - const xy = getXY(this.context.graphCanvas, event); - + private onMoveNewConnection(event: MouseEvent, point: Point, newTargetComponent?: Block | Anchor) { + // Update connection state with adjusted position from magnetism this.connectionState = { ...this.connectionState, - tx: xy[0], - ty: xy[1], + tx: point.x, + ty: point.y, }; this.performRender(); @@ -344,6 +409,7 @@ export class ConnectionLayer extends Layer< } // Only process if the target has changed or if there was no previous target + // Also ensure we're not targeting the source component if ( (!this.target || this.target.connectedState !== newTargetComponent.connectedState) && newTargetComponent.connectedState !== this.sourceComponent @@ -370,12 +436,11 @@ export class ConnectionLayer extends Layer< } } - private onEndNewConnection(point: Point) { + private onEndNewConnection(point: Point, targetComponent?: Block | Anchor) { if (!this.sourceComponent) { return; } - const targetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); this.connectionState = { sx: 0, sy: 0, diff --git a/src/components/canvas/layers/graphLayer/GraphLayer.ts b/src/components/canvas/layers/graphLayer/GraphLayer.ts index 917e7ccf..d2b07d74 100644 --- a/src/components/canvas/layers/graphLayer/GraphLayer.ts +++ b/src/components/canvas/layers/graphLayer/GraphLayer.ts @@ -2,7 +2,7 @@ import { Graph } from "../../../../graph"; import { GraphMouseEventNames, isNativeGraphEventName } from "../../../../graphEvents"; import { Component } from "../../../../lib/Component"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; -import { Camera, TCameraProps } from "../../../../services/camera/Camera"; +import { Camera, TCameraProps, TEdgePanningConfig } from "../../../../services/camera/Camera"; import { ICamera } from "../../../../services/camera/CameraService"; import { getEventDelta } from "../../../../utils/functions"; import { EventedComponent } from "../../EventedComponent/EventedComponent"; @@ -113,6 +113,14 @@ export class GraphLayer extends Layer { super.afterInit(); } + public enableEdgePanning(config: Partial = {}): void { + this.$.camera.enableEdgePanning(config); + } + + public disableEdgePanning(): void { + this.$.camera.disableEdgePanning(); + } + /** * Attaches DOM event listeners to the root element. * All event listeners are registered with the rootOn wrapper method to ensure they are properly cleaned up diff --git a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts index 7ff617ac..64750f84 100644 --- a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts +++ b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts @@ -1,11 +1,11 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; +import { DragHandler } from "../../../../services/Drag/DragController"; +import { DragInfo } from "../../../../services/Drag/DragInfo"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; 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 { Point, TPoint } from "../../../../utils/types/shapes"; import { ESelectionStrategy } from "../../../../utils/types/types"; import { Block } from "../../../canvas/blocks/Block"; @@ -110,12 +110,15 @@ export class NewBlockLayer extends Layer< nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - dragListener(this.root.ownerDocument) - .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)) - ); + + const newBlockHandler: DragHandler = { + onDragStart: (dragEvent: MouseEvent, _dragInfo: DragInfo) => this.onStartNewBlock(dragEvent, target), + onDragUpdate: (dragEvent: MouseEvent, _dragInfo: DragInfo) => this.onMoveNewBlock(dragEvent), + onDragEnd: (dragEvent: MouseEvent, dragInfo: DragInfo) => + this.onEndNewBlock(dragEvent, new Point(dragInfo.lastCameraX as number, dragInfo.lastCameraY as number)), + }; + + this.context.graph.dragController.start(newBlockHandler, event); } }; diff --git a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts index 1a1071db..91f08742 100644 --- a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts +++ b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts @@ -1,10 +1,10 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; +import { DragHandler } from "../../../../services/Drag/DragController"; +import { DragInfo } from "../../../../services/Drag/DragInfo"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { selectBlockList } from "../../../../store/block/selectors"; -import { getXY, isBlock, isMetaKeyEvent } from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; +import { isBlock, isMetaKeyEvent } from "../../../../utils/functions"; import { render } from "../../../../utils/renderers/render"; -import { EVENTS } from "../../../../utils/types/events"; import { TRect } from "../../../../utils/types/shapes"; import { Anchor } from "../../anchors"; import { Block } from "../../blocks/Block"; @@ -39,6 +39,10 @@ export class SelectionLayer extends Layer< this.setContext({ canvas: this.getCanvas(), ctx: this.getCanvas().getContext("2d"), + camera: props.camera, + constants: this.props.graph.graphConstants, + colors: this.props.graph.graphColors, + graph: this.props.graph, }); } @@ -46,14 +50,13 @@ export class SelectionLayer extends Layer< * Called after initialization and when the layer is reattached. * This is where we set up event subscriptions to ensure they work properly * after the layer is unmounted and reattached. + * @returns {void} */ protected afterInit(): void { - // Set up event handlers here instead of in constructor this.onGraphEvent("mousedown", this.handleMouseDown, { capture: true, }); - // Call parent afterInit to ensure proper initialization super.afterInit(); } @@ -74,13 +77,16 @@ export class SelectionLayer extends Layer< ctx.fillStyle = this.context.colors.selection.background; ctx.strokeStyle = this.context.colors.selection.border; ctx.beginPath(); - ctx.roundRect( - this.selection.x, - this.selection.y, - this.selection.width, - this.selection.height, - Number(this.context.graph.layers.getDPR()) - ); + + const scale = this.context.camera.getCameraScale(); + const cameraRect = this.context.camera.getCameraRect(); + + const canvasX = this.selection.x * scale + cameraRect.x; + const canvasY = this.selection.y * scale + cameraRect.y; + const canvasWidth = this.selection.width * scale; + const canvasHeight = this.selection.height * scale; + + ctx.roundRect(canvasX, canvasY, canvasWidth, canvasHeight, Number(this.context.graph.layers.getDPR())); ctx.closePath(); ctx.fill(); @@ -102,40 +108,42 @@ export class SelectionLayer extends Layer< if (event && isMetaKeyEvent(event)) { nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - dragListener(this.root.ownerDocument) - .on(EVENTS.DRAG_START, this.startSelectionRender) - .on(EVENTS.DRAG_UPDATE, this.updateSelectionRender) - .on(EVENTS.DRAG_END, this.endSelectionRender); + + const selectionHandler: DragHandler = { + onDragStart: (dragEvent: MouseEvent, dragInfo: DragInfo) => this.startSelectionRender(dragEvent, dragInfo), + onDragUpdate: (dragEvent: MouseEvent, dragInfo: DragInfo) => this.updateSelectionRender(dragEvent, dragInfo), + onDragEnd: (dragEvent: MouseEvent, dragInfo: DragInfo) => this.endSelectionRender(dragEvent, dragInfo), + }; + + this.context.graph.dragController.start(selectionHandler, event); } }; - private updateSelectionRender = (event: MouseEvent) => { - const [x, y] = getXY(this.context.canvas, event); - this.selection.width = x - this.selection.x; - this.selection.height = y - this.selection.y; + private updateSelectionRender = (event: MouseEvent, dragInfo: DragInfo) => { + // Используем готовые координаты из dragInfo + this.selection.width = (dragInfo.lastCameraX as number) - this.selection.x; + this.selection.height = (dragInfo.lastCameraY as number) - this.selection.y; this.performRender(); }; - private startSelectionRender = (event: MouseEvent) => { - const [x, y] = getXY(this.context.canvas, event); - this.selection.x = x; - this.selection.y = y; + private startSelectionRender = (event: MouseEvent, dragInfo: DragInfo) => { + // Используем готовые координаты из dragInfo + this.selection.x = dragInfo.startCameraX; + this.selection.y = dragInfo.startCameraY; }; - private endSelectionRender = (event: MouseEvent) => { + private endSelectionRender = (event: MouseEvent, dragInfo: DragInfo) => { if (this.selection.width === 0 && this.selection.height === 0) { return; } - const [x, y] = getXY(this.context.canvas, event); - const selectionRect = getSelectionRect(this.selection.x, this.selection.y, x, y); - const cameraRect = this.context.graph.cameraService.applyToRect( - selectionRect[0], - selectionRect[1], - selectionRect[2], - selectionRect[3] - ); - this.applySelectedArea(cameraRect[0], cameraRect[1], cameraRect[2], cameraRect[3]); + // Используем готовые координаты из dragInfo + const endX = dragInfo.lastCameraX as number; + const endY = dragInfo.lastCameraY as number; + + const selectionRect = getSelectionRect(this.selection.x, this.selection.y, endX, endY); + + this.applySelectedArea(selectionRect[0], selectionRect[1], selectionRect[2], selectionRect[3]); this.selection.width = 0; this.selection.height = 0; this.performRender(); diff --git a/src/examples/DragMiddlewareExample.ts b/src/examples/DragMiddlewareExample.ts new file mode 100644 index 00000000..26157d7c --- /dev/null +++ b/src/examples/DragMiddlewareExample.ts @@ -0,0 +1,85 @@ +import { Layer } from "../services/Layer"; +import { Graph } from "../graph"; +import { Block } from "../components/canvas/blocks/Block"; +import { IDragMiddleware, DragModifier, DragInfo, DragContext } from "../services/Drag/DragInfo"; +import { Point } from "../utils/types/shapes"; + +/** + * Example implementation of a layer that provides drag middleware + * similar to the user's request + */ +export class ExampleLayer extends Layer implements IDragMiddleware { + private lines: Array<{ x: number; y: number; width: number; height: number }> = []; + + public dragModifier(): DragModifier { + return { + name: "exampleLayer", + priority: 8, + applicable: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => { + // Check if we're dragging a Block and if there are nearby borders + return ctx.stage === "dragging" && "dragEntity" in ctx && ctx.dragEntity instanceof Block; + }, + suggest: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => { + // Example: snap to grid or borders + const snappedX = Math.round(pos.x / 20) * 20; + const snappedY = Math.round(pos.y / 20) * 20; + return new Point(snappedX, snappedY); + }, + onApply: (dragInfo: DragInfo, ctx: DragContext) => { + // Update visual indicators when this modifier is applied + console.log("Example modifier applied during drag"); + + // In real implementation, you might update visual state here + if ("closestBorder" in ctx && Array.isArray(ctx.closestBorder)) { + this.lines = ctx.closestBorder.map((border: any) => ({ + x: border.point.x, + y: border.point.y, + width: 10, + height: 10, + })); + } + }, + }; + } + + public afterInit(): void { + // Subscribe to drag events to manage modifiers dynamically + this.context.graph.on("drag-start", (event) => { + const { dragInfo } = event.detail; + + // Add our modifier if we're dragging a Block + if (dragInfo.context.dragEntity instanceof Block) { + dragInfo.addModifier(this.dragModifier()); + } + }); + + this.context.graph.on("drag-update", (event) => { + const { dragInfo } = event.detail; + + // Check if our modifier is applied and update visual state + if (dragInfo.isApplied(this.dragModifier())) { + // Update visual indicators based on current drag state + console.log("Our modifier is currently applied"); + + // In real implementation, you might call setState or similar + // this.setState({ lines: this.lines }); + } + }); + + this.context.graph.on("drag-end", (event) => { + const { dragInfo } = event.detail; + + // Clean up: remove our modifier after drag ends + dragInfo.removeModifier(this.dragModifier().name); + + // Clear visual indicators + this.lines = []; + // this.setState({ lines: [] }); + }); + } + + // Example of how to access the current lines state + public getLines(): Array<{ x: number; y: number; width: number; height: number }> { + return this.lines; + } +} \ No newline at end of file diff --git a/src/graph.ts b/src/graph.ts index 93114032..0589003b 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -10,6 +10,7 @@ import { SelectionLayer } from "./components/canvas/layers/selectionLayer/Select import { TGraphColors, TGraphConstants, initGraphColors, initGraphConstants } from "./graphConfig"; import { GraphEventParams, GraphEventsDefinitions } from "./graphEvents"; import { scheduler } from "./lib/Scheduler"; +import { DragController } from "./services/Drag/DragController"; import { HitTest } from "./services/HitTest"; import { Layer } from "./services/Layer"; import { Layers } from "./services/LayersService"; @@ -63,6 +64,8 @@ export class Graph { public hitTest = new HitTest(); + public dragController = new DragController(this); + protected graphLayer: GraphLayer; protected belowLayer: BelowLayer; @@ -383,6 +386,7 @@ export class Graph { * In order to initialize hitboxes we need to start scheduler and wait untils every component registered in hitTest service * Immediatelly after registering startign a rendering process. * @param cb - Callback to run after graph is ready + * @returns void */ public runAfterGraphReady(cb: () => void) { this.hitTest.waitUsableRectUpdate(cb); diff --git a/src/graphConfig.ts b/src/graphConfig.ts index fc621eef..e196e759 100644 --- a/src/graphConfig.ts +++ b/src/graphConfig.ts @@ -95,6 +95,13 @@ export type TGraphConstants = { SPEED: number; /* Step on camera scale */ STEP: number; + /* Edge panning settings */ + EDGE_PANNING: { + /* Size of edge detection zone in pixels */ + EDGE_SIZE: number; + /* Speed of camera movement during edge panning */ + SPEED: number; + }; }; block: { @@ -140,6 +147,10 @@ export const initGraphConstants: TGraphConstants = { camera: { SPEED: 1, STEP: 0.008, + EDGE_PANNING: { + EDGE_SIZE: 150, + SPEED: 15, + }, }, block: { WIDTH_MIN: 16 * 10, diff --git a/src/graphEvents.ts b/src/graphEvents.ts index bd6e69e4..3c596db8 100644 --- a/src/graphEvents.ts +++ b/src/graphEvents.ts @@ -2,6 +2,7 @@ import { EventedComponent } from "./components/canvas/EventedComponent/EventedCo import { GraphState } from "./graph"; import { TGraphColors, TGraphConstants } from "./graphConfig"; import { TCameraState } from "./services/camera/CameraService"; +import { DragInfo } from "./services/Drag/DragInfo"; export type GraphMouseEvent = CustomEvent<{ target?: EventedComponent; @@ -41,6 +42,9 @@ export interface GraphEventsDefinitions extends BaseGraphEventDefinition { "constants-changed": (event: CustomEvent<{ constants: TGraphConstants }>) => void; "colors-changed": (event: CustomEvent<{ colors: TGraphColors }>) => void; "state-change": (event: CustomEvent<{ state: GraphState }>) => void; + "drag-start": (event: CustomEvent<{ dragInfo: DragInfo }>) => void; + "drag-update": (event: CustomEvent<{ dragInfo: DragInfo }>) => void; + "drag-end": (event: CustomEvent<{ dragInfo: DragInfo }>) => void; } const graphMouseEvents = ["mousedown", "click", "dblclick", "mouseenter", "mousemove", "mouseleave"]; diff --git a/src/index.ts b/src/index.ts index 3943bf73..3d089994 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,16 @@ export type { TGraphColors, TGraphConstants } from "./graphConfig"; export { type UnwrapGraphEventsDetail } from "./graphEvents"; export * from "./plugins"; export { ECameraScaleLevel } from "./services/camera/CameraService"; +export { DragController, type DragHandler, type DragControllerConfig } from "./services/Drag/DragController"; +export { + DragInfo, + type PositionModifier, + type DragModifier, + type IDragMiddleware, + type DragContext, + type ModifierSuggestion, + type DragStage, +} from "./services/Drag/DragInfo"; export * from "./services/Layer"; export * from "./store"; export { EAnchorType } from "./store/anchor/Anchor"; @@ -24,4 +34,5 @@ export * from "./components/canvas/groups"; export * from "./components/canvas/layers/newBlockLayer/NewBlockLayer"; export * from "./components/canvas/layers/connectionLayer/ConnectionLayer"; +export * from "./components/canvas/layers/alignmentLayer"; export * from "./lib/Component"; diff --git a/src/services/Drag/DragController.ts b/src/services/Drag/DragController.ts new file mode 100644 index 00000000..b0828bfc --- /dev/null +++ b/src/services/Drag/DragController.ts @@ -0,0 +1,304 @@ +import { Graph } from "../../graph"; +import { ESchedulerPriority, scheduler } from "../../lib/Scheduler"; +import { dragListener } from "../../utils/functions/dragListener"; +import { EVENTS } from "../../utils/types/events"; + +import { DragInfo, DragModifier, PositionModifier } from "./DragInfo"; + +/** + * Interface for components that can be dragged + */ +export interface DragHandler { + /** + * Called before position update to select a modifier + * @param dragInfo - Stateful model with drag information + */ + beforeUpdate?(dragInfo: DragInfo): void; + + /** + * Called when dragging starts + * @param event - Mouse event + * @param dragInfo - Stateful model with drag information + */ + onDragStart(event: MouseEvent, dragInfo: DragInfo): void; + + /** + * Called when position is updated during dragging + * @param event - Mouse event + * @param dragInfo - Stateful model with drag information + */ + onDragUpdate(event: MouseEvent, dragInfo: DragInfo): void; + + /** + * Called when dragging ends + * @param event - Mouse event + * @param dragInfo - Stateful model with drag information + */ + onDragEnd(event: MouseEvent, dragInfo: DragInfo): void; +} + +/** + * Configuration for DragController + */ +export interface DragControllerConfig { + /** Enable automatic camera movement when approaching edges */ + enableEdgePanning?: boolean; + /** Position modifiers for coordinate correction during dragging */ + positionModifiers?: (PositionModifier | DragModifier)[]; + /** Additional context to pass to modifiers */ + context?: Record; + /** Initial position of the dragged entity in camera space */ + initialEntityPosition?: { x: number; y: number }; +} + +/** + * Centralized controller for managing component dragging + */ +export class DragController { + private graph: Graph; + + private currentDragHandler?: DragHandler; + + private isDragging = false; + + private lastMouseEvent?: MouseEvent; + + private dragInfo: DragInfo; + + private updateScheduler?: () => void; + + constructor(graph: Graph) { + this.graph = graph; + } + + /** + * Starts the dragging process for the specified component + * @param component - Component that will be dragged + * @param event - Initial mouse event + * @param config - Drag configuration + * @returns void + */ + public start(component: DragHandler, event: MouseEvent, config: DragControllerConfig = {}): void { + if (this.isDragging) { + // eslint-disable-next-line no-console + console.warn("DragController: attempt to start dragging while already dragging"); + return; + } + + this.currentDragHandler = component; + this.isDragging = true; + this.lastMouseEvent = event; + + // Create DragInfo with modifiers, context and initialize + this.dragInfo = new DragInfo( + this.graph, + config.positionModifiers || [], + config.context, + config.initialEntityPosition + ); + this.dragInfo.init(event); + + if (config.enableEdgePanning ?? true) { + const defaultConfig = this.graph.graphConstants.camera.EDGE_PANNING; + + this.graph.getGraphLayer().enableEdgePanning({ + speed: defaultConfig.SPEED, + edgeSize: defaultConfig.EDGE_SIZE, + }); + + // Start periodic component updates for synchronization with camera movement + this.startContinuousUpdate(); + } + + component.onDragStart(event, this.dragInfo); + + // Emit drag-start event + this.graph.emit("drag-start", { dragInfo: this.dragInfo }); + + this.startDragListener(event); + } + + /** + * Updates the dragging state + * @param event - Mouse event + * @returns void + */ + public update(event: MouseEvent): void { + if (!this.isDragging || !this.currentDragHandler) { + return; + } + + this.lastMouseEvent = event; + + // Update DragInfo state + this.dragInfo.update(event); + + // Analyze position modifiers + this.dragInfo.analyzeSuggestions(); + + // Give opportunity to select modifier in beforeUpdate + if (this.currentDragHandler.beforeUpdate) { + this.currentDragHandler.beforeUpdate(this.dragInfo); + } else { + // Default strategy - by distance + this.dragInfo.selectDefault(); + } + + this.currentDragHandler.onDragUpdate(event, this.dragInfo); + + // Emit drag-update event + this.graph.emit("drag-update", { dragInfo: this.dragInfo }); + } + + /** + * Ends the dragging process + * @param event - Mouse event + * @returns void + */ + public end(event: MouseEvent): void { + if (!this.isDragging || !this.currentDragHandler) { + return; + } + + // TODO: Need to pass EventedComponent instead of DragController + // this.graph.getGraphLayer().releaseCapture(); + + // Stop continuous updates + this.stopContinuousUpdate(); + + // Disable edge panning + this.graph.getGraphLayer().disableEdgePanning(); + + // Complete the process in DragInfo (sets stage to 'drop') + this.dragInfo.end(event); + + // Analyze modifiers at 'drop' stage + this.dragInfo.analyzeSuggestions(); + + // Give opportunity to select modifier at drop stage + if (this.currentDragHandler.beforeUpdate) { + this.currentDragHandler.beforeUpdate(this.dragInfo); + } else { + // Default strategy - by distance + this.dragInfo.selectDefault(); + } + + // Call onDragUpdate with final positions (at 'drop' stage) + this.currentDragHandler.onDragUpdate(event, this.dragInfo); + + // Then call the drag end handler + this.currentDragHandler.onDragEnd(event, this.dragInfo); + + // Emit drag-end event + this.graph.emit("drag-end", { dragInfo: this.dragInfo }); + + // Reset state + this.currentDragHandler = undefined; + this.isDragging = false; + this.lastMouseEvent = undefined; + this.dragInfo.reset(); + } + + /** + * Checks if dragging is currently in progress + * @returns true if dragging is in progress + */ + public isDragInProgress(): boolean { + return this.isDragging; + } + + /** + * Gets the current dragged component + * @returns current DragHandler or undefined + */ + public getCurrentDragHandler(): DragHandler | undefined { + return this.currentDragHandler; + } + + /** + * Gets current drag information + * @returns DragInfo instance (always available) + */ + public getCurrentDragInfo(): DragInfo { + return this.dragInfo; + } + + /** + * Starts continuous component updates for synchronization with camera movement + * @returns void + */ + private startContinuousUpdate(): void { + if (this.updateScheduler) { + return; + } + + const update = () => { + if (!this.isDragging || !this.currentDragHandler || !this.lastMouseEvent) { + return; + } + + // Create synthetic mouse event with current coordinates + // This allows components to update their position during camera movement + // even when physical mouse movement doesn't occur + const syntheticEvent = new MouseEvent("mousemove", { + clientX: this.lastMouseEvent.clientX, + clientY: this.lastMouseEvent.clientY, + bubbles: false, + cancelable: false, + }); + + // Copy pageX/pageY manually since they're not in MouseEventInit + Object.defineProperty(syntheticEvent, "pageX", { value: this.lastMouseEvent.pageX }); + Object.defineProperty(syntheticEvent, "pageY", { value: this.lastMouseEvent.pageY }); + + // Update DragInfo state for synthetic event + this.dragInfo.update(this.lastMouseEvent); + + this.currentDragHandler.onDragUpdate(syntheticEvent, this.dragInfo); + }; + + // Use medium priority for updates to synchronize with camera movement + this.updateScheduler = scheduler.addScheduler({ performUpdate: update }, ESchedulerPriority.MEDIUM); + } + + /** + * Stops continuous updates + * @returns void + */ + private stopContinuousUpdate(): void { + if (this.updateScheduler) { + this.updateScheduler(); + this.updateScheduler = undefined; + } + } + + /** + * Starts dragListener to track mouse events + * @param _initialEvent - Initial mouse event (unused) + * @returns void + */ + private startDragListener(_initialEvent: MouseEvent): void { + const ownerDocument = this.graph.getGraphCanvas().ownerDocument; + + dragListener(ownerDocument) + .on(EVENTS.DRAG_START, (event: MouseEvent) => { + this.lastMouseEvent = event; + }) + .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => { + this.update(event); + }) + .on(EVENTS.DRAG_END, (event: MouseEvent) => { + this.end(event); + }); + } + + /** + * Forcibly ends current dragging (e.g., during unmounting) + * @returns void + */ + public forceEnd(): void { + if (this.isDragging && this.lastMouseEvent) { + this.end(this.lastMouseEvent); + } + } +} diff --git a/src/services/Drag/DragInfo.ts b/src/services/Drag/DragInfo.ts new file mode 100644 index 00000000..4c96a07a --- /dev/null +++ b/src/services/Drag/DragInfo.ts @@ -0,0 +1,730 @@ +import { Graph } from "../../graph"; +import { Point } from "../../utils/types/shapes"; + +/** + * Drag lifecycle stages + */ +export type DragStage = "start" | "dragging" | "drop"; + +/** + * Interface for position modifier during dragging + */ +export interface PositionModifier { + name: string; + priority: number; + + /** Checks if the modifier is applicable for the given position */ + applicable: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => boolean; + + /** Suggests a new position (lazy evaluation) */ + suggest: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => Point | null; +} + +/** + * Extended drag modifier with additional lifecycle hooks + */ +export interface DragModifier extends PositionModifier { + /** Called when modifier is applied during dragging */ + onApply?: (dragInfo: DragInfo, ctx: DragContext) => void; +} + +/** + * Interface for layers and components that can provide drag modifiers + */ +export interface IDragMiddleware { + /** Returns a drag modifier for this middleware */ + dragModifier(): DragModifier; +} + +/** + * Context for drag modifiers + */ +export interface DragContext { + graph: Graph; + currentPosition: Point; + stage: DragStage; + [key: string]: unknown; +} + +/** + * Modifier suggestion with lazy evaluation + */ +export interface ModifierSuggestion { + name: string; + priority: number; + distance: number | null; + + /** Gets the suggested position (with caching) */ + getSuggestedPosition(): Point | null; + + /** @private Lazy calculation function */ + _suggester: () => Point | null; + + /** @private Position cache */ + _cachedPosition?: Point | null; +} + +/** + * Stateful model for storing drag process information + * Uses lazy calculations through getters for optimal performance + */ +export class DragInfo { + protected initialEvent: MouseEvent | null = null; + protected currentEvent: MouseEvent | null = null; + + // Cache for camera coordinates + private _startCameraPoint: Point | null = null; + private _currentCameraPoint: Point | null = null; + + // Position modifier system + private modifiers: (PositionModifier | DragModifier)[] = []; + private suggestions: ModifierSuggestion[] = []; + private selectedModifier: string | null = null; + private appliedModifiers: Set = new Set(); + private contextCache: DragContext | null = null; + private customContext: Record; + + // Drag stage + private currentStage: DragStage = "start"; + + // Position of the dragged entity + private entityStartPosition: Point | null = null; + private mouseToEntityOffset: Point | null = null; + + constructor( + protected graph: Graph, + modifiers: (PositionModifier | DragModifier)[] = [], + customContext?: Record, + initialEntityPosition?: { x: number; y: number } + ) { + this.modifiers = modifiers; + this.customContext = customContext || {}; + + if (initialEntityPosition) { + this.entityStartPosition = new Point(initialEntityPosition.x, initialEntityPosition.y); + } + } + + /** + * Resets DragInfo state + * @returns void + */ + public reset(): void { + this.initialEvent = null; + this.currentEvent = null; + this._startCameraPoint = null; + this._currentCameraPoint = null; + this.suggestions = []; + this.selectedModifier = null; + this.appliedModifiers.clear(); + this.contextCache = null; + this.currentStage = "start"; // Return to initial stage + // Don't reset custom context as it's set during DragInfo creation + } + + /** + * Gets the current drag stage + */ + public get stage(): DragStage { + return this.currentStage; + } + + /** + * Initializes the initial drag state + * @param event - Initial mouse event + * @returns void + */ + public init(event: MouseEvent): void { + this.initialEvent = event; + this.currentEvent = event; + this._startCameraPoint = null; // Will be calculated lazily + this._currentCameraPoint = null; + this.currentStage = "start"; // Set initialization stage + + // Calculate offset between mouse and entity during initialization + if (this.entityStartPosition) { + const mouseStartPoint = this.graph.getPointInCameraSpace(event); + this.mouseToEntityOffset = new Point( + mouseStartPoint.x - this.entityStartPosition.x, + mouseStartPoint.y - this.entityStartPosition.y + ); + } + } + + public get context(): DragContext | null { + return this.getDragContext(); + } + + /** + * Updates the current drag state + * @param event - Current mouse event + * @returns void + */ + public update(event: MouseEvent): void { + this.currentEvent = event; + this._currentCameraPoint = null; // Reset cache for recalculation + this.currentStage = "dragging"; // Set active drag stage + this.contextCache = null; // Reset context cache to update stage + } + + /** + * Ends the drag process + * @param event - Final mouse event + * @returns void + */ + public end(event: MouseEvent): void { + this.currentEvent = event; + this._currentCameraPoint = null; // Final update + this.currentStage = "drop"; // Set completion stage + this.contextCache = null; // Reset context cache to update stage + } + + /** + * Updates custom context during drag operation + * @param newContext - New context data to merge with existing + * @returns void + */ + public updateContext(newContext: Record): void { + this.customContext = { ...this.customContext, ...newContext }; + this.contextCache = null; // Reset context cache for recalculation + } + + // === LAZY GETTERS FOR SCREEN COORDINATES === + + /** + * Initial X coordinates in screen space + */ + public get startX(): number { + return this.initialEvent?.clientX ?? 0; + } + + /** + * Initial Y coordinates in screen space + */ + public get startY(): number { + return this.initialEvent?.clientY ?? 0; + } + + /** + * Current X coordinates in screen space + */ + public get lastX(): number { + return this.currentEvent?.clientX ?? this.startX; + } + + /** + * Current Y coordinates in screen space + */ + public get lastY(): number { + return this.currentEvent?.clientY ?? this.startY; + } + + // === LAZY GETTERS FOR CAMERA COORDINATES === + + /** + * Initial coordinates in camera space + */ + protected get startCameraPoint(): Point { + if (!this._startCameraPoint && this.initialEvent) { + this._startCameraPoint = this.graph.getPointInCameraSpace(this.initialEvent); + } + return this._startCameraPoint ?? new Point(0, 0); + } + + /** + * Current coordinates in camera space + */ + protected get currentCameraPoint(): Point { + if (!this._currentCameraPoint && this.currentEvent) { + this._currentCameraPoint = this.graph.getPointInCameraSpace(this.currentEvent); + } + return this._currentCameraPoint ?? this.startCameraPoint; + } + + /** + * Initial X coordinate in camera space + */ + public get startCameraX(): number { + return this.startCameraPoint.x; + } + + /** + * Initial Y coordinate in camera space + */ + public get startCameraY(): number { + return this.startCameraPoint.y; + } + + /** + * Current X coordinate in camera space + */ + public get lastCameraX(): number { + return this.currentCameraPoint.x; + } + + /** + * Current Y coordinate in camera space + */ + public get lastCameraY(): number { + return this.currentCameraPoint.y; + } + + // === COMPUTED PROPERTIES === + + /** + * Coordinate difference in screen space + */ + public get screenDelta(): { x: number; y: number } { + return { + x: this.lastX - this.startX, + y: this.lastY - this.startY, + }; + } + + /** + * Coordinate difference in camera space + */ + public get worldDelta(): { x: number; y: number } { + return { + x: this.lastCameraX - this.startCameraX, + y: this.lastCameraY - this.startCameraY, + }; + } + + /** + * Drag distance in screen space + */ + public get screenDistance(): number { + const delta = this.screenDelta; + return Math.sqrt(delta.x * delta.x + delta.y * delta.y); + } + + /** + * Drag distance in camera space + */ + public get worldDistance(): number { + const delta = this.worldDelta; + return Math.sqrt(delta.x * delta.x + delta.y * delta.y); + } + + /** + * Drag direction in camera space + */ + public get worldDirection(): "horizontal" | "vertical" | "diagonal" | "none" { + const delta = this.worldDelta; + const deltaX = Math.abs(delta.x); + const deltaY = Math.abs(delta.y); + + if (deltaX < 3 && deltaY < 3) return "none"; + + const ratio = deltaX / deltaY; + if (ratio > 2) return "horizontal"; + if (ratio < 0.5) return "vertical"; + return "diagonal"; + } + + /** + * Checks if dragging is a micro-movement + * @param threshold - Distance threshold in pixels (default 5) + * @returns true if distance is less than threshold + */ + public isMicroDrag(threshold = 5): boolean { + return this.worldDistance < threshold; + } + + /** + * Drag duration in milliseconds + */ + public get duration(): number { + if (!this.initialEvent || !this.currentEvent) return 0; + return this.currentEvent.timeStamp - this.initialEvent.timeStamp; + } + + /** + * Drag velocity in pixels per millisecond + */ + public get velocity(): { vx: number; vy: number } { + const duration = this.duration; + if (duration <= 0) return { vx: 0, vy: 0 }; + + const delta = this.worldDelta; + return { + vx: delta.x / duration, + vy: delta.y / duration, + }; + } + + /** + * Initial mouse event + */ + public get initialMouseEvent(): MouseEvent | null { + return this.initialEvent; + } + + /** + * Current mouse event + */ + public get currentMouseEvent(): MouseEvent | null { + return this.currentEvent; + } + + /** + * Checks if DragInfo is initialized + */ + public get isInitialized(): boolean { + return this.initialEvent !== null; + } + + /** + * Checks if there's movement since initialization + */ + public get hasMovement(): boolean { + return this.currentEvent !== this.initialEvent; + } + + // === POSITION MODIFIER SYSTEM === + + /** + * Analyzes all modifiers and creates suggestions + * @returns void + */ + public analyzeSuggestions(): void { + if (this.modifiers.length === 0) { + this.suggestions = []; + return; + } + + // Use entity position for modifiers, not mouse position + const entityPos = this.currentEntityPosition; + const context = this.getDragContext(); + + this.suggestions = this.modifiers + .filter((m) => m.applicable(entityPos, this, context)) + .map((modifier) => this.createSuggestion(modifier, entityPos, context)); + } + + /** + * Creates a lazy modifier suggestion + * @param modifier - Position modifier + * @param pos - Initial position + * @param ctx - Drag context + * @returns Suggestion with lazy evaluation + */ + private createSuggestion(modifier: PositionModifier, pos: Point, ctx: DragContext): ModifierSuggestion { + return { + name: modifier.name, + priority: modifier.priority, + distance: null, // Lazy evaluation + _suggester: () => modifier.suggest(pos, this, ctx), + _cachedPosition: undefined, + + getSuggestedPosition(): Point | null { + if (this._cachedPosition === undefined) { + this._cachedPosition = this._suggester(); + } + return this._cachedPosition; + }, + }; + } + + /** + * Applies a modifier by name + * @param name - Modifier name + * @returns void + * @private + */ + private applyModifier(name: string | null): void { + if (name) { + this.appliedModifiers.add(name); + + // Call onApply for DragModifier if available + const modifier = this.modifiers.find((m) => m.name === name); + if (modifier && "onApply" in modifier && modifier.onApply) { + modifier.onApply(this, this.getDragContext()); + } + } + } + + /** + * Selects modifier by priority (first with lowest priority) + * @returns void + */ + public selectByPriority(): void { + const best = this.suggestions.sort((a, b) => a.priority - b.priority)[0]; + this.selectedModifier = best?.name || null; + this.applyModifier(this.selectedModifier); + } + + /** + * Selects modifier by distance (closest to original position) + * @returns void + */ + public selectByDistance(): void { + const withDistances = this.suggestions + .map((s) => ({ + ...s, + distance: this.calculateDistance(s), + })) + .sort((a, b) => a.distance - b.distance); + + this.selectedModifier = withDistances[0]?.name || null; + this.applyModifier(this.selectedModifier); + } + + /** + * Selects modifier using custom function + * @param selector - Modifier selection function + * @returns void + */ + public selectByCustom(selector: (suggestions: ModifierSuggestion[]) => string | null): void { + this.selectedModifier = selector(this.suggestions); + this.applyModifier(this.selectedModifier); + } + + /** + * Selects specific modifier by name + * @param name - Modifier name + * @returns void + */ + public selectModifier(name: string): void { + if (this.suggestions.some((s) => s.name === name)) { + this.selectedModifier = name; + this.applyModifier(name); + } + } + + /** + * Selects default modifier (by distance) + * @returns void + */ + public selectDefault(): void { + this.selectByDistance(); + } + + /** + * Calculates distance from original to suggested position + * @param suggestion - Modifier suggestion + * @returns Distance in pixels + */ + private calculateDistance(suggestion: ModifierSuggestion): number { + const original = new Point(this.lastCameraX, this.lastCameraY); + const suggested = suggestion.getSuggestedPosition(); + + if (!suggested) return Infinity; + + return Math.sqrt((suggested.x - original.x) ** 2 + (suggested.y - original.y) ** 2); + } + + /** + * Checks if modifier with specified name is applicable + * @param modifierName - Modifier name + * @returns true if modifier is applicable + */ + public isApplicable(modifierName: string): boolean { + return this.suggestions.some((s) => s.name === modifierName); + } + + /** + * Checks if modifier with specified name is applied + * @param modifierName - Modifier name + * @returns true if modifier is applied + */ + public isModified(modifierName: string): boolean { + return this.selectedModifier === modifierName; + } + + /** + * Gets adjusted position considering applied modifier + */ + public get adjustedPosition(): Point { + if (!this.selectedModifier) { + return new Point(this.lastCameraX, this.lastCameraY); + } + + const suggestion = this.suggestions.find((s) => s.name === this.selectedModifier); + const adjustedPos = suggestion?.getSuggestedPosition(); + + return adjustedPos || new Point(this.lastCameraX, this.lastCameraY); + } + + /** + * Gets adjusted X coordinate + */ + public get adjustedCameraX(): number { + return this.adjustedPosition.x; + } + + /** + * Gets adjusted Y coordinate + */ + public get adjustedCameraY(): number { + return this.adjustedPosition.y; + } + + // === ENTITY POSITION === + + /** + * Initial entity position + */ + public get entityStartX(): number { + return this.entityStartPosition?.x ?? 0; + } + + /** + * Initial entity position + */ + public get entityStartY(): number { + return this.entityStartPosition?.y ?? 0; + } + + /** + * Current entity position (without modifiers) + */ + public get currentEntityPosition(): Point { + if (!this.entityStartPosition || !this.mouseToEntityOffset) { + // Fallback to mouse position if no entity data + return new Point(this.lastCameraX, this.lastCameraY); + } + + const currentMousePos = new Point(this.lastCameraX, this.lastCameraY); + return new Point(currentMousePos.x - this.mouseToEntityOffset.x, currentMousePos.y - this.mouseToEntityOffset.y); + } + + /** + * Adjusted entity position considering modifiers + */ + public get adjustedEntityPosition(): Point { + if (!this.selectedModifier) { + return this.currentEntityPosition; + } + + const suggestion = this.suggestions.find((s) => s.name === this.selectedModifier); + const adjustedPos = suggestion?.getSuggestedPosition(); + + return adjustedPos || this.currentEntityPosition; + } + + /** + * Adjusted entity X coordinate + */ + public get adjustedEntityX(): number { + return this.adjustedEntityPosition.x; + } + + /** + * Adjusted entity Y coordinate + */ + public get adjustedEntityY(): number { + return this.adjustedEntityPosition.y; + } + + /** + * Delta between initial and adjusted entity position + * Used to apply the same delta to other entities + */ + public get adjustedWorldDelta(): { x: number; y: number } { + if (!this.entityStartPosition) { + return { x: 0, y: 0 }; + } + + const adjustedPos = this.adjustedEntityPosition; + return { + x: adjustedPos.x - this.entityStartPosition.x, + y: adjustedPos.y - this.entityStartPosition.y, + }; + } + + /** + * Applies adjusted delta to arbitrary starting position + * @param startX - Initial entity X coordinate + * @param startY - Initial entity Y coordinate + * @returns New position with applied delta + */ + public applyAdjustedDelta(startX: number, startY: number): { x: number; y: number } { + const delta = this.adjustedWorldDelta; + return { + x: startX + delta.x, + y: startY + delta.y, + }; + } + + // === DRAG CONTEXT === + + /** + * Gets drag context (with caching) + * @returns Drag context + */ + private getDragContext(): DragContext { + if (!this.contextCache) { + this.contextCache = this.createSimpleContext(); + } + return this.contextCache; + } + + /** + * Creates simple drag context + * @returns Basic context with additional user data + */ + private createSimpleContext(): DragContext { + const mousePos = new Point(this.lastCameraX, this.lastCameraY); + const entityPos = this.currentEntityPosition; + + return { + graph: this.graph, + currentPosition: mousePos, // Mouse position (for compatibility) + currentEntityPosition: entityPos, // Entity position + entityStartPosition: this.entityStartPosition, + stage: this.currentStage, // Current drag stage + // Add user context + ...this.customContext, + }; + } + + // === DYNAMIC MODIFIER MANAGEMENT === + + /** + * Adds a modifier dynamically during the drag process + * @param modifier - The modifier to add + * @returns void + */ + public addModifier(modifier: PositionModifier | DragModifier): void { + // Check if modifier with this name already exists + const existingIndex = this.modifiers.findIndex((m) => m.name === modifier.name); + if (existingIndex !== -1) { + // Replace existing modifier + this.modifiers[existingIndex] = modifier; + } else { + // Add new modifier + this.modifiers.push(modifier); + } + + // Clear suggestions cache to recalculate with new modifier + this.suggestions = []; + } + + /** + * Removes a modifier by name + * @param modifierName - Name of the modifier to remove + * @returns void + */ + public removeModifier(modifierName: string): void { + const index = this.modifiers.findIndex((m) => m.name === modifierName); + if (index !== -1) { + this.modifiers.splice(index, 1); + // Clear suggestions cache to recalculate without removed modifier + this.suggestions = []; + // Remove from applied modifiers if present + this.appliedModifiers.delete(modifierName); + } + } + + /** + * Checks if a modifier is currently applied + * @param modifier - The modifier to check (can be name or modifier object) + * @returns true if modifier is applied + */ + public isApplied(modifier: string | PositionModifier | DragModifier): boolean { + const modifierName = typeof modifier === "string" ? modifier : modifier.name; + return this.appliedModifiers.has(modifierName); + } +} diff --git a/src/services/Drag/modifiers/GridSnapModifier.ts b/src/services/Drag/modifiers/GridSnapModifier.ts new file mode 100644 index 00000000..e88ccc77 --- /dev/null +++ b/src/services/Drag/modifiers/GridSnapModifier.ts @@ -0,0 +1,92 @@ +import { Point } from "../../../utils/types/shapes"; +import { DragContext, DragStage, PositionModifier } from "../DragInfo"; + +/** + * Configuration for the grid snap modifier. + */ +export type GridSnapModifierConfig = { + /** Size of the grid in pixels. Positions will snap to multiples of this value. */ + gridSize: number; + /** Drag stage when this modifier should be active (e.g., 'dragging', 'drop'). */ + stage: DragStage; +}; + +/** + * Extended drag context for grid snap modifier operations. + */ +type GridSnapModifierContext = DragContext & { + /** Whether grid snapping is enabled. Can be used to temporarily disable snapping. */ + enableGridSnap: boolean; + /** Override grid size from context. If provided, takes precedence over config gridSize. */ + gridSize?: number; +}; + +/** + * Creates a grid snap position modifier that aligns dragged elements to a regular grid. + * + * This modifier snaps positions to the nearest grid intersection based on the specified + * grid size. It can be configured to activate only during specific drag stages (e.g., + * only on drop for clean final positioning, or during dragging for real-time feedback). + * + * @example + * ```typescript + * // Snap to 20px grid only when dropping + * const dropGridSnap = createGridSnapModifier({ + * gridSize: 20, + * stage: 'drop' + * }); + * + * // Real-time snapping during drag with 25px grid + * const realtimeGridSnap = createGridSnapModifier({ + * gridSize: 25, + * stage: 'dragging' + * }); + * + * // Using with dynamic grid size from context + * dragController.start(handler, event, { + * positionModifiers: [dropGridSnap], + * context: { + * enableGridSnap: !event.ctrlKey, // Disable with Ctrl key + * gridSize: zoomLevel > 2 ? 10 : 20 // Smaller grid when zoomed in + * } + * }); + * ``` + * + * @param params - Configuration for the grid snap modifier + * @returns A position modifier that provides grid snapping functionality + */ +export const createGridSnapModifier = (params: GridSnapModifierConfig): PositionModifier => ({ + name: "grid-snap", + priority: 10, + + /** + * Determines if grid snapping should be applied for the current drag state. + * + * @param _pos - Current position (unused) + * @param dragInfo - Current drag information + * @param ctx - Drag context with grid snap settings + * @returns true if grid snapping should be applied + */ + applicable: (_pos, dragInfo, ctx: GridSnapModifierContext) => { + // Apply only if there's actual movement (not micro-movement) + const isEnabled = ctx.enableGridSnap !== false; // Enabled by default + return !dragInfo.isMicroDrag() && isEnabled && ctx.stage === params.stage; + }, + + /** + * Calculates the grid-snapped position. + * + * @param pos - Current position to snap to grid + * @param _dragInfo - Current drag information (unused) + * @param ctx - Drag context that may override grid size + * @returns Position snapped to the nearest grid intersection + */ + suggest: (pos, _dragInfo, ctx: GridSnapModifierContext) => { + // Grid size can be overridden through context + const effectiveGridSize = ctx.gridSize || params.gridSize; + return new Point( + Math.round(pos.x / effectiveGridSize) * effectiveGridSize, + Math.round(pos.y / effectiveGridSize) * effectiveGridSize + ); + }, +}); diff --git a/src/services/Drag/modifiers/MagneticBorderModifier.ts b/src/services/Drag/modifiers/MagneticBorderModifier.ts new file mode 100644 index 00000000..2d75f3ee --- /dev/null +++ b/src/services/Drag/modifiers/MagneticBorderModifier.ts @@ -0,0 +1,494 @@ +import { GraphComponent } from "../../../components/canvas/GraphComponent"; +import { Point } from "../../../utils/types/shapes"; +import { DragContext, DragInfo } from "../DragInfo"; + +/** + * Configuration for the magnetic border modifier. + */ +export type MagneticBorderModifierConfig = { + /** + * Distance threshold for magnetism in pixels, or 'auto' to use camera viewport. + * - number: Elements within this distance will trigger magnetism + * - 'auto': All elements in camera viewport will be considered for magnetism + */ + magnetismDistance: number | "auto"; + /** + * Distance threshold for actual snapping in pixels. + * If not provided, uses magnetismDistance value. + * Must be <= magnetismDistance when both are numbers. + */ + snapThreshold?: number; + /** Array of component types to search for magnetism. If not provided, searches all components. */ + targets?: Constructor[]; + /** + * Function to resolve the bounding box of an element. + * Should return null/undefined if the element should not provide a bounding box. + * @param element - The element to resolve bounding box for + * @returns Bounding box coordinates or null if not applicable + */ + resolveBounds?: (element: GraphComponent) => { x: number; y: number; width: number; height: number } | null; + /** + * Function to filter which elements can be snap targets. + * @param element - The element to test + * @returns true if element should be considered for magnetism + */ + filter?: (element: GraphComponent, dragInfo: DragInfo, ctx: DragContext) => boolean; + /** + * Which borders to consider for snapping. + * @default ['top', 'right', 'bottom', 'left'] + */ + enabledBorders?: Array<"top" | "right" | "bottom" | "left">; + /** + * Whether to allow snapping to multiple borders simultaneously. + * @default false + */ + allowMultipleSnap?: boolean; +}; + +/** + * Extended drag context for magnetic border modifier operations. + */ +type MagneticBorderModifierContext = DragContext & { + magneticBorderModifier: { + magnetismDistance: number | "auto"; + snapThreshold?: number; + targets?: Constructor[]; + resolveBounds?: (element: GraphComponent) => { x: number; y: number; width: number; height: number } | null; + filter?: (element: GraphComponent) => boolean; + enabledBorders?: Array<"top" | "right" | "bottom" | "left">; + allowMultipleSnap?: boolean; + }; +}; + +/** + * Represents a border of a bounding box. + */ +type BorderInfo = { + element: GraphComponent; + border: "top" | "right" | "bottom" | "left"; + point: Point; + distance: number; +}; + +/** + * Calculates the minimum distance from any border of a dragged block to an infinite line. + * @param currentPos - Current position (top-left corner) where the block would be placed + * @param blockSize - Size of the dragged block (width and height) + * @param lineInfo - Information about the infinite line + * @returns Minimum distance from any border of the block to the line + */ +function getDistanceFromBlockBordersToLine( + currentPos: Point, + blockSize: { width: number; height: number }, + lineInfo: { border: "top" | "right" | "bottom" | "left"; point: Point } +): number { + const { border, point } = lineInfo; + + if (border === "top" || border === "bottom") { + // Horizontal line - check distance from top and bottom borders of dragged block + const topDistance = Math.abs(currentPos.y - point.y); + const bottomDistance = Math.abs(currentPos.y + blockSize.height - point.y); + return Math.min(topDistance, bottomDistance); + } else { + // Vertical line - check distance from left and right borders of dragged block + const leftDistance = Math.abs(currentPos.x - point.x); + const rightDistance = Math.abs(currentPos.x + blockSize.width - point.x); + return Math.min(leftDistance, rightDistance); + } +} + +/** + * Calculates the correct position for the dragged block when snapping to a line. + * Determines which border of the block should snap to the line and calculates + * the corresponding top-left position of the block. + * @param currentPos - Current position (top-left corner) of the block + * @param blockSize - Size of the block (width and height) + * @param lineInfo - Information about the line to snap to + * @returns New position for the block's top-left corner + */ +function calculateSnapPosition( + currentPos: Point, + blockSize: { width: number; height: number }, + lineInfo: { border: "top" | "right" | "bottom" | "left"; point: Point } +): Point { + const { border, point } = lineInfo; + + if (border === "top" || border === "bottom") { + // Horizontal line - determine which border (top or bottom) should snap + const topDistance = Math.abs(currentPos.y - point.y); + const bottomDistance = Math.abs(currentPos.y + blockSize.height - point.y); + + if (topDistance <= bottomDistance) { + // Top border snaps to line + return new Point(currentPos.x, point.y); + } else { + // Bottom border snaps to line + return new Point(currentPos.x, point.y - blockSize.height); + } + } else { + // Vertical line - determine which border (left or right) should snap + const leftDistance = Math.abs(currentPos.x - point.x); + const rightDistance = Math.abs(currentPos.x + blockSize.width - point.x); + + if (leftDistance <= rightDistance) { + // Left border snaps to line + return new Point(point.x, currentPos.y); + } else { + // Right border snaps to line + return new Point(point.x - blockSize.width, currentPos.y); + } + } +} + +/** + * Processes a single element to collect its border lines and snap candidates. + * @param element - The graph component to process + * @param pos - Current cursor position + * @param enabledBorders - Which borders to consider + * @param snapThreshold - Distance threshold for snapping + * @param draggedSize - Size of the dragged element (optional) + * @param resolveBounds - Function to resolve element bounds + * @returns Object containing all border lines and snap candidates + */ +function processElementBorderLines( + element: GraphComponent, + pos: Point, + enabledBorders: Array<"top" | "right" | "bottom" | "left">, + snapThreshold: number, + draggedSize: { width: number; height: number } | null, + resolveBounds?: (element: GraphComponent) => { x: number; y: number; width: number; height: number } | null +): { allLines: BorderInfo[]; snapCandidates: BorderInfo[] } { + const bounds = resolveBounds?.(element); + if (!bounds) { + return { allLines: [], snapCandidates: [] }; + } + + const allLines: BorderInfo[] = []; + const snapCandidates: BorderInfo[] = []; + const borderLines = getClosestBorderLines(pos, bounds, enabledBorders); + + for (const borderLine of borderLines) { + const borderInfo: BorderInfo = { + element, + border: borderLine.border, + point: borderLine.point, + distance: borderLine.distance, + }; + + allLines.push(borderInfo); + + // Check if any border of the dragged block is close to this infinite line + let shouldSnap = false; + if (draggedSize) { + // Use the current position (pos) to calculate where the block would be + const blockToLineDistance = getDistanceFromBlockBordersToLine(pos, draggedSize, { + border: borderLine.border, + point: borderLine.point, + }); + shouldSnap = blockToLineDistance <= snapThreshold; + } else { + // Fallback to original point-based logic if no dragged size + shouldSnap = borderLine.distance <= snapThreshold; + } + + if (shouldSnap) { + snapCandidates.push(borderInfo); + } + } + + return { allLines, snapCandidates }; +} + +/** + * Calculates the final position after applying snapping to selected borders. + * @param selectedBorders - Array of borders to snap to + * @param draggedSize - Size of the dragged element (optional) + * @param pos - Current position + * @returns Final position after snapping + */ +function calculateFinalPosition( + selectedBorders: BorderInfo[], + draggedSize: { width: number; height: number } | null, + pos: Point +): Point { + if (selectedBorders.length === 0) { + return pos; // No snapping + } + + let newPos = pos; + + if (draggedSize) { + // Use smart positioning that considers which border of the block should snap + for (const border of selectedBorders) { + const snapPos = calculateSnapPosition(newPos, draggedSize, { + border: border.border, + point: border.point, + }); + + if (border.border === "top" || border.border === "bottom") { + // Update Y coordinate from horizontal line snap + newPos = new Point(newPos.x, snapPos.y); + } else { + // Update X coordinate from vertical line snap + newPos = new Point(snapPos.x, newPos.y); + } + } + } else { + // Fallback to original logic if no dragged size + let newX = pos.x; + let newY = pos.y; + + for (const border of selectedBorders) { + if (border.border === "top" || border.border === "bottom") { + newY = border.point.y; + } else if (border.border === "left" || border.border === "right") { + newX = border.point.x; + } + } + + newPos = new Point(newX, newY); + } + + return newPos; +} + +/** + * Calculates the closest point on infinite lines extending through rectangle borders. + * Unlike border snapping, this projects the point onto infinite lines that pass through the borders. + * @param point - The point to find the closest line projection for + * @param bounds - The bounding box of the rectangle + * @param enabledBorders - Which border lines to consider + * @returns Array of line projection information sorted by distance + */ +function getClosestBorderLines( + point: Point, + bounds: { x: number; y: number; width: number; height: number }, + enabledBorders: Array<"top" | "right" | "bottom" | "left"> = ["top", "right", "bottom", "left"] +): Array<{ border: "top" | "right" | "bottom" | "left"; point: Point; distance: number }> { + const { x, y, width, height } = bounds; + const linePoints: Array<{ border: "top" | "right" | "bottom" | "left"; point: Point; distance: number }> = []; + + // Top border line (horizontal line y = bounds.y) + if (enabledBorders.includes("top")) { + const linePoint = new Point(point.x, y); // Project point onto horizontal line + const distance = Math.abs(point.y - y); // Distance is just the Y difference + linePoints.push({ border: "top", point: linePoint, distance }); + } + + // Right border line (vertical line x = bounds.x + width) + if (enabledBorders.includes("right")) { + const linePoint = new Point(x + width, point.y); // Project point onto vertical line + const distance = Math.abs(point.x - (x + width)); // Distance is just the X difference + linePoints.push({ border: "right", point: linePoint, distance }); + } + + // Bottom border line (horizontal line y = bounds.y + height) + if (enabledBorders.includes("bottom")) { + const linePoint = new Point(point.x, y + height); // Project point onto horizontal line + const distance = Math.abs(point.y - (y + height)); // Distance is just the Y difference + linePoints.push({ border: "bottom", point: linePoint, distance }); + } + + // Left border line (vertical line x = bounds.x) + if (enabledBorders.includes("left")) { + const linePoint = new Point(x, point.y); // Project point onto vertical line + const distance = Math.abs(point.x - x); // Distance is just the X difference + linePoints.push({ border: "left", point: linePoint, distance }); + } + + return linePoints.sort((a, b) => a.distance - b.distance); +} + +/** + * Creates a magnetic border modifier that snaps dragged elements to infinite lines extending through element borders. + * + * This modifier searches for elements within a specified distance (or camera viewport in world + * coordinates for 'auto' mode) and snaps the dragged position to the closest infinite line that + * passes through element borders. Unlike border snapping which limits to the actual border edges, + * this projects onto infinite lines for perfect alignment. It uses viewport-based element filtering + * for optimal performance and supports custom target types, bounding box resolution, and border line filtering. + * + * @example + * ```typescript + * // Basic line magnetism to block borders with distance threshold + * const lineMagnetism = MagneticBorderModifier({ + * magnetismDistance: 20, + * targets: [Block], + * resolveBounds: (element) => { + * if (element instanceof Block) { + * return { + * x: element.state.x, + * y: element.state.y, + * width: element.state.width, + * height: element.state.height + * }; + * } + * return null; + * } + * }); + * + * // Auto mode: snap to all visible blocks in camera viewport (world coordinates) + * const globalLineMagnetism = MagneticBorderModifier({ + * magnetismDistance: "auto", // Use entire camera viewport in world coordinates + * targets: [Block], + * resolveBounds: (element) => element.getBounds() + * }); + * + * // Snap only to horizontal lines (through top/bottom borders) + * const horizontalLineSnap = MagneticBorderModifier({ + * magnetismDistance: 15, + * targets: [Block], + * enabledBorders: ["top", "bottom"], + * resolveBounds: (element) => element.getBounds() + * }); + * + * // Auto mode with vertical lines only + * const globalVerticalAlign = MagneticBorderModifier({ + * magnetismDistance: "auto", + * targets: [Block], + * enabledBorders: ["left", "right"], + * resolveBounds: (element) => element.getBounds() + * }); + * ``` + * + * @param params - Configuration for the magnetic border modifier + * @returns A position modifier that provides border line magnetism functionality + */ +export const MagneticBorderModifier = (params: MagneticBorderModifierConfig) => { + let config = params; + return { + name: "magneticBorder", + priority: 8, // Slightly lower priority than point magnetism + + /** + * Updates the modifier configuration with new parameters. + * @param nextParams - Partial configuration to merge with existing config + * @returns void + */ + setParams: (nextParams: Partial) => { + config = Object.assign({}, config, nextParams); + }, + + /** + * Determines if the magnetic border modifier should be applied for the current drag state. + * + * @param pos - Current position being evaluated + * @param dragInfo - Current drag information + * @param ctx - Drag context containing stage and other metadata + * @returns true if border magnetism should be applied + */ + applicable: (pos: Point, dragInfo: DragInfo, ctx: MagneticBorderModifierContext) => { + // Only apply during dragging and drop stages, not during start + if (ctx.stage === "start") return false; + + // Don't apply for micro-movements to prevent jitter + if (dragInfo.isMicroDrag()) return false; + + return true; + }, + + /** + * Calculates the magnetic snap position based on infinite lines through element borders. + * + * Searches for target elements within the magnetism distance and returns + * the projected position on the closest infinite line passing through element borders. + * Updates the drag context with the found target information for use by other systems. + * + * @param pos - Current position to evaluate for magnetism + * @param dragInfo - Current drag information + * @param ctx - Drag context containing graph and other metadata + * @returns Modified position if a border line is found, otherwise original position + */ + suggest: (pos: Point, dragInfo: DragInfo, ctx: MagneticBorderModifierContext) => { + const enabledBorders = config.enabledBorders || ["right", "left"]; + const isAutoMode = config.magnetismDistance === "auto"; + const allowMultipleSnap = config.allowMultipleSnap || false; + + // Get snap threshold - defaults to magnetismDistance if not provided + const snapThreshold = isAutoMode ? Infinity : config.snapThreshold ?? (config.magnetismDistance as number); + + // Get elements within search area + let elementsInRect = []; + + if (isAutoMode) { + elementsInRect = ctx.graph.getElementsInViewport(config.targets ? [...config.targets] : []); + } else { + // Distance mode: create search rectangle around current position + const distance = config.magnetismDistance as number; + const searchRect = { + x: pos.x - distance, + y: pos.y - distance, + width: distance * 2, + height: distance * 2, + }; + elementsInRect = ctx.graph.getElementsOverRect(searchRect, config.targets ? [...config.targets] : []); + } + + if (config.filter) { + elementsInRect = elementsInRect.filter((element) => config.filter(element, dragInfo, ctx)); + } + + // Get dragged element size for border distance calculations + const draggedElement = (ctx as MagneticBorderModifierContext & { dragEntity?: GraphComponent }).dragEntity; + const draggedBounds = draggedElement ? config.resolveBounds?.(draggedElement) : null; + const draggedSize = draggedBounds ? { width: draggedBounds.width, height: draggedBounds.height } : null; + + // Collect infinite border lines from all found elements + const allBorderLines: BorderInfo[] = []; + // Collect lines that are close enough for actual snapping + const snapCandidates: BorderInfo[] = []; + + // Check all found elements for their borders + elementsInRect.forEach((element) => { + const result = processElementBorderLines( + element, + pos, + enabledBorders, + snapThreshold, + draggedSize, + config.resolveBounds + ); + allBorderLines.push(...result.allLines); + snapCandidates.push(...result.snapCandidates); + }); + + // Sort candidates by distance + allBorderLines.sort((a, b) => a.distance - b.distance); + snapCandidates.sort((a, b) => a.distance - b.distance); + + // Find the best borders to snap to + let selectedBorders: BorderInfo[] = []; + + if (snapCandidates.length > 0) { + if (allowMultipleSnap) { + // Group by axis (horizontal/vertical) and take closest from each + const horizontalBorders = snapCandidates.filter((b) => b.border === "top" || b.border === "bottom"); + const verticalBorders = snapCandidates.filter((b) => b.border === "left" || b.border === "right"); + + if (horizontalBorders.length > 0) { + selectedBorders.push(horizontalBorders[0]); // Closest horizontal + } + if (verticalBorders.length > 0) { + selectedBorders.push(verticalBorders[0]); // Closest vertical + } + } else { + // Take only the single closest border + selectedBorders = [snapCandidates[0]]; + } + } + + // Update context with border information for visualization + const closestBorder = allBorderLines.length > 0 ? allBorderLines[0] : null; + dragInfo.updateContext({ + closestBorder, + closestBorderElement: closestBorder?.element, + closestBorderSide: closestBorder?.border, + allBorderLines, // All lines for visualization + selectedBorders, // Lines that are actually snapped to + }); + + // Calculate and return final position + return calculateFinalPosition(selectedBorders, draggedSize, pos); + }, + }; +}; diff --git a/src/services/Drag/modifiers/MagneticModifier.ts b/src/services/Drag/modifiers/MagneticModifier.ts new file mode 100644 index 00000000..b71872ef --- /dev/null +++ b/src/services/Drag/modifiers/MagneticModifier.ts @@ -0,0 +1,169 @@ +import { GraphComponent } from "../../../components/canvas/GraphComponent"; +import { Point } from "../../../utils/types/shapes"; +import { DragContext, DragInfo } from "../DragInfo"; + +/** + * Configuration for the magnetic modifier. + */ +export type MagneticModifierConfig = { + /** Distance threshold for magnetism in pixels. Elements within this distance will trigger magnetism. */ + magnetismDistance: number; + /** Array of component types to search for magnetism. If not provided, searches all components. */ + targets?: Constructor[]; + /** + * Function to resolve the snap position of an element. + * Should return null/undefined if the element should not provide a snap position. + * @param element - The element to resolve position for + * @returns Position coordinates or null if not applicable + */ + resolvePosition?: (element: GraphComponent) => { x: number; y: number } | null; + /** + * Function to filter which elements can be snap targets. + * @param element - The element to test + * @returns true if element should be considered for magnetism + */ + filter?: (element: GraphComponent) => boolean; +}; + +/** + * Extended drag context for magnetic modifier operations. + */ +type MagneticModifierContext = DragContext & { + magneticModifier: { + magnetismDistance: number; + targets?: Constructor[]; + resolvePosition?: (element: GraphComponent) => { x: number; y: number } | null; + filter?: (element: GraphComponent) => boolean; + }; +}; + +/** + * Creates a magnetic position modifier that snaps dragged elements to nearby targets. + * + * This modifier searches for elements within a specified distance and snaps the dragged + * position to the closest valid target. It uses viewport-based element filtering for + * optimal performance and supports custom target types, position resolution, and filtering. + * + * @example + * ```typescript + * // Basic magnetism to blocks and anchors + * const magneticModifier = MagneticModifier({ + * magnetismDistance: 50, + * targets: [Block, Anchor], + * resolvePosition: (element) => { + * if (element instanceof Block) return element.getConnectionPoint("in"); + * if (element instanceof Anchor) return element.getPosition(); + * return null; + * } + * }); + * + * // Anchor-to-anchor magnetism with type filtering + * const anchorMagnetism = MagneticModifier({ + * magnetismDistance: 30, + * targets: [Anchor], + * filter: (element) => element instanceof Anchor && element.type !== sourceAnchor.type + * }); + * ``` + * + * @param params - Configuration for the magnetic modifier + * @returns A position modifier that provides magnetism functionality + */ +export const MagneticModifier = (params: MagneticModifierConfig) => { + let config = params; + return { + name: "magnetic", + priority: 10, + + /** + * Updates the modifier configuration with new parameters. + * @param nextParams - Partial configuration to merge with existing config + * @returns void + */ + setParams: (nextParams: Partial) => { + config = Object.assign({}, config, nextParams); + }, + + /** + * Determines if the magnetic modifier should be applied for the current drag state. + * + * @param pos - Current position being evaluated + * @param dragInfo - Current drag information + * @param ctx - Drag context containing stage and other metadata + * @returns true if magnetism should be applied + */ + applicable: (pos: Point, dragInfo: DragInfo, ctx: MagneticModifierContext) => { + // Only apply during dragging and drop stages, not during start + if (ctx.stage === "start") return false; + + // Don't apply for micro-movements to prevent jitter + if (dragInfo.isMicroDrag()) return false; + + return true; + }, + + /** + * Calculates the magnetic snap position based on nearby elements. + * + * Searches for target elements within the magnetism distance and returns + * the position of the closest valid target. Updates the drag context with + * the found target for use by other systems. + * + * @param pos - Current position to evaluate for magnetism + * @param dragInfo - Current drag information + * @param ctx - Drag context containing graph and other metadata + * @returns Modified position if a target is found, otherwise original position + */ + suggest: (pos: Point, dragInfo: DragInfo, ctx: MagneticModifierContext) => { + const distance = config.magnetismDistance; + + // Create a search rectangle around the current position + const searchRect = { + x: pos.x - distance, + y: pos.y - distance, + width: distance * 2, + height: distance * 2, + }; + + // Get elements within the search area based on useBlockAnchors setting + let elementsInRect = ctx.graph.getElementsOverRect(searchRect, [...config.targets]); + + if (config.filter) { + elementsInRect = elementsInRect.filter(config.filter); + } + + let closestTarget: GraphComponent | null = null; + let closestDistance = distance; + + // Check all found elements (only blocks or only anchors based on settings) + elementsInRect.forEach((element) => { + const position = config.resolvePosition?.(element); + if (!position) { + return; + } + + const dist = Math.sqrt(Math.pow(pos.x - position.x, 2) + Math.pow(pos.y - position.y, 2)); + + if (dist < closestDistance) { + closestTarget = element; + closestDistance = dist; + } + }); + + // Update context with closest target for use in drag handlers + dragInfo.updateContext({ + closestTarget, + }); + + // If we found a nearby target, snap to it + if (closestTarget) { + const position = config.resolvePosition?.(closestTarget); + if (position) { + return new Point(position.x, position.y); + } + } + + // No snapping suggestion - return original position + return pos; + }, + }; +}; diff --git a/src/services/Drag/modifiers/index.ts b/src/services/Drag/modifiers/index.ts new file mode 100644 index 00000000..7b330821 --- /dev/null +++ b/src/services/Drag/modifiers/index.ts @@ -0,0 +1,15 @@ +/** + * Position modifiers for the drag system. + * + * These modifiers can be used to modify dragged positions in real-time, + * providing features like grid snapping, magnetism to elements, and border alignment. + */ + +export { createGridSnapModifier } from "./GridSnapModifier"; +export type { GridSnapModifierConfig } from "./GridSnapModifier"; + +export { MagneticModifier } from "./MagneticModifier"; +export type { MagneticModifierConfig } from "./MagneticModifier"; + +export { MagneticBorderModifier } from "./MagneticBorderModifier"; +export type { MagneticBorderModifierConfig } from "./MagneticBorderModifier"; \ No newline at end of file diff --git a/src/services/camera/Camera.ts b/src/services/camera/Camera.ts index a1f0bb3b..f25f9a8a 100644 --- a/src/services/camera/Camera.ts +++ b/src/services/camera/Camera.ts @@ -1,12 +1,13 @@ import { EventedComponent } from "../../components/canvas/EventedComponent/EventedComponent"; import { TGraphLayerContext } from "../../components/canvas/layers/graphLayer/GraphLayer"; -import { Component } from "../../lib"; +import { Component, ESchedulerPriority } from "../../lib"; import { TComponentProps, TComponentState } from "../../lib/Component"; import { ComponentDescriptor } from "../../lib/CoreComponent"; import { getXY, isMetaKeyEvent, isTrackpadWheelEvent, isWindows } from "../../utils/functions"; import { clamp } from "../../utils/functions/clamp"; import { dragListener } from "../../utils/functions/dragListener"; import { EVENTS } from "../../utils/types/events"; +import { schedule } from "../../utils/utils/schedule"; import { ICamera } from "./CameraService"; @@ -15,6 +16,18 @@ export type TCameraProps = TComponentProps & { children: ComponentDescriptor[]; }; +export type TEdgePanningConfig = { + edgeSize: number; // размер зоны для активации edge panning (в пикселях) + speed: number; // скорость движения камеры + enabled: boolean; // включен ли режим +}; + +const DEFAULT_EDGE_PANNING_CONFIG: TEdgePanningConfig = { + edgeSize: 100, + speed: 15, + enabled: false, +}; + export class Camera extends EventedComponent { private camera: ICamera; @@ -22,6 +35,12 @@ export class Camera extends EventedComponent void; + + private lastMousePosition: { x: number; y: number } = { x: 0, y: 0 }; + constructor(props: TCameraProps, parent: Component) { super(props, parent); @@ -61,6 +80,7 @@ export class Camera extends EventedComponent { @@ -69,8 +89,8 @@ export class Camera extends EventedComponent this.onDragStart(event)) - .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => this.onDragUpdate(event)) + .on(EVENTS.DRAG_START, (dragEvent: MouseEvent) => this.onDragStart(dragEvent)) + .on(EVENTS.DRAG_UPDATE, (dragEvent: MouseEvent) => this.onDragUpdate(dragEvent)) .on(EVENTS.DRAG_END, () => this.onDragEnd()); } }; @@ -167,4 +187,120 @@ export class Camera extends EventedComponent = {}): void { + this.edgePanningConfig = { ...this.edgePanningConfig, ...config, enabled: true }; + + if (this.props.root) { + this.props.root.addEventListener("mousemove", this.handleEdgePanningMouseMove); + this.props.root.addEventListener("mouseleave", this.handleEdgePanningMouseLeave); + } + } + + /** + * Отключает автоматическое перемещение камеры + * @returns void + */ + public disableEdgePanning(): void { + this.edgePanningConfig.enabled = false; + + if (this.props.root) { + this.props.root.removeEventListener("mousemove", this.handleEdgePanningMouseMove); + this.props.root.removeEventListener("mouseleave", this.handleEdgePanningMouseLeave); + } + + this.stopEdgePanningAnimation(); + } + + private handleEdgePanningMouseMove = (event: MouseEvent): void => { + if (!this.edgePanningConfig.enabled || !this.props.root) { + return; + } + + this.lastMousePosition = { x: event.clientX, y: event.clientY }; + this.updateEdgePanning(); + }; + + private handleEdgePanningMouseLeave = (): void => { + this.stopEdgePanningAnimation(); + }; + + private updateEdgePanning(): void { + if (!this.edgePanningConfig.enabled || !this.props.root) { + return; + } + + const rect = this.props.root.getBoundingClientRect(); + const { x, y } = this.lastMousePosition; + const { edgeSize, speed } = this.edgePanningConfig; + + // Вычисляем расстояния до границ + const distanceToLeft = x - rect.left; + const distanceToRight = rect.right - x; + const distanceToTop = y - rect.top; + const distanceToBottom = rect.bottom - y; + + let deltaX = 0; + let deltaY = 0; + + // Проверяем левую границу - при приближении к левому краю двигаем камеру вправо + if (distanceToLeft < edgeSize && distanceToLeft >= 0) { + const intensity = 1 - distanceToLeft / edgeSize; + deltaX = speed * intensity; // Положительный - камера двигается вправо + } + // Проверяем правую границу - при приближении к правому краю двигаем камеру влево + else if (distanceToRight < edgeSize && distanceToRight >= 0) { + const intensity = 1 - distanceToRight / edgeSize; + deltaX = -speed * intensity; // Отрицательный - камера двигается влево + } + + // Проверяем верхнюю границу - при приближении к верхнему краю двигаем камеру вниз + if (distanceToTop < edgeSize && distanceToTop >= 0) { + const intensity = 1 - distanceToTop / edgeSize; + deltaY = speed * intensity; // Положительный - камера двигается вниз + } + // Проверяем нижнюю границу - при приближении к нижнему краю двигаем камеру вверх + else if (distanceToBottom < edgeSize && distanceToBottom >= 0) { + const intensity = 1 - distanceToBottom / edgeSize; + deltaY = -speed * intensity; // Отрицательный - камера двигается вверх + } + + // Если нужно двигать камеру + if (deltaX !== 0 || deltaY !== 0) { + this.startEdgePanningAnimation(deltaX, deltaY); + } else { + this.stopEdgePanningAnimation(); + } + } + + private startEdgePanningAnimation(deltaX: number, deltaY: number): void { + // Останавливаем предыдущую анимацию + this.stopEdgePanningAnimation(); + + // Используем schedule с высоким приоритетом и частотой обновления каждый фрейм + this.edgePanningAnimation = schedule( + () => { + if (this.edgePanningConfig.enabled) { + this.camera.move(deltaX, deltaY); + } + }, + { + priority: ESchedulerPriority.HIGH, + frameInterval: 1, // Каждый фрейм + once: false, // Повторяющаяся анимация + } + ); + } + + private stopEdgePanningAnimation(): void { + if (this.edgePanningAnimation) { + this.edgePanningAnimation(); // Вызываем функцию для остановки + this.edgePanningAnimation = undefined; + } + } } diff --git a/src/store/settings.ts b/src/store/settings.ts index c49fc8cc..2a4d8343 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -18,6 +18,8 @@ export enum ECanChangeBlockGeometry { export type TGraphSettingsConfig = { canDragCamera: boolean; canZoomCamera: boolean; + /** Enable automatic camera movement when mouse approaches viewport edges during block dragging */ + enableEdgePanning: boolean; /** @deprecated Use NewBlockLayer parameters instead */ canDuplicateBlocks?: boolean; canChangeBlockGeometry: ECanChangeBlockGeometry; @@ -37,6 +39,7 @@ export type TGraphSettingsConfig; + snapColor?: string; + guideColor?: string; + lineWidth?: number; + snapDashPattern?: number[]; + guideDashPattern?: number[]; +} + +const AlignmentStory = ({ + magnetismDistance = 200, // Увеличиваем для демонстрации бесконечных линий + snapThreshold = 15, // Прилипание только при близком расстоянии + allowMultipleSnap = true, + enabledBorders = ["top", "right", "bottom", "left"], + snapColor = "#007AFF", + guideColor = "#E0E0E0", + lineWidth = 1, + snapDashPattern = [5, 5], + guideDashPattern = [3, 3], +}: AlignmentStoryProps) => { + const renderBlock = useCallback((graph: Graph, block: TBlock) => { + return ( + +
+ {block.name} +
+
+ ); + }, []); + + const { graph } = useGraph({ + settings: { + canChangeBlockGeometry: ECanChangeBlockGeometry.ALL, + canDragCamera: true, + canZoomCamera: true, + }, + }); + + useLayer(graph, AlignmentLinesLayer, { + magneticBorderConfig: { + magnetismDistance, + snapThreshold, + allowMultipleSnap, + enabledBorders, + }, + lineStyle: { + snapColor, + guideColor, + width: lineWidth, + snapDashPattern, + guideDashPattern, + }, + }); + + useEffect(() => { + if (graph) { + graph.setEntities({ + blocks: sampleBlocks, + connections: sampleConnections, + }); + graph.start(); + } + }, [graph]); + + return ( +
+
+ 💡 Инструкция: Перетащите блок любой границей к линии. Прилипание работает для всех границ + блока (верх, низ, лево, право) +
+ +
+ ); +}; + +const GraphApp = (props: AlignmentStoryProps) => { + return ( + + + + ); +}; + +const meta: Meta = { + title: "Examples/Alignment Layer", + component: GraphApp, + parameters: { + layout: "fullscreen", + }, + argTypes: { + magnetismDistance: { + control: { type: "select" }, + options: ["auto", 100, 200, 300, 500], + description: "Радиус поиска блоков для создания бесконечных линий", + }, + snapThreshold: { + control: { type: "range", min: 5, max: 50, step: 5 }, + description: "Расстояние до линии для срабатывания прилипания", + }, + allowMultipleSnap: { + control: { type: "boolean" }, + description: "Разрешить прилипание к нескольким линиям одновременно", + }, + enabledBorders: { + control: { type: "check" }, + options: ["top", "right", "bottom", "left"], + description: "Включенные границы для выравнивания", + }, + snapColor: { + control: { type: "color" }, + description: "Цвет линий прилипания (активное выравнивание)", + }, + guideColor: { + control: { type: "color" }, + description: "Цвет вспомогательных линий (потенциальное выравнивание)", + }, + lineWidth: { + control: { type: "range", min: 1, max: 5, step: 1 }, + description: "Толщина линий в пикселях", + }, + snapDashPattern: { + control: { type: "object" }, + description: "Паттерн пунктира для линий прилипания", + }, + guideDashPattern: { + control: { type: "object" }, + description: "Паттерн пунктира для вспомогательных линий", + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** + * Демонстрирует бесконечные линии выравнивания с прилипанием по расстоянию до линии. + * Серые линии видны всегда, синие появляются при приближении к линии. + */ +export const DefaultAlignmentLines: Story = { + args: { + magnetismDistance: 200, // Большое расстояние для показа бесконечных линий + snapThreshold: 15, // Прилипание только при близком расстоянии к линии + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#007AFF", + guideColor: "#E0E0E0", + lineWidth: 1, + snapDashPattern: [5, 5], + guideDashPattern: [3, 3], + }, + parameters: { + docs: { + description: { + story: ` +Система бесконечных линий выравнивания с прилипанием любой границы блока. + +**Ключевые особенности:** +1. **Бесконечные линии выравнивания**: + - Серые линии показывают все возможные направления выравнивания + - Линии видны для всех блоков в радиусе magnetismDistance (200px) + - Линии продолжаются бесконечно, не ограничиваются размерами блоков + +2. **Прилипание всех границ блока**: + - Прилипание работает для любой границы: верх, низ, лево, право + - Система автоматически определяет ближайшую границу блока к линии + - Прилипание происходит при приближении любой границы на snapThreshold (15px) + +3. **Множественное прилипание**: + - Одновременное прилипание к горизонтальной и вертикальной линиям + - Позволяет точно выравнивать блоки по углам используя разные границы + +**Как использовать:** +1. Перетащите блок - увидите серые бесконечные линии от всех блоков поблизости +2. Приблизьте ЛЮБУЮ границу блока к серой линии - она станет синей и сработает прилипание +3. Попробуйте выровнять правую границу одного блока с левой границей другого +4. Попробуйте выровнять нижнюю границу с верхней границей другого блока + +**Преимущества:** +- Интуитивное поведение: любая граница блока может прилипнуть к линии +- Точное управление: блок позиционируется правильно в зависимости от прилипающей границы +- Универсальность: работает для всех сценариев выравнивания блоков + `, + }, + }, + }, +}; + +/** + * Демонстрирует плавное управление с разными порогами прилипания + */ +export const SmoothSnapControl: Story = { + args: { + magnetismDistance: 80, + snapThreshold: 10, + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#FF3B30", + guideColor: "#FFE5E5", + lineWidth: 2, + snapDashPattern: [8, 4], + guideDashPattern: [2, 2], + }, + parameters: { + docs: { + description: { + story: ` +Пример с плавным управлением: +- Большое расстояние обнаружения (80px) - рано показывает вспомогательные линии +- Малый порог прилипания (10px) - требует точного позиционирования +- Красные линии прилипания на розовом фоне вспомогательных линий +- Идеально для точного позиционирования элементов + `, + }, + }, + }, +}; + +/** + * Пример с жестким прилипанием (как в старой версии) + */ +export const StrictSnapping: Story = { + args: { + magnetismDistance: 30, + snapThreshold: 30, // Равно magnetismDistance - прилипание сразу + allowMultipleSnap: false, + enabledBorders: ["top", "bottom"], + snapColor: "#34C759", + guideColor: "#34C759", // Тот же цвет - нет разделения + lineWidth: 2, + snapDashPattern: [10, 5], + guideDashPattern: [10, 5], + }, + parameters: { + docs: { + description: { + story: ` +Эмуляция старого поведения (без плавного управления): +- snapThreshold = magnetismDistance (30px) - прилипание сразу при обнаружении +- allowMultipleSnap = false - только одна линия +- Только горизонтальное выравнивание +- Одинаковый стиль для всех линий + `, + }, + }, + }, +}; + +/** + * Демонстрирует прилипание всех границ блока к линиям + */ +export const AllBordersSnappingDemo: Story = { + args: { + magnetismDistance: 300, + snapThreshold: 20, // Немного больший порог для удобства демонстрации + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#34C759", + guideColor: "#D1D1D6", + lineWidth: 2, + snapDashPattern: [6, 4], + guideDashPattern: [2, 2], + }, + parameters: { + docs: { + description: { + story: ` +Демонстрация прилипания всех границ блока: + +**Эксперименты:** +1. **Левая граница**: Перетащите блок так, чтобы его левая сторона приблизилась к вертикальной линии +2. **Правая граница**: Перетащите блок так, чтобы его правая сторона приблизилась к вертикальной линии +3. **Верхняя граница**: Перетащите блок так, чтобы его верх приблизился к горизонтальной линии +4. **Нижняя граница**: Перетащите блок так, чтобы его низ приблизился к горизонтальной линии +5. **Углы**: Попробуйте выровнять углы блоков - сработает двойное прилипание + +**Обратите внимание:** +- Блок правильно позиционируется в зависимости от того, какая граница прилипает +- snapThreshold = 20px для удобства демонстрации +- Зеленые линии четко показывают момент срабатывания прилипания + `, + }, + }, + }, +}; + +/** + * Демонстрирует разницу между бесконечными линиями и прилипанием по расстоянию + */ +export const InfiniteLinesDemo: Story = { + args: { + magnetismDistance: 400, // Очень большой радиус для демонстрации + snapThreshold: 10, // Малое расстояние прилипания + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#FF3B30", + guideColor: "#C7C7CC", + lineWidth: 1, + snapDashPattern: [8, 4], + guideDashPattern: [2, 2], + }, + parameters: { + docs: { + description: { + story: ` +Демонстрация концепции бесконечных линий: +- magnetismDistance = 400px - показывает линии от всех блоков на экране +- snapThreshold = 10px - очень точное прилипание только вблизи линий +- Серые пунктирные линии показывают все возможности выравнивания +- Красные линии появляются только при точном наведении на линию +- Попробуйте двигать блок по всему экрану - увидите все линии выравнивания + `, + }, + }, + }, +}; + +/** + * Демонстрирует множественное прилипание к углу + */ +export const MultipleSnapDemo: Story = { + args: { + magnetismDistance: 60, + snapThreshold: 20, + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#AF52DE", + guideColor: "#F5E5FF", + lineWidth: 1, + snapDashPattern: [6, 3], + guideDashPattern: [2, 2], + }, + parameters: { + docs: { + description: { + story: ` +Пример множественного прилипания: +- Попробуйте выровнять блок по углу другого блока +- Увидите одновременно горизонтальную и вертикальную линии прилипания +- Фиолетовые линии на светло-фиолетовом фоне +- Идеально для точного позиционирования в сетке + `, + }, + }, + }, +}; diff --git a/src/utils/functions/index.ts b/src/utils/functions/index.ts index c3661762..744069b4 100644 --- a/src/utils/functions/index.ts +++ b/src/utils/functions/index.ts @@ -1,8 +1,9 @@ import { Block } from "../../components/canvas/blocks/Block"; +import type { DragStage, PositionModifier } from "../../services/Drag/DragInfo"; import { BlockState, TBlockId } from "../../store/block/Block"; import { ECanChangeBlockGeometry } from "../../store/settings"; import { EVENTS_DETAIL, SELECTION_EVENT_TYPES } from "../types/events"; -import { Rect, TRect } from "../types/shapes"; +import { Point, Rect, TRect } from "../types/shapes"; export { parseClassNames } from "./classNames";