/** * 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, DragMouseEnterColumnEventPayload, DragColumnChangeEventPayload } from '../types/EventTypes'; import { MousePosition } from '../types/DragDropTypes'; export class DragDropManager { private eventBus: IEventBus; // Mouse tracking with optimized state private mouseDownPosition: MousePosition = { x: 0, y: 0 }; private currentMousePosition: MousePosition = { x: 0, y: 0 }; private mouseOffset: MousePosition = { x: 0, y: 0 }; // Drag state private originalElement!: HTMLElement | null; private draggedClone!: HTMLElement | null; private currentColumn: ColumnBounds | null = null; private previousColumn: 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 // Smooth drag animation private dragAnimationId: number | null = null; private targetY = 0; private currentY = 0; private targetColumn: ColumnBounds | null = null; constructor(eventBus: IEventBus) { this.eventBus = eventBus; // Get config values const gridSettings = calendarConfig.getGridSettings(); this.init(); } /** * 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)); const calendarContainer = document.querySelector('swp-calendar-container'); if (calendarContainer) { calendarContainer.addEventListener('mouseleave', () => { if (this.originalElement && 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-day-column')) { this.handleColumnMouseEnter(e as MouseEvent); } else if (target.closest('swp-event')) { this.handleEventMouseEnter(e as MouseEvent); } }, 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.initialMousePosition = { x: event.clientX, y: event.clientY }; // Check if mousedown is on an event const target = event.target as HTMLElement; if (target.closest('swp-resize-handle')) return; 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; } if (eventElement) { // Normal drag - prepare for potential dragging this.originalElement = eventElement; // Calculate mouse offset within event const eventRect = eventElement.getBoundingClientRect(); this.mouseOffset = { x: event.clientX - eventRect.left, y: event.clientY - eventRect.top }; this.mouseDownPosition = { x: event.clientX, y: event.clientY }; } } /** * 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 }; this.currentMousePosition = currentPosition; // Track current mouse position // Try to initialize drag if not started if (!this.isDragStarted && this.originalElement) { if (!this.initializeDrag(currentPosition)) { return; // Not enough movement yet } } // Continue drag if started //TODO: This has to be fixed... it fires way too many events, we can do better if (this.isDragStarted && this.originalElement && this.draggedClone) { //console.log("Continue drag if started", this.draggedClone); this.continueDrag(currentPosition); this.detectColumnChange(currentPosition); } } } /** * Try to initialize drag based on movement threshold * Returns true if drag was initialized, false if not enough movement */ private initializeDrag(currentPosition: MousePosition): boolean { const deltaX = Math.abs(currentPosition.x - this.mouseDownPosition.x); const deltaY = Math.abs(currentPosition.y - this.mouseDownPosition.y); const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY); if (totalMovement < this.dragThreshold) { return false; // Not enough movement } // Start drag this.isDragStarted = true; // Set high z-index on event-group if exists, otherwise on event itself const eventGroup = this.originalElement!.closest('swp-event-group'); if (eventGroup) { eventGroup.style.zIndex = '9999'; } else { this.originalElement!.style.zIndex = '9999'; } const originalElement = this.originalElement as BaseSwpEventElement; this.currentColumn = ColumnDetectionUtils.getColumnBounds(currentPosition); this.draggedClone = originalElement.createClone(); const dragStartPayload: DragStartEventPayload = { originalElement: this.originalElement!, draggedClone: this.draggedClone, mousePosition: this.mouseDownPosition, mouseOffset: this.mouseOffset, columnBounds: this.currentColumn }; this.eventBus.emit('drag:start', dragStartPayload); return true; } private continueDrag(currentPosition: MousePosition): void { if (!this.draggedClone!.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); this.targetColumn = column; // Start animation loop if not already running if (this.dragAnimationId === null) { this.currentY = parseFloat(this.draggedClone!.style.top) || 0; this.animateDrag(); } } } } /** * Detect column change and emit event */ private detectColumnChange(currentPosition: MousePosition): void { const newColumn = ColumnDetectionUtils.getColumnBounds(currentPosition); if (newColumn == null) return; if (newColumn.index !== this.currentColumn?.index) { this.previousColumn = this.currentColumn; this.currentColumn = newColumn; const dragColumnChangePayload: DragColumnChangeEventPayload = { originalElement: this.originalElement!, draggedClone: this.draggedClone!, previousColumn: this.previousColumn, newColumn, mousePosition: currentPosition }; this.eventBus.emit('drag:column-change', dragColumnChangePayload); } } /** * Optimized mouse up handler with consolidated cleanup */ private handleMouseUp(event: MouseEvent): void { this.stopDragAnimation(); if (this.originalElement) { // 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) return; // Get current position and snap it to grid 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"; const dragEndPayload: DragEndEventPayload = { originalElement: this.originalElement, draggedClone: this.draggedClone, mousePosition, sourceColumn: this.previousColumn!!, finalPosition: { column, snappedY }, // Where drag ended target: dropTarget }; console.log('DragEndEventPayload', dragEndPayload); this.eventBus.emit('drag:end', dragEndPayload); this.cleanupDragState(); } else { // This was just a click - emit click event instead this.eventBus.emit('event:click', { clickedElement: this.originalElement, 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.originalElement) return; console.log('๐Ÿšซ DragDropManager: Cancelling drag - mouse left grid container'); this.cleanupAllClones(); this.originalElement.style.opacity = ''; this.originalElement.style.cursor = ''; this.eventBus.emit('drag:cancelled', { originalElement: this.originalElement, reason: 'mouse-left-grid' }); this.cleanupDragState(); this.stopDragAnimation(); } /** * 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 * Emits drag:move events with current draggedClone reference on each frame */ private animateDrag(): void { //TODO: this can be optimized... WAIT !!! 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 current draggedClone reference const dragMovePayload: DragMoveEventPayload = { originalElement: this.originalElement!, draggedClone: this.draggedClone, // Always uses current reference mousePosition: this.currentMousePosition, // Use current mouse position! 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; // Emit final position const dragMovePayload: DragMoveEventPayload = { originalElement: this.originalElement!, draggedClone: this.draggedClone, mousePosition: this.currentMousePosition, // Use current mouse position! snappedY: this.currentY, columnBounds: this.targetColumn, mouseOffset: this.mouseOffset }; this.eventBus.emit('drag:move', dragMovePayload); this.dragAnimationId = 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.previousColumn = null; this.originalElement = null; this.draggedClone = null; this.currentColumn = 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 swp-event - activate hover tracking */ private handleEventMouseEnter(event: MouseEvent): void { const target = event.target as HTMLElement; const eventElement = target.closest('swp-event'); // Only handle hover if mouse button is up if (eventElement && !this.isDragStarted && event.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'); } } /** * 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) { const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone); const dragMouseEnterPayload: DragMouseEnterHeaderEventPayload = { targetColumn: targetColumn, mousePosition: position, originalElement: this.originalElement, draggedClone: this.draggedClone, calendarEvent: calendarEvent, replaceClone: (newClone: HTMLElement) => { this.draggedClone = newClone; this.dragAnimationId === null; } }; console.log('DragMouseEnterHeaderEventPayload', dragMouseEnterPayload); this.eventBus.emit('drag:mouseenter-header', dragMouseEnterPayload); } } /** * Handle mouse enter on day column - for converting all-day to timed events */ private handleColumnMouseEnter(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 entered day column'); const position: MousePosition = { x: event.clientX, y: event.clientY }; const targetColumn = ColumnDetectionUtils.getColumnBounds(position); if (!targetColumn) { console.warn("No column detected when entering day column"); return; } // Calculate snapped Y position const snappedY = this.calculateSnapPosition(position.y, targetColumn); // Extract CalendarEvent from the dragged clone const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone); const dragMouseEnterPayload: DragMouseEnterColumnEventPayload = { targetColumn: targetColumn, mousePosition: position, snappedY: snappedY, originalElement: this.originalElement, draggedClone: this.draggedClone, calendarEvent: calendarEvent, replaceClone: (newClone: HTMLElement) => { this.draggedClone = newClone; this.dragAnimationId === null; this.stopDragAnimation(); } }; this.eventBus.emit('drag:mouseenter-column', 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.originalElement, 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; } } }