Calendar/src/managers/DragDropManager.ts
Janus C. H. Knudsen 3fd42f1f9b Improves drag scroll compensation accuracy
Refactors drag scroll compensation to use a direct scroll delta instead of accumulating the scroll.

This improves accuracy and responsiveness during drag operations when the scrollable content is being scrolled. It now updates the target position immediately as the user scrolls.
2025-10-13 21:22:33 +02:00

680 lines
22 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,
DragMouseEnterColumnEventPayload,
DragColumnChangeEventPayload
} from '../types/EventTypes';
import { MousePosition } from '../types/DragDropTypes';
import { CoreEvents } from '../constants/CoreEvents';
export class DragDropManager {
private eventBus: IEventBus;
// Mouse tracking with optimized state
private mouseDownPosition: MousePosition = { x: 0, y: 0 };
private currentMousePosition: MousePosition = { x: 0, y: 0 };
private mouseOffset: MousePosition = { x: 0, y: 0 };
// Drag state
private originalElement!: HTMLElement | null;
private draggedClone!: HTMLElement | null;
private currentColumn: ColumnBounds | null = null;
private previousColumn: 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
// 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
private hasScrolledDuringDrag = false; // Track if we have scrolled during this drag operation
private scrollListener: ((e: Event) => void) | null = null;
// Smooth drag animation
private dragAnimationId: number | null = null;
private targetY = 0;
private currentY = 0;
private targetColumn: ColumnBounds | null = null;
constructor(eventBus: IEventBus) {
this.eventBus = eventBus;
// Get config values
const gridSettings = calendarConfig.getGridSettings();
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);
} else if (target.closest('swp-event')) {
this.handleEventMouseEnter(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;
this.hasScrolledDuringDrag = true;
// Gem nuværende scroll position for delta beregning
if (this.scrollableContent) {
this.lastScrollTop = this.scrollableContent.scrollTop;
}
console.log('🎬 DragDropManager: Edge-scroll started');
});
this.eventBus.on('edgescroll:stopped', () => {
this.isScrollCompensating = false;
console.log('🛑 DragDropManager: Edge-scroll stopped');
});
}
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 };
}
}
/**
* 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) {
// 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: MousePosition): 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.draggedClone = originalElement.createClone();
const dragStartPayload: DragStartEventPayload = {
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: MousePosition): 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: MousePosition): 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: DragColumnChangeEventPayload = {
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: MousePosition = { 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: DragEndEventPayload = {
originalElement: this.originalElement,
draggedClone: this.draggedClone,
mousePosition,
sourceColumn: this.previousColumn!!,
finalPosition: { column, snappedY }, // Where drag ended
target: dropTarget
};
console.log('DragEndEventPayload', dragEndPayload);
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) {
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.originalElement) return;
console.log('🚫 DragDropManager: Cancelling drag - mouse left grid container');
this.cleanupAllClones();
this.originalElement.style.opacity = '';
this.originalElement.style.cursor = '';
this.eventBus.emit('drag:cancelled', {
originalElement: this.originalElement,
reason: 'mouse-left-grid'
});
this.cleanupDragState();
this.stopDragAnimation();
}
/**
* 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
* 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: DragMoveEventPayload = {
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: DragMoveEventPayload = {
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);
console.log('📜 DragDropManager: Scroll compensation', {
currentScrollTop,
lastScrollTop: this.lastScrollTop - scrollDelta,
scrollDelta,
scrollDeltaY: this.scrollDeltaY
});
}
/**
* 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.isDragStarted = false;
this.hasScrolledDuringDrag = false;
this.scrollDeltaY = 0;
this.lastScrollTop = 0;
}
/**
* 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 swp-event - activate hover tracking
*/
private handleEventMouseEnter(event: MouseEvent): void {
const target = event.target as HTMLElement;
const eventElement = target.closest<HTMLElement>('swp-event');
// Only handle hover if mouse button is up
if (eventElement && !this.isDragStarted && event.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');
}
}
/**
* 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) {
const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone);
const dragMouseEnterPayload: DragMouseEnterHeaderEventPayload = {
targetColumn: targetColumn,
mousePosition: position,
originalElement: this.originalElement,
draggedClone: this.draggedClone,
calendarEvent: calendarEvent,
replaceClone: (newClone: HTMLElement) => {
this.draggedClone = newClone;
this.dragAnimationId === null;
}
};
console.log('DragMouseEnterHeaderEventPayload', dragMouseEnterPayload);
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;
}
console.log('🎯 DragDropManager: Mouse entered day column');
const position: MousePosition = { x: event.clientX, y: event.clientY };
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (!targetColumn) {
console.warn("No column detected when entering day column");
return;
}
// Calculate snapped Y position
const snappedY = this.calculateSnapPosition(position.y, targetColumn);
// Extract CalendarEvent from the dragged clone
const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone);
const dragMouseEnterPayload: DragMouseEnterColumnEventPayload = {
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;
}
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.originalElement,
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;
}
}
}