/** * 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 { ColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils'; import { SwpEventElement } from '../elements/SwpEventElement'; import { DragStartEventPayload, DragMoveEventPayload, DragEndEventPayload, DragMouseEnterHeaderEventPayload, DragMouseLeaveHeaderEventPayload, DragColumnChangeEventPayload } from '../types/EventTypes'; import { MousePosition } from '../types/DragDropTypes'; interface CachedElements { scrollContainer: HTMLElement | null; } export class DragDropManager { private eventBus: IEventBus; // Mouse tracking with optimized state private lastMousePosition: MousePosition = { x: 0, y: 0 }; private lastLoggedPosition: MousePosition = { x: 0, y: 0 }; private currentMouseY = 0; private mouseOffset: MousePosition = { x: 0, y: 0 }; private initialMousePosition: MousePosition = { x: 0, y: 0 }; private lastColumn: ColumnBounds | null = null; // Drag state private draggedElement!: HTMLElement | null; private draggedClone!: HTMLElement | null; private currentColumnBounds: ColumnBounds | null = null; private isDragStarted = false; // Header tracking state private isInHeader = false; // Movement threshold to distinguish click from drag private readonly dragThreshold = 5; // pixels private scrollContainer!: HTMLElement | null; // Cached DOM elements for performance // 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 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 { // Add event listeners document.body.addEventListener('mousemove', this.handleMouseMove.bind(this)); document.body.addEventListener('mousedown', this.handleMouseDown.bind(this)); document.body.addEventListener('mouseup', this.handleMouseUp.bind(this)); this.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement; 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 { // Clean up drag state first this.cleanupDragState(); ColumnDetectionUtils.updateColumnBoundsCache(); 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; this.lastColumn = ColumnDetectionUtils.getColumnBounds(this.lastMousePosition) // Calculate mouse offset within event const eventRect = eventElement.getBoundingClientRect(); this.mouseOffset = { x: event.clientX - eventRect.left, y: event.clientY - eventRect.top }; } } /** * 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 }; if (event.buttons === 1) { const currentPosition: MousePosition = { x: event.clientX, y: event.clientY }; //TODO: Is this really needed? why not just use event.clientX + Y directly // Check for header enter/leave during drag if (this.draggedClone) { this.checkHeaderEnterLeave(event); } // Check if we need to start drag (movement threshold) if (!this.isDragStarted && this.draggedElement) { 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; // Detect current column this.currentColumnBounds = ColumnDetectionUtils.getColumnBounds(currentPosition); // 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, columnBounds: this.currentColumnBounds }; 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 && this.draggedElement && this.draggedClone) { 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, draggedClone: this.draggedClone, mousePosition: currentPosition, snappedY: positionData.snappedY, columnBounds: positionData.column, mouseOffset: this.mouseOffset }; this.eventBus.emit('drag:move', dragMovePayload); } // Check for auto-scroll this.checkAutoScroll(currentPosition); // Check for column change using cached data const newColumn = ColumnDetectionUtils.getColumnBounds(currentPosition); if (newColumn == null) return; if (newColumn?.index !== this.currentColumnBounds?.index) { const previousColumn = this.currentColumnBounds; this.currentColumnBounds = newColumn; const dragColumnChangePayload: DragColumnChangeEventPayload = { originalElement: 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) { // Only emit drag:end if drag was actually started if (this.isDragStarted) { const mousePosition: MousePosition = { 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); if (!dropTarget) throw "dropTarget is null"; console.log('๐ŸŽฏ DragDropManager: Emitting drag:end', { draggedElement: this.draggedElement.dataset.eventId, finalColumn: positionData.column, finalY: positionData.snappedY, dropTarget: dropTarget, isDragStarted: this.isDragStarted }); const dragEndPayload: DragEndEventPayload = { originalElement: this.draggedElement, draggedClone: this.draggedClone, mousePosition, finalPosition: positionData, target: dropTarget }; this.eventBus.emit('drag:end', dragEndPayload); this.draggedElement = null; } else { // This was just a click - emit click event instead this.eventBus.emit('event:click', { draggedElement: this.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: MousePosition): { column: ColumnBounds | null; snappedY: number } { let column = ColumnDetectionUtils.getColumnBounds(mousePosition); let snappedY = 0; if (column) { snappedY = this.calculateSnapPosition(mousePosition.y, column); return { column, snappedY }; } return { column, snappedY }; } /** * Optimized snap position calculation using PositionUtils */ private calculateSnapPosition(mouseY: number, column: ColumnBounds): number { const snappedY = PositionUtils.getPositionFromCoordinate(mouseY, column); return Math.max(0, snappedY); } /** * Optimized auto-scroll check with cached container */ private checkAutoScroll(mousePosition: MousePosition): void { if (this.scrollContainer == null) return; const containerRect = this.scrollContainer.getBoundingClientRect(); const mouseY = mousePosition.clientY; // Calculate distances from edges const distanceFromTop = mousePosition.y - containerRect.top; const distanceFromBottom = containerRect.bottom - mousePosition.y; // Check if we need to scroll if (distanceFromTop <= this.scrollThreshold && distanceFromTop > 0) { this.startAutoScroll('up', mousePosition); } else if (distanceFromBottom <= this.scrollThreshold && distanceFromBottom > 0) { this.startAutoScroll('down', mousePosition); } else { this.stopAutoScroll(); } } /** * Optimized auto-scroll with cached container reference */ private startAutoScroll(direction: 'up' | 'down', event: MousePosition): void { if (this.autoScrollAnimationId !== null) return; const scroll = () => { if (!this.scrollContainer || !this.draggedElement) { this.stopAutoScroll(); return; } const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed; this.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 = ColumnDetectionUtils.getColumnBounds(event); if (columnElement) { const columnRect = columnElement.boundingClientRect; // 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.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.isDragStarted = false; this.isInHeader = false; } /** * Detect drop target - whether dropped in swp-day-column or swp-day-header */ private detectDropTarget(position: MousePosition): 'swp-day-column' | 'swp-day-header' | null { // Traverse up the DOM tree to find the target container let currentElement = this.draggedClone; while (currentElement && currentElement !== document.body) { if (currentElement.tagName === 'SWP-ALLDAY-CONTAINER') { 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 { let position: MousePosition = { x: event.clientX, y: event.clientY }; 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.draggedClone) { this.isInHeader = true; // Calculate target date using existing method const targetColumn = ColumnDetectionUtils.getColumnBounds(position); if (targetColumn) { console.log('๐ŸŽฏ DragDropManager: Emitting drag:mouseenter-header', { targetDate: targetColumn }); const dragMouseEnterPayload: DragMouseEnterHeaderEventPayload = { targetColumn: targetColumn, mousePosition: { x: event.clientX, y: event.clientY }, originalElement: this.draggedElement, draggedClone: this.draggedClone }; 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 targetColumn = ColumnDetectionUtils.getColumnBounds(position); if (!targetColumn) { console.warn("No column detected, unknown reason"); return; } const dragMouseLeavePayload: DragMouseLeaveHeaderEventPayload = { targetDate: targetColumn.date, mousePosition: { x: event.clientX, y: event.clientY }, originalElement: this.draggedElement, draggedClone: this.draggedClone }; this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload); } } }