Enhances drag and drop functionality

Improves drag and drop event handling, including conversion between all-day and timed events.

Introduces HeaderManager to handle header-related event logic and
centralizes header event handling for better code organization and
separation of concerns.

Optimizes event listeners and throttles events for improved performance.
Removes redundant code and improves the overall drag and drop
experience.
This commit is contained in:
Janus Knudsen 2025-09-10 22:07:40 +02:00
parent d087e333fe
commit 3bd74d6f4e
6 changed files with 418 additions and 170 deletions

View file

@ -140,6 +140,12 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
this.handleConvertToAllDay(eventId, targetDate, headerRenderer);
});
// Handle convert to timed event
eventBus.on('drag:convert-to-timed', (event) => {
const { eventId, targetColumn, targetY } = (event as CustomEvent).detail;
this.handleConvertToTimed(eventId, targetColumn, targetY);
});
// Handle navigation period change (when slide animation completes)
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
// Animate all-day height after navigation completes
@ -183,6 +189,45 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
return 60;
}
/**
* Apply common drag styling to an element
*/
private applyDragStyling(element: HTMLElement): void {
element.style.position = 'absolute';
element.style.zIndex = '999999';
element.style.pointerEvents = 'none';
element.style.opacity = '0.8';
element.style.left = '2px';
element.style.right = '2px';
element.style.marginLeft = '0px';
element.style.width = '';
}
/**
* Create event inner structure (swp-event-time and swp-event-title)
*/
private createEventInnerStructure(event: CalendarEvent): string {
const startTime = this.formatTime(event.start);
const endTime = this.formatTime(event.end);
const durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60);
return `
<swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${event.title}</swp-event-title>
`;
}
/**
* Apply standard event positioning
*/
private applyEventPositioning(element: HTMLElement, top: number, height: number): void {
element.style.position = 'absolute';
element.style.top = `${top}px`;
element.style.height = `${height}px`;
element.style.left = '2px';
element.style.right = '2px';
}
/**
* Create a clone of an event for dragging
*/
@ -199,18 +244,10 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
const originalDurationMinutes = this.getOriginalEventDuration(originalEvent);
clone.dataset.originalDuration = originalDurationMinutes.toString();
// Apply common drag styling
this.applyDragStyling(clone);
// Style for dragging
clone.style.position = 'absolute';
clone.style.zIndex = '999999';
clone.style.pointerEvents = 'none';
clone.style.opacity = '0.8';
// Dragged event skal have fuld kolonne bredde
clone.style.left = '2px';
clone.style.right = '2px';
clone.style.marginLeft = '0px';
clone.style.width = '';
// Set height from original event
clone.style.height = originalEvent.style.height || `${originalEvent.getBoundingClientRect().height}px`;
return clone;
@ -220,6 +257,10 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
* Update clone timestamp based on new position
*/
private updateCloneTimestamp(clone: HTMLElement, snappedY: number): void {
//important as events can pile up, so they will still fire after event has been converted to another rendered type
if(clone.dataset.allDay == "true") return;
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
@ -248,7 +289,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
clone.dataset.start = startDate.toISOString();
clone.dataset.end = endDate.toISOString();
// Update display
const timeElement = clone.querySelector('swp-event-time');
if (timeElement) {
@ -283,7 +323,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
* Handle drag start event
*/
private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void {
console.log('handleDragStart:', eventId);
this.originalEvent = originalElement;
// Remove stacking styling during drag will be handled by new system
@ -346,10 +385,9 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
* Handle drag end event
*/
private handleDragEnd(eventId: string, originalElement: HTMLElement, finalColumn: string, finalY: number): void {
console.log('handleDragEnd:', eventId);
if (!this.draggedClone || !this.originalEvent) {
console.log('Missing draggedClone or originalEvent');
console.warn('Missing draggedClone or originalEvent');
return;
}
@ -443,11 +481,13 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
this.draggedClone.style.userSelect = '';
// Behold z-index hvis det er et stacked event
// Update dataset with new times after successful drop
const newEvent = this.elementToCalendarEvent(this.draggedClone);
if (newEvent) {
this.draggedClone.dataset.start = newEvent.start.toISOString();
this.draggedClone.dataset.end = newEvent.end.toISOString();
// Update dataset with new times after successful drop (only for timed events)
if (this.draggedClone.tagName !== 'SWP-ALLDAY-EVENT') {
const newEvent = this.elementToCalendarEvent(this.draggedClone);
if (newEvent) {
this.draggedClone.dataset.start = newEvent.start.toISOString();
this.draggedClone.dataset.end = newEvent.end.toISOString();
}
}
// Detect overlaps with other events in the target column and reposition if needed
@ -687,12 +727,15 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
const allDayEvent = document.createElement('swp-allday-event');
allDayEvent.dataset.eventId = clone.dataset.eventId || '';
allDayEvent.dataset.title = eventTitle;
allDayEvent.dataset.start = `${targetDate}T${eventTime.split(' - ')[0]}:00`;
allDayEvent.dataset.end = `${targetDate}T${eventTime.split(' - ')[1]}:00`;
allDayEvent.dataset.start = `${targetDate}T00:00:00`;
allDayEvent.dataset.end = `${targetDate}T23:59:59`;
allDayEvent.dataset.type = clone.dataset.type || 'work';
allDayEvent.dataset.duration = eventDuration;
allDayEvent.dataset.allDay = "true";
allDayEvent.textContent = eventTitle;
console.log("allDayEvent", allDayEvent.dataset);
// Position in grid
(allDayEvent as HTMLElement).style.gridColumn = columnIndex.toString();
// grid-row will be set by checkAndAnimateAllDayHeight() based on actual position
@ -711,8 +754,101 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
// Check if height animation is needed
this.triggerAllDayHeightAnimation();
}
/**
* Handle conversion from all-day to timed event
*/
private handleConvertToTimed(eventId: string, targetColumn: string, targetY: number): void {
if (!this.draggedClone) return;
// Only convert if it's an all-day event
if (this.draggedClone.tagName !== 'SWP-ALLDAY-EVENT') return;
// Transform clone to timed format
this.transformAllDayToTimed(this.draggedClone, targetColumn, targetY);
}
/**
* Transform clone from all-day to timed event
*/
private transformAllDayToTimed(allDayClone: HTMLElement, targetColumn: string, targetY: number): void {
// Find target column element
const columnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`);
if (!columnElement) return;
const eventsLayer = columnElement.querySelector('swp-events-layer');
if (!eventsLayer) return;
// Extract event data from all-day element
const eventId = allDayClone.dataset.eventId || '';
const eventTitle = allDayClone.dataset.title || allDayClone.textContent || 'Untitled';
const eventType = allDayClone.dataset.type || 'work';
// Calculate time from Y position
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
const snapInterval = gridSettings.snapInterval;
// Calculate start time from position
const minutesFromGridStart = (targetY / hourHeight) * 60;
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
// Use default duration or extract from dataset
const duration = parseInt(allDayClone.dataset.duration || '60');
const endMinutes = snappedStartMinutes + duration;
// Create dates with target column date
const columnDate = new Date(targetColumn + 'T00:00:00');
const startDate = new Date(columnDate);
startDate.setMinutes(snappedStartMinutes);
const endDate = new Date(columnDate);
endDate.setMinutes(endMinutes);
// Create CalendarEvent object for helper methods
const tempEvent: CalendarEvent = {
id: eventId,
title: eventTitle,
start: startDate,
end: endDate,
type: eventType,
allDay: false,
syncStatus: 'synced',
metadata: {
duration: duration
}
};
// Create timed event element
const timedEvent = document.createElement('swp-event');
timedEvent.dataset.eventId = eventId;
timedEvent.dataset.title = eventTitle;
timedEvent.dataset.type = eventType;
timedEvent.dataset.start = startDate.toISOString();
timedEvent.dataset.end = endDate.toISOString();
timedEvent.dataset.duration = duration.toString();
timedEvent.dataset.originalDuration = duration.toString();
// Create inner structure using helper method
timedEvent.innerHTML = this.createEventInnerStructure(tempEvent);
// Apply drag styling and positioning
this.applyDragStyling(timedEvent);
const eventHeight = (duration / 60) * hourHeight - 3;
timedEvent.style.height = `${eventHeight}px`;
timedEvent.style.top = `${targetY}px`;
// Remove all-day element
allDayClone.remove();
// Add timed event to events layer
eventsLayer.appendChild(timedEvent);
// Update reference
this.draggedClone = timedEvent;
}
/**
* Fade out and remove element
@ -872,26 +1008,14 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
eventElement.dataset.type = event.type;
eventElement.dataset.duration = event.metadata?.duration?.toString() || '60';
// Calculate position based on time
// Calculate and apply position based on time
const position = this.calculateEventPosition(event);
eventElement.style.position = 'absolute';
eventElement.style.top = `${position.top + 1}px`;
eventElement.style.height = `${position.height - 3}px`; //adjusted so bottom does not cover horizontal time lines.
this.applyEventPositioning(eventElement, position.top + 1, position.height - 3);
// Color is now handled by CSS classes based on data-type attribute
// Format time for display using unified method
const startTime = this.formatTime(event.start);
const endTime = this.formatTime(event.end);
// Calculate duration in minutes
const durationMinutes = (event.end.getTime() - event.start.getTime()) / (1000 * 60);
// Create event content
eventElement.innerHTML = `
<swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${event.title}</swp-event-title>
`;
// Create event content using helper method
eventElement.innerHTML = this.createEventInnerStructure(event);
// Setup resize handles on first mouseover only
eventElement.addEventListener('mouseover', () => {

View file

@ -11,7 +11,6 @@ import { DateCalculator } from '../utils/DateCalculator';
* Optimized to reduce redundant DOM operations and improve performance
*/
export class GridRenderer {
private headerEventListener: ((event: Event) => void) | null = null;
private cachedGridContainer: HTMLElement | null = null;
private cachedCalendarHeader: HTMLElement | null = null;
private cachedTimeAxis: HTMLElement | null = null;
@ -158,8 +157,8 @@ export class GridRenderer {
// Always ensure all-day containers exist for all days
headerRenderer.ensureAllDayContainers(calendarHeader);
// Setup optimized event listener
this.setupOptimizedHeaderEventListener(calendarHeader);
// Setup only grid-related event listeners
this.setupGridEventListeners();
}
/**
@ -209,83 +208,74 @@ export class GridRenderer {
}
/**
* Setup optimized event delegation listener with better performance
* Setup grid-only event listeners (column events)
*/
private setupOptimizedHeaderEventListener(calendarHeader: HTMLElement): void {
// Remove existing listener if any
if (this.headerEventListener) {
calendarHeader.removeEventListener('mouseover', this.headerEventListener);
}
private setupGridEventListeners(): void {
// Setup grid body mouseover listener for all-day to timed conversion
this.setupGridBodyMouseOver();
}
// Create optimized listener with throttling
/**
* Setup grid body mouseover listener for all-day to timed conversion
*/
private setupGridBodyMouseOver(): void {
const grid = this.cachedGridContainer;
if (!grid) return;
const columnContainer = grid.querySelector('swp-day-columns');
if (!columnContainer) return;
// Throttle for better performance
let lastEmitTime = 0;
const throttleDelay = 16; // ~60fps
this.headerEventListener = (event) => {
const gridBodyEventListener = (event: Event) => {
const now = Date.now();
if (now - lastEmitTime < throttleDelay) {
return; // Throttle events for better performance
return;
}
lastEmitTime = now;
const target = event.target as HTMLElement;
const dayColumn = target.closest('swp-day-column');
// Optimized element detection
const dayHeader = target.closest('swp-day-header');
const allDayContainer = target.closest('swp-allday-container');
if (dayHeader || allDayContainer) {
let hoveredElement: HTMLElement;
let targetDate: string | undefined;
if (dayHeader) {
hoveredElement = dayHeader as HTMLElement;
targetDate = hoveredElement.dataset.date;
} else if (allDayContainer) {
hoveredElement = allDayContainer as HTMLElement;
if (dayColumn) {
const targetColumn = (dayColumn as HTMLElement).dataset.date;
if (targetColumn) {
// Calculate Y position relative to the column
const columnRect = dayColumn.getBoundingClientRect();
const mouseY = (event as MouseEvent).clientY;
const targetY = mouseY - columnRect.top;
// Optimized day calculation using cached header rect
const headerRect = calendarHeader.getBoundingClientRect();
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
const mouseX = (event as MouseEvent).clientX - headerRect.left;
const dayWidth = headerRect.width / dayHeaders.length;
const dayIndex = Math.max(0, Math.min(dayHeaders.length - 1, Math.floor(mouseX / dayWidth)));
const targetDayHeader = dayHeaders[dayIndex] as HTMLElement;
targetDate = targetDayHeader?.dataset.date;
} else {
return;
eventBus.emit('column:mouseover', {
targetColumn,
targetY
});
}
// Get header renderer once and cache
const calendarType = calendarConfig.getCalendarMode();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
eventBus.emit('header:mouseover', {
element: hoveredElement,
targetDate,
headerRenderer
});
}
};
// Add the optimized listener
calendarHeader.addEventListener('mouseover', this.headerEventListener);
columnContainer.addEventListener('mouseover', gridBodyEventListener);
// Store reference for cleanup
(this as any).gridBodyEventListener = gridBodyEventListener;
(this as any).cachedColumnContainer = columnContainer;
}
/**
* Clean up cached elements and event listeners
*/
public destroy(): void {
// Clean up event listeners
if (this.headerEventListener && this.cachedCalendarHeader) {
this.cachedCalendarHeader.removeEventListener('mouseover', this.headerEventListener);
// Clean up grid-only event listeners
if ((this as any).gridBodyEventListener && (this as any).cachedColumnContainer) {
(this as any).cachedColumnContainer.removeEventListener('mouseover', (this as any).gridBodyEventListener);
}
// Clear cached references
this.cachedGridContainer = null;
this.cachedCalendarHeader = null;
this.cachedTimeAxis = null;
this.headerEventListener = null;
(this as any).gridBodyEventListener = null;
(this as any).cachedColumnContainer = null;
}
}

View file

@ -4,7 +4,6 @@ import { calendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
import { EventRenderingService } from './EventRendererManager';
import { CalendarTypeFactory } from '../factories/CalendarTypeFactory';
import { eventBus } from '../core/EventBus';
/**
* NavigationRenderer - Handles DOM rendering for navigation containers
@ -12,8 +11,6 @@ import { eventBus } from '../core/EventBus';
*/
export class NavigationRenderer {
private eventBus: IEventBus;
private dateCalculator: DateCalculator;
private eventRenderer: EventRenderingService;
// Cached DOM elements to avoid redundant queries
private cachedWeekNumberElement: HTMLElement | null = null;
@ -21,9 +18,7 @@ export class NavigationRenderer {
constructor(eventBus: IEventBus, eventRenderer: EventRenderingService) {
this.eventBus = eventBus;
this.eventRenderer = eventRenderer;
DateCalculator.initialize(calendarConfig);
this.dateCalculator = new DateCalculator();
this.setupEventListeners();
}
@ -202,9 +197,6 @@ export class NavigationRenderer {
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarConfig.getCalendarMode());
headerRenderer.ensureAllDayContainers(header as HTMLElement);
// Add event delegation listener for drag & drop functionality
this.setupHeaderEventListener(header as HTMLElement);
// Render day columns for target week
dates.forEach(date => {
const column = document.createElement('swp-day-column');
@ -217,55 +209,6 @@ export class NavigationRenderer {
});
}
/**
* Setup event delegation listener for header mouseover (same logic as GridRenderer)
*/
private setupHeaderEventListener(calendarHeader: HTMLElement): void {
calendarHeader.addEventListener('mouseover', (event) => {
const target = event.target as HTMLElement;
// Check what was hovered - could be day-header OR all-day-container
const dayHeader = target.closest('swp-day-header');
const allDayContainer = target.closest('swp-allday-container');
if (dayHeader || allDayContainer) {
let hoveredElement: HTMLElement;
let targetDate: string | undefined;
if (dayHeader) {
hoveredElement = dayHeader as HTMLElement;
targetDate = hoveredElement.dataset.date;
} else if (allDayContainer) {
// For all-day areas, we need to determine which day column we're over
hoveredElement = allDayContainer as HTMLElement;
// Calculate which day we're hovering over based on mouse position
const headerRect = calendarHeader.getBoundingClientRect();
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
const mouseX = (event as MouseEvent).clientX - headerRect.left;
const dayWidth = headerRect.width / dayHeaders.length;
const dayIndex = Math.floor(mouseX / dayWidth);
const targetDayHeader = dayHeaders[dayIndex] as HTMLElement;
targetDate = targetDayHeader?.dataset.date;
} else {
return; // No valid element found
}
// Get the header renderer for addToAllDay functionality
const calendarType = calendarConfig.getCalendarMode();
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarType);
eventBus.emit('header:mouseover', {
element: hoveredElement,
targetDate,
headerRenderer
});
}
});
}
/**
* Public cleanup method for cached elements
*/