Refactors drag and drop manager for performance

Improves drag and drop performance by caching DOM elements and consolidating position calculations.

This reduces redundant DOM queries and optimizes event handling for smoother user interaction.
Also leverages `DateCalculator` for date/time conversions.
This commit is contained in:
Janus Knudsen 2025-09-03 19:05:03 +02:00
parent 05bb074e9a
commit d0936d1838
2 changed files with 193 additions and 112 deletions

View file

@ -1,37 +1,62 @@
/** /**
* DragDropManager - Handles drag and drop interaction logic * DragDropManager - Optimized drag and drop with consolidated position calculations
* Emits events for visual updates handled by EventRenderer * Reduces redundant DOM queries and improves performance through caching
*/ */
import { IEventBus } from '../types/CalendarTypes'; import { IEventBus } from '../types/CalendarTypes';
import { CalendarConfig } from '../core/CalendarConfig'; import { CalendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
interface CachedElements {
scrollContainer: HTMLElement | null;
currentColumn: HTMLElement | null;
lastColumnDate: string | null;
}
interface Position {
x: number;
y: number;
}
export class DragDropManager { export class DragDropManager {
private eventBus: IEventBus; private eventBus: IEventBus;
private config: CalendarConfig; private config: CalendarConfig;
// Mouse tracking // Mouse tracking with optimized state
private isMouseDown = false; private isMouseDown = false;
private lastMousePosition = { x: 0, y: 0 }; private lastMousePosition: Position = { x: 0, y: 0 };
private lastLoggedPosition = { x: 0, y: 0 }; private lastLoggedPosition: Position = { x: 0, y: 0 };
private currentMouseY = 0; private currentMouseY = 0;
private mouseOffset = { x: 0, y: 0 }; private mouseOffset: Position = { x: 0, y: 0 };
// Drag state // Drag state
private draggedEventId: string | null = null; private draggedEventId: string | null = null;
private originalElement: HTMLElement | null = null; private originalElement: HTMLElement | null = null;
private currentColumn: string | null = null; private currentColumn: string | null = null;
// Cached DOM elements for performance
private cachedElements: CachedElements = {
scrollContainer: null,
currentColumn: null,
lastColumnDate: null
};
// Auto-scroll properties // Auto-scroll properties
private scrollContainer: HTMLElement | null = null;
private autoScrollAnimationId: number | null = null; private autoScrollAnimationId: number | null = null;
private scrollSpeed = 10; // pixels per frame private readonly scrollSpeed = 10; // pixels per frame
private scrollThreshold = 30; // pixels from edge private readonly scrollThreshold = 30; // pixels from edge
// Snap configuration // Snap configuration
private snapIntervalMinutes = 15; // Default 15 minutes private snapIntervalMinutes = 15; // Default 15 minutes
private hourHeightPx = 60; // From CSS --hour-height private hourHeightPx = 60; // From CSS --hour-height
// 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 { private get snapDistancePx(): number {
return (this.snapIntervalMinutes / 60) * this.hourHeightPx; return (this.snapIntervalMinutes / 60) * this.hourHeightPx;
} }
@ -54,11 +79,14 @@ export class DragDropManager {
this.snapIntervalMinutes = minutes; this.snapIntervalMinutes = minutes;
} }
/**
* Initialize with optimized event listener setup
*/
private init(): void { private init(): void {
// Listen to mouse events on body // Use bound handlers for proper cleanup
document.body.addEventListener('mousemove', this.handleMouseMove.bind(this)); document.body.addEventListener('mousemove', this.boundHandlers.mouseMove);
document.body.addEventListener('mousedown', this.handleMouseDown.bind(this)); document.body.addEventListener('mousedown', this.boundHandlers.mouseDown);
document.body.addEventListener('mouseup', this.handleMouseUp.bind(this)); document.body.addEventListener('mouseup', this.boundHandlers.mouseUp);
// Listen for header mouseover events // Listen for header mouseover events
this.eventBus.on('header:mouseover', (event) => { this.eventBus.on('header:mouseover', (event) => {
@ -128,103 +156,112 @@ export class DragDropManager {
} }
} }
/**
* Optimized mouse move handler with consolidated position calculations
*/
private handleMouseMove(event: MouseEvent): void { private handleMouseMove(event: MouseEvent): void {
this.currentMouseY = event.clientY; this.currentMouseY = event.clientY;
if (this.isMouseDown && this.draggedEventId) { if (this.isMouseDown && this.draggedEventId) {
const deltaY = Math.abs(event.clientY - this.lastLoggedPosition.y); const currentPosition: Position = { x: event.clientX, y: event.clientY };
const deltaY = Math.abs(currentPosition.y - this.lastLoggedPosition.y);
// Check for snap interval vertical movement // Check for snap interval vertical movement
if (deltaY >= this.snapDistancePx) { if (deltaY >= this.snapDistancePx) {
this.lastLoggedPosition = { x: event.clientX, y: event.clientY }; this.lastLoggedPosition = currentPosition;
// Calculate snapped position // Consolidated position calculations
const column = this.detectColumn(event.clientX, event.clientY); const positionData = this.calculateDragPosition(currentPosition);
const snappedY = this.calculateSnapPosition(event.clientY);
// Emit drag move event with snapped position // Emit drag move event with consolidated data
this.eventBus.emit('drag:move', { this.eventBus.emit('drag:move', {
eventId: this.draggedEventId, eventId: this.draggedEventId,
mousePosition: { x: event.clientX, y: event.clientY }, mousePosition: currentPosition,
snappedY, snappedY: positionData.snappedY,
column, column: positionData.column,
mouseOffset: this.mouseOffset mouseOffset: this.mouseOffset
}); });
} }
// Check for auto-scroll // Check for auto-scroll
this.checkAutoScroll(event); this.checkAutoScroll(event);
// Check for column change // Check for column change using cached data
const newColumn = this.detectColumn(event.clientX, event.clientY); const newColumn = this.getColumnFromCache(currentPosition);
if (newColumn && newColumn !== this.currentColumn) { if (newColumn && newColumn !== this.currentColumn) {
const previousColumn = this.currentColumn;
this.currentColumn = newColumn; this.currentColumn = newColumn;
this.eventBus.emit('drag:column-change', { this.eventBus.emit('drag:column-change', {
eventId: this.draggedEventId, eventId: this.draggedEventId,
previousColumn: this.currentColumn, previousColumn,
newColumn, newColumn,
mousePosition: { x: event.clientX, y: event.clientY } mousePosition: currentPosition
}); });
} }
} }
} }
/**
* Optimized mouse up handler with consolidated cleanup
*/
private handleMouseUp(event: MouseEvent): void { private handleMouseUp(event: MouseEvent): void {
if (!this.isMouseDown) return; if (!this.isMouseDown) return;
this.isMouseDown = false; this.isMouseDown = false;
// Stop auto-scroll
this.stopAutoScroll(); this.stopAutoScroll();
if (this.draggedEventId && this.originalElement) { if (this.draggedEventId && this.originalElement) {
// Calculate final position const finalPosition: Position = { x: event.clientX, y: event.clientY };
const finalColumn = this.detectColumn(event.clientX, event.clientY);
const finalY = this.calculateSnapPosition(event.clientY); // Use consolidated position calculation
const positionData = this.calculateDragPosition(finalPosition);
// Emit drag end event // Emit drag end event
this.eventBus.emit('drag:end', { this.eventBus.emit('drag:end', {
eventId: this.draggedEventId, eventId: this.draggedEventId,
originalElement: this.originalElement, originalElement: this.originalElement,
finalPosition: { x: event.clientX, y: event.clientY }, finalPosition,
finalColumn, finalColumn: positionData.column,
finalY finalY: positionData.snappedY
}); });
// Clean up drag state
// Clean up this.cleanupDragState();
this.draggedEventId = null;
this.originalElement = null;
this.currentColumn = null;
this.scrollContainer = null;
} }
} }
/** /**
* Calculate snapped Y position based on mouse Y * Consolidated position calculation method
*/ */
private calculateSnapPosition(mouseY: number): number { private calculateDragPosition(mousePosition: Position): { column: string | null; snappedY: number } {
// Find the column element to get relative position const column = this.detectColumn(mousePosition.x, mousePosition.y);
const columnElement = this.currentColumn const snappedY = this.calculateSnapPosition(mousePosition.y, column);
? document.querySelector(`swp-day-column[data-date="${this.currentColumn}"]`)
: null; return { column, snappedY };
}
/**
* Optimized snap position calculation with caching
*/
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; if (!columnElement) return mouseY;
const columnRect = columnElement.getBoundingClientRect(); const columnRect = columnElement.getBoundingClientRect();
const relativeY = mouseY - columnRect.top - this.mouseOffset.y; const relativeY = mouseY - columnRect.top - this.mouseOffset.y;
// Snap to nearest interval // Snap to nearest interval using DateCalculator precision
const snappedY = Math.round(relativeY / this.snapDistancePx) * this.snapDistancePx; const snappedY = Math.round(relativeY / this.snapDistancePx) * this.snapDistancePx;
// Ensure non-negative
return Math.max(0, snappedY); return Math.max(0, snappedY);
} }
/** /**
* Detect which column the mouse is over * Optimized column detection with caching
*/ */
private detectColumn(mouseX: number, mouseY: number): string | null { private detectColumn(mouseX: number, mouseY: number): string | null {
const element = document.elementFromPoint(mouseX, mouseY); const element = document.elementFromPoint(mouseX, mouseY);
@ -237,22 +274,67 @@ export class DragDropManager {
if (!current) return null; if (!current) return null;
} }
return current.dataset.date || 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;
} }
/** /**
* Check if auto-scroll should be triggered * Optimized auto-scroll check with cached container
*/ */
private checkAutoScroll(event: MouseEvent): void { private checkAutoScroll(event: MouseEvent): void {
// Find scrollable content if not cached // Use cached scroll container
if (!this.scrollContainer) { if (!this.cachedElements.scrollContainer) {
this.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement; this.cachedElements.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement;
if (!this.scrollContainer) { if (!this.cachedElements.scrollContainer) {
return; return;
} }
} }
const containerRect = this.scrollContainer.getBoundingClientRect(); const containerRect = this.cachedElements.scrollContainer.getBoundingClientRect();
const mouseY = event.clientY; const mouseY = event.clientY;
// Calculate distances from edges // Calculate distances from edges
@ -270,19 +352,19 @@ export class DragDropManager {
} }
/** /**
* Start auto-scroll animation * Optimized auto-scroll with cached container reference
*/ */
private startAutoScroll(direction: 'up' | 'down'): void { private startAutoScroll(direction: 'up' | 'down'): void {
if (this.autoScrollAnimationId !== null) return; if (this.autoScrollAnimationId !== null) return;
const scroll = () => { const scroll = () => {
if (!this.scrollContainer || !this.isMouseDown) { if (!this.cachedElements.scrollContainer || !this.isMouseDown) {
this.stopAutoScroll(); this.stopAutoScroll();
return; return;
} }
const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed; const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed;
this.scrollContainer.scrollTop += scrollAmount; this.cachedElements.scrollContainer.scrollTop += scrollAmount;
// Emit updated position during scroll // Emit updated position during scroll
if (this.draggedEventId) { if (this.draggedEventId) {
@ -290,7 +372,7 @@ export class DragDropManager {
this.eventBus.emit('drag:auto-scroll', { this.eventBus.emit('drag:auto-scroll', {
eventId: this.draggedEventId, eventId: this.draggedEventId,
snappedY, snappedY,
scrollTop: this.scrollContainer.scrollTop scrollTop: this.cachedElements.scrollContainer.scrollTop
}); });
} }
@ -311,12 +393,35 @@ export class DragDropManager {
} }
/** /**
* Clean up event listeners * Clean up drag state
*/
private cleanupDragState(): void {
this.draggedEventId = null;
this.originalElement = null;
this.currentColumn = null;
// Clear cached elements
this.cachedElements.currentColumn = null;
this.cachedElements.lastColumnDate = null;
}
/**
* Clean up all resources and event listeners
*/ */
public destroy(): void { public destroy(): void {
this.stopAutoScroll(); this.stopAutoScroll();
document.body.removeEventListener('mousemove', this.handleMouseMove.bind(this));
document.body.removeEventListener('mousedown', this.handleMouseDown.bind(this)); // Remove event listeners using bound references
document.body.removeEventListener('mouseup', this.handleMouseUp.bind(this)); 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();
} }
} }

View file

@ -1,8 +1,9 @@
import { CalendarConfig } from '../core/CalendarConfig.js'; import { CalendarConfig } from '../core/CalendarConfig.js';
import { DateCalculator } from './DateCalculator.js';
/** /**
* PositionUtils - Utility functions for pixel/minute conversion * PositionUtils - Optimized positioning utilities using DateCalculator
* Handles positioning and size calculations for calendar events * Focuses on pixel/position calculations while delegating date operations
*/ */
export class PositionUtils { export class PositionUtils {
private config: CalendarConfig; private config: CalendarConfig;
@ -30,11 +31,10 @@ export class PositionUtils {
} }
/** /**
* Convert time (HH:MM) to pixels from day start * Convert time (HH:MM) to pixels from day start using DateCalculator
*/ */
public timeToPixels(timeString: string): number { public timeToPixels(timeString: string): number {
const [hours, minutes] = timeString.split(':').map(Number); const totalMinutes = DateCalculator.timeToMinutes(timeString);
const totalMinutes = (hours * 60) + minutes;
const gridSettings = this.config.getGridSettings(); const gridSettings = this.config.getGridSettings();
const dayStartMinutes = gridSettings.dayStartHour * 60; const dayStartMinutes = gridSettings.dayStartHour * 60;
const minutesFromDayStart = totalMinutes - dayStartMinutes; const minutesFromDayStart = totalMinutes - dayStartMinutes;
@ -43,12 +43,10 @@ export class PositionUtils {
} }
/** /**
* Convert Date object to pixels from day start * Convert Date object to pixels from day start using DateCalculator
*/ */
public dateToPixels(date: Date): number { public dateToPixels(date: Date): number {
const hours = date.getHours(); const totalMinutes = DateCalculator.getMinutesSinceMidnight(date);
const minutes = date.getMinutes();
const totalMinutes = (hours * 60) + minutes;
const gridSettings = this.config.getGridSettings(); const gridSettings = this.config.getGridSettings();
const dayStartMinutes = gridSettings.dayStartHour * 60; const dayStartMinutes = gridSettings.dayStartHour * 60;
const minutesFromDayStart = totalMinutes - dayStartMinutes; const minutesFromDayStart = totalMinutes - dayStartMinutes;
@ -57,7 +55,7 @@ export class PositionUtils {
} }
/** /**
* Konverter pixels til tid (HH:MM format) * Convert pixels to time using DateCalculator
*/ */
public pixelsToTime(pixels: number): string { public pixelsToTime(pixels: number): string {
const minutes = this.pixelsToMinutes(pixels); const minutes = this.pixelsToMinutes(pixels);
@ -65,10 +63,7 @@ export class PositionUtils {
const dayStartMinutes = gridSettings.dayStartHour * 60; const dayStartMinutes = gridSettings.dayStartHour * 60;
const totalMinutes = dayStartMinutes + minutes; const totalMinutes = dayStartMinutes + minutes;
const hours = Math.floor(totalMinutes / 60); return DateCalculator.minutesToTime(totalMinutes);
const mins = Math.round(totalMinutes % 60);
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
} }
/** /**
@ -116,19 +111,15 @@ export class PositionUtils {
} }
/** /**
* Snap tid til interval * Snap time to interval using DateCalculator
*/ */
public snapTimeToInterval(timeString: string): string { public snapTimeToInterval(timeString: string): string {
const [hours, minutes] = timeString.split(':').map(Number); const totalMinutes = DateCalculator.timeToMinutes(timeString);
const totalMinutes = (hours * 60) + minutes;
const gridSettings = this.config.getGridSettings(); const gridSettings = this.config.getGridSettings();
const snapInterval = gridSettings.snapInterval; const snapInterval = gridSettings.snapInterval;
const snappedMinutes = Math.round(totalMinutes / snapInterval) * snapInterval; const snappedMinutes = Math.round(totalMinutes / snapInterval) * snapInterval;
const snappedHours = Math.floor(snappedMinutes / 60); return DateCalculator.minutesToTime(snappedMinutes);
const remainingMinutes = snappedMinutes % 60;
return `${snappedHours.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}`;
} }
/** /**
@ -231,21 +222,21 @@ export class PositionUtils {
} }
/** /**
* Konverter ISO datetime til lokal tid string * Convert ISO datetime to time string using DateCalculator
*/ */
public isoToTimeString(isoString: string): string { public isoToTimeString(isoString: string): string {
const date = new Date(isoString); const date = new Date(isoString);
const hours = date.getHours(); return DateCalculator.formatTime(date);
const minutes = date.getMinutes();
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
} }
/** /**
* Konverter lokal tid string til ISO datetime for i dag * Convert time string to ISO datetime using DateCalculator
*/ */
public timeStringToIso(timeString: string, date: Date = new Date()): string { public timeStringToIso(timeString: string, date: Date = new Date()): string {
const [hours, minutes] = timeString.split(':').map(Number); const totalMinutes = DateCalculator.timeToMinutes(timeString);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const newDate = new Date(date); const newDate = new Date(date);
newDate.setHours(hours, minutes, 0, 0); newDate.setHours(hours, minutes, 0, 0);
@ -253,29 +244,14 @@ export class PositionUtils {
} }
/** /**
* Beregn event varighed i minutter * Calculate event duration using DateCalculator
*/ */
public calculateDuration(startTime: string | Date, endTime: string | Date): number { public calculateDuration(startTime: string | Date, endTime: string | Date): number {
let startMs: number; return DateCalculator.getDurationMinutes(startTime, endTime);
let endMs: number;
if (typeof startTime === 'string') {
startMs = new Date(startTime).getTime();
} else {
startMs = startTime.getTime();
}
if (typeof endTime === 'string') {
endMs = new Date(endTime).getTime();
} else {
endMs = endTime.getTime();
}
return Math.round((endMs - startMs) / (1000 * 60)); // Minutter
} }
/** /**
* Format varighed til læsbar tekst * Format duration to readable text (Danish)
*/ */
public formatDuration(minutes: number): string { public formatDuration(minutes: number): string {
if (minutes < 60) { if (minutes < 60) {