Refactors all-day event drag and drop handling for improved accuracy and performance. Introduces a shared `ColumnDetectionUtils` for consistent column detection. Simplifies all-day conversion during drag, placing events in row 1 and calculating the column from the target date. Implements differential updates during drag end, updating only changed events for smoother transitions.
625 lines
20 KiB
TypeScript
625 lines
20 KiB
TypeScript
/**
|
|
* 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 { PositionUtils } from '../utils/PositionUtils';
|
|
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
|
|
import {
|
|
DragStartEventPayload,
|
|
DragMoveEventPayload,
|
|
DragEndEventPayload,
|
|
DragMouseEnterHeaderEventPayload,
|
|
DragMouseLeaveHeaderEventPayload,
|
|
DragColumnChangeEventPayload
|
|
} from '../types/EventTypes';
|
|
|
|
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 draggedElement!: HTMLElement | null;
|
|
private currentColumn: string | null = null;
|
|
private isDragStarted = false;
|
|
|
|
// Header tracking state
|
|
private isInHeader = 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);
|
|
|
|
// Add mouseleave listener to calendar container for drag cancellation
|
|
const calendarContainer = document.querySelector('swp-calendar-container');
|
|
if (calendarContainer) {
|
|
calendarContainer.addEventListener('mouseleave', () => {
|
|
if (this.draggedElement && this.isDragStarted) {
|
|
this.cancelDrag();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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();
|
|
});
|
|
|
|
}
|
|
|
|
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.draggedElement = eventElement;
|
|
|
|
// 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;
|
|
this.lastMousePosition = { x: event.clientX, y: event.clientY };
|
|
|
|
// Check for header enter/leave during drag
|
|
if (this.draggedElement) {
|
|
this.checkHeaderEnterLeave(event);
|
|
}
|
|
|
|
if (event.buttons === 1 && this.draggedElement) {
|
|
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;
|
|
|
|
const dragStartPayload: DragStartEventPayload = {
|
|
draggedElement: this.draggedElement,
|
|
mousePosition: this.initialMousePosition,
|
|
mouseOffset: this.mouseOffset,
|
|
column: this.currentColumn
|
|
};
|
|
this.eventBus.emit('drag:start', dragStartPayload);
|
|
} 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)
|
|
const dragMovePayload: DragMoveEventPayload = {
|
|
draggedElement: this.draggedElement,
|
|
mousePosition: currentPosition,
|
|
snappedY: positionData.snappedY,
|
|
column: positionData.column,
|
|
mouseOffset: this.mouseOffset
|
|
};
|
|
this.eventBus.emit('drag:move', dragMovePayload);
|
|
}
|
|
|
|
// 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;
|
|
|
|
const dragColumnChangePayload: DragColumnChangeEventPayload = {
|
|
draggedElement: this.draggedElement,
|
|
previousColumn,
|
|
newColumn,
|
|
mousePosition: currentPosition
|
|
};
|
|
this.eventBus.emit('drag:column-change', dragColumnChangePayload);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Optimized mouse up handler with consolidated cleanup
|
|
*/
|
|
private handleMouseUp(event: MouseEvent): void {
|
|
this.stopAutoScroll();
|
|
|
|
if (this.draggedElement) {
|
|
// Store variables locally before cleanup
|
|
const draggedElement = this.draggedElement;
|
|
const isDragStarted = this.isDragStarted;
|
|
|
|
// Clean up drag state first
|
|
this.cleanupDragState();
|
|
|
|
|
|
// Only emit drag:end if drag was actually started
|
|
if (isDragStarted) {
|
|
const mousePosition: Position = { x: event.clientX, y: event.clientY };
|
|
|
|
// Use consolidated position calculation
|
|
const positionData = this.calculateDragPosition(mousePosition);
|
|
|
|
// Detect drop target (swp-day-column or swp-day-header)
|
|
const dropTarget = this.detectDropTarget(mousePosition);
|
|
|
|
console.log('🎯 DragDropManager: Emitting drag:end', {
|
|
draggedElement: draggedElement.dataset.eventId,
|
|
finalColumn: positionData.column,
|
|
finalY: positionData.snappedY,
|
|
dropTarget: dropTarget,
|
|
isDragStarted: isDragStarted
|
|
});
|
|
|
|
const dragEndPayload: DragEndEventPayload = {
|
|
draggedElement: draggedElement,
|
|
mousePosition,
|
|
finalPosition: positionData,
|
|
target: dropTarget
|
|
};
|
|
this.eventBus.emit('drag:end', dragEndPayload);
|
|
|
|
draggedElement.remove();
|
|
|
|
} else {
|
|
// This was just a click - emit click event instead
|
|
this.eventBus.emit('event:click', {
|
|
draggedElement: draggedElement,
|
|
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) {
|
|
console.log(`🧹 DragDropManager: Removing ${allClones.length} clone(s)`);
|
|
allClones.forEach(clone => clone.remove());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel drag operation when mouse leaves grid container
|
|
*/
|
|
private cancelDrag(): void {
|
|
if (!this.draggedElement) return;
|
|
|
|
console.log('🚫 DragDropManager: Cancelling drag - mouse left grid container');
|
|
|
|
const draggedElement = this.draggedElement;
|
|
|
|
// 1. Remove all clones
|
|
this.cleanupAllClones();
|
|
|
|
// 2. Restore original element
|
|
if (draggedElement) {
|
|
draggedElement.style.opacity = '';
|
|
draggedElement.style.cursor = '';
|
|
}
|
|
|
|
// 3. Emit cancellation event
|
|
this.eventBus.emit('drag:cancelled', {
|
|
draggedElement: draggedElement,
|
|
reason: 'mouse-left-grid'
|
|
});
|
|
|
|
// 4. Clean up state
|
|
this.cleanupDragState();
|
|
this.stopAutoScroll();
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
|
|
/**
|
|
* Coordinate-based column detection (replaces DOM traversal)
|
|
*/
|
|
private detectColumn(mouseX: number, mouseY: number): string | null {
|
|
// Brug den koordinatbaserede metode direkte
|
|
const columnDate = ColumnDetectionUtils.getColumnDateFromX(mouseX);
|
|
|
|
// Opdater stadig den eksisterende cache hvis vi finder en kolonne
|
|
if (columnDate && columnDate !== this.cachedElements.lastColumnDate) {
|
|
const columnElement = document.querySelector(`swp-day-column[data-date="${columnDate}"]`) as HTMLElement;
|
|
if (columnElement) {
|
|
this.cachedElements.currentColumn = columnElement;
|
|
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.draggedElement) {
|
|
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.draggedElement) {
|
|
// 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', {
|
|
draggedElement: this.draggedElement,
|
|
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.draggedElement = null;
|
|
this.currentColumn = null;
|
|
this.isDragStarted = false;
|
|
this.isInHeader = false;
|
|
|
|
// Clear cached elements
|
|
this.cachedElements.currentColumn = null;
|
|
this.cachedElements.lastColumnDate = null;
|
|
}
|
|
|
|
/**
|
|
* Detect drop target - whether dropped in swp-day-column or swp-day-header
|
|
*/
|
|
private detectDropTarget(position: Position): 'swp-day-column' | 'swp-day-header' | null {
|
|
const elementAtPosition = document.elementFromPoint(position.x, position.y);
|
|
if (!elementAtPosition) return null;
|
|
|
|
// Traverse up the DOM tree to find the target container
|
|
let currentElement = elementAtPosition as HTMLElement;
|
|
while (currentElement && currentElement !== document.body) {
|
|
if (currentElement.tagName === 'SWP-DAY-HEADER') {
|
|
return 'swp-day-header';
|
|
}
|
|
if (currentElement.tagName === 'SWP-DAY-COLUMN') {
|
|
return 'swp-day-column';
|
|
}
|
|
currentElement = currentElement.parentElement as HTMLElement;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check for header enter/leave during drag operations
|
|
*/
|
|
private checkHeaderEnterLeave(event: MouseEvent): void {
|
|
const elementAtPosition = document.elementFromPoint(event.clientX, event.clientY);
|
|
if (!elementAtPosition) return;
|
|
|
|
// Check if we're in a header area
|
|
const headerElement = elementAtPosition.closest('swp-day-header, swp-calendar-header');
|
|
const isCurrentlyInHeader = !!headerElement;
|
|
|
|
// Detect header enter
|
|
if (!this.isInHeader && isCurrentlyInHeader) {
|
|
this.isInHeader = true;
|
|
|
|
// Calculate target date using existing method
|
|
const targetDate = ColumnDetectionUtils.getColumnDateFromX(event.clientX);
|
|
|
|
if (targetDate) {
|
|
console.log('🎯 DragDropManager: Emitting drag:mouseenter-header', { targetDate });
|
|
|
|
// Find clone element (if it exists)
|
|
const eventId = this.draggedElement?.dataset.eventId;
|
|
const cloneElement = document.querySelector(`[data-event-id="clone-${eventId}"]`) as HTMLElement;
|
|
|
|
const dragMouseEnterPayload: DragMouseEnterHeaderEventPayload = {
|
|
targetDate,
|
|
mousePosition: { x: event.clientX, y: event.clientY },
|
|
originalElement: this.draggedElement,
|
|
cloneElement: cloneElement
|
|
};
|
|
this.eventBus.emit('drag:mouseenter-header', dragMouseEnterPayload);
|
|
}
|
|
}
|
|
|
|
// Detect header leave
|
|
if (this.isInHeader && !isCurrentlyInHeader) {
|
|
this.isInHeader = false;
|
|
|
|
console.log('🚪 DragDropManager: Emitting drag:mouseleave-header');
|
|
|
|
// Calculate target date using existing method
|
|
const targetDate = ColumnDetectionUtils.getColumnDateFromX(event.clientX);
|
|
|
|
// Find clone element (if it exists)
|
|
const eventId = this.draggedElement?.dataset.eventId;
|
|
const cloneElement = document.querySelector(`[data-event-id="clone-${eventId}"]`) as HTMLElement;
|
|
|
|
const dragMouseLeavePayload: DragMouseLeaveHeaderEventPayload = {
|
|
targetDate,
|
|
mousePosition: { x: event.clientX, y: event.clientY },
|
|
originalElement: this.draggedElement,
|
|
cloneElement: cloneElement
|
|
};
|
|
this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
}
|