Calendar/src/managers/DragDropManager.ts

526 lines
17 KiB
TypeScript
Raw Normal View History

/**
* 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 { DateCalculator } from '../utils/DateCalculator';
import { PositionUtils } from '../utils/PositionUtils';
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 };
2025-09-09 14:35:21 +02:00
private initialMousePosition: Position = { x: 0, y: 0 };
// Drag state
private draggedEventId: string | null = null;
private originalElement: HTMLElement | null = null;
private currentColumn: string | null = null;
2025-09-09 14:35:21 +02:00
private isDragStarted = 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);
// Listen for header mouseover events
this.eventBus.on('header:mouseover', (event) => {
const { targetDate, headerRenderer } = (event as CustomEvent).detail;
if (this.draggedEventId && targetDate) {
// Find dragget element dynamisk
const draggedElement = document.querySelector(`swp-event[data-event-id="${this.draggedEventId}"]`);
if (draggedElement) {
// Element findes stadig som day-event, så konverter
this.eventBus.emit('drag:convert-to-allday', {
targetDate,
originalElement: draggedElement,
headerRenderer
});
}
}
});
// Listen for column mouseover events (for all-day to timed conversion)
this.eventBus.on('column:mouseover', (event) => {
const { targetColumn, targetY } = (event as CustomEvent).detail;
if (this.draggedEventId && this.isAllDayEventBeingDragged()) {
// Emit event to convert to timed
this.eventBus.emit('drag:convert-to-timed', {
eventId: this.draggedEventId,
targetColumn,
targetY
});
}
});
// Listen for header mouseleave events (convert from all-day back to day)
this.eventBus.on('header:mouseleave', (event) => {
// Check if we're dragging ANY event
if (this.draggedEventId) {
this.eventBus.emit('drag:convert-from-allday', {
draggedEventId: this.draggedEventId
});
}
});
}
private handleMouseDown(event: MouseEvent): void {
2025-09-09 14:35:21 +02:00
this.isDragStarted = false;
this.lastMousePosition = { x: event.clientX, y: event.clientY };
this.lastLoggedPosition = { x: event.clientX, y: event.clientY };
2025-09-09 14:35:21 +02:00
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') {
2025-09-11 12:10:34 +02:00
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;
}
2025-09-09 14:35:21 +02:00
// Found an event - prepare for potential 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;
}
2025-09-09 14:35:21 +02:00
// 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;
if (event.buttons === 1 && this.draggedEventId) {
const currentPosition: Position = { x: event.clientX, y: event.clientY };
2025-09-09 14:35:21 +02:00
// 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);
2025-09-09 14:35:21 +02:00
if (totalMovement >= this.dragThreshold) {
// Start drag - emit drag:start event
this.isDragStarted = true;
this.eventBus.emit('drag:start', {
eventId: this.draggedEventId,
mousePosition: this.initialMousePosition,
mouseOffset: this.mouseOffset,
column: this.currentColumn
});
} else {
// Not enough movement yet - don't start drag
return;
}
}
2025-09-09 14:35:21 +02:00
// Continue with normal drag behavior only if drag has started
if (this.isDragStarted) {
const deltaY = Math.abs(currentPosition.y - this.lastLoggedPosition.y);
2025-09-09 14:35:21 +02:00
// 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)
this.eventBus.emit('drag:move', {
eventId: this.draggedEventId,
mousePosition: currentPosition,
snappedY: positionData.snappedY,
column: positionData.column,
mouseOffset: this.mouseOffset
});
}
// 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;
this.eventBus.emit('drag:column-change', {
eventId: this.draggedEventId,
previousColumn,
newColumn,
mousePosition: currentPosition
});
}
}
}
}
/**
* Optimized mouse up handler with consolidated cleanup
*/
private handleMouseUp(event: MouseEvent): void {
this.stopAutoScroll();
if (this.draggedEventId && this.originalElement) {
// Store variables locally before cleanup
const eventId = this.draggedEventId;
const originalElement = this.originalElement;
const isDragStarted = this.isDragStarted;
// Clean up drag state first
this.cleanupDragState();
2025-09-09 14:35:21 +02:00
// Only emit drag:end if drag was actually started
if (isDragStarted) {
2025-09-09 14:35:21 +02:00
const finalPosition: Position = { x: event.clientX, y: event.clientY };
// Use consolidated position calculation
const positionData = this.calculateDragPosition(finalPosition);
console.log('🎯 DragDropManager: Emitting drag:end', {
eventId: eventId,
finalColumn: positionData.column,
finalY: positionData.snappedY,
isDragStarted: isDragStarted
});
2025-09-09 14:35:21 +02:00
this.eventBus.emit('drag:end', {
eventId: eventId,
2025-09-09 14:35:21 +02:00
finalPosition,
finalColumn: positionData.column,
finalY: positionData.snappedY
});
} else {
// This was just a click - emit click event instead
this.eventBus.emit('event:click', {
eventId: eventId,
2025-09-09 14:35:21 +02:00
mousePosition: { x: event.clientX, y: event.clientY }
});
}
}
}
/**
* 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 };
}
/**
* Calculate free position (follows mouse exactly)
*/
private calculateFreePosition(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;
const relativeY = PositionUtils.getPositionFromCoordinate(mouseY, columnElement);
// Return free position (no snapping)
return Math.max(0, relativeY);
}
/**
* 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);
}
/**
* Optimized column detection with caching
*/
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;
}
const columnDate = current.dataset.date || null;
// Update cache if we found a new column
if (columnDate && columnDate !== this.cachedElements.lastColumnDate) {
this.cachedElements.currentColumn = current;
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.draggedEventId) {
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.draggedEventId) {
// 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', {
eventId: this.draggedEventId,
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.draggedEventId = null;
this.originalElement = null;
this.currentColumn = null;
2025-09-09 14:35:21 +02:00
this.isDragStarted = false;
// Clear cached elements
this.cachedElements.currentColumn = null;
this.cachedElements.lastColumnDate = null;
}
/**
* Check if an all-day event is currently being dragged
*/
private isAllDayEventBeingDragged(): boolean {
if (!this.draggedEventId) return false;
// Check if element exists as all-day event
const allDayElement = document.querySelector(`swp-allday-event[data-event-id="${this.draggedEventId}"]`);
return allDayElement !== null;
}
/**
* 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();
}
}