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 } 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 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; 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; }; 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 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(); } } /** * 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; } }