Calendar/src/managers/DragDropManager.ts

322 lines
9.9 KiB
TypeScript
Raw Normal View History

/**
* 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;
}
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
});
}
}
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
});
}
// Check for auto-scroll
this.checkAutoScroll(event);
// Check for column change
const newColumn = this.detectColumn(event.clientX, event.clientY);
if (newColumn && newColumn !== this.currentColumn) {
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
});
// 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) {
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));
}
}