Calendar/src/managers/DragDropManager.ts
Janus C. H. Knudsen fba85094d7 Tracks original column during drag and drop
Introduces originalSourceColumn to accurately track the starting column during drag events

Improves event rendering by ensuring correct column updates and maintaining drag context
Modifies drag end handling to use original source column for re-rendering
Adds async support for column rendering methods
2025-11-06 16:18:31 +01:00

748 lines
25 KiB
TypeScript

/**
* DragDropManager - Advanced drag-and-drop system with smooth animations and event type conversion
*
* ARCHITECTURE OVERVIEW:
* =====================
* DragDropManager provides a sophisticated drag-and-drop system for calendar events that supports:
* - Smooth animated dragging with requestAnimationFrame
* - Automatic event type conversion (timed events ↔ all-day events)
* - Scroll compensation during edge scrolling
* - Grid snapping for precise event placement
* - Column detection and change tracking
*
* KEY FEATURES:
* =============
* 1. DRAG DETECTION
* - Movement threshold (5px) to distinguish clicks from drags
* - Immediate visual feedback with cloned element
* - Mouse offset tracking for natural drag feel
*
* 2. SMOOTH ANIMATION
* - Uses requestAnimationFrame for 60fps animations
* - Interpolated movement (30% per frame) for smooth transitions
* - Continuous drag:move events for real-time updates
*
* 3. EVENT TYPE CONVERSION
* - Timed → All-day: When dragging into calendar header
* - All-day → Timed: When dragging into day columns
* - Automatic clone replacement with appropriate element type
*
* 4. SCROLL COMPENSATION
* - Tracks scroll delta during edge-scrolling
* - Compensates dragged element position during scroll
* - Prevents visual "jumping" when scrolling while dragging
*
* 5. GRID SNAPPING
* - Snaps to time grid on mouse up
* - Uses PositionUtils for consistent positioning
* - Accounts for mouse offset within event
*
* STATE MANAGEMENT:
* =================
* Mouse Tracking:
* - mouseDownPosition: Initial click position
* - currentMousePosition: Latest mouse position
* - mouseOffset: Click offset within event (for natural dragging)
*
* Drag State:
* - originalElement: Source event being dragged
* - draggedClone: Animated clone following mouse
* - currentColumn: Column mouse is currently over
* - previousColumn: Last column (for detecting changes)
* - isDragStarted: Whether drag threshold exceeded
*
* Scroll State:
* - scrollDeltaY: Accumulated scroll offset during drag
* - lastScrollTop: Previous scroll position
* - isScrollCompensating: Whether edge-scroll is active
*
* Animation State:
* - dragAnimationId: requestAnimationFrame ID
* - targetY: Desired position for smooth interpolation
* - currentY: Current interpolated position
*
* EVENT FLOW:
* ===========
* 1. Mouse Down (handleMouseDown)
* ├─ Store originalElement and mouse offset
* └─ Wait for movement
*
* 2. Mouse Move (handleMouseMove)
* ├─ Check movement threshold
* ├─ Initialize drag if threshold exceeded (initializeDrag)
* │ ├─ Create clone
* │ ├─ Emit drag:start
* │ └─ Start animation loop
* ├─ Continue drag (continueDrag)
* │ ├─ Calculate target position with scroll compensation
* │ └─ Update animation target
* └─ Detect column changes (detectColumnChange)
* └─ Emit drag:column-change
*
* 3. Animation Loop (animateDrag)
* ├─ Interpolate currentY toward targetY
* ├─ Emit drag:move on each frame
* └─ Schedule next frame until target reached
*
* 4. Event Type Conversion
* ├─ Entering header (handleHeaderMouseEnter)
* │ ├─ Emit drag:mouseenter-header
* │ └─ AllDayManager creates all-day clone
* └─ Entering column (handleColumnMouseEnter)
* ├─ Emit drag:mouseenter-column
* └─ EventRenderingService creates timed clone
*
* 5. Mouse Up (handleMouseUp)
* ├─ Stop animation
* ├─ Snap to grid
* ├─ Detect drop target (header or column)
* ├─ Emit drag:end with final position
* └─ Cleanup drag state
*
* SCROLL COMPENSATION SYSTEM:
* ===========================
* Problem: When EdgeScrollManager scrolls the grid during drag, the dragged element
* can appear to "jump" because the mouse position stays the same but the
* coordinate system (scrollable content) has moved.
*
* Solution: Track cumulative scroll delta and add it to mouse position calculations
*
* Flow:
* 1. EdgeScrollManager starts scrolling → emit edgescroll:started
* 2. DragDropManager sets isScrollCompensating = true
* 3. On each scroll event:
* ├─ Calculate scrollDelta = currentScrollTop - lastScrollTop
* ├─ Accumulate into scrollDeltaY
* └─ Call continueDrag with adjusted position
* 4. continueDrag adds scrollDeltaY to mouse Y coordinate
* 5. On event conversion, reset scrollDeltaY (new clone, new coordinate system)
*
* PERFORMANCE OPTIMIZATIONS:
* ==========================
* - Uses ColumnDetectionUtils cache for fast column lookups
* - Single requestAnimationFrame loop (not per-mousemove)
* - Interpolated animation reduces update frequency
* - Passive scroll listeners
* - Event delegation for header/column detection
*
* USAGE:
* ======
* const dragDropManager = new DragDropManager(eventBus, positionUtils);
* // Automatically attaches event listeners and manages drag lifecycle
* // Other managers listen to drag:start, drag:move, drag:end, etc.
*/
import { IEventBus } from '../types/CalendarTypes';
import { PositionUtils } from '../utils/PositionUtils';
import { IColumnBounds, ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { SwpEventElement, BaseSwpEventElement } from '../elements/SwpEventElement';
import {
IDragStartEventPayload,
IDragMoveEventPayload,
IDragEndEventPayload,
IDragMouseEnterHeaderEventPayload,
IDragMouseLeaveHeaderEventPayload,
IDragMouseEnterColumnEventPayload,
IDragColumnChangeEventPayload
} from '../types/EventTypes';
import { IMousePosition } from '../types/DragDropTypes';
import { CoreEvents } from '../constants/CoreEvents';
export class DragDropManager {
private eventBus: IEventBus;
// Mouse tracking with optimized state
private mouseDownPosition: IMousePosition = { x: 0, y: 0 };
private currentMousePosition: IMousePosition = { x: 0, y: 0 };
private mouseOffset: IMousePosition = { x: 0, y: 0 };
// Drag state
private originalElement!: HTMLElement | null;
private draggedClone!: HTMLElement | null;
private currentColumn: IColumnBounds | null = null;
private previousColumn: IColumnBounds | null = null;
private originalSourceColumn: IColumnBounds | null = null; // Track original start column
private isDragStarted = false;
// Movement threshold to distinguish click from drag
private readonly dragThreshold = 5; // pixels
// Scroll compensation
private scrollableContent: HTMLElement | null = null;
private scrollDeltaY = 0; // Current scroll delta to apply in continueDrag
private lastScrollTop = 0; // Last scroll position for delta calculation
private isScrollCompensating = false; // Track if scroll compensation is active
// Smooth drag animation
private dragAnimationId: number | null = null;
private targetY = 0;
private currentY = 0;
private targetColumn: IColumnBounds | null = null;
private positionUtils: PositionUtils;
constructor(eventBus: IEventBus, positionUtils: PositionUtils) {
this.eventBus = eventBus;
this.positionUtils = positionUtils;
this.init();
}
/**
* 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));
const calendarContainer = document.querySelector('swp-calendar-container');
if (calendarContainer) {
calendarContainer.addEventListener('mouseleave', () => {
if (this.originalElement && 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-day-column')) {
this.handleColumnMouseEnter(e as MouseEvent);
}
}, 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();
});
this.eventBus.on(CoreEvents.GRID_RENDERED, (event: Event) => {
this.handleGridRendered(event as CustomEvent);
});
// Listen to edge-scroll events to control scroll compensation
this.eventBus.on('edgescroll:started', () => {
this.isScrollCompensating = true;
// Gem nuværende scroll position for delta beregning
if (this.scrollableContent) {
this.lastScrollTop = this.scrollableContent.scrollTop;
}
});
this.eventBus.on('edgescroll:stopped', () => {
this.isScrollCompensating = false;
});
// Reset scrollDeltaY when event converts (new clone created)
this.eventBus.on('drag:mouseenter-header', () => {
this.scrollDeltaY = 0;
this.lastScrollTop = 0;
});
this.eventBus.on('drag:mouseenter-column', () => {
this.scrollDeltaY = 0;
this.lastScrollTop = 0;
});
}
private handleGridRendered(event: CustomEvent) {
this.scrollableContent = document.querySelector('swp-scrollable-content');
this.scrollableContent!.addEventListener('scroll', this.handleScroll.bind(this), { passive: true });
}
private handleMouseDown(event: MouseEvent): void {
// Clean up drag state first
this.cleanupDragState();
ColumnDetectionUtils.updateColumnBoundsCache();
//this.lastMousePosition = { 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;
if (target.closest('swp-resize-handle')) return;
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;
}
if (eventElement) {
// Normal drag - prepare for potential dragging
this.originalElement = eventElement;
// Calculate mouse offset within event
const eventRect = eventElement.getBoundingClientRect();
this.mouseOffset = {
x: event.clientX - eventRect.left,
y: event.clientY - eventRect.top
};
this.mouseDownPosition = { x: event.clientX, y: event.clientY };
}
}
private handleMouseMove(event: MouseEvent): void {
if (event.buttons === 1) {
// Always update mouse position from event
this.currentMousePosition = { x: event.clientX, y: event.clientY };
// Try to initialize drag if not started
if (!this.isDragStarted && this.originalElement) {
if (!this.initializeDrag(this.currentMousePosition)) {
return; // Not enough movement yet
}
}
// Continue drag if started (også under scroll - accumulatedScrollDelta kompenserer)
if (this.isDragStarted && this.originalElement && this.draggedClone) {
this.continueDrag(this.currentMousePosition);
this.detectColumnChange(this.currentMousePosition);
}
}
}
/**
* Try to initialize drag based on movement threshold
* Returns true if drag was initialized, false if not enough movement
*/
private initializeDrag(currentPosition: IMousePosition): boolean {
const deltaX = Math.abs(currentPosition.x - this.mouseDownPosition.x);
const deltaY = Math.abs(currentPosition.y - this.mouseDownPosition.y);
const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (totalMovement < this.dragThreshold) {
return false; // Not enough movement
}
// Start drag
this.isDragStarted = true;
// Set high z-index on event-group if exists, otherwise on event itself
const eventGroup = this.originalElement!.closest<HTMLElement>('swp-event-group');
if (eventGroup) {
eventGroup.style.zIndex = '9999';
} else {
this.originalElement!.style.zIndex = '9999';
}
const originalElement = this.originalElement as BaseSwpEventElement;
this.currentColumn = ColumnDetectionUtils.getColumnBounds(currentPosition);
this.originalSourceColumn = this.currentColumn; // Store original source column at drag start
this.draggedClone = originalElement.createClone();
const dragStartPayload: IDragStartEventPayload = {
originalElement: this.originalElement!,
draggedClone: this.draggedClone,
mousePosition: this.mouseDownPosition,
mouseOffset: this.mouseOffset,
columnBounds: this.currentColumn
};
this.eventBus.emit('drag:start', dragStartPayload);
return true;
}
private continueDrag(currentPosition: IMousePosition): void {
if (!this.draggedClone!.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;
// Beregn position fra mus + scroll delta kompensation
const adjustedMouseY = currentPosition.y + this.scrollDeltaY;
const eventTopY = adjustedMouseY - columnRect.top - this.mouseOffset.y;
this.targetY = Math.max(0, eventTopY);
this.targetColumn = column;
// Start animation loop if not already running
if (this.dragAnimationId === null) {
this.currentY = parseFloat(this.draggedClone!.style.top) || 0;
this.animateDrag();
}
}
}
}
/**
* Detect column change and emit event
*/
private detectColumnChange(currentPosition: IMousePosition): void {
const newColumn = ColumnDetectionUtils.getColumnBounds(currentPosition);
if (newColumn == null) return;
if (newColumn.index !== this.currentColumn?.index) {
this.previousColumn = this.currentColumn;
this.currentColumn = newColumn;
const dragColumnChangePayload: IDragColumnChangeEventPayload = {
originalElement: this.originalElement!,
draggedClone: this.draggedClone!,
previousColumn: this.previousColumn,
newColumn,
mousePosition: currentPosition
};
this.eventBus.emit('drag:column-change', dragColumnChangePayload);
}
}
/**
* Optimized mouse up handler with consolidated cleanup
*/
private handleMouseUp(event: MouseEvent): void {
this.stopDragAnimation();
if (this.originalElement) {
// Only emit drag:end if drag was actually started
if (this.isDragStarted) {
const mousePosition: IMousePosition = { x: event.clientX, y: event.clientY };
// Snap to grid on mouse up (like ResizeHandleManager)
const column = ColumnDetectionUtils.getColumnBounds(mousePosition);
if (!column) return;
// Get current position and snap it to grid
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";
const dragEndPayload: IDragEndEventPayload = {
originalElement: this.originalElement,
draggedClone: this.draggedClone,
mousePosition,
originalSourceColumn: this.originalSourceColumn!!,
finalPosition: { column, snappedY }, // Where drag ended
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', {
clickedElement: this.originalElement,
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) {
allClones.forEach(clone => clone.remove());
}
}
/**
* Cancel drag operation when mouse leaves grid container
* Animates clone back to original position before cleanup
*/
private cancelDrag(): void {
if (!this.originalElement || !this.draggedClone) return;
// Get current clone position
const cloneRect = this.draggedClone.getBoundingClientRect();
// Get original element position
const originalRect = this.originalElement.getBoundingClientRect();
// Calculate distance to animate
const deltaX = originalRect.left - cloneRect.left;
const deltaY = originalRect.top - cloneRect.top;
// Add transition for smooth animation
this.draggedClone.style.transition = 'transform 300ms ease-out';
this.draggedClone.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
// Wait for animation to complete, then cleanup
setTimeout(() => {
this.cleanupAllClones();
if (this.originalElement) {
this.originalElement.style.opacity = '';
this.originalElement.style.cursor = '';
}
this.eventBus.emit('drag:cancelled', {
originalElement: this.originalElement,
reason: 'mouse-left-grid'
});
this.cleanupDragState();
this.stopDragAnimation();
}, 300);
}
/**
* Optimized snap position calculation using PositionUtils
*/
private calculateSnapPosition(mouseY: number, column: IColumnBounds): 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 = this.positionUtils.getPositionFromCoordinate(eventTopY, column);
return Math.max(0, snappedY);
}
/**
* Smooth drag animation using requestAnimationFrame
* Emits drag:move events with current draggedClone reference on each frame
*/
private animateDrag(): void { //TODO: this can be optimized... WAIT !!!
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 current draggedClone reference
const dragMovePayload: IDragMoveEventPayload = {
originalElement: this.originalElement!,
draggedClone: this.draggedClone, // Always uses current reference
mousePosition: this.currentMousePosition, // Use current mouse position!
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;
// Emit final position
const dragMovePayload: IDragMoveEventPayload = {
originalElement: this.originalElement!,
draggedClone: this.draggedClone,
mousePosition: this.currentMousePosition, // Use current mouse position!
snappedY: this.currentY,
columnBounds: this.targetColumn,
mouseOffset: this.mouseOffset
};
this.eventBus.emit('drag:move', dragMovePayload);
this.dragAnimationId = null;
}
}
/**
* Handle scroll during drag - update scrollDeltaY and call continueDrag
*/
private handleScroll(): void {
if (!this.isDragStarted || !this.draggedClone || !this.scrollableContent || !this.isScrollCompensating) return;
const currentScrollTop = this.scrollableContent.scrollTop;
const scrollDelta = currentScrollTop - this.lastScrollTop;
// Gem scroll delta for continueDrag
this.scrollDeltaY += scrollDelta;
this.lastScrollTop = currentScrollTop;
// Kald continueDrag med nuværende mus position
this.continueDrag(this.currentMousePosition);
}
/**
* Stop drag animation
*/
private stopDragAnimation(): void {
if (this.dragAnimationId !== null) {
cancelAnimationFrame(this.dragAnimationId);
this.dragAnimationId = null;
}
}
/**
* Clean up drag state
*/
private cleanupDragState(): void {
this.previousColumn = null;
this.originalElement = null;
this.draggedClone = null;
this.currentColumn = null;
this.originalSourceColumn = null;
this.isDragStarted = false;
this.scrollDeltaY = 0;
this.lastScrollTop = 0;
}
/**
* Detect drop target - whether dropped in swp-day-column or swp-day-header
*/
private detectDropTarget(position: IMousePosition): '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: IMousePosition = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (targetColumn) {
const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone);
const dragMouseEnterPayload: IDragMouseEnterHeaderEventPayload = {
targetColumn: targetColumn,
mousePosition: position,
originalElement: this.originalElement,
draggedClone: this.draggedClone,
calendarEvent: calendarEvent,
replaceClone: (newClone: HTMLElement) => {
this.draggedClone = newClone;
this.dragAnimationId === null;
}
};
this.eventBus.emit('drag:mouseenter-header', dragMouseEnterPayload);
}
}
/**
* Handle mouse enter on day column - for converting all-day to timed events
*/
private handleColumnMouseEnter(event: MouseEvent): void {
// Only handle if we're dragging an all-day event
if (!this.isDragStarted || !this.draggedClone || !this.draggedClone.hasAttribute('data-allday')) {
return;
}
const position: IMousePosition = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (!targetColumn) {
return;
}
// Calculate snapped Y position
const snappedY = this.calculateSnapPosition(position.y, targetColumn);
// Extract ICalendarEvent from the dragged clone
const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone);
const dragMouseEnterPayload: IDragMouseEnterColumnEventPayload = {
targetColumn: targetColumn,
mousePosition: position,
snappedY: snappedY,
originalElement: this.originalElement,
draggedClone: this.draggedClone,
calendarEvent: calendarEvent,
replaceClone: (newClone: HTMLElement) => {
this.draggedClone = newClone;
this.dragAnimationId === null;
this.stopDragAnimation();
}
};
this.eventBus.emit('drag:mouseenter-column', 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;
}
const position: IMousePosition = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (!targetColumn) {
return;
}
const dragMouseLeavePayload: IDragMouseLeaveHeaderEventPayload = {
targetDate: targetColumn.date,
mousePosition: position,
originalElement: this.originalElement,
draggedClone: this.draggedClone
};
this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload);
}
}