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.
1228 lines
No EOL
42 KiB
TypeScript
1228 lines
No EOL
42 KiB
TypeScript
// Event rendering strategy interface and implementations
|
|
|
|
import { CalendarEvent } from '../types/CalendarTypes';
|
|
import { calendarConfig } from '../core/CalendarConfig';
|
|
import { DateCalculator } from '../utils/DateCalculator';
|
|
import { eventBus } from '../core/EventBus';
|
|
import { CoreEvents } from '../constants/CoreEvents';
|
|
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
|
|
import { ResizeManager } from '../managers/ResizeManager';
|
|
|
|
/**
|
|
* Interface for event rendering strategies
|
|
*/
|
|
export interface EventRendererStrategy {
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement): void;
|
|
clearEvents(container?: HTMLElement): void;
|
|
}
|
|
|
|
/**
|
|
* Base class for event renderers with common functionality
|
|
*/
|
|
export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|
protected dateCalculator: DateCalculator;
|
|
|
|
// Drag and drop state
|
|
private draggedClone: HTMLElement | null = null;
|
|
private originalEvent: HTMLElement | null = null;
|
|
|
|
// Resize manager
|
|
private resizeManager: ResizeManager;
|
|
|
|
constructor(dateCalculator?: DateCalculator) {
|
|
if (!dateCalculator) {
|
|
DateCalculator.initialize(calendarConfig);
|
|
}
|
|
this.dateCalculator = dateCalculator || new DateCalculator();
|
|
this.resizeManager = new ResizeManager(eventBus);
|
|
}
|
|
|
|
// ============================================
|
|
// NEW OVERLAP DETECTION SYSTEM
|
|
// All new functions prefixed with new_
|
|
// ============================================
|
|
|
|
protected overlapDetector = new OverlapDetector();
|
|
|
|
/**
|
|
* Ny hovedfunktion til at håndtere event overlaps
|
|
* @param events - Events der skal renderes i kolonnen
|
|
* @param container - Container element at rendere i
|
|
*/
|
|
protected new_handleEventOverlaps(events: CalendarEvent[], container: HTMLElement): void {
|
|
if (events.length === 0) return;
|
|
|
|
if (events.length === 1) {
|
|
const element = this.renderEvent(events[0]);
|
|
container.appendChild(element);
|
|
return;
|
|
}
|
|
|
|
// Track hvilke events der allerede er blevet processeret
|
|
const processedEvents = new Set<string>();
|
|
|
|
// Gå gennem hvert event og find overlaps
|
|
events.forEach((currentEvent, index) => {
|
|
// Skip events der allerede er processeret som del af en overlap gruppe
|
|
if (processedEvents.has(currentEvent.id)) {
|
|
return;
|
|
}
|
|
|
|
const remainingEvents = events.slice(index + 1);
|
|
const overlappingEvents = this.overlapDetector.resolveOverlap(currentEvent, remainingEvents);
|
|
|
|
if (overlappingEvents.length > 0) {
|
|
// Der er overlaps - opret stack links
|
|
const result = this.overlapDetector.decorateWithStackLinks(currentEvent, overlappingEvents);
|
|
this.new_renderOverlappingEvents(result, container);
|
|
|
|
// Marker alle events i overlap gruppen som processeret
|
|
overlappingEvents.forEach(event => processedEvents.add(event.id));
|
|
} else {
|
|
// Intet overlap - render normalt
|
|
const element = this.renderEvent(currentEvent);
|
|
container.appendChild(element);
|
|
processedEvents.add(currentEvent.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Setup listeners for drag events from DragDropManager
|
|
*/
|
|
protected 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 auto-scroll (when dragging near edges triggers scroll)
|
|
eventBus.on('drag:auto-scroll', (event) => {
|
|
const { eventId, snappedY } = (event as CustomEvent).detail;
|
|
if (!this.draggedClone) return;
|
|
|
|
// Update position directly using the calculated snapped position
|
|
this.draggedClone.style.top = snappedY + 'px';
|
|
|
|
// Update timestamp display
|
|
this.updateCloneTimestamp(this.draggedClone, snappedY);
|
|
});
|
|
|
|
// Handle drag end
|
|
eventBus.on('drag:end', (event) => {
|
|
const { eventId, originalElement, finalColumn, finalY } = (event as CustomEvent).detail;
|
|
this.handleDragEnd(eventId, originalElement, finalColumn, finalY);
|
|
});
|
|
|
|
// Handle click (when drag threshold not reached)
|
|
eventBus.on('event:click', (event) => {
|
|
const { eventId, originalElement } = (event as CustomEvent).detail;
|
|
this.handleEventClick(eventId, originalElement);
|
|
});
|
|
|
|
// 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);
|
|
});
|
|
|
|
// 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
|
|
this.triggerAllDayHeightAnimation();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Trigger all-day height animation without creating new renderer instance
|
|
*/
|
|
private triggerAllDayHeightAnimation(): void {
|
|
import('./HeaderRenderer').then(({ DateHeaderRenderer }) => {
|
|
const headerRenderer = new DateHeaderRenderer();
|
|
headerRenderer.checkAndAnimateAllDayHeight();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cleanup method for proper resource management
|
|
*/
|
|
public destroy(): void {
|
|
this.draggedClone = null;
|
|
this.originalEvent = null;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
return durationMinutes;
|
|
}
|
|
}
|
|
|
|
// Fallback to 60 minutes if attribute not found
|
|
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
|
|
*/
|
|
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();
|
|
|
|
// Apply common drag styling
|
|
this.applyDragStyling(clone);
|
|
|
|
// Set height from original event
|
|
clone.style.height = originalEvent.style.height || `${originalEvent.getBoundingClientRect().height}px`;
|
|
|
|
return clone;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
const snapInterval = gridSettings.snapInterval;
|
|
|
|
// Calculate minutes from grid start (not from midnight)
|
|
const minutesFromGridStart = (snappedY / hourHeight) * 60;
|
|
|
|
// Add dayStartHour offset to get actual time
|
|
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
|
|
|
|
// Snap to interval
|
|
const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
|
|
|
|
// Use cached original duration (no recalculation)
|
|
const cachedDuration = parseInt(clone.dataset.originalDuration || '60');
|
|
const endTotalMinutes = snappedStartMinutes + cachedDuration;
|
|
|
|
// Update dataset with reference date for performance
|
|
const referenceDate = new Date('1970-01-01T00:00:00');
|
|
const startDate = new Date(referenceDate);
|
|
startDate.setMinutes(startDate.getMinutes() + snappedStartMinutes);
|
|
|
|
const endDate = new Date(referenceDate);
|
|
endDate.setMinutes(endDate.getMinutes() + endTotalMinutes);
|
|
|
|
clone.dataset.start = startDate.toISOString();
|
|
clone.dataset.end = endDate.toISOString();
|
|
// Update display
|
|
const timeElement = clone.querySelector('swp-event-time');
|
|
if (timeElement) {
|
|
const newTimeText = `${this.formatTime(snappedStartMinutes)} - ${this.formatTime(endTotalMinutes)}`;
|
|
timeElement.textContent = newTimeText;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unified time formatting method - handles both total minutes and Date objects
|
|
*/
|
|
private formatTime(input: number | Date | string): string {
|
|
let hours: number, minutes: number;
|
|
|
|
if (typeof input === 'number') {
|
|
// Total minutes input
|
|
hours = Math.floor(input / 60) % 24;
|
|
minutes = input % 60;
|
|
} else {
|
|
// Date or ISO string input
|
|
const date = typeof input === 'string' ? new Date(input) : input;
|
|
hours = date.getHours();
|
|
minutes = date.getMinutes();
|
|
}
|
|
|
|
const period = hours >= 12 ? 'PM' : 'AM';
|
|
const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
|
|
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;
|
|
|
|
// Remove stacking styling during drag will be handled by new system
|
|
|
|
// Create clone
|
|
this.draggedClone = this.createEventClone(originalElement);
|
|
|
|
// Add to current column's events layer (not directly to column)
|
|
const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`);
|
|
if (columnElement) {
|
|
const eventsLayer = columnElement.querySelector('swp-events-layer');
|
|
if (eventsLayer) {
|
|
eventsLayer.appendChild(this.draggedClone);
|
|
} else {
|
|
// Fallback to column if events layer not found
|
|
columnElement.appendChild(this.draggedClone);
|
|
}
|
|
}
|
|
|
|
// Make original semi-transparent
|
|
originalElement.style.opacity = '0.3';
|
|
originalElement.style.userSelect = 'none';
|
|
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
}
|
|
|
|
/**
|
|
* Handle column change during drag
|
|
*/
|
|
private handleColumnChange(eventId: string, newColumn: string): void {
|
|
if (!this.draggedClone) return;
|
|
|
|
// Move clone to new column's events layer
|
|
const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`);
|
|
if (newColumnElement) {
|
|
const eventsLayer = newColumnElement.querySelector('swp-events-layer');
|
|
if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) {
|
|
eventsLayer.appendChild(this.draggedClone);
|
|
} else if (!eventsLayer && this.draggedClone.parentElement !== newColumnElement) {
|
|
// Fallback to column if events layer not found
|
|
newColumnElement.appendChild(this.draggedClone);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle drag end event
|
|
*/
|
|
private handleDragEnd(eventId: string, originalElement: HTMLElement, finalColumn: string, finalY: number): void {
|
|
|
|
if (!this.draggedClone || !this.originalEvent) {
|
|
console.warn('Missing draggedClone or originalEvent');
|
|
return;
|
|
}
|
|
|
|
// Check om original event var del af en stack
|
|
const originalStackLink = this.originalEvent.dataset.stackLink;
|
|
|
|
if (originalStackLink) {
|
|
try {
|
|
const stackData = JSON.parse(originalStackLink);
|
|
|
|
// Saml ALLE event IDs fra hele stack chain
|
|
const allStackEventIds: Set<string> = new Set();
|
|
|
|
// Recursive funktion til at traversere stack chain
|
|
const traverseStack = (linkData: any, visitedIds: Set<string>) => {
|
|
if (linkData.prev && !visitedIds.has(linkData.prev)) {
|
|
visitedIds.add(linkData.prev);
|
|
const prevElement = document.querySelector(`swp-time-grid [data-event-id="${linkData.prev}"]`) as HTMLElement;
|
|
if (prevElement?.dataset.stackLink) {
|
|
try {
|
|
const prevLinkData = JSON.parse(prevElement.dataset.stackLink);
|
|
traverseStack(prevLinkData, visitedIds);
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
if (linkData.next && !visitedIds.has(linkData.next)) {
|
|
visitedIds.add(linkData.next);
|
|
const nextElement = document.querySelector(`swp-time-grid [data-event-id="${linkData.next}"]`) as HTMLElement;
|
|
if (nextElement?.dataset.stackLink) {
|
|
try {
|
|
const nextLinkData = JSON.parse(nextElement.dataset.stackLink);
|
|
traverseStack(nextLinkData, visitedIds);
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Start traversering fra original event's stackLink
|
|
traverseStack(stackData, allStackEventIds);
|
|
|
|
// Fjern original eventId da det bliver flyttet
|
|
allStackEventIds.delete(eventId);
|
|
|
|
// Find alle stack events og fjern dem
|
|
const stackEvents: CalendarEvent[] = [];
|
|
let container: HTMLElement | null = null;
|
|
|
|
allStackEventIds.forEach(id => {
|
|
const element = document.querySelector(`swp-time-grid [data-event-id="${id}"]`) as HTMLElement;
|
|
if (element) {
|
|
// Gem container reference fra første element
|
|
if (!container) {
|
|
container = element.closest('swp-events-layer') as HTMLElement;
|
|
}
|
|
|
|
const event = this.elementToCalendarEvent(element);
|
|
if (event) {
|
|
stackEvents.push(event);
|
|
}
|
|
|
|
// Fjern elementet
|
|
element.remove();
|
|
}
|
|
});
|
|
|
|
// Re-render stack events hvis vi fandt nogle
|
|
if (stackEvents.length > 0 && container) {
|
|
this.new_handleEventOverlaps(stackEvents, container);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to parse stackLink data:', e);
|
|
}
|
|
}
|
|
|
|
// Remove original event from any existing groups first
|
|
this.removeEventFromExistingGroups(this.originalEvent);
|
|
|
|
// Fade out original
|
|
this.fadeOutAndRemove(this.originalEvent);
|
|
|
|
// Remove clone prefix and normalize clone to be a regular event
|
|
const cloneId = this.draggedClone.dataset.eventId;
|
|
if (cloneId && cloneId.startsWith('clone-')) {
|
|
this.draggedClone.dataset.eventId = cloneId.replace('clone-', '');
|
|
}
|
|
|
|
// Fully normalize the clone to be a regular event
|
|
this.draggedClone.style.pointerEvents = '';
|
|
this.draggedClone.style.opacity = '';
|
|
this.draggedClone.style.userSelect = '';
|
|
// Behold z-index hvis det er et stacked event
|
|
|
|
// 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
|
|
this.handleDragDropOverlaps(this.draggedClone, finalColumn);
|
|
|
|
// Fjern stackLink data fra dropped element
|
|
if (this.draggedClone.dataset.stackLink) {
|
|
delete this.draggedClone.dataset.stackLink;
|
|
}
|
|
|
|
// Clean up
|
|
this.draggedClone = null;
|
|
this.originalEvent = null;
|
|
|
|
}
|
|
|
|
/**
|
|
* Handle event click (when drag threshold not reached)
|
|
*/
|
|
private handleEventClick(eventId: string, originalElement: HTMLElement): void {
|
|
console.log('handleEventClick:', eventId);
|
|
|
|
// Clean up any drag artifacts from failed drag attempt
|
|
if (this.draggedClone) {
|
|
this.draggedClone.remove();
|
|
this.draggedClone = null;
|
|
}
|
|
|
|
// Restore original element styling if it was modified
|
|
if (this.originalEvent) {
|
|
this.originalEvent.style.opacity = '';
|
|
this.originalEvent.style.userSelect = '';
|
|
this.originalEvent = null;
|
|
}
|
|
|
|
// Emit a clean click event for other components to handle
|
|
eventBus.emit('event:clicked', {
|
|
eventId: eventId,
|
|
element: originalElement
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle overlap detection and re-rendering after drag-drop
|
|
*/
|
|
private handleDragDropOverlaps(droppedElement: HTMLElement, targetColumn: string): void {
|
|
const targetColumnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`);
|
|
if (!targetColumnElement) return;
|
|
|
|
const eventsLayer = targetColumnElement.querySelector('swp-events-layer') as HTMLElement;
|
|
if (!eventsLayer) return;
|
|
|
|
// Convert dropped element to CalendarEvent with new position
|
|
const droppedEvent = this.elementToCalendarEvent(droppedElement);
|
|
if (!droppedEvent) return;
|
|
|
|
// Get existing events in the column (excluding the dropped element)
|
|
const existingEvents = this.getEventsInColumn(eventsLayer, droppedElement.dataset.eventId);
|
|
|
|
// Find overlaps with the dropped event
|
|
const overlappingEvents = this.overlapDetector.resolveOverlap(droppedEvent, existingEvents);
|
|
|
|
if (overlappingEvents.length > 0) {
|
|
// Remove only affected events from DOM
|
|
const affectedEventIds = [droppedEvent.id, ...overlappingEvents.map(e => e.id)];
|
|
eventsLayer.querySelectorAll('swp-event').forEach(el => {
|
|
const eventId = (el as HTMLElement).dataset.eventId;
|
|
if (eventId && affectedEventIds.includes(eventId)) {
|
|
el.remove();
|
|
}
|
|
});
|
|
|
|
// Re-render affected events with overlap handling
|
|
const affectedEvents = [droppedEvent, ...overlappingEvents];
|
|
this.new_handleEventOverlaps(affectedEvents, eventsLayer);
|
|
} else {
|
|
// Reset z-index for non-overlapping events
|
|
droppedElement.style.zIndex = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all events in a column as CalendarEvent objects
|
|
*/
|
|
private getEventsInColumn(eventsLayer: HTMLElement, excludeEventId?: string): CalendarEvent[] {
|
|
const eventElements = eventsLayer.querySelectorAll('swp-event');
|
|
const events: CalendarEvent[] = [];
|
|
|
|
eventElements.forEach(el => {
|
|
const element = el as HTMLElement;
|
|
const eventId = element.dataset.eventId;
|
|
|
|
// Skip the excluded event (e.g., the dropped event)
|
|
if (excludeEventId && eventId === excludeEventId) {
|
|
return;
|
|
}
|
|
|
|
const event = this.elementToCalendarEvent(element);
|
|
if (event) {
|
|
events.push(event);
|
|
}
|
|
});
|
|
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* Remove event from any existing groups and cleanup empty containers
|
|
* In the new system, this is handled automatically by re-rendering overlaps
|
|
*/
|
|
private removeEventFromExistingGroups(eventElement: HTMLElement): void {
|
|
// With the new system, overlap relationships are recalculated on drop
|
|
// No need to manually track and remove from groups
|
|
}
|
|
|
|
/**
|
|
* Update element's dataset with new times after successful drop
|
|
*/
|
|
private updateElementDataset(element: HTMLElement, event: CalendarEvent): void {
|
|
element.dataset.start = event.start.toISOString();
|
|
element.dataset.end = event.end.toISOString();
|
|
|
|
// Update the time display
|
|
const timeElement = element.querySelector('swp-event-time');
|
|
if (timeElement) {
|
|
const startTime = this.formatTime(event.start);
|
|
const endTime = this.formatTime(event.end);
|
|
timeElement.textContent = `${startTime} - ${endTime}`;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Convert DOM element to CalendarEvent - handles both normal and 1970 reference dates
|
|
*/
|
|
private elementToCalendarEvent(element: HTMLElement): CalendarEvent | null {
|
|
const eventId = element.dataset.eventId;
|
|
const title = element.dataset.title;
|
|
const type = element.dataset.type;
|
|
const start = element.dataset.start;
|
|
const end = element.dataset.end;
|
|
|
|
if (!eventId || !title || !type || !start || !end) {
|
|
return null;
|
|
}
|
|
|
|
let startDate = new Date(start);
|
|
let endDate = new Date(end);
|
|
|
|
// Check if we have 1970 reference date (from drag operations)
|
|
if (startDate.getFullYear() === 1970) {
|
|
// Find the parent column to get the actual date
|
|
const columnElement = element.closest('swp-day-column') as HTMLElement;
|
|
if (columnElement && columnElement.dataset.date) {
|
|
const columnDate = new Date(columnElement.dataset.date);
|
|
|
|
// Keep the time portion from the 1970 dates, but use the column's date
|
|
startDate = new Date(
|
|
columnDate.getFullYear(),
|
|
columnDate.getMonth(),
|
|
columnDate.getDate(),
|
|
startDate.getHours(),
|
|
startDate.getMinutes()
|
|
);
|
|
|
|
endDate = new Date(
|
|
columnDate.getFullYear(),
|
|
columnDate.getMonth(),
|
|
columnDate.getDate(),
|
|
endDate.getHours(),
|
|
endDate.getMinutes()
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: eventId,
|
|
title: title,
|
|
start: startDate,
|
|
end: endDate,
|
|
type: type,
|
|
allDay: false,
|
|
syncStatus: 'synced',
|
|
metadata: {
|
|
duration: element.dataset.duration
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
}
|
|
|
|
/**
|
|
* 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 all original event data
|
|
const titleElement = clone.querySelector('swp-event-title');
|
|
const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled';
|
|
|
|
const timeElement = clone.querySelector('swp-event-time');
|
|
const eventTime = timeElement ? timeElement.textContent || '' : '';
|
|
const eventDuration = timeElement ? timeElement.getAttribute('data-duration') || '' : '';
|
|
|
|
// 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 with standardized data attributes
|
|
const allDayEvent = document.createElement('swp-allday-event');
|
|
allDayEvent.dataset.eventId = clone.dataset.eventId || '';
|
|
allDayEvent.dataset.title = eventTitle;
|
|
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
|
|
|
|
// Remove original clone
|
|
if (clone.parentElement) {
|
|
clone.parentElement.removeChild(clone);
|
|
}
|
|
|
|
// Add to all-day container
|
|
allDayContainer.appendChild(allDayEvent);
|
|
|
|
// Update reference
|
|
this.draggedClone = allDayEvent;
|
|
|
|
// 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
|
|
*/
|
|
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): void {
|
|
|
|
// NOTE: Removed clearEvents() to support sliding animation
|
|
// With sliding animation, multiple grid containers exist simultaneously
|
|
// clearEvents() would remove events from all containers, breaking the animation
|
|
// Events are now rendered directly into the new container without clearing
|
|
|
|
// Separate all-day events from regular events
|
|
const allDayEvents = events.filter(event => event.allDay);
|
|
const regularEvents = events.filter(event => !event.allDay);
|
|
|
|
|
|
// Always call renderAllDayEvents to ensure height is set correctly (even to 0)
|
|
this.renderAllDayEvents(allDayEvents, container);
|
|
|
|
// Find columns in the specific container for regular events
|
|
const columns = this.getColumns(container);
|
|
|
|
columns.forEach(column => {
|
|
const columnEvents = this.getEventsForColumn(column, regularEvents);
|
|
|
|
const eventsLayer = column.querySelector('swp-events-layer');
|
|
if (eventsLayer) {
|
|
// NY TILGANG: Kald vores nye overlap handling
|
|
this.new_handleEventOverlaps(columnEvents, eventsLayer as HTMLElement);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Abstract methods that subclasses must implement
|
|
protected abstract getColumns(container: HTMLElement): HTMLElement[];
|
|
protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
|
|
|
|
/**
|
|
* Render all-day events in the header row 2
|
|
*/
|
|
protected renderAllDayEvents(allDayEvents: CalendarEvent[], container: HTMLElement): void {
|
|
|
|
// Find the calendar header
|
|
const calendarHeader = container.querySelector('swp-calendar-header');
|
|
if (!calendarHeader) {
|
|
return;
|
|
}
|
|
|
|
// Find the all-day container (should always exist now)
|
|
const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement;
|
|
if (!allDayContainer) {
|
|
console.warn('All-day container not found - this should not happen');
|
|
return;
|
|
}
|
|
|
|
// Clear existing events
|
|
allDayContainer.innerHTML = '';
|
|
|
|
if (allDayEvents.length === 0) {
|
|
// No events - container exists but is empty and hidden
|
|
return;
|
|
}
|
|
|
|
// Build date to column mapping
|
|
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
|
|
const dateToColumnMap = new Map<string, number>();
|
|
|
|
dayHeaders.forEach((header, index) => {
|
|
const dateStr = (header as any).dataset.date;
|
|
if (dateStr) {
|
|
dateToColumnMap.set(dateStr, index + 1); // 1-based column index
|
|
}
|
|
});
|
|
|
|
// Calculate grid spans for all events
|
|
const eventSpans = allDayEvents.map(event => ({
|
|
event,
|
|
span: this.calculateEventGridSpan(event, dateToColumnMap)
|
|
})).filter(item => item.span.columnSpan > 0); // Remove events outside visible range
|
|
|
|
// Simple row assignment using overlap detection
|
|
const eventPlacements: Array<{ event: CalendarEvent, span: { startColumn: number, columnSpan: number }, row: number }> = [];
|
|
|
|
eventSpans.forEach(eventItem => {
|
|
let assignedRow = 1;
|
|
|
|
// Find first row where this event doesn't overlap with any existing event
|
|
while (true) {
|
|
const rowEvents = eventPlacements.filter(item => item.row === assignedRow);
|
|
const hasOverlap = rowEvents.some(rowEvent =>
|
|
this.spansOverlap(eventItem.span, rowEvent.span)
|
|
);
|
|
|
|
if (!hasOverlap) {
|
|
break; // Found available row
|
|
}
|
|
assignedRow++;
|
|
}
|
|
|
|
eventPlacements.push({
|
|
event: eventItem.event,
|
|
span: eventItem.span,
|
|
row: assignedRow
|
|
});
|
|
});
|
|
|
|
// Get max row needed
|
|
const maxRow = Math.max(...eventPlacements.map(item => item.row), 1);
|
|
|
|
// Place events directly in the single container
|
|
eventPlacements.forEach(({ event, span, row }) => {
|
|
// Create the all-day event element
|
|
const allDayEvent = document.createElement('swp-allday-event');
|
|
allDayEvent.textContent = event.title;
|
|
|
|
// Set data attributes directly from CalendarEvent
|
|
allDayEvent.dataset.eventId = event.id;
|
|
allDayEvent.dataset.title = event.title;
|
|
allDayEvent.dataset.start = event.start.toISOString();
|
|
allDayEvent.dataset.end = event.end.toISOString();
|
|
allDayEvent.dataset.type = event.type;
|
|
allDayEvent.dataset.duration = event.metadata?.duration?.toString() || '60';
|
|
|
|
// Set grid position (column and row)
|
|
(allDayEvent as HTMLElement).style.gridColumn = span.columnSpan > 1
|
|
? `${span.startColumn} / span ${span.columnSpan}`
|
|
: `${span.startColumn}`;
|
|
(allDayEvent as HTMLElement).style.gridRow = row.toString();
|
|
|
|
// Use event metadata for color if available
|
|
if (event.metadata?.color) {
|
|
(allDayEvent as HTMLElement).style.backgroundColor = event.metadata.color;
|
|
}
|
|
|
|
allDayContainer.appendChild(allDayEvent);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
protected renderEvent(event: CalendarEvent): HTMLElement {
|
|
const eventElement = document.createElement('swp-event');
|
|
eventElement.dataset.eventId = event.id;
|
|
eventElement.dataset.title = event.title;
|
|
eventElement.dataset.start = event.start.toISOString();
|
|
eventElement.dataset.end = event.end.toISOString();
|
|
eventElement.dataset.type = event.type;
|
|
eventElement.dataset.duration = event.metadata?.duration?.toString() || '60';
|
|
|
|
// Calculate and apply position based on time
|
|
const position = this.calculateEventPosition(event);
|
|
this.applyEventPositioning(eventElement, position.top + 1, position.height - 3);
|
|
|
|
// Color is now handled by CSS classes based on data-type attribute
|
|
|
|
// Create event content using helper method
|
|
eventElement.innerHTML = this.createEventInnerStructure(event);
|
|
|
|
// Setup resize handles on first mouseover only
|
|
eventElement.addEventListener('mouseover', () => {
|
|
if (eventElement.dataset.hasResizeHandlers !== 'true') {
|
|
this.resizeManager.setupResizeHandles(eventElement);
|
|
eventElement.dataset.hasResizeHandlers = 'true';
|
|
}
|
|
}, { once: true });
|
|
|
|
return eventElement;
|
|
}
|
|
|
|
protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
|
|
|
|
const gridSettings = calendarConfig.getGridSettings();
|
|
const dayStartHour = gridSettings.dayStartHour;
|
|
const hourHeight = gridSettings.hourHeight;
|
|
|
|
// Calculate minutes from midnight
|
|
const startMinutes = event.start.getHours() * 60 + event.start.getMinutes();
|
|
const endMinutes = event.end.getHours() * 60 + event.end.getMinutes();
|
|
const dayStartMinutes = dayStartHour * 60;
|
|
|
|
// Calculate top position relative to visible grid start
|
|
// If dayStartHour=6 and event starts at 09:00 (540 min), then:
|
|
// top = ((540 - 360) / 60) * hourHeight = 3 * hourHeight (3 hours from grid start)
|
|
const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight;
|
|
|
|
// Calculate height based on event duration
|
|
const durationMinutes = endMinutes - startMinutes;
|
|
const height = (durationMinutes / 60) * hourHeight;
|
|
|
|
return { top, height };
|
|
}
|
|
|
|
/**
|
|
* Calculate grid column span for event
|
|
*/
|
|
private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map<string, number>): { startColumn: number, columnSpan: number } {
|
|
const startDateKey = DateCalculator.formatISODate(event.start);
|
|
const startColumn = dateToColumnMap.get(startDateKey);
|
|
|
|
if (!startColumn) {
|
|
return { startColumn: 0, columnSpan: 0 }; // Event outside visible range
|
|
}
|
|
|
|
// Calculate span by checking each day
|
|
let endColumn = startColumn;
|
|
const currentDate = new Date(event.start);
|
|
|
|
while (currentDate <= event.end) {
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
const dateKey = DateCalculator.formatISODate(currentDate);
|
|
const col = dateToColumnMap.get(dateKey);
|
|
if (col) {
|
|
endColumn = col;
|
|
} else {
|
|
break; // Event extends beyond visible range
|
|
}
|
|
}
|
|
|
|
const columnSpan = endColumn - startColumn + 1;
|
|
return { startColumn, columnSpan };
|
|
}
|
|
|
|
/**
|
|
* Check if two column spans overlap (for all-day events)
|
|
*/
|
|
private spansOverlap(event1Span: { startColumn: number, columnSpan: number }, event2Span: { startColumn: number, columnSpan: number }): boolean {
|
|
const event1End = event1Span.startColumn + event1Span.columnSpan - 1;
|
|
const event2End = event2Span.startColumn + event2Span.columnSpan - 1;
|
|
|
|
return !(event1End < event2Span.startColumn || event2End < event1Span.startColumn);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
clearEvents(container?: HTMLElement): void {
|
|
const selector = 'swp-event, swp-event-group';
|
|
const existingEvents = container
|
|
? container.querySelectorAll(selector)
|
|
: document.querySelectorAll(selector);
|
|
|
|
existingEvents.forEach(event => event.remove());
|
|
}
|
|
|
|
/**
|
|
* Renderer overlappende events baseret på OverlapResult
|
|
* @param result - OverlapResult med events og stack links
|
|
* @param container - Container at rendere i
|
|
*/
|
|
protected new_renderOverlappingEvents(result: OverlapResult, container: HTMLElement): void {
|
|
// Iterate direkte gennem stackLinks - allerede sorteret fra decorateWithStackLinks
|
|
for (const [eventId, stackLink] of result.stackLinks.entries()) {
|
|
const event = result.overlappingEvents.find(e => e.id === eventId);
|
|
if (!event) continue;
|
|
|
|
const element = this.renderEvent(event);
|
|
|
|
// Gem stack link information på DOM elementet
|
|
element.dataset.stackLink = JSON.stringify({
|
|
prev: stackLink.prev,
|
|
next: stackLink.next,
|
|
stackLevel: stackLink.stackLevel
|
|
});
|
|
|
|
// Check om dette event deler kolonne med foregående (samme start tid)
|
|
if (stackLink.prev) {
|
|
const prevEvent = result.overlappingEvents.find(e => e.id === stackLink.prev);
|
|
if (prevEvent && prevEvent.start.getTime() === event.start.getTime()) {
|
|
// Samme start tid - del kolonne (side by side)
|
|
this.new_applyColumnSharingStyling([element]);
|
|
} else {
|
|
// Forskellige start tider - stack vertikalt
|
|
this.new_applyStackStyling(element, stackLink.stackLevel);
|
|
}
|
|
} else {
|
|
// Første event i stack
|
|
this.new_applyStackStyling(element, stackLink.stackLevel);
|
|
}
|
|
|
|
container.appendChild(element);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Applicerer stack styling (margin-left og z-index)
|
|
* @param element - Event element
|
|
* @param stackLevel - Stack niveau
|
|
*/
|
|
protected new_applyStackStyling(element: HTMLElement, stackLevel: number): void {
|
|
element.style.marginLeft = `${stackLevel * 15}px`;
|
|
element.style.zIndex = `${100 + stackLevel}`;
|
|
}
|
|
|
|
/**
|
|
* Applicerer column sharing styling (flexbox)
|
|
* @param elements - Event elements der skal dele plads
|
|
*/
|
|
protected new_applyColumnSharingStyling(elements: HTMLElement[]): void {
|
|
elements.forEach(element => {
|
|
element.style.flex = '1';
|
|
element.style.minWidth = '50px';
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Date-based event renderer
|
|
*/
|
|
export class DateEventRenderer extends BaseEventRenderer {
|
|
constructor(dateCalculator?: DateCalculator) {
|
|
super(dateCalculator);
|
|
this.setupDragEventListeners();
|
|
}
|
|
|
|
protected getColumns(container: HTMLElement): HTMLElement[] {
|
|
const columns = container.querySelectorAll('swp-day-column');
|
|
return Array.from(columns) as HTMLElement[];
|
|
}
|
|
|
|
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
|
|
const columnDate = column.dataset.date;
|
|
if (!columnDate) {
|
|
return [];
|
|
}
|
|
|
|
const columnEvents = events.filter(event => {
|
|
const eventDateStr = DateCalculator.formatISODate(event.start);
|
|
const matches = eventDateStr === columnDate;
|
|
|
|
|
|
return matches;
|
|
});
|
|
|
|
return columnEvents;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resource-based event renderer
|
|
*/
|
|
export class ResourceEventRenderer extends BaseEventRenderer {
|
|
protected getColumns(container: HTMLElement): HTMLElement[] {
|
|
const columns = container.querySelectorAll('swp-resource-column');
|
|
return Array.from(columns) as HTMLElement[];
|
|
}
|
|
|
|
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
|
|
const resourceName = column.dataset.resource;
|
|
if (!resourceName) return [];
|
|
|
|
const columnEvents = events.filter(event => {
|
|
return event.resource?.name === resourceName;
|
|
});
|
|
|
|
return columnEvents;
|
|
}
|
|
|
|
// ============================================
|
|
// NEW OVERLAP DETECTION SYSTEM
|
|
// All new functions prefixed with new_
|
|
// ============================================
|
|
|
|
protected overlapDetector = new OverlapDetector();
|
|
|
|
} |