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

@ -4,6 +4,7 @@ import { CalendarEvent } from '../types/CalendarTypes';
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
import { CalendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
import { eventBus } from '../core/EventBus';
/**
* Interface for event rendering strategies
@ -18,9 +19,312 @@ export interface EventRendererStrategy {
*/
export abstract class BaseEventRenderer implements EventRendererStrategy {
protected dateCalculator: DateCalculator;
protected config: CalendarConfig;
// Drag and drop state
private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null;
constructor(config: CalendarConfig) {
this.config = config;
this.dateCalculator = new DateCalculator(config);
this.setupDragEventListeners();
}
/**
* Setup listeners for drag events from DragDropManager
*/
private setupDragEventListeners(): void {
// Handle drag start
eventBus.on('drag:start', (event) => {
const { originalElement, eventId, mouseOffset, column } = (event as CustomEvent).detail;
this.handleDragStart(originalElement, eventId, mouseOffset, column);
});
// Handle drag move
eventBus.on('drag:move', (event) => {
const { eventId, snappedY, column, mouseOffset } = (event as CustomEvent).detail;
this.handleDragMove(eventId, snappedY, column, mouseOffset);
});
// Handle drag end
eventBus.on('drag:end', (event) => {
const { eventId, originalElement, finalColumn, finalY } = (event as CustomEvent).detail;
this.handleDragEnd(eventId, originalElement, finalColumn, finalY);
});
// Handle column change
eventBus.on('drag:column-change', (event) => {
const { eventId, newColumn } = (event as CustomEvent).detail;
this.handleColumnChange(eventId, newColumn);
});
// Handle convert to all-day
eventBus.on('drag:convert-to-allday', (event) => {
const { eventId, targetDate, headerRenderer } = (event as CustomEvent).detail;
this.handleConvertToAllDay(eventId, targetDate, headerRenderer);
});
}
/**
* Get original event duration from data-duration attribute
*/
private getOriginalEventDuration(originalEvent: HTMLElement): number {
// Find the swp-event-time element with data-duration attribute
const timeElement = originalEvent.querySelector('swp-event-time');
if (timeElement) {
const duration = timeElement.getAttribute('data-duration');
if (duration) {
const durationMinutes = parseInt(duration);
console.log(`EventRenderer: Read duration ${durationMinutes} minutes from data-duration attribute`);
return durationMinutes;
}
}
// Fallback to 60 minutes if attribute not found
console.warn('EventRenderer: No data-duration found, using fallback 60 minutes');
return 60;
}
/**
* Create a clone of an event for dragging
*/
private createEventClone(originalEvent: HTMLElement): HTMLElement {
const clone = originalEvent.cloneNode(true) as HTMLElement;
// Prefix ID with "clone-"
const originalId = originalEvent.dataset.eventId;
if (originalId) {
clone.dataset.eventId = `clone-${originalId}`;
}
// Get and cache original duration from data-duration attribute
const originalDurationMinutes = this.getOriginalEventDuration(originalEvent);
clone.dataset.originalDuration = originalDurationMinutes.toString();
console.log(`EventRenderer: Clone created with ${originalDurationMinutes} minutes duration from data-duration`);
// Style for dragging
clone.style.position = 'absolute';
clone.style.zIndex = '999999';
clone.style.pointerEvents = 'none';
clone.style.opacity = '0.8';
// Keep original dimensions (height stays the same)
const rect = originalEvent.getBoundingClientRect();
clone.style.width = rect.width + 'px';
clone.style.height = rect.height + 'px';
return clone;
}
/**
* Update clone timestamp based on new position
*/
private updateCloneTimestamp(clone: HTMLElement, snappedY: number): void {
const gridSettings = this.config.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
const snapInterval = 15; // TODO: Get from config
// Calculate total minutes from top
const totalMinutesFromTop = (snappedY / hourHeight) * 60;
const startTotalMinutes = Math.max(
dayStartHour * 60,
Math.round((dayStartHour * 60 + totalMinutesFromTop) / snapInterval) * snapInterval
);
// Use cached original duration (no recalculation)
const cachedDuration = parseInt(clone.dataset.originalDuration || '60');
const endTotalMinutes = startTotalMinutes + cachedDuration;
// Update display
const timeElement = clone.querySelector('swp-event-time');
if (timeElement) {
const newTimeText = `${this.formatTime(startTotalMinutes)} - ${this.formatTime(endTotalMinutes)}`;
timeElement.textContent = newTimeText;
console.log(`EventRenderer: Updated timestamp to ${newTimeText} (${cachedDuration} min duration)`);
}
}
/**
* Calculate event duration in minutes from element height
*/
private getEventDuration(element: HTMLElement): number {
const gridSettings = this.config.getGridSettings();
const hourHeight = gridSettings.hourHeight;
// Get height from style or computed
let heightPx = parseFloat(element.style.height) || 0;
if (!heightPx) {
const rect = element.getBoundingClientRect();
heightPx = rect.height;
}
return Math.round((heightPx / hourHeight) * 60);
}
/**
* Format time from total minutes
*/
private formatTime(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60) % 24;
const minutes = totalMinutes % 60;
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
/**
* Handle drag start event
*/
private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void {
this.originalEvent = originalElement;
// Create clone
this.draggedClone = this.createEventClone(originalElement);
// Add to current column
const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`);
if (columnElement) {
columnElement.appendChild(this.draggedClone);
}
// Make original semi-transparent
originalElement.style.opacity = '0.3';
originalElement.style.userSelect = 'none';
console.log('EventRenderer: Drag started, clone created');
}
/**
* Handle drag move event
*/
private handleDragMove(eventId: string, snappedY: number, column: string, mouseOffset: any): void {
if (!this.draggedClone) return;
// Update position
this.draggedClone.style.top = snappedY + 'px';
// Update timestamp display
this.updateCloneTimestamp(this.draggedClone, snappedY);
console.log('EventRenderer: Clone position and timestamp updated');
}
/**
* Handle column change during drag
*/
private handleColumnChange(eventId: string, newColumn: string): void {
if (!this.draggedClone) return;
// Move clone to new column
const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`);
if (newColumnElement && this.draggedClone.parentElement !== newColumnElement) {
newColumnElement.appendChild(this.draggedClone);
console.log(`EventRenderer: Clone moved to column ${newColumn}`);
}
}
/**
* Handle drag end event
*/
private handleDragEnd(eventId: string, originalElement: HTMLElement, finalColumn: string, finalY: number): void {
if (!this.draggedClone || !this.originalEvent) return;
// Fade out original
this.fadeOutAndRemove(this.originalEvent);
// Remove clone prefix and enable pointer events
const cloneId = this.draggedClone.dataset.eventId;
if (cloneId && cloneId.startsWith('clone-')) {
this.draggedClone.dataset.eventId = cloneId.replace('clone-', '');
}
this.draggedClone.style.pointerEvents = '';
this.draggedClone.style.opacity = '';
// Clean up
this.draggedClone = null;
this.originalEvent = null;
console.log('EventRenderer: Drag completed');
}
/**
* Handle conversion to all-day event
*/
private handleConvertToAllDay(eventId: string, targetDate: string, headerRenderer: any): void {
if (!this.draggedClone) return;
// Only convert once
if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') return;
// Transform clone to all-day format
this.transformCloneToAllDay(this.draggedClone, targetDate);
// Expand header if needed
headerRenderer.addToAllDay(this.draggedClone.parentElement);
console.log(`EventRenderer: Converted to all-day event for date ${targetDate}`);
}
/**
* Transform clone from timed to all-day event
*/
private transformCloneToAllDay(clone: HTMLElement, targetDate: string): void {
const calendarHeader = document.querySelector('swp-calendar-header');
if (!calendarHeader) return;
// Find all-day container
const allDayContainer = calendarHeader.querySelector('swp-allday-container');
if (!allDayContainer) return;
// Extract title
const titleElement = clone.querySelector('swp-event-title');
const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled';
// Calculate column index
const dayHeaders = document.querySelectorAll('swp-day-header');
let columnIndex = 1;
dayHeaders.forEach((header, index) => {
if ((header as HTMLElement).dataset.date === targetDate) {
columnIndex = index + 1;
}
});
// Create all-day event
const allDayEvent = document.createElement('swp-allday-event');
allDayEvent.dataset.eventId = clone.dataset.eventId || '';
allDayEvent.dataset.type = clone.dataset.type || 'work';
allDayEvent.textContent = eventTitle;
// Position in grid
(allDayEvent as HTMLElement).style.gridColumn = columnIndex.toString();
(allDayEvent as HTMLElement).style.gridRow = '1';
// Remove original clone
if (clone.parentElement) {
clone.parentElement.removeChild(clone);
}
// Add to all-day container
allDayContainer.appendChild(allDayEvent);
// Update reference
this.draggedClone = allDayEvent;
}
/**
* Fade out and remove element
*/
private fadeOutAndRemove(element: HTMLElement): void {
element.style.transition = 'opacity 0.3s ease-out';
element.style.opacity = '0';
setTimeout(() => {
element.remove();
}, 300);
}
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void {
console.log('BaseEventRenderer: renderEvents called with', events.length, 'events');
@ -186,14 +490,21 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
// Color is now handled by CSS classes based on data-type attribute
// Format time for display
const startTime = this.dateCalculator.formatTime(new Date(event.start));
const endTime = this.dateCalculator.formatTime(new Date(event.end));
const startTime = this.formatTimeFromISOString(event.start);
const endTime = this.formatTimeFromISOString(event.end);
// Calculate duration in minutes
const startDate = new Date(event.start);
const endDate = new Date(event.end);
const durationMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60);
// Create event content
eventElement.innerHTML = `
<swp-event-time>${startTime} - ${endTime}</swp-event-time>
<swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${event.title}</swp-event-title>
`;
console.log(`BaseEventRenderer: Rendered "${event.title}" with ${durationMinutes} minutes duration`);
container.appendChild(eventElement);
@ -240,7 +551,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
return { top, height };
}
protected formatTime(isoString: string): string {
protected formatTimeFromISOString(isoString: string): string {
const date = new Date(isoString);
const hours = date.getHours();
const minutes = date.getMinutes();