/** * DragDropManager - Advanced drag-and-drop system with smooth animations and event type conversion * * ARCHITECTURE OVERVIEW: * ===================== * DragDropManager provides a sophisticated drag-and-drop system for calendar events that supports: * - Smooth animated dragging with requestAnimationFrame * - Automatic event type conversion (timed events ↔ all-day events) * - Scroll compensation during edge scrolling * - Grid snapping for precise event placement * - Column detection and change tracking * * KEY FEATURES: * ============= * 1. DRAG DETECTION * - Movement threshold (5px) to distinguish clicks from drags * - Immediate visual feedback with cloned element * - Mouse offset tracking for natural drag feel * * 2. SMOOTH ANIMATION * - Uses requestAnimationFrame for 60fps animations * - Interpolated movement (30% per frame) for smooth transitions * - Continuous drag:move events for real-time updates * * 3. EVENT TYPE CONVERSION * - Timed → All-day: When dragging into calendar header * - All-day → Timed: When dragging into day columns * - Automatic clone replacement with appropriate element type * * 4. SCROLL COMPENSATION * - Tracks scroll delta during edge-scrolling * - Compensates dragged element position during scroll * - Prevents visual "jumping" when scrolling while dragging * * 5. GRID SNAPPING * - Snaps to time grid on mouse up * - Uses PositionUtils for consistent positioning * - Accounts for mouse offset within event * * STATE MANAGEMENT: * ================= * Mouse Tracking: * - mouseDownPosition: Initial click position * - currentMousePosition: Latest mouse position * - mouseOffset: Click offset within event (for natural dragging) * * Drag State: * - originalElement: Source event being dragged * - draggedClone: Animated clone following mouse * - currentColumn: Column mouse is currently over * - previousColumn: Last column (for detecting changes) * - isDragStarted: Whether drag threshold exceeded * * Scroll State: * - scrollDeltaY: Accumulated scroll offset during drag * - lastScrollTop: Previous scroll position * - isScrollCompensating: Whether edge-scroll is active * * Animation State: * - dragAnimationId: requestAnimationFrame ID * - targetY: Desired position for smooth interpolation * - currentY: Current interpolated position * * EVENT FLOW: * =========== * 1. Mouse Down (handleMouseDown) * ├─ Store originalElement and mouse offset * └─ Wait for movement * * 2. Mouse Move (handleMouseMove) * ├─ Check movement threshold * ├─ Initialize drag if threshold exceeded (initializeDrag) * │ ├─ Create clone * │ ├─ Emit drag:start * │ └─ Start animation loop * ├─ Continue drag (continueDrag) * │ ├─ Calculate target position with scroll compensation * │ └─ Update animation target * └─ Detect column changes (detectColumnChange) * └─ Emit drag:column-change * * 3. Animation Loop (animateDrag) * ├─ Interpolate currentY toward targetY * ├─ Emit drag:move on each frame * └─ Schedule next frame until target reached * * 4. Event Type Conversion * ├─ Entering header (handleHeaderMouseEnter) * │ ├─ Emit drag:mouseenter-header * │ └─ AllDayManager creates all-day clone * └─ Entering column (handleColumnMouseEnter) * ├─ Emit drag:mouseenter-column * └─ EventRenderingService creates timed clone * * 5. Mouse Up (handleMouseUp) * ├─ Stop animation * ├─ Snap to grid * ├─ Detect drop target (header or column) * ├─ Emit drag:end with final position * └─ Cleanup drag state * * SCROLL COMPENSATION SYSTEM: * =========================== * Problem: When EdgeScrollManager scrolls the grid during drag, the dragged element * can appear to "jump" because the mouse position stays the same but the * coordinate system (scrollable content) has moved. * * Solution: Track cumulative scroll delta and add it to mouse position calculations * * Flow: * 1. EdgeScrollManager starts scrolling → emit edgescroll:started * 2. DragDropManager sets isScrollCompensating = true * 3. On each scroll event: * ├─ Calculate scrollDelta = currentScrollTop - lastScrollTop * ├─ Accumulate into scrollDeltaY * └─ Call continueDrag with adjusted position * 4. continueDrag adds scrollDeltaY to mouse Y coordinate * 5. On event conversion, reset scrollDeltaY (new clone, new coordinate system) * * PERFORMANCE OPTIMIZATIONS: * ========================== * - Uses ColumnDetectionUtils cache for fast column lookups * - Single requestAnimationFrame loop (not per-mousemove) * - Interpolated animation reduces update frequency * - Passive scroll listeners * - Event delegation for header/column detection * * USAGE: * ====== * const dragDropManager = new DragDropManager(eventBus, positionUtils); * // Automatically attaches event listeners and manages drag lifecycle * // Other managers listen to drag:start, drag:move, drag:end, etc. */ import { IEventBus } from '../types/CalendarTypes'; import { PositionUtils } from '../utils/PositionUtils'; import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils'; import { SwpEventElement, BaseSwpEventElement } from '../elements/SwpEventElement'; import { IDragStartEventPayload, IDragMoveEventPayload, IDragEndEventPayload, IDragMouseEnterHeaderEventPayload, IDragMouseLeaveHeaderEventPayload, IDragMouseEnterColumnEventPayload, IDragColumnChangeEventPayload } from '../types/EventTypes'; import { IMousePosition } from '../types/DragDropTypes'; import { CoreEvents } from '../constants/CoreEvents'; export class DragDropManager { private eventBus: IEventBus; // Mouse tracking with optimized state private mouseDownPosition: IMousePosition = { x: 0, y: 0 }; private currentMousePosition: IMousePosition = { x: 0, y: 0 }; private mouseOffset: IMousePosition = { x: 0, y: 0 }; // Drag state private originalElement!: HTMLElement | null; private draggedClone!: HTMLElement | null; private currentColumn: IColumnBounds | null = null; private previousColumn: IColumnBounds | null = null; private originalSourceColumn: IColumnBounds | null = null; // Track original start column private isDragStarted = false; // Movement threshold to distinguish click from drag private readonly dragThreshold = 5; // pixels // Scroll compensation private scrollableContent: HTMLElement | null = null; private scrollDeltaY = 0; // Current scroll delta to apply in continueDrag private lastScrollTop = 0; // Last scroll position for delta calculation private isScrollCompensating = false; // Track if scroll compensation is active // Smooth drag animation private dragAnimationId: number | null = null; private targetY = 0; private currentY = 0; private targetColumn: IColumnBounds | null = null; private positionUtils: PositionUtils; constructor(eventBus: IEventBus, positionUtils: PositionUtils) { this.eventBus = eventBus; this.positionUtils = positionUtils; 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); } }, 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(); }); this.eventBus.on(CoreEvents.GRID_RENDERED, (event: Event) => { this.handleGridRendered(event as CustomEvent); }); // Listen to edge-scroll events to control scroll compensation this.eventBus.on('edgescroll:started', () => { this.isScrollCompensating = true; // Gem nuværende scroll position for delta beregning if (this.scrollableContent) { this.lastScrollTop = this.scrollableContent.scrollTop; } }); this.eventBus.on('edgescroll:stopped', () => { this.isScrollCompensating = false; }); // Reset scrollDeltaY when event converts (new clone created) this.eventBus.on('drag:mouseenter-header', () => { this.scrollDeltaY = 0; this.lastScrollTop = 0; }); this.eventBus.on('drag:mouseenter-column', () => { this.scrollDeltaY = 0; this.lastScrollTop = 0; }); } private handleGridRendered(event: CustomEvent) { this.scrollableContent = document.querySelector('swp-scrollable-content'); this.scrollableContent!.addEventListener('scroll', this.handleScroll.bind(this), { passive: true }); } 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 }; } } private handleMouseMove(event: MouseEvent): void { if (event.buttons === 1) { // Always update mouse position from event this.currentMousePosition = { x: event.clientX, y: event.clientY }; // Try to initialize drag if not started if (!this.isDragStarted && this.originalElement) { if (!this.initializeDrag(this.currentMousePosition)) { return; // Not enough movement yet } } // Continue drag if started (også under scroll - accumulatedScrollDelta kompenserer) if (this.isDragStarted && this.originalElement && this.draggedClone) { this.continueDrag(this.currentMousePosition); this.detectColumnChange(this.currentMousePosition); } } } /** * Try to initialize drag based on movement threshold * Returns true if drag was initialized, false if not enough movement */ private initializeDrag(currentPosition: IMousePosition): 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.originalSourceColumn = this.currentColumn; // Store original source column at drag start this.draggedClone = originalElement.createClone(); const dragStartPayload: IDragStartEventPayload = { 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: IMousePosition): 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; // Beregn position fra mus + scroll delta kompensation const adjustedMouseY = currentPosition.y + this.scrollDeltaY; const eventTopY = adjustedMouseY - 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: IMousePosition): 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: IDragColumnChangeEventPayload = { 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: IMousePosition = { 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: IDragEndEventPayload = { originalElement: this.originalElement, draggedClone: this.draggedClone, mousePosition, originalSourceColumn: this.originalSourceColumn!!, finalPosition: { column, snappedY }, // Where drag ended 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', { 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) { allClones.forEach(clone => clone.remove()); } } /** * Cancel drag operation when mouse leaves grid container * Animates clone back to original position before cleanup */ private cancelDrag(): void { if (!this.originalElement || !this.draggedClone) return; // Get current clone position const cloneRect = this.draggedClone.getBoundingClientRect(); // Get original element position const originalRect = this.originalElement.getBoundingClientRect(); // Calculate distance to animate const deltaX = originalRect.left - cloneRect.left; const deltaY = originalRect.top - cloneRect.top; // Add transition for smooth animation this.draggedClone.style.transition = 'transform 300ms ease-out'; this.draggedClone.style.transform = `translate(${deltaX}px, ${deltaY}px)`; // Wait for animation to complete, then cleanup setTimeout(() => { this.cleanupAllClones(); if (this.originalElement) { this.originalElement.style.opacity = ''; this.originalElement.style.cursor = ''; } this.eventBus.emit('drag:cancelled', { originalElement: this.originalElement, reason: 'mouse-left-grid' }); this.cleanupDragState(); this.stopDragAnimation(); }, 300); } /** * Optimized snap position calculation using PositionUtils */ private calculateSnapPosition(mouseY: number, column: IColumnBounds): 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 = this.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: IDragMoveEventPayload = { 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: IDragMoveEventPayload = { 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; } } /** * Handle scroll during drag - update scrollDeltaY and call continueDrag */ private handleScroll(): void { if (!this.isDragStarted || !this.draggedClone || !this.scrollableContent || !this.isScrollCompensating) return; const currentScrollTop = this.scrollableContent.scrollTop; const scrollDelta = currentScrollTop - this.lastScrollTop; // Gem scroll delta for continueDrag this.scrollDeltaY += scrollDelta; this.lastScrollTop = currentScrollTop; // Kald continueDrag med nuværende mus position this.continueDrag(this.currentMousePosition); } /** * 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.originalSourceColumn = null; this.isDragStarted = false; this.scrollDeltaY = 0; this.lastScrollTop = 0; } /** * Detect drop target - whether dropped in swp-day-column or swp-day-header */ private detectDropTarget(position: IMousePosition): '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: IMousePosition = { x: event.clientX, y: event.clientY }; const targetColumn = ColumnDetectionUtils.getColumnBounds(position); if (targetColumn) { const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone); const dragMouseEnterPayload: IDragMouseEnterHeaderEventPayload = { targetColumn: targetColumn, mousePosition: position, originalElement: this.originalElement, draggedClone: this.draggedClone, calendarEvent: calendarEvent, replaceClone: (newClone: HTMLElement) => { this.draggedClone = newClone; this.dragAnimationId === null; } }; 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; } const position: IMousePosition = { x: event.clientX, y: event.clientY }; const targetColumn = ColumnDetectionUtils.getColumnBounds(position); if (!targetColumn) { return; } // Calculate snapped Y position const snappedY = this.calculateSnapPosition(position.y, targetColumn); // Extract ICalendarEvent from the dragged clone const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone); const dragMouseEnterPayload: IDragMouseEnterColumnEventPayload = { 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; } const position: IMousePosition = { x: event.clientX, y: event.clientY }; const targetColumn = ColumnDetectionUtils.getColumnBounds(position); if (!targetColumn) { return; } const dragMouseLeavePayload: IDragMouseLeaveHeaderEventPayload = { targetDate: targetColumn.date, mousePosition: position, originalElement: this.originalElement, draggedClone: this.draggedClone }; this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload); } }