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
* Emits events for visual updates handled by EventRenderer
* 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 { DateCalculator } from '../utils/DateCalculator';
interface CachedElements {
scrollContainer: HTMLElement | null;
currentColumn: HTMLElement | null;
lastColumnDate: string | null;
}
interface Position {
x: number;
y: number;
}
export class DragDropManager {
private eventBus: IEventBus;
private config: CalendarConfig;
// Mouse tracking
// Mouse tracking with optimized state
private isMouseDown = false;
private lastMousePosition = { x: 0, y: 0 };
private lastLoggedPosition = { x: 0, y: 0 };
private lastMousePosition: Position = { x: 0, y: 0 };
private lastLoggedPosition: Position = { x: 0, y: 0 };
private currentMouseY = 0;
private mouseOffset = { x: 0, y: 0 };
private mouseOffset: Position = { x: 0, y: 0 };
// Drag state
private draggedEventId: string | null = null;
private originalElement: HTMLElement | null = null;
private currentColumn: string | null = null;
// Cached DOM elements for performance
private cachedElements: CachedElements = {
scrollContainer: null,
currentColumn: null,
lastColumnDate: null
};
// Auto-scroll properties
private scrollContainer: HTMLElement | null = null;
private autoScrollAnimationId: number | null = null;
private scrollSpeed = 10; // pixels per frame
private scrollThreshold = 30; // pixels from edge
private readonly scrollSpeed = 10; // pixels per frame
private readonly scrollThreshold = 30; // pixels from edge
// Snap configuration
private snapIntervalMinutes = 15; // Default 15 minutes
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 {
return (this.snapIntervalMinutes / 60) * this.hourHeightPx;
}
@ -54,11 +79,14 @@ export class DragDropManager {
this.snapIntervalMinutes = minutes;
}
/**
* Initialize with optimized event listener setup
*/
private init(): void {
// Listen to mouse events on body
document.body.addEventListener('mousemove', this.handleMouseMove.bind(this));
document.body.addEventListener('mousedown', this.handleMouseDown.bind(this));
document.body.addEventListener('mouseup', this.handleMouseUp.bind(this));
// Use bound handlers for proper cleanup
document.body.addEventListener('mousemove', this.boundHandlers.mouseMove);
document.body.addEventListener('mousedown', this.boundHandlers.mouseDown);
document.body.addEventListener('mouseup', this.boundHandlers.mouseUp);
// Listen for header mouseover events
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 {
this.currentMouseY = event.clientY;
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
if (deltaY >= this.snapDistancePx) {
this.lastLoggedPosition = { x: event.clientX, y: event.clientY };
this.lastLoggedPosition = currentPosition;
// Calculate snapped position
const column = this.detectColumn(event.clientX, event.clientY);
const snappedY = this.calculateSnapPosition(event.clientY);
// Consolidated position calculations
const positionData = this.calculateDragPosition(currentPosition);
// Emit drag move event with snapped position
// Emit drag move event with consolidated data
this.eventBus.emit('drag:move', {
eventId: this.draggedEventId,
mousePosition: { x: event.clientX, y: event.clientY },
snappedY,
column,
mousePosition: currentPosition,
snappedY: positionData.snappedY,
column: positionData.column,
mouseOffset: this.mouseOffset
});
}
// Check for auto-scroll
this.checkAutoScroll(event);
// Check for column change
const newColumn = this.detectColumn(event.clientX, event.clientY);
// Check for column change using cached data
const newColumn = this.getColumnFromCache(currentPosition);
if (newColumn && newColumn !== this.currentColumn) {
const previousColumn = this.currentColumn;
this.currentColumn = newColumn;
this.eventBus.emit('drag:column-change', {
eventId: this.draggedEventId,
previousColumn: this.currentColumn,
previousColumn,
newColumn,
mousePosition: { x: event.clientX, y: event.clientY }
mousePosition: currentPosition
});
}
}
}
/**
* Optimized mouse up handler with consolidated cleanup
*/
private handleMouseUp(event: MouseEvent): void {
if (!this.isMouseDown) return;
this.isMouseDown = false;
// Stop auto-scroll
this.stopAutoScroll();
if (this.draggedEventId && this.originalElement) {
// Calculate final position
const finalColumn = this.detectColumn(event.clientX, event.clientY);
const finalY = this.calculateSnapPosition(event.clientY);
const finalPosition: Position = { x: event.clientX, y: event.clientY };
// Use consolidated position calculation
const positionData = this.calculateDragPosition(finalPosition);
// Emit drag end event
this.eventBus.emit('drag:end', {
eventId: this.draggedEventId,
originalElement: this.originalElement,
finalPosition: { x: event.clientX, y: event.clientY },
finalColumn,
finalY
finalPosition,
finalColumn: positionData.column,
finalY: positionData.snappedY
});
// Clean up
this.draggedEventId = null;
this.originalElement = null;
this.currentColumn = null;
this.scrollContainer = null;
// Clean up drag state
this.cleanupDragState();
}
}
/**
* Calculate snapped Y position based on mouse Y
* Consolidated position calculation method
*/
private calculateSnapPosition(mouseY: number): number {
// Find the column element to get relative position
const columnElement = this.currentColumn
? document.querySelector(`swp-day-column[data-date="${this.currentColumn}"]`)
: null;
private calculateDragPosition(mousePosition: Position): { column: string | null; snappedY: number } {
const column = this.detectColumn(mousePosition.x, mousePosition.y);
const snappedY = this.calculateSnapPosition(mousePosition.y, column);
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;
const columnRect = columnElement.getBoundingClientRect();
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;
// Ensure non-negative
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 {
const element = document.elementFromPoint(mouseX, mouseY);
@ -237,22 +274,67 @@ export class DragDropManager {
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;
}
/**
* Check if auto-scroll should be triggered
* 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;
}
/**
* Optimized auto-scroll check with cached container
*/
private checkAutoScroll(event: MouseEvent): void {
// Find scrollable content if not cached
if (!this.scrollContainer) {
this.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement;
if (!this.scrollContainer) {
// Use cached scroll container
if (!this.cachedElements.scrollContainer) {
this.cachedElements.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement;
if (!this.cachedElements.scrollContainer) {
return;
}
}
const containerRect = this.scrollContainer.getBoundingClientRect();
const containerRect = this.cachedElements.scrollContainer.getBoundingClientRect();
const mouseY = event.clientY;
// 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 {
if (this.autoScrollAnimationId !== null) return;
const scroll = () => {
if (!this.scrollContainer || !this.isMouseDown) {
if (!this.cachedElements.scrollContainer || !this.isMouseDown) {
this.stopAutoScroll();
return;
}
const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed;
this.scrollContainer.scrollTop += scrollAmount;
this.cachedElements.scrollContainer.scrollTop += scrollAmount;
// Emit updated position during scroll
if (this.draggedEventId) {
@ -290,7 +372,7 @@ export class DragDropManager {
this.eventBus.emit('drag:auto-scroll', {
eventId: this.draggedEventId,
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 {
this.stopAutoScroll();
document.body.removeEventListener('mousemove', this.handleMouseMove.bind(this));
document.body.removeEventListener('mousedown', this.handleMouseDown.bind(this));
document.body.removeEventListener('mouseup', this.handleMouseUp.bind(this));
// Remove event listeners using bound references
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 { DateCalculator } from './DateCalculator.js';
/**
* PositionUtils - Utility functions for pixel/minute conversion
* Handles positioning and size calculations for calendar events
* PositionUtils - Optimized positioning utilities using DateCalculator
* Focuses on pixel/position calculations while delegating date operations
*/
export class PositionUtils {
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 {
const [hours, minutes] = timeString.split(':').map(Number);
const totalMinutes = (hours * 60) + minutes;
const totalMinutes = DateCalculator.timeToMinutes(timeString);
const gridSettings = this.config.getGridSettings();
const dayStartMinutes = gridSettings.dayStartHour * 60;
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 {
const hours = date.getHours();
const minutes = date.getMinutes();
const totalMinutes = (hours * 60) + minutes;
const totalMinutes = DateCalculator.getMinutesSinceMidnight(date);
const gridSettings = this.config.getGridSettings();
const dayStartMinutes = gridSettings.dayStartHour * 60;
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 {
const minutes = this.pixelsToMinutes(pixels);
@ -65,10 +63,7 @@ export class PositionUtils {
const dayStartMinutes = gridSettings.dayStartHour * 60;
const totalMinutes = dayStartMinutes + minutes;
const hours = Math.floor(totalMinutes / 60);
const mins = Math.round(totalMinutes % 60);
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
return DateCalculator.minutesToTime(totalMinutes);
}
/**
@ -116,19 +111,15 @@ export class PositionUtils {
}
/**
* Snap tid til interval
* Snap time to interval using DateCalculator
*/
public snapTimeToInterval(timeString: string): string {
const [hours, minutes] = timeString.split(':').map(Number);
const totalMinutes = (hours * 60) + minutes;
const totalMinutes = DateCalculator.timeToMinutes(timeString);
const gridSettings = this.config.getGridSettings();
const snapInterval = gridSettings.snapInterval;
const snappedMinutes = Math.round(totalMinutes / snapInterval) * snapInterval;
const snappedHours = Math.floor(snappedMinutes / 60);
const remainingMinutes = snappedMinutes % 60;
return `${snappedHours.toString().padStart(2, '0')}:${remainingMinutes.toString().padStart(2, '0')}`;
return DateCalculator.minutesToTime(snappedMinutes);
}
/**
@ -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 {
const date = new Date(isoString);
const hours = date.getHours();
const minutes = date.getMinutes();
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
return DateCalculator.formatTime(date);
}
/**
* 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 {
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);
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 {
let startMs: number;
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
return DateCalculator.getDurationMinutes(startTime, endTime);
}
/**
* Format varighed til læsbar tekst
* Format duration to readable text (Danish)
*/
public formatDuration(minutes: number): string {
if (minutes < 60) {