Implements drag and drop functionality

Introduces a DragDropManager to handle event dragging and dropping, replacing the ColumnDetector.

This change centralizes drag and drop logic, improving code organization and maintainability.

The EventRenderer now uses the DragDropManager's events to visually update the calendar during drag operations.

Removes ColumnDetector which is now replaced by the drag and drop manager.
This commit is contained in:
Janus Knudsen 2025-08-27 22:50:13 +02:00
parent be4a8af7c4
commit f697944d75
4 changed files with 658 additions and 676 deletions

View file

@ -0,0 +1,338 @@
/**
* DragDropManager - Handles drag and drop interaction logic
* Emits events for visual updates handled by EventRenderer
*/
import { IEventBus } from '../types/CalendarTypes';
import { CalendarConfig } from '../core/CalendarConfig';
export class DragDropManager {
private eventBus: IEventBus;
private config: CalendarConfig;
// Mouse tracking
private isMouseDown = false;
private lastMousePosition = { x: 0, y: 0 };
private lastLoggedPosition = { x: 0, y: 0 };
private currentMouseY = 0;
private mouseOffset = { x: 0, y: 0 };
// Drag state
private draggedEventId: string | null = null;
private originalElement: HTMLElement | null = null;
private currentColumn: string | null = 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
// Snap configuration
private snapIntervalMinutes = 15; // Default 15 minutes
private hourHeightPx = 60; // From CSS --hour-height
private get snapDistancePx(): number {
return (this.snapIntervalMinutes / 60) * this.hourHeightPx;
}
constructor(eventBus: IEventBus, config: CalendarConfig) {
this.eventBus = eventBus;
this.config = config;
// Get config values
const gridSettings = config.getGridSettings();
this.hourHeightPx = gridSettings.hourHeight;
this.init();
}
/**
* Configure snap interval
*/
public setSnapInterval(minutes: number): void {
this.snapIntervalMinutes = minutes;
console.log(`DragDropManager: Snap interval set to ${minutes} minutes (${this.snapDistancePx}px)`);
}
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));
// Listen for header mouseover events
this.eventBus.on('header:mouseover', (event) => {
const { element, targetDate, headerRenderer } = (event as CustomEvent).detail;
if (this.isMouseDown && this.draggedEventId && targetDate) {
// Emit event to convert to all-day
this.eventBus.emit('drag:convert-to-allday', {
eventId: this.draggedEventId,
targetDate,
element,
headerRenderer
});
}
});
}
private handleMouseDown(event: MouseEvent): void {
this.isMouseDown = true;
this.lastMousePosition = { x: event.clientX, y: event.clientY };
this.lastLoggedPosition = { 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-EVENTS-LAYER') {
if (eventElement.tagName === 'SWP-EVENT' || eventElement.tagName === 'SWP-ALLDAY-EVENT') {
break;
}
eventElement = eventElement.parentElement as HTMLElement;
if (!eventElement) return;
}
// If we reached SWP-EVENTS-LAYER without finding an event, return
if (!eventElement || eventElement.tagName === 'SWP-EVENTS-LAYER') {
return;
}
// Found an event - start dragging
if (eventElement) {
this.originalElement = eventElement;
this.draggedEventId = eventElement.dataset.eventId || null;
// Calculate mouse offset within event
const eventRect = eventElement.getBoundingClientRect();
this.mouseOffset = {
x: event.clientX - eventRect.left,
y: event.clientY - eventRect.top
};
// Detect current column
const column = this.detectColumn(event.clientX, event.clientY);
if (column) {
this.currentColumn = column;
}
// Emit drag start event
this.eventBus.emit('drag:start', {
originalElement: eventElement,
eventId: this.draggedEventId,
mousePosition: { x: event.clientX, y: event.clientY },
mouseOffset: this.mouseOffset,
column: this.currentColumn
});
console.log('DragDropManager: Drag started', {
eventId: this.draggedEventId,
column: this.currentColumn
});
}
}
private handleMouseMove(event: MouseEvent): void {
this.currentMouseY = event.clientY;
if (this.isMouseDown && this.draggedEventId) {
const deltaY = Math.abs(event.clientY - this.lastLoggedPosition.y);
// Check for snap interval vertical movement
if (deltaY >= this.snapDistancePx) {
this.lastLoggedPosition = { x: event.clientX, y: event.clientY };
// Calculate snapped position
const column = this.detectColumn(event.clientX, event.clientY);
const snappedY = this.calculateSnapPosition(event.clientY);
// Emit drag move event with snapped position
this.eventBus.emit('drag:move', {
eventId: this.draggedEventId,
mousePosition: { x: event.clientX, y: event.clientY },
snappedY,
column,
mouseOffset: this.mouseOffset
});
console.log(`DragDropManager: Drag moved ${this.snapIntervalMinutes} minutes`, {
snappedY,
column
});
}
// Check for auto-scroll
this.checkAutoScroll(event);
// Check for column change
const newColumn = this.detectColumn(event.clientX, event.clientY);
if (newColumn && newColumn !== this.currentColumn) {
console.log(`DragDropManager: Column changed from ${this.currentColumn} to ${newColumn}`);
this.currentColumn = newColumn;
this.eventBus.emit('drag:column-change', {
eventId: this.draggedEventId,
previousColumn: this.currentColumn,
newColumn,
mousePosition: { x: event.clientX, y: event.clientY }
});
}
}
}
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);
// Emit drag end event
this.eventBus.emit('drag:end', {
eventId: this.draggedEventId,
originalElement: this.originalElement,
finalPosition: { x: event.clientX, y: event.clientY },
finalColumn,
finalY
});
console.log('DragDropManager: Drag ended', {
eventId: this.draggedEventId,
finalColumn,
finalY
});
// Clean up
this.draggedEventId = null;
this.originalElement = null;
this.currentColumn = null;
this.scrollContainer = null;
}
}
/**
* Calculate snapped Y position based on mouse Y
*/
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;
if (!columnElement) return mouseY;
const columnRect = columnElement.getBoundingClientRect();
const relativeY = mouseY - columnRect.top - this.mouseOffset.y;
// Snap to nearest interval
const snappedY = Math.round(relativeY / this.snapDistancePx) * this.snapDistancePx;
// Ensure non-negative
return Math.max(0, snappedY);
}
/**
* Detect which column the mouse is over
*/
private detectColumn(mouseX: number, mouseY: number): string | null {
const element = document.elementFromPoint(mouseX, mouseY);
if (!element) return null;
// Walk up DOM tree to find swp-day-column
let current = element as HTMLElement;
while (current && current.tagName !== 'SWP-DAY-COLUMN') {
current = current.parentElement as HTMLElement;
if (!current) return null;
}
return current.dataset.date || null;
}
/**
* Check if auto-scroll should be triggered
*/
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) {
console.warn('DragDropManager: Could not find swp-scrollable-content for auto-scroll');
return;
}
}
const containerRect = this.scrollContainer.getBoundingClientRect();
const mouseY = event.clientY;
// Calculate distances from edges
const distanceFromTop = mouseY - containerRect.top;
const distanceFromBottom = containerRect.bottom - mouseY;
// Check if we need to scroll
if (distanceFromTop <= this.scrollThreshold && distanceFromTop > 0) {
this.startAutoScroll('up');
} else if (distanceFromBottom <= this.scrollThreshold && distanceFromBottom > 0) {
this.startAutoScroll('down');
} else {
this.stopAutoScroll();
}
}
/**
* Start auto-scroll animation
*/
private startAutoScroll(direction: 'up' | 'down'): void {
if (this.autoScrollAnimationId !== null) return;
const scroll = () => {
if (!this.scrollContainer || !this.isMouseDown) {
this.stopAutoScroll();
return;
}
const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed;
this.scrollContainer.scrollTop += scrollAmount;
// Emit updated position during scroll
if (this.draggedEventId) {
const snappedY = this.calculateSnapPosition(this.currentMouseY);
this.eventBus.emit('drag:auto-scroll', {
eventId: this.draggedEventId,
snappedY,
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;
}
}
/**
* Clean up 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));
}
}