import { IEventBus } from '../types/CalendarTypes'; import { IGridConfig } from '../core/IGridConfig'; import { CoreEvents } from '../constants/CoreEvents'; import { snapToGrid } from '../utils/PositionUtils'; import { IMousePosition, IDragStartPayload, IDragMovePayload, IDragEndPayload, IDragCancelPayload, IDragColumnChangePayload, IDragEnterHeaderPayload, IDragMoveHeaderPayload, IDragLeaveHeaderPayload } from '../types/DragTypes'; interface DragState { eventId: string; element: HTMLElement; ghostElement: HTMLElement; startY: number; mouseOffset: IMousePosition; columnElement: HTMLElement; currentColumn: HTMLElement; targetY: number; currentY: number; animationId: number; } /** * DragDropManager - Handles drag-drop for calendar events * * Strategy: Drag original element, leave ghost-clone in place * - mousedown: Store initial state, wait for movement * - mousemove (>5px): Create ghost, start dragging original * - mouseup: Snap to grid, remove ghost, emit drag:end * - cancel: Animate back to startY, remove ghost */ export class DragDropManager { private dragState: DragState | null = null; private mouseDownPosition: IMousePosition | null = null; private pendingElement: HTMLElement | null = null; private pendingMouseOffset: IMousePosition | null = null; private container: HTMLElement | null = null; private inHeader = false; private readonly DRAG_THRESHOLD = 5; private readonly INTERPOLATION_FACTOR = 0.3; constructor( private eventBus: IEventBus, private gridConfig: IGridConfig ) { this.setupScrollListener(); } private setupScrollListener(): void { this.eventBus.on(CoreEvents.EDGE_SCROLL_TICK, (e) => { if (!this.dragState) return; const { scrollDelta } = (e as CustomEvent<{ scrollDelta: number }>).detail; // Element skal flytte med scroll for at forblive under musen // (elementets top er relativ til kolonnen, som scroller med viewport) this.dragState.targetY += scrollDelta; this.dragState.currentY += scrollDelta; this.dragState.element.style.top = `${this.dragState.currentY}px`; }); } /** * Initialize drag-drop on a container element */ init(container: HTMLElement): void { this.container = container; container.addEventListener('pointerdown', this.handlePointerDown); document.addEventListener('pointermove', this.handlePointerMove); document.addEventListener('pointerup', this.handlePointerUp); } private handlePointerDown = (e: PointerEvent): void => { const target = e.target as HTMLElement; // Ignore if clicking on resize handle if (target.closest('swp-resize-handle')) return; const eventElement = target.closest('swp-event') as HTMLElement; if (!eventElement) return; // Store for potential drag this.mouseDownPosition = { x: e.clientX, y: e.clientY }; this.pendingElement = eventElement; // Calculate mouse offset within element const rect = eventElement.getBoundingClientRect(); this.pendingMouseOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; // Capture pointer for reliable tracking eventElement.setPointerCapture(e.pointerId); }; private handlePointerMove = (e: PointerEvent): void => { // Not in potential drag state if (!this.mouseDownPosition || !this.pendingElement) { // Already dragging - update target if (this.dragState) { this.updateDragTarget(e); } return; } // Check threshold const deltaX = Math.abs(e.clientX - this.mouseDownPosition.x); const deltaY = Math.abs(e.clientY - this.mouseDownPosition.y); const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); if (distance < this.DRAG_THRESHOLD) return; // Start drag this.initializeDrag(this.pendingElement, this.pendingMouseOffset!, e); this.mouseDownPosition = null; this.pendingElement = null; this.pendingMouseOffset = null; }; private handlePointerUp = (_e: PointerEvent): void => { // Clear pending state this.mouseDownPosition = null; this.pendingElement = null; this.pendingMouseOffset = null; if (!this.dragState) return; // Stop animation cancelAnimationFrame(this.dragState.animationId); // Snap to grid const snappedY = snapToGrid(this.dragState.currentY, this.gridConfig); this.dragState.element.style.top = `${snappedY}px`; // Remove ghost this.dragState.ghostElement.remove(); // Get column data const dateKey = this.dragState.columnElement.dataset.date || ''; const resourceId = this.dragState.columnElement.dataset.resourceId; // Emit drag:end const payload: IDragEndPayload = { eventId: this.dragState.eventId, element: this.dragState.element, snappedY, columnElement: this.dragState.columnElement, dateKey, resourceId }; this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload); // Cleanup this.dragState.element.classList.remove('dragging'); this.dragState = null; this.inHeader = false; }; private initializeDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent): void { const eventId = element.dataset.eventId || ''; const columnElement = element.closest('swp-day-column') as HTMLElement; if (!columnElement) return; const startY = parseFloat(element.style.top) || 0; // Create ghost clone const ghostElement = element.cloneNode(true) as HTMLElement; ghostElement.classList.add('drag-ghost'); ghostElement.style.opacity = '0.3'; ghostElement.style.pointerEvents = 'none'; // Insert ghost before original element.parentNode?.insertBefore(ghostElement, element); // Setup element for dragging element.classList.add('dragging'); // Calculate initial target from mouse position const columnRect = columnElement.getBoundingClientRect(); const targetY = e.clientY - columnRect.top - mouseOffset.y; // Initialize drag state this.dragState = { eventId, element, ghostElement, startY, mouseOffset, columnElement, currentColumn: columnElement, targetY: Math.max(0, targetY), currentY: startY, animationId: 0 }; // Emit drag:start const payload: IDragStartPayload = { eventId, element, ghostElement, startY, mouseOffset, columnElement }; this.eventBus.emit(CoreEvents.EVENT_DRAG_START, payload); // Start animation loop this.animateDrag(); } private updateDragTarget(e: PointerEvent): void { if (!this.dragState) return; // Check header zone first this.checkHeaderZone(e); // Skip normal grid handling if in header if (this.inHeader) return; // Check for column change const columnAtPoint = this.getColumnAtPoint(e.clientX); if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn) { const payload: IDragColumnChangePayload = { eventId: this.dragState.eventId, element: this.dragState.element, previousColumn: this.dragState.currentColumn, newColumn: columnAtPoint, currentY: this.dragState.currentY }; this.eventBus.emit(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, payload); this.dragState.currentColumn = columnAtPoint; this.dragState.columnElement = columnAtPoint; } const columnRect = this.dragState.columnElement.getBoundingClientRect(); const targetY = e.clientY - columnRect.top - this.dragState.mouseOffset.y; this.dragState.targetY = Math.max(0, targetY); // Start animation if not running if (!this.dragState.animationId) { this.animateDrag(); } } /** * Check if pointer is in header zone and emit appropriate events */ private checkHeaderZone(e: PointerEvent): void { if (!this.dragState) return; const headerViewport = document.querySelector('swp-header-viewport'); if (!headerViewport) return; const rect = headerViewport.getBoundingClientRect(); const isInHeader = e.clientY < rect.bottom; if (isInHeader && !this.inHeader) { // Entered header this.inHeader = true; const payload: IDragEnterHeaderPayload = { eventId: this.dragState.eventId, element: this.dragState.element, sourceColumnIndex: this.getColumnIndex(this.dragState.columnElement), sourceDate: this.dragState.columnElement.dataset.date || '', title: this.dragState.element.querySelector('swp-event-title')?.textContent || '', colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-')), itemType: 'event', duration: 1 }; this.eventBus.emit(CoreEvents.EVENT_DRAG_ENTER_HEADER, payload); } else if (!isInHeader && this.inHeader) { // Left header this.inHeader = false; const payload: IDragLeaveHeaderPayload = { eventId: this.dragState.eventId }; this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload); } else if (isInHeader) { // Moving within header const column = this.getColumnAtX(e.clientX); if (column) { const payload: IDragMoveHeaderPayload = { eventId: this.dragState.eventId, columnIndex: this.getColumnIndex(column), dateKey: column.dataset.date || '' }; this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE_HEADER, payload); } } } /** * Get column index (0-based) for a column element */ private getColumnIndex(column: HTMLElement): number { if (!this.container) return 0; const columns = Array.from(this.container.querySelectorAll('swp-day-column')); return columns.indexOf(column); } /** * Get column at X coordinate (alias for getColumnAtPoint) */ private getColumnAtX(clientX: number): HTMLElement | null { return this.getColumnAtPoint(clientX); } /** * Find column element at given X coordinate */ private getColumnAtPoint(clientX: number): HTMLElement | null { if (!this.container) return null; const columns = this.container.querySelectorAll('swp-day-column'); for (const col of columns) { const rect = col.getBoundingClientRect(); if (clientX >= rect.left && clientX <= rect.right) { return col as HTMLElement; } } return null; } private animateDrag = (): void => { if (!this.dragState) return; const diff = this.dragState.targetY - this.dragState.currentY; // Stop animation when close enough to target if (Math.abs(diff) <= 0.5) { this.dragState.animationId = 0; return; } // Interpolate towards target this.dragState.currentY += diff * this.INTERPOLATION_FACTOR; // Update element position this.dragState.element.style.top = `${this.dragState.currentY}px`; // Emit drag:move const payload: IDragMovePayload = { eventId: this.dragState.eventId, element: this.dragState.element, currentY: this.dragState.currentY, columnElement: this.dragState.columnElement }; this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE, payload); // Continue animation this.dragState.animationId = requestAnimationFrame(this.animateDrag); }; /** * Cancel drag and animate back to start position */ cancelDrag(): void { if (!this.dragState) return; // Stop animation cancelAnimationFrame(this.dragState.animationId); const { element, ghostElement, startY, eventId } = this.dragState; // Animate back to start element.style.transition = 'top 200ms ease-out'; element.style.top = `${startY}px`; // Remove ghost after animation setTimeout(() => { ghostElement.remove(); element.style.transition = ''; element.classList.remove('dragging'); }, 200); // Emit drag:cancel const payload: IDragCancelPayload = { eventId, element, startY }; this.eventBus.emit(CoreEvents.EVENT_DRAG_CANCEL, payload); this.dragState = null; this.inHeader = false; } }