Calendar/src/managers/DragDropManager.ts

633 lines
22 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 { PositionUtils } from '../utils/PositionUtils';
import { ColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { SwpEventElement, BaseSwpEventElement } from '../elements/SwpEventElement';
import {
DragStartEventPayload,
DragMoveEventPayload,
DragEndEventPayload,
DragMouseEnterHeaderEventPayload,
DragMouseLeaveHeaderEventPayload,
DragColumnChangeEventPayload
} from '../types/EventTypes';
import { MousePosition } from '../types/DragDropTypes';
interface CachedElements {
scrollContainer: HTMLElement | null;
}
export class DragDropManager {
private eventBus: IEventBus;
// Mouse tracking with optimized state
private lastMousePosition: MousePosition = { x: 0, y: 0 };
private lastLoggedPosition: MousePosition = { x: 0, y: 0 };
private currentMouseY = 0;
private mouseOffset: MousePosition = { x: 0, y: 0 };
private initialMousePosition: MousePosition = { x: 0, y: 0 };
private lastColumn: ColumnBounds | null = null;
// Drag state
private draggedElement!: HTMLElement | null;
private draggedClone!: HTMLElement | null;
private currentColumnBounds: ColumnBounds | null = null;
2025-09-09 14:35:21 +02:00
private isDragStarted = false;
2025-10-08 00:58:38 +02:00
// Hover state
private isHoverTrackingActive = false;
private currentHoveredEvent: HTMLElement | null = null;
2025-09-09 14:35:21 +02:00
// Movement threshold to distinguish click from drag
private readonly dragThreshold = 5; // pixels
private scrollContainer!: HTMLElement | null;
// Cached DOM elements for performance
// 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
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 {
2025-10-01 22:38:15 +02:00
// 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));
this.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement;
const calendarContainer = document.querySelector('swp-calendar-container');
if (calendarContainer) {
calendarContainer.addEventListener('mouseleave', () => {
if (this.draggedElement && 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);
2025-10-08 00:58:38 +02:00
} else if (target.closest('swp-event')) {
// Entered an event - activate hover tracking and set color
const eventElement = target.closest<HTMLElement>('swp-event');
const mouseEvent = e as MouseEvent;
// Only handle hover if mouse button is up
if (eventElement && !this.isDragStarted && mouseEvent.buttons === 0) {
// Clear any previous hover first
if (this.currentHoveredEvent && this.currentHoveredEvent !== eventElement) {
this.currentHoveredEvent.style.backgroundColor = '';
}
this.isHoverTrackingActive = true;
this.currentHoveredEvent = eventElement;
eventElement.style.backgroundColor = 'red';
console.log('🎨 Mouse entered event:', eventElement.dataset.eventId, 'buttons:', mouseEvent.buttons, 'isDragStarted:', this.isDragStarted);
}
}
}, 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);
}
2025-10-08 00:58:38 +02:00
// 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();
});
}
private handleMouseDown(event: MouseEvent): void {
// Clean up drag state first
this.cleanupDragState();
ColumnDetectionUtils.updateColumnBoundsCache();
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-GRID-CONTAINER') {
if (eventElement.tagName === 'SWP-EVENT' || eventElement.tagName === 'SWP-ALLDAY-EVENT') {
break;
}
eventElement = eventElement.parentElement as HTMLElement;
if (!eventElement) return;
}
2025-10-08 00:58:38 +02:00
// Found an event - check if in resize zone first
if (eventElement) {
2025-10-08 00:58:38 +02:00
// Check if click is in bottom resize zone
const rect = eventElement.getBoundingClientRect();
const mouseY = event.clientY;
const distanceFromBottom = rect.bottom - mouseY;
const resizeZoneHeight = 15; // Match ResizeHandleManager
// If in resize zone, don't handle this - let ResizeHandleManager take over
if (distanceFromBottom >= 0 && distanceFromBottom <= resizeZoneHeight) {
return; // Exit early - this is a resize operation
}
// Normal drag - prepare for potential dragging
this.draggedElement = eventElement;
this.lastColumn = ColumnDetectionUtils.getColumnBounds(this.lastMousePosition)
// Calculate mouse offset within event
const eventRect = eventElement.getBoundingClientRect();
this.mouseOffset = {
x: event.clientX - eventRect.left,
y: event.clientY - eventRect.top
};
}
}
/**
* 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 };
2025-10-08 00:58:38 +02:00
// Log which element we're over during drag
if (this.isDragStarted) {
const elementAtPoint = document.elementFromPoint(event.clientX, event.clientY);
const eventElement = elementAtPoint?.closest('swp-event');
if (eventElement) {
console.log('🖱️ Dragging over event:', (eventElement as HTMLElement).dataset.eventId);
}
}
2025-10-08 00:58:38 +02:00
// Check for event hover (coordinate-based) - only when mouse button is up
if (this.isHoverTrackingActive && event.buttons === 0) {
this.checkEventHover(event);
}
if (event.buttons === 1) {
const currentPosition: MousePosition = { 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 && this.draggedElement) {
2025-09-09 14:35:21 +02:00
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;
2025-10-08 00:58:38 +02:00
// Set high z-index on event-group if exists, otherwise on event itself
const eventGroup = this.draggedElement.closest<HTMLElement>('swp-event-group');
if (eventGroup) {
console.log('🔝 Setting z-index 9999 on event-group', eventGroup);
eventGroup.style.zIndex = '9999';
} else {
console.log('🔝 Setting z-index 9999 on event', this.draggedElement.dataset.eventId);
this.draggedElement.style.zIndex = '9999';
}
// Detect current column
this.currentColumnBounds = ColumnDetectionUtils.getColumnBounds(currentPosition);
// Cast to BaseSwpEventElement and create clone (works for both SwpEventElement and SwpAllDayEventElement)
const originalElement = this.draggedElement as BaseSwpEventElement;
this.draggedClone = originalElement.createClone();
const dragStartPayload: DragStartEventPayload = {
draggedElement: this.draggedElement,
draggedClone: this.draggedClone,
2025-09-09 14:35:21 +02:00
mousePosition: this.initialMousePosition,
mouseOffset: this.mouseOffset,
columnBounds: this.currentColumnBounds
};
this.eventBus.emit('drag:start', dragStartPayload);
2025-09-09 14:35:21 +02:00
} 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 && this.draggedElement && this.draggedClone) {
2025-10-02 23:11:26 +02:00
if (!this.draggedElement.hasAttribute("data-allday")) {
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,
draggedClone: this.draggedClone,
mousePosition: currentPosition,
snappedY: positionData.snappedY,
columnBounds: positionData.column,
mouseOffset: this.mouseOffset
};
this.eventBus.emit('drag:move', dragMovePayload);
}
// Check for auto-scroll
this.checkAutoScroll(currentPosition);
2025-09-09 14:35:21 +02:00
}
const newColumn = ColumnDetectionUtils.getColumnBounds(currentPosition);
if (newColumn == null)
return;
if (newColumn?.index !== this.currentColumnBounds?.index) {
const previousColumn = this.currentColumnBounds;
this.currentColumnBounds = newColumn;
const dragColumnChangePayload: DragColumnChangeEventPayload = {
originalElement: this.draggedElement,
draggedClone: this.draggedClone,
2025-09-09 14:35:21 +02:00
previousColumn,
newColumn,
mousePosition: currentPosition
};
this.eventBus.emit('drag:column-change', dragColumnChangePayload);
2025-09-09 14:35:21 +02:00
}
}
}
}
/**
* Optimized mouse up handler with consolidated cleanup
*/
private handleMouseUp(event: MouseEvent): void {
this.stopAutoScroll();
if (this.draggedElement) {
2025-09-09 14:35:21 +02:00
// Only emit drag:end if drag was actually started
if (this.isDragStarted) {
const mousePosition: MousePosition = { x: event.clientX, y: event.clientY };
2025-09-09 14:35:21 +02:00
// Use consolidated position calculation
const positionData = this.calculateDragPosition(mousePosition);
// Detect drop target (swp-day-column or swp-day-header)
const dropTarget = this.detectDropTarget(mousePosition);
if (!dropTarget)
throw "dropTarget is null";
console.log('🎯 DragDropManager: Emitting drag:end', {
draggedElement: this.draggedElement.dataset.eventId,
finalColumn: positionData.column,
finalY: positionData.snappedY,
dropTarget: dropTarget,
isDragStarted: this.isDragStarted
});
const dragEndPayload: DragEndEventPayload = {
originalElement: this.draggedElement,
draggedClone: this.draggedClone,
mousePosition,
finalPosition: positionData,
target: dropTarget
};
this.eventBus.emit('drag:end', dragEndPayload);
2025-10-02 23:11:26 +02:00
this.cleanupDragState();
2025-09-09 14:35:21 +02:00
} else {
// This was just a click - emit click event instead
this.eventBus.emit('event:click', {
draggedElement: this.draggedElement,
2025-09-09 14:35:21 +02:00
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: MousePosition): { column: ColumnBounds | null; snappedY: number } {
let column = ColumnDetectionUtils.getColumnBounds(mousePosition);
let snappedY = 0;
if (column) {
snappedY = this.calculateSnapPosition(mousePosition.y, column);
return { column, snappedY };
}
return { column, snappedY };
}
/**
* Optimized snap position calculation using PositionUtils
*/
private calculateSnapPosition(mouseY: number, column: ColumnBounds): 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 = PositionUtils.getPositionFromCoordinate(eventTopY, column);
return Math.max(0, snappedY);
}
/**
* Optimized auto-scroll check with cached container
*/
private checkAutoScroll(mousePosition: MousePosition): void {
if (this.scrollContainer == null)
return;
const containerRect = this.scrollContainer.getBoundingClientRect();
const mouseY = mousePosition.clientY;
// Calculate distances from edges
const distanceFromTop = mousePosition.y - containerRect.top;
const distanceFromBottom = containerRect.bottom - mousePosition.y;
// Check if we need to scroll
if (distanceFromTop <= this.scrollThreshold && distanceFromTop > 0) {
this.startAutoScroll('up', mousePosition);
} else if (distanceFromBottom <= this.scrollThreshold && distanceFromBottom > 0) {
this.startAutoScroll('down', mousePosition);
} else {
this.stopAutoScroll();
}
}
/**
* Optimized auto-scroll with cached container reference
*/
private startAutoScroll(direction: 'up' | 'down', event: MousePosition): void {
if (this.autoScrollAnimationId !== null) return;
const scroll = () => {
if (!this.scrollContainer || !this.draggedElement) {
this.stopAutoScroll();
return;
}
const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed;
this.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 = ColumnDetectionUtils.getColumnBounds(event);
if (columnElement) {
const columnRect = columnElement.boundingClientRect;
// 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.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.draggedClone = null;
2025-09-09 14:35:21 +02:00
this.isDragStarted = false;
}
/**
* Detect drop target - whether dropped in swp-day-column or swp-day-header
*/
private detectDropTarget(position: MousePosition): '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: MousePosition = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (targetColumn) {
console.log('🎯 DragDropManager: Mouse entered header', { targetDate: targetColumn });
// Extract CalendarEvent from the dragged clone
const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone);
2025-10-04 16:20:09 +02:00
const dragMouseEnterPayload: DragMouseEnterHeaderEventPayload = {
targetColumn: targetColumn,
mousePosition: position,
originalElement: this.draggedElement,
draggedClone: this.draggedClone,
calendarEvent: calendarEvent,
// Delegate pattern - allows AllDayManager to replace the clone
replaceClone: (newClone: HTMLElement) => {
this.draggedClone = newClone;
}
};
this.eventBus.emit('drag:mouseenter-header', 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;
}
console.log('🚪 DragDropManager: Mouse left header');
const position: MousePosition = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (!targetColumn) {
console.warn("No column detected when leaving header");
return;
}
const dragMouseLeavePayload: DragMouseLeaveHeaderEventPayload = {
targetDate: targetColumn.date,
mousePosition: position,
originalElement: this.draggedElement,
draggedClone: this.draggedClone
};
this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload);
}
2025-10-08 00:58:38 +02:00
private checkEventHover(event: MouseEvent): void {
// Use currentHoveredEvent to check if mouse is still within bounds
if (!this.currentHoveredEvent) return;
const rect = this.currentHoveredEvent.getBoundingClientRect();
const mouseX = event.clientX;
const mouseY = event.clientY;
// Check if mouse is still within the current hovered event
const isStillInside = mouseX >= rect.left && mouseX <= rect.right &&
mouseY >= rect.top && mouseY <= rect.bottom;
// If mouse left the event
if (!isStillInside) {
// Only disable tracking and clear if mouse is NOT pressed (allow resize to work)
if (event.buttons === 0) {
console.log('🚪 Mouse left event:', this.currentHoveredEvent.dataset.eventId, 'clearing hover');
this.isHoverTrackingActive = false;
this.clearEventHover();
}
}
}
private clearEventHover(): void {
if (this.currentHoveredEvent) {
this.currentHoveredEvent.style.backgroundColor = '';
this.currentHoveredEvent = null;
}
}
}