/** * DragDropManager - Optimized drag and drop with consolidated position calculations * Reduces redundant DOM queries and improves performance through caching */ import { IEventBus } from '../types/CalendarTypes'; import { calendarConfig } from '../core/CalendarConfig'; import { PositionUtils } from '../utils/PositionUtils'; import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils'; import { SwpEventElement } from '../elements/SwpEventElement'; import { DragStartEventPayload, DragMoveEventPayload, DragEndEventPayload, DragMouseEnterHeaderEventPayload, DragMouseLeaveHeaderEventPayload, DragColumnChangeEventPayload } from '../types/EventTypes'; interface CachedElements { scrollContainer: HTMLElement | null; currentColumn: HTMLElement | null; lastColumnDate: string | null; } interface Position { x: number; y: number; } export class DragDropManager { private eventBus: IEventBus; // Mouse tracking with optimized state private lastMousePosition: Position = { x: 0, y: 0 }; private lastLoggedPosition: Position = { x: 0, y: 0 }; private currentMouseY = 0; private mouseOffset: Position = { x: 0, y: 0 }; private initialMousePosition: Position = { x: 0, y: 0 }; // Drag state private draggedElement!: HTMLElement | null; private draggedClone!: HTMLElement | null; private currentColumn: string | null = null; private isDragStarted = false; // Header tracking state private isInHeader = false; // Movement threshold to distinguish click from drag private readonly dragThreshold = 5; // pixels // Cached DOM elements for performance private cachedElements: CachedElements = { scrollContainer: null, currentColumn: null, lastColumnDate: null }; // Auto-scroll properties private autoScrollAnimationId: number | null = null; private readonly scrollSpeed = 10; // pixels per frame private readonly scrollThreshold = 30; // pixels from edge // Snap configuration private snapIntervalMinutes = 15; // Default 15 minutes private hourHeightPx: number; // Will be set from config // Event listener references for proper cleanup private boundHandlers = { mouseMove: this.handleMouseMove.bind(this), mouseDown: this.handleMouseDown.bind(this), mouseUp: this.handleMouseUp.bind(this) }; private get snapDistancePx(): number { return (this.snapIntervalMinutes / 60) * this.hourHeightPx; } constructor(eventBus: IEventBus) { this.eventBus = eventBus; // Get config values const gridSettings = calendarConfig.getGridSettings(); this.hourHeightPx = gridSettings.hourHeight; this.snapIntervalMinutes = gridSettings.snapInterval; this.init(); } /** * Configure snap interval */ public setSnapInterval(minutes: number): void { this.snapIntervalMinutes = minutes; } /** * Initialize with optimized event listener setup */ private init(): void { // Use bound handlers for proper cleanup document.body.addEventListener('mousemove', this.boundHandlers.mouseMove); document.body.addEventListener('mousedown', this.boundHandlers.mouseDown); document.body.addEventListener('mouseup', this.boundHandlers.mouseUp); // Add mouseleave listener to calendar container for drag cancellation const calendarContainer = document.querySelector('swp-calendar-container'); if (calendarContainer) { calendarContainer.addEventListener('mouseleave', () => { if (this.draggedElement && this.isDragStarted) { this.cancelDrag(); } }); } // Initialize column bounds cache ColumnDetectionUtils.updateColumnBoundsCache(); // Listen to resize events to update cache window.addEventListener('resize', () => { ColumnDetectionUtils.updateColumnBoundsCache(); }); // Listen to navigation events to update cache this.eventBus.on('navigation:completed', () => { ColumnDetectionUtils.updateColumnBoundsCache(); }); } private handleMouseDown(event: MouseEvent): void { this.isDragStarted = false; this.lastMousePosition = { x: event.clientX, y: event.clientY }; this.lastLoggedPosition = { x: event.clientX, y: event.clientY }; this.initialMousePosition = { x: event.clientX, y: event.clientY }; // Check if mousedown is on an event const target = event.target as HTMLElement; let eventElement = target; while (eventElement && eventElement.tagName !== 'SWP-EVENTS-LAYER') { if (eventElement.tagName === 'SWP-EVENT') { break; } eventElement = eventElement.parentElement as HTMLElement; if (!eventElement) return; } // If we reached SWP-EVENTS-LAYER without finding an event, return if (!eventElement || eventElement.tagName === 'SWP-EVENTS-LAYER') { return; } // Found an event - prepare for potential dragging if (eventElement) { this.draggedElement = eventElement; // Calculate mouse offset within event const eventRect = eventElement.getBoundingClientRect(); this.mouseOffset = { x: event.clientX - eventRect.left, y: event.clientY - eventRect.top }; // Detect current column const column = this.detectColumn(event.clientX, event.clientY); if (column) { this.currentColumn = column; } // Don't emit drag:start yet - wait for movement threshold } } /** * Optimized mouse move handler with consolidated position calculations */ private handleMouseMove(event: MouseEvent): void { this.currentMouseY = event.clientY; this.lastMousePosition = { x: event.clientX, y: event.clientY }; // Check for header enter/leave during drag if (this.draggedElement) { this.checkHeaderEnterLeave(event); } if (event.buttons === 1 && this.draggedElement) { const currentPosition: Position = { x: event.clientX, y: event.clientY }; // Check if we need to start drag (movement threshold) if (!this.isDragStarted) { const deltaX = Math.abs(currentPosition.x - this.initialMousePosition.x); const deltaY = Math.abs(currentPosition.y - this.initialMousePosition.y); const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY); if (totalMovement >= this.dragThreshold) { // Start drag - emit drag:start event this.isDragStarted = true; // Create SwpEventElement from existing DOM element and clone it const originalSwpEvent = SwpEventElement.fromExistingElement(this.draggedElement); const clonedSwpEvent = originalSwpEvent.createClone(); // Get the cloned DOM element this.draggedClone = clonedSwpEvent.getElement(); const dragStartPayload: DragStartEventPayload = { draggedElement: this.draggedElement, draggedClone: this.draggedClone, mousePosition: this.initialMousePosition, mouseOffset: this.mouseOffset, column: this.currentColumn }; this.eventBus.emit('drag:start', dragStartPayload); } else { // Not enough movement yet - don't start drag return; } } // Continue with normal drag behavior only if drag has started if (this.isDragStarted) { const deltaY = Math.abs(currentPosition.y - this.lastLoggedPosition.y); // Check for snap interval vertical movement (normal drag behavior) if (deltaY >= this.snapDistancePx) { this.lastLoggedPosition = currentPosition; // Consolidated position calculations with snapping for normal drag const positionData = this.calculateDragPosition(currentPosition); // Emit drag move event with snapped position (normal behavior) const dragMovePayload: DragMoveEventPayload = { draggedElement: this.draggedElement, mousePosition: currentPosition, snappedY: positionData.snappedY, column: positionData.column, mouseOffset: this.mouseOffset }; this.eventBus.emit('drag:move', dragMovePayload); } // Check for auto-scroll this.checkAutoScroll(event); // Check for column change using cached data const newColumn = this.getColumnFromCache(currentPosition); if (newColumn && newColumn !== this.currentColumn) { const previousColumn = this.currentColumn; this.currentColumn = newColumn; const dragColumnChangePayload: DragColumnChangeEventPayload = { draggedElement: this.draggedElement, draggedClone: this.draggedClone, previousColumn, newColumn, mousePosition: currentPosition }; this.eventBus.emit('drag:column-change', dragColumnChangePayload); } } } } /** * Optimized mouse up handler with consolidated cleanup */ private handleMouseUp(event: MouseEvent): void { this.stopAutoScroll(); if (this.draggedElement) { // Store variables locally before cleanup const draggedElement = this.draggedElement; const isDragStarted = this.isDragStarted; // Clean up drag state first this.cleanupDragState(); // Only emit drag:end if drag was actually started if (isDragStarted) { const mousePosition: Position = { x: event.clientX, y: event.clientY }; // Use consolidated position calculation const positionData = this.calculateDragPosition(mousePosition); // Detect drop target (swp-day-column or swp-day-header) const dropTarget = this.detectDropTarget(mousePosition); console.log('๐ŸŽฏ DragDropManager: Emitting drag:end', { draggedElement: draggedElement.dataset.eventId, finalColumn: positionData.column, finalY: positionData.snappedY, dropTarget: dropTarget, isDragStarted: isDragStarted }); const dragEndPayload: DragEndEventPayload = { draggedElement: draggedElement, mousePosition, finalPosition: positionData, target: dropTarget }; this.eventBus.emit('drag:end', dragEndPayload); draggedElement.remove(); } else { // This was just a click - emit click event instead this.eventBus.emit('event:click', { draggedElement: draggedElement, mousePosition: { x: event.clientX, y: event.clientY } }); } } } // Add a cleanup method that finds and removes ALL clones private cleanupAllClones(): void { // Remove clones from all possible locations const allClones = document.querySelectorAll('[data-event-id^="clone"]'); if (allClones.length > 0) { console.log(`๐Ÿงน DragDropManager: Removing ${allClones.length} clone(s)`); allClones.forEach(clone => clone.remove()); } } /** * Cancel drag operation when mouse leaves grid container */ private cancelDrag(): void { if (!this.draggedElement) return; console.log('๐Ÿšซ DragDropManager: Cancelling drag - mouse left grid container'); const draggedElement = this.draggedElement; // 1. Remove all clones this.cleanupAllClones(); // 2. Restore original element if (draggedElement) { draggedElement.style.opacity = ''; draggedElement.style.cursor = ''; } // 3. Emit cancellation event this.eventBus.emit('drag:cancelled', { draggedElement: draggedElement, reason: 'mouse-left-grid' }); // 4. Clean up state this.cleanupDragState(); this.stopAutoScroll(); } /** * Consolidated position calculation method using PositionUtils */ private calculateDragPosition(mousePosition: Position): { column: string | null; snappedY: number } { const column = this.detectColumn(mousePosition.x, mousePosition.y); const snappedY = this.calculateSnapPosition(mousePosition.y, column); return { column, snappedY }; } /** * Optimized snap position calculation using PositionUtils */ private calculateSnapPosition(mouseY: number, column: string | null = null): number { const targetColumn = column || this.currentColumn; // Use cached column element if available const columnElement = this.getCachedColumnElement(targetColumn); if (!columnElement) return mouseY; // Use PositionUtils for consistent snapping behavior const snappedY = PositionUtils.getPositionFromCoordinate(mouseY, columnElement); return Math.max(0, snappedY); } /** * Coordinate-based column detection (replaces DOM traversal) */ private detectColumn(mouseX: number, mouseY: number): string | null { // Brug den koordinatbaserede metode direkte const columnDate = ColumnDetectionUtils.getColumnDateFromX(mouseX); // Opdater stadig den eksisterende cache hvis vi finder en kolonne if (columnDate && columnDate !== this.cachedElements.lastColumnDate) { const columnElement = document.querySelector(`swp-day-column[data-date="${columnDate}"]`) as HTMLElement; if (columnElement) { this.cachedElements.currentColumn = columnElement; this.cachedElements.lastColumnDate = columnDate; } } return columnDate; } /** * Get column from cache or detect new one */ private getColumnFromCache(mousePosition: Position): string | null { // Try to use cached column first if (this.cachedElements.currentColumn && this.cachedElements.lastColumnDate) { const rect = this.cachedElements.currentColumn.getBoundingClientRect(); if (mousePosition.x >= rect.left && mousePosition.x <= rect.right) { return this.cachedElements.lastColumnDate; } } // Cache miss - detect new column return this.detectColumn(mousePosition.x, mousePosition.y); } /** * Get cached column element or query for new one */ private getCachedColumnElement(columnDate: string | null): HTMLElement | null { if (!columnDate) return null; // Return cached element if it matches if (this.cachedElements.lastColumnDate === columnDate && this.cachedElements.currentColumn) { return this.cachedElements.currentColumn; } // Query for new element and cache it const element = document.querySelector(`swp-day-column[data-date="${columnDate}"]`) as HTMLElement; if (element) { this.cachedElements.currentColumn = element; this.cachedElements.lastColumnDate = columnDate; } return element; } /** * Optimized auto-scroll check with cached container */ private checkAutoScroll(event: MouseEvent): void { // Use cached scroll container if (!this.cachedElements.scrollContainer) { this.cachedElements.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement; if (!this.cachedElements.scrollContainer) { return; } } const containerRect = this.cachedElements.scrollContainer.getBoundingClientRect(); const mouseY = event.clientY; // Calculate distances from edges const distanceFromTop = mouseY - containerRect.top; const distanceFromBottom = containerRect.bottom - mouseY; // Check if we need to scroll if (distanceFromTop <= this.scrollThreshold && distanceFromTop > 0) { this.startAutoScroll('up'); } else if (distanceFromBottom <= this.scrollThreshold && distanceFromBottom > 0) { this.startAutoScroll('down'); } else { this.stopAutoScroll(); } } /** * Optimized auto-scroll with cached container reference */ private startAutoScroll(direction: 'up' | 'down'): void { if (this.autoScrollAnimationId !== null) return; const scroll = () => { if (!this.cachedElements.scrollContainer || !this.draggedElement) { this.stopAutoScroll(); return; } const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed; this.cachedElements.scrollContainer.scrollTop += scrollAmount; // Emit updated position during scroll - adjust for scroll movement if (this.draggedElement) { // During autoscroll, we need to calculate position relative to the scrolled content // The mouse hasn't moved, but the content has scrolled const columnElement = this.getCachedColumnElement(this.currentColumn); if (columnElement) { const columnRect = columnElement.getBoundingClientRect(); // Calculate free position relative to column, accounting for scroll movement (no snapping during scroll) const relativeY = this.currentMouseY - columnRect.top - this.mouseOffset.y; const freeY = Math.max(0, relativeY); this.eventBus.emit('drag:auto-scroll', { draggedElement: this.draggedElement, snappedY: freeY, // Actually free position during scroll scrollTop: this.cachedElements.scrollContainer.scrollTop }); } } this.autoScrollAnimationId = requestAnimationFrame(scroll); }; this.autoScrollAnimationId = requestAnimationFrame(scroll); } /** * Stop auto-scroll animation */ private stopAutoScroll(): void { if (this.autoScrollAnimationId !== null) { cancelAnimationFrame(this.autoScrollAnimationId); this.autoScrollAnimationId = null; } } /** * Clean up drag state */ private cleanupDragState(): void { this.draggedElement = null; this.draggedClone = null; this.currentColumn = null; this.isDragStarted = false; this.isInHeader = false; // Clear cached elements this.cachedElements.currentColumn = null; this.cachedElements.lastColumnDate = null; } /** * Detect drop target - whether dropped in swp-day-column or swp-day-header */ private detectDropTarget(position: Position): 'swp-day-column' | 'swp-day-header' | null { const elementAtPosition = document.elementFromPoint(position.x, position.y); if (!elementAtPosition) return null; // Traverse up the DOM tree to find the target container let currentElement = elementAtPosition as HTMLElement; while (currentElement && currentElement !== document.body) { if (currentElement.tagName === 'SWP-DAY-HEADER') { return 'swp-day-header'; } if (currentElement.tagName === 'SWP-DAY-COLUMN') { return 'swp-day-column'; } currentElement = currentElement.parentElement as HTMLElement; } return null; } /** * Check for header enter/leave during drag operations */ private checkHeaderEnterLeave(event: MouseEvent): void { const elementAtPosition = document.elementFromPoint(event.clientX, event.clientY); if (!elementAtPosition) return; // Check if we're in a header area const headerElement = elementAtPosition.closest('swp-day-header, swp-calendar-header'); const isCurrentlyInHeader = !!headerElement; // Detect header enter if (!this.isInHeader && isCurrentlyInHeader) { this.isInHeader = true; // Calculate target date using existing method const targetDate = ColumnDetectionUtils.getColumnDateFromX(event.clientX); if (targetDate) { console.log('๐ŸŽฏ DragDropManager: Emitting drag:mouseenter-header', { targetDate }); // Find clone element (if it exists) const eventId = this.draggedElement?.dataset.eventId; const cloneElement = document.querySelector(`[data-event-id="clone-${eventId}"]`) as HTMLElement; const dragMouseEnterPayload: DragMouseEnterHeaderEventPayload = { targetDate, mousePosition: { x: event.clientX, y: event.clientY }, originalElement: this.draggedElement, cloneElement: cloneElement }; this.eventBus.emit('drag:mouseenter-header', dragMouseEnterPayload); } } // Detect header leave if (this.isInHeader && !isCurrentlyInHeader) { this.isInHeader = false; console.log('๐Ÿšช DragDropManager: Emitting drag:mouseleave-header'); // Calculate target date using existing method const targetDate = ColumnDetectionUtils.getColumnDateFromX(event.clientX); // Find clone element (if it exists) const eventId = this.draggedElement?.dataset.eventId; const cloneElement = document.querySelector(`[data-event-id="clone-${eventId}"]`) as HTMLElement; const dragMouseLeavePayload: DragMouseLeaveHeaderEventPayload = { targetDate, mousePosition: { x: event.clientX, y: event.clientY }, originalElement: this.draggedElement, cloneElement: cloneElement }; this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload); } } /** * Clean up all resources and event listeners */ public destroy(): void { this.stopAutoScroll(); // Remove event listeners using bound references document.body.removeEventListener('mousemove', this.boundHandlers.mouseMove); document.body.removeEventListener('mousedown', this.boundHandlers.mouseDown); document.body.removeEventListener('mouseup', this.boundHandlers.mouseUp); // Clear all cached elements this.cachedElements.scrollContainer = null; this.cachedElements.currentColumn = null; this.cachedElements.lastColumnDate = null; // Clean up drag state this.cleanupDragState(); } }