/** * 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 { DateCalculator } from '../utils/DateCalculator'; import { PositionUtils } from '../utils/PositionUtils'; 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 draggedEventId: string | null = null; private originalElement: HTMLElement | null = null; private currentColumn: string | null = null; private isDragStarted = 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); // Listen for header mouseover events this.eventBus.on('header:mouseover', (event) => { const { targetDate, headerRenderer } = (event as CustomEvent).detail; if (this.draggedEventId && targetDate) { // Find dragget element dynamisk const draggedElement = document.querySelector(`swp-event[data-event-id="${this.draggedEventId}"]`); if (draggedElement) { // Element findes stadig som day-event, så konverter this.eventBus.emit('drag:convert-to-allday', { targetDate, originalElement: draggedElement, headerRenderer }); // Hide drag clone completely const dragClone = document.querySelector(`swp-event[data-event-id="clone-${this.draggedEventId}"]`); if (dragClone) { (dragClone as HTMLElement).style.display = 'none'; } } } }); // Listen for column mouseover events (for all-day to timed conversion) this.eventBus.on('column:mouseover', (event) => { const { targetColumn, targetY } = (event as CustomEvent).detail; if (this.draggedEventId && this.isAllDayEventBeingDragged()) { // Emit event to convert to timed this.eventBus.emit('drag:convert-to-timed', { eventId: this.draggedEventId, targetColumn, targetY }); } }); // Listen for header mouseleave events (remove all-day event, let clone take over) this.eventBus.on('header:mouseleave', (event) => { // Check if we're dragging ANY event if (this.draggedEventId) { // Find and remove all-day event specifically in the container const allDayEvent = document.querySelector(`swp-allday-container swp-allday-event[data-event-id="${this.draggedEventId}"]`); if (allDayEvent) { allDayEvent.remove(); } // Show drag clone again const dragClone = document.querySelector(`swp-event[data-event-id="clone-${this.draggedEventId}"]`); if (dragClone) { (dragClone as HTMLElement).style.display = 'block'; } } }); } 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.originalElement = eventElement; this.draggedEventId = eventElement.dataset.eventId || null; // 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; if (event.buttons === 1 && this.draggedEventId) { 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; this.eventBus.emit('drag:start', { eventId: this.draggedEventId, mousePosition: this.initialMousePosition, mouseOffset: this.mouseOffset, column: this.currentColumn }); } 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) this.eventBus.emit('drag:move', { eventId: this.draggedEventId, mousePosition: currentPosition, snappedY: positionData.snappedY, column: positionData.column, mouseOffset: this.mouseOffset }); } // 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; this.eventBus.emit('drag:column-change', { eventId: this.draggedEventId, previousColumn, newColumn, mousePosition: currentPosition }); } } } } /** * Optimized mouse up handler with consolidated cleanup */ private handleMouseUp(event: MouseEvent): void { this.stopAutoScroll(); if (this.draggedEventId && this.originalElement) { // Store variables locally before cleanup const eventId = this.draggedEventId; const originalElement = this.originalElement; const isDragStarted = this.isDragStarted; // Clean up drag state first this.cleanupDragState(); // Only emit drag:end if drag was actually started if (isDragStarted) { const finalPosition: Position = { x: event.clientX, y: event.clientY }; // Use consolidated position calculation const positionData = this.calculateDragPosition(finalPosition); this.eventBus.emit('drag:end', { eventId: eventId, finalPosition, finalColumn: positionData.column, finalY: positionData.snappedY }); } else { // This was just a click - emit click event instead this.eventBus.emit('event:click', { eventId: eventId, mousePosition: { x: event.clientX, y: event.clientY } }); } } } /** * 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 }; } /** * Calculate free position (follows mouse exactly) */ private calculateFreePosition(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; const relativeY = PositionUtils.getPositionFromCoordinate(mouseY, columnElement); // Return free position (no snapping) return Math.max(0, relativeY); } /** * 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); } /** * Optimized column detection with caching */ private detectColumn(mouseX: number, mouseY: number): string | null { const element = document.elementFromPoint(mouseX, mouseY); if (!element) return null; // Walk up DOM tree to find swp-day-column let current = element as HTMLElement; while (current && current.tagName !== 'SWP-DAY-COLUMN') { current = current.parentElement as HTMLElement; if (!current) return null; } const columnDate = current.dataset.date || null; // Update cache if we found a new column if (columnDate && columnDate !== this.cachedElements.lastColumnDate) { this.cachedElements.currentColumn = current; 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.draggedEventId) { 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.draggedEventId) { // 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', { eventId: this.draggedEventId, 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.draggedEventId = null; this.originalElement = null; this.currentColumn = null; this.isDragStarted = false; // Clear cached elements this.cachedElements.currentColumn = null; this.cachedElements.lastColumnDate = null; } /** * Check if an all-day event is currently being dragged */ private isAllDayEventBeingDragged(): boolean { if (!this.draggedEventId) return false; // Check if element exists as all-day event const allDayElement = document.querySelector(`swp-allday-event[data-event-id="${this.draggedEventId}"]`); return allDayElement !== null; } /** * 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(); } }