/** * 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, BaseSwpEventElement } 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; // Hover state private isHoverTrackingActive = false; private currentHoveredEvent: HTMLElement | null = null; // 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 // Smooth drag animation private dragAnimationId: number | null = null; private targetY = 0; private currentY = 0; private targetColumn: ColumnBounds | null = null; 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(); } }); // Event delegation for header enter/leave calendarContainer.addEventListener('mouseenter', (e) => { const target = e.target as HTMLElement; if (target.closest('swp-calendar-header')) { this.handleHeaderMouseEnter(e as MouseEvent); } else if (target.closest('swp-event')) { // Entered an event - activate hover tracking and set color const eventElement = target.closest('swp-event'); const mouseEvent = e as MouseEvent; // Only handle hover if mouse button is up if (eventElement && !this.isDragStarted && mouseEvent.buttons === 0) { // Clear any previous hover first if (this.currentHoveredEvent && this.currentHoveredEvent !== eventElement) { this.currentHoveredEvent.classList.remove('hover'); } this.isHoverTrackingActive = true; this.currentHoveredEvent = eventElement; eventElement.classList.add('hover'); } } }, true); // Use capture phase calendarContainer.addEventListener('mouseleave', (e) => { const target = e.target as HTMLElement; if (target.closest('swp-calendar-header')) { this.handleHeaderMouseLeave(e as MouseEvent); } // Don't handle swp-event mouseleave here - let mousemove handle it }, true); // Use capture phase } // 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-GRID-CONTAINER') { if (eventElement.tagName === 'SWP-EVENT' || eventElement.tagName === 'SWP-ALLDAY-EVENT') { break; } eventElement = eventElement.parentElement as HTMLElement; if (!eventElement) return; } // Found an event - check if in resize zone first if (eventElement) { // Check if click is in bottom resize zone const rect = eventElement.getBoundingClientRect(); const mouseY = event.clientY; const distanceFromBottom = rect.bottom - mouseY; const resizeZoneHeight = 15; // Match ResizeHandleManager // If in resize zone, don't handle this - let ResizeHandleManager take over if (distanceFromBottom >= 0 && distanceFromBottom <= resizeZoneHeight) { return; // Exit early - this is a resize operation } // Normal drag - prepare for potential dragging 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 }; // Check for event hover (coordinate-based) - only when mouse button is up if (this.isHoverTrackingActive && event.buttons === 0) { this.checkEventHover(event); } if (event.buttons === 1) { const currentPosition: MousePosition = { x: event.clientX, y: event.clientY }; // 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; // Set high z-index on event-group if exists, otherwise on event itself const eventGroup = this.draggedElement.closest('swp-event-group'); if (eventGroup) { eventGroup.style.zIndex = '9999'; } else { this.draggedElement.style.zIndex = '9999'; } // Detect current column this.currentColumnBounds = ColumnDetectionUtils.getColumnBounds(currentPosition); // Cast to BaseSwpEventElement and create clone (works for both SwpEventElement and SwpAllDayEventElement) const originalElement = this.draggedElement as BaseSwpEventElement; this.draggedClone = originalElement.createClone(); 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) { if (!this.draggedElement.hasAttribute("data-allday")) { // Calculate raw position from mouse (no snapping) const column = ColumnDetectionUtils.getColumnBounds(currentPosition); if (column) { // Calculate raw Y position relative to column (accounting for mouse offset) const columnRect = column.boundingClientRect; const eventTopY = currentPosition.y - columnRect.top - this.mouseOffset.y; this.targetY = Math.max(0, eventTopY); // Store raw Y as target (no snapping) this.targetColumn = column; // Start animation loop if not already running if (this.dragAnimationId === null) { this.currentY = parseFloat(this.draggedClone.style.top) || 0; this.animateDrag(); } } // Check for auto-scroll this.checkAutoScroll(currentPosition); } 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(); this.stopDragAnimation(); if (this.draggedElement) { // Only emit drag:end if drag was actually started if (this.isDragStarted) { const mousePosition: MousePosition = { x: event.clientX, y: event.clientY }; // Snap to grid on mouse up (like ResizeHandleManager) const column = ColumnDetectionUtils.getColumnBounds(mousePosition); if (!column) { console.warn('No column detected on mouseUp'); return; } // Get current position and snap it to grid const currentY = parseFloat(this.draggedClone?.style.top || '0'); const snappedY = this.calculateSnapPosition(mousePosition.y, column); // Update clone to snapped position immediately if (this.draggedClone) { this.draggedClone.style.top = `${snappedY}px`; } // 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: column, finalY: snappedY, dropTarget: dropTarget, isDragStarted: this.isDragStarted }); const dragEndPayload: DragEndEventPayload = { originalElement: this.draggedElement, draggedClone: this.draggedClone, mousePosition, finalPosition: { column, snappedY }, target: dropTarget }; this.eventBus.emit('drag:end', dragEndPayload); this.cleanupDragState(); } 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(); this.stopDragAnimation(); } /** * 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 { // Calculate where the event top would be (accounting for mouse offset) const eventTopY = mouseY - this.mouseOffset.y; // Snap the event top position, not the mouse position const snappedY = PositionUtils.getPositionFromCoordinate(eventTopY, column); return Math.max(0, snappedY); } /** * Smooth drag animation using requestAnimationFrame */ private animateDrag(): void { if (!this.isDragStarted || !this.draggedClone || !this.targetColumn) { this.dragAnimationId = null; return; } // Smooth interpolation towards target const diff = this.targetY - this.currentY; const step = diff * 0.3; // 30% of distance per frame // Update if difference is significant if (Math.abs(diff) > 0.5) { this.currentY += step; // Emit drag move event with interpolated position const dragMovePayload: DragMoveEventPayload = { draggedElement: this.draggedElement!, draggedClone: this.draggedClone, mousePosition: this.lastMousePosition, snappedY: this.currentY, columnBounds: this.targetColumn, mouseOffset: this.mouseOffset }; this.eventBus.emit('drag:move', dragMovePayload); this.dragAnimationId = requestAnimationFrame(() => this.animateDrag()); } else { // Close enough - snap to target this.currentY = this.targetY; const dragMovePayload: DragMoveEventPayload = { draggedElement: this.draggedElement!, draggedClone: this.draggedClone, mousePosition: this.lastMousePosition, snappedY: this.currentY, columnBounds: this.targetColumn, mouseOffset: this.mouseOffset }; this.eventBus.emit('drag:move', dragMovePayload); this.dragAnimationId = null; } } /** * 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; } } /** * Stop drag animation */ private stopDragAnimation(): void { if (this.dragAnimationId !== null) { cancelAnimationFrame(this.dragAnimationId); this.dragAnimationId = null; } } /** * Clean up drag state */ private cleanupDragState(): void { this.draggedElement = null; this.draggedClone = null; this.isDragStarted = 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; } /** * Handle mouse enter on calendar header - simplified using native events */ private handleHeaderMouseEnter(event: MouseEvent): void { // Only handle if we're dragging a timed event (not all-day) if (!this.isDragStarted || !this.draggedClone) { return; } const position: MousePosition = { x: event.clientX, y: event.clientY }; const targetColumn = ColumnDetectionUtils.getColumnBounds(position); if (targetColumn) { console.log('๐ŸŽฏ DragDropManager: Mouse entered header', { targetDate: targetColumn }); // Extract CalendarEvent from the dragged clone const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone); const dragMouseEnterPayload: DragMouseEnterHeaderEventPayload = { targetColumn: targetColumn, mousePosition: position, originalElement: this.draggedElement, draggedClone: this.draggedClone, calendarEvent: calendarEvent, // Delegate pattern - allows AllDayManager to replace the clone replaceClone: (newClone: HTMLElement) => { this.draggedClone = newClone; } }; this.eventBus.emit('drag:mouseenter-header', dragMouseEnterPayload); } } /** * Handle mouse leave from calendar header - simplified using native events */ private handleHeaderMouseLeave(event: MouseEvent): void { // Only handle if we're dragging an all-day event if (!this.isDragStarted || !this.draggedClone || !this.draggedClone.hasAttribute("data-allday")) { return; } console.log('๐Ÿšช DragDropManager: Mouse left header'); const position: MousePosition = { x: event.clientX, y: event.clientY }; const targetColumn = ColumnDetectionUtils.getColumnBounds(position); if (!targetColumn) { console.warn("No column detected when leaving header"); return; } const dragMouseLeavePayload: DragMouseLeaveHeaderEventPayload = { targetDate: targetColumn.date, mousePosition: position, originalElement: this.draggedElement, draggedClone: this.draggedClone }; this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload); } private checkEventHover(event: MouseEvent): void { // Use currentHoveredEvent to check if mouse is still within bounds if (!this.currentHoveredEvent) return; const rect = this.currentHoveredEvent.getBoundingClientRect(); const mouseX = event.clientX; const mouseY = event.clientY; // Check if mouse is still within the current hovered event const isStillInside = mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom; // If mouse left the event if (!isStillInside) { // Only disable tracking and clear if mouse is NOT pressed (allow resize to work) if (event.buttons === 0) { this.isHoverTrackingActive = false; this.clearEventHover(); } } } private clearEventHover(): void { if (this.currentHoveredEvent) { this.currentHoveredEvent.classList.remove('hover'); this.currentHoveredEvent = null; } } }