Calendar/src/managers/DragDropManager.ts
Janus C. H. Knudsen 75d03fe577 Improves drag and drop position calculation
Refines drag and drop functionality by calculating position relative to the column during drag and snapping to the grid on mouse up, resulting in more precise placement.

Addresses an issue where the dragged element's position was not correctly calculated relative to the target column.
2025-10-08 19:01:35 +02:00

692 lines
23 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 { 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;
private isDragStarted = false;
// Hover state
private isHoverTrackingActive = false;
private currentHoveredEvent: HTMLElement | null = null;
// 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
// Smooth drag animation
private dragAnimationId: number | null = null;
private targetY = 0;
private currentY = 0;
private targetColumn: ColumnBounds | null = null;
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 {
// 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);
} 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.classList.remove('hover');
}
this.isHoverTrackingActive = true;
this.currentHoveredEvent = eventElement;
eventElement.classList.add('hover');
}
}
}, 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);
}
// 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 };
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;
}
// Found an event - check if in resize zone first
if (eventElement) {
// 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 };
// 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 };
// Check if we need to start drag (movement threshold)
if (!this.isDragStarted && this.draggedElement) {
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;
// Set high z-index on event-group if exists, otherwise on event itself
const eventGroup = this.draggedElement.closest<HTMLElement>('swp-event-group');
if (eventGroup) {
eventGroup.style.zIndex = '9999';
} else {
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,
mousePosition: this.initialMousePosition,
mouseOffset: this.mouseOffset,
columnBounds: this.currentColumnBounds
};
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 && this.draggedElement && this.draggedClone) {
if (!this.draggedElement.hasAttribute("data-allday")) {
// Calculate raw position from mouse (no snapping)
const column = ColumnDetectionUtils.getColumnBounds(currentPosition);
if (column) {
// Calculate raw Y position relative to column (accounting for mouse offset)
const columnRect = column.boundingClientRect;
const eventTopY = currentPosition.y - columnRect.top - this.mouseOffset.y;
this.targetY = Math.max(0, eventTopY); // Store raw Y as target (no snapping)
this.targetColumn = column;
// Start animation loop if not already running
if (this.dragAnimationId === null) {
this.currentY = parseFloat(this.draggedClone.style.top) || 0;
this.animateDrag();
}
}
// Check for auto-scroll
this.checkAutoScroll(currentPosition);
}
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,
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();
this.stopDragAnimation();
if (this.draggedElement) {
// Only emit drag:end if drag was actually started
if (this.isDragStarted) {
const mousePosition: MousePosition = { x: event.clientX, y: event.clientY };
// Snap to grid on mouse up (like ResizeHandleManager)
const column = ColumnDetectionUtils.getColumnBounds(mousePosition);
if (!column) {
console.warn('No column detected on mouseUp');
return;
}
// Get current position and snap it to grid
const currentY = parseFloat(this.draggedClone?.style.top || '0');
const snappedY = this.calculateSnapPosition(mousePosition.y, column);
// Update clone to snapped position immediately
if (this.draggedClone) {
this.draggedClone.style.top = `${snappedY}px`;
}
// 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: column,
finalY: snappedY,
dropTarget: dropTarget,
isDragStarted: this.isDragStarted
});
const dragEndPayload: DragEndEventPayload = {
originalElement: this.draggedElement,
draggedClone: this.draggedClone,
mousePosition,
finalPosition: { column, snappedY },
target: dropTarget
};
this.eventBus.emit('drag:end', dragEndPayload);
this.cleanupDragState();
} else {
// This was just a click - emit click event instead
this.eventBus.emit('event:click', {
draggedElement: this.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();
this.stopDragAnimation();
}
/**
* 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);
}
/**
* Smooth drag animation using requestAnimationFrame
*/
private animateDrag(): void {
if (!this.isDragStarted || !this.draggedClone || !this.targetColumn) {
this.dragAnimationId = null;
return;
}
// Smooth interpolation towards target
const diff = this.targetY - this.currentY;
const step = diff * 0.3; // 30% of distance per frame
// Update if difference is significant
if (Math.abs(diff) > 0.5) {
this.currentY += step;
// Emit drag move event with interpolated position
const dragMovePayload: DragMoveEventPayload = {
draggedElement: this.draggedElement!,
draggedClone: this.draggedClone,
mousePosition: this.lastMousePosition,
snappedY: this.currentY,
columnBounds: this.targetColumn,
mouseOffset: this.mouseOffset
};
this.eventBus.emit('drag:move', dragMovePayload);
this.dragAnimationId = requestAnimationFrame(() => this.animateDrag());
} else {
// Close enough - snap to target
this.currentY = this.targetY;
const dragMovePayload: DragMoveEventPayload = {
draggedElement: this.draggedElement!,
draggedClone: this.draggedClone,
mousePosition: this.lastMousePosition,
snappedY: this.currentY,
columnBounds: this.targetColumn,
mouseOffset: this.mouseOffset
};
this.eventBus.emit('drag:move', dragMovePayload);
this.dragAnimationId = null;
}
}
/**
* 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;
}
}
/**
* Stop drag animation
*/
private stopDragAnimation(): void {
if (this.dragAnimationId !== null) {
cancelAnimationFrame(this.dragAnimationId);
this.dragAnimationId = null;
}
}
/**
* Clean up drag state
*/
private cleanupDragState(): void {
this.draggedElement = null;
this.draggedClone = null;
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);
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);
}
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) {
this.isHoverTrackingActive = false;
this.clearEventHover();
}
}
}
private clearEventHover(): void {
if (this.currentHoveredEvent) {
this.currentHoveredEvent.classList.remove('hover');
this.currentHoveredEvent = null;
}
}
}