/** * DragDropManager - Handles drag and drop interaction logic * Emits events for visual updates handled by EventRenderer */ import { IEventBus } from '../types/CalendarTypes'; import { CalendarConfig } from '../core/CalendarConfig'; export class DragDropManager { private eventBus: IEventBus; private config: CalendarConfig; // Mouse tracking private isMouseDown = false; private lastMousePosition = { x: 0, y: 0 }; private lastLoggedPosition = { x: 0, y: 0 }; private currentMouseY = 0; private mouseOffset = { x: 0, y: 0 }; // Drag state private draggedEventId: string | null = null; private originalElement: HTMLElement | null = null; private currentColumn: string | null = null; // Auto-scroll properties private scrollContainer: HTMLElement | null = null; private autoScrollAnimationId: number | null = null; private scrollSpeed = 10; // pixels per frame private scrollThreshold = 30; // pixels from edge // Snap configuration private snapIntervalMinutes = 15; // Default 15 minutes private hourHeightPx = 60; // From CSS --hour-height private get snapDistancePx(): number { return (this.snapIntervalMinutes / 60) * this.hourHeightPx; } constructor(eventBus: IEventBus, config: CalendarConfig) { this.eventBus = eventBus; this.config = config; // Get config values const gridSettings = config.getGridSettings(); this.hourHeightPx = gridSettings.hourHeight; this.init(); } /** * Configure snap interval */ public setSnapInterval(minutes: number): void { this.snapIntervalMinutes = minutes; } private init(): void { // Listen to mouse events on body document.body.addEventListener('mousemove', this.handleMouseMove.bind(this)); document.body.addEventListener('mousedown', this.handleMouseDown.bind(this)); document.body.addEventListener('mouseup', this.handleMouseUp.bind(this)); // Listen for header mouseover events this.eventBus.on('header:mouseover', (event) => { const { element, targetDate, headerRenderer } = (event as CustomEvent).detail; if (this.isMouseDown && this.draggedEventId && targetDate) { // Emit event to convert to all-day this.eventBus.emit('drag:convert-to-allday', { eventId: this.draggedEventId, targetDate, element, headerRenderer }); } }); } private handleMouseDown(event: MouseEvent): void { this.isMouseDown = true; this.lastMousePosition = { x: event.clientX, y: event.clientY }; this.lastLoggedPosition = { 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' || eventElement.tagName === 'SWP-ALLDAY-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 - start 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; } // Emit drag start event this.eventBus.emit('drag:start', { originalElement: eventElement, eventId: this.draggedEventId, mousePosition: { x: event.clientX, y: event.clientY }, mouseOffset: this.mouseOffset, column: this.currentColumn }); } } private handleMouseMove(event: MouseEvent): void { this.currentMouseY = event.clientY; if (this.isMouseDown && this.draggedEventId) { const deltaY = Math.abs(event.clientY - this.lastLoggedPosition.y); // Check for snap interval vertical movement if (deltaY >= this.snapDistancePx) { this.lastLoggedPosition = { x: event.clientX, y: event.clientY }; // Calculate snapped position const column = this.detectColumn(event.clientX, event.clientY); const snappedY = this.calculateSnapPosition(event.clientY); // Emit drag move event with snapped position this.eventBus.emit('drag:move', { eventId: this.draggedEventId, mousePosition: { x: event.clientX, y: event.clientY }, snappedY, column, mouseOffset: this.mouseOffset }); } // Check for auto-scroll this.checkAutoScroll(event); // Check for column change const newColumn = this.detectColumn(event.clientX, event.clientY); if (newColumn && newColumn !== this.currentColumn) { this.currentColumn = newColumn; this.eventBus.emit('drag:column-change', { eventId: this.draggedEventId, previousColumn: this.currentColumn, newColumn, mousePosition: { x: event.clientX, y: event.clientY } }); } } } private handleMouseUp(event: MouseEvent): void { if (!this.isMouseDown) return; this.isMouseDown = false; // Stop auto-scroll this.stopAutoScroll(); if (this.draggedEventId && this.originalElement) { // Calculate final position const finalColumn = this.detectColumn(event.clientX, event.clientY); const finalY = this.calculateSnapPosition(event.clientY); // Emit drag end event this.eventBus.emit('drag:end', { eventId: this.draggedEventId, originalElement: this.originalElement, finalPosition: { x: event.clientX, y: event.clientY }, finalColumn, finalY }); // Clean up this.draggedEventId = null; this.originalElement = null; this.currentColumn = null; this.scrollContainer = null; } } /** * Calculate snapped Y position based on mouse Y */ private calculateSnapPosition(mouseY: number): number { // Find the column element to get relative position const columnElement = this.currentColumn ? document.querySelector(`swp-day-column[data-date="${this.currentColumn}"]`) : null; if (!columnElement) return mouseY; const columnRect = columnElement.getBoundingClientRect(); const relativeY = mouseY - columnRect.top - this.mouseOffset.y; // Snap to nearest interval const snappedY = Math.round(relativeY / this.snapDistancePx) * this.snapDistancePx; // Ensure non-negative return Math.max(0, snappedY); } /** * Detect which column the mouse is over */ 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; } return current.dataset.date || null; } /** * Check if auto-scroll should be triggered */ private checkAutoScroll(event: MouseEvent): void { // Find scrollable content if not cached if (!this.scrollContainer) { this.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement; if (!this.scrollContainer) { return; } } const containerRect = this.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(); } } /** * Start auto-scroll animation */ private startAutoScroll(direction: 'up' | 'down'): void { if (this.autoScrollAnimationId !== null) return; const scroll = () => { if (!this.scrollContainer || !this.isMouseDown) { this.stopAutoScroll(); return; } const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed; this.scrollContainer.scrollTop += scrollAmount; // Emit updated position during scroll if (this.draggedEventId) { const snappedY = this.calculateSnapPosition(this.currentMouseY); this.eventBus.emit('drag:auto-scroll', { eventId: this.draggedEventId, snappedY, 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 event listeners */ public destroy(): void { this.stopAutoScroll(); document.body.removeEventListener('mousemove', this.handleMouseMove.bind(this)); document.body.removeEventListener('mousedown', this.handleMouseDown.bind(this)); document.body.removeEventListener('mouseup', this.handleMouseUp.bind(this)); } }