Refactors event dropping logic to first check for existing event groups. If a group exists and the dropped event overlaps with it, the dropped event is added to the group. Otherwise, it proceeds with full overlap detection against individual events. This change prevents unnecessary group creation and optimizes event arrangement within columns. It also ensures the dragged event is placed on the rightmost side by adding it to the overlapping events array last.
1111 lines
No EOL
39 KiB
TypeScript
1111 lines
No EOL
39 KiB
TypeScript
// Event rendering strategy interface and implementations
|
|
|
|
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';
|
|
import { CoreEvents } from '../constants/CoreEvents';
|
|
import { EventOverlapManager, OverlapType } from '../managers/EventOverlapManager';
|
|
|
|
/**
|
|
* 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;
|
|
protected overlapManager: EventOverlapManager;
|
|
|
|
// Drag and drop state
|
|
private draggedClone: HTMLElement | null = null;
|
|
private originalEvent: HTMLElement | null = null;
|
|
|
|
constructor(dateCalculator?: DateCalculator) {
|
|
if (!dateCalculator) {
|
|
DateCalculator.initialize(calendarConfig);
|
|
}
|
|
this.dateCalculator = dateCalculator || new DateCalculator();
|
|
this.overlapManager = new EventOverlapManager();
|
|
}
|
|
|
|
/**
|
|
* 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 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 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;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
|
|
// 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.width = '';
|
|
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 {
|
|
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 display
|
|
const timeElement = clone.querySelector('swp-event-time');
|
|
if (timeElement) {
|
|
const newTimeText = `${this.formatTime(snappedStartMinutes)} - ${this.formatTime(endTotalMinutes)}`;
|
|
timeElement.textContent = newTimeText;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate event duration in minutes from element height
|
|
*/
|
|
private getEventDuration(element: HTMLElement): number {
|
|
const gridSettings = calendarConfig.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);
|
|
}
|
|
|
|
/**
|
|
* 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 from original event before creating clone
|
|
if (this.overlapManager.isStackedEvent(originalElement)) {
|
|
this.overlapManager.removeStackedStyling(originalElement);
|
|
}
|
|
|
|
// 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) {
|
|
return;
|
|
}
|
|
|
|
// 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
|
|
|
|
// Detect overlaps with other events in the target column and reposition if needed
|
|
this.detectAndHandleOverlaps(this.draggedClone, finalColumn);
|
|
|
|
// Clean up
|
|
this.draggedClone = null;
|
|
this.originalEvent = null;
|
|
|
|
}
|
|
|
|
/**
|
|
* Remove event from any existing groups and cleanup empty containers
|
|
*/
|
|
private removeEventFromExistingGroups(eventElement: HTMLElement): void {
|
|
const eventGroup = this.overlapManager.getEventGroup(eventElement);
|
|
if (eventGroup) {
|
|
const eventId = eventElement.dataset.eventId;
|
|
if (eventId) {
|
|
this.overlapManager.removeFromEventGroup(eventGroup, eventId);
|
|
// Gendan normal kolonne bredde efter fjernelse fra group
|
|
this.restoreNormalEventStyling(eventElement);
|
|
}
|
|
} else if (this.overlapManager.isStackedEvent(eventElement)) {
|
|
// Remove stacking styling if it's a stacked event
|
|
this.overlapManager.removeStackedStyling(eventElement);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore normal event styling (full column width)
|
|
*/
|
|
private restoreNormalEventStyling(eventElement: HTMLElement): void {
|
|
eventElement.style.position = 'absolute';
|
|
eventElement.style.left = '2px';
|
|
eventElement.style.right = '2px';
|
|
eventElement.style.width = '';
|
|
// Behold z-index for stacked events
|
|
}
|
|
|
|
/**
|
|
* Detect overlaps with other events in target column and handle repositioning
|
|
*/
|
|
private detectAndHandleOverlaps(droppedElement: HTMLElement, targetColumn: string): 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;
|
|
|
|
// Convert dropped element to CalendarEvent using its NEW position
|
|
const droppedEvent = this.elementToCalendarEventWithNewPosition(droppedElement, targetColumn);
|
|
if (!droppedEvent) return;
|
|
|
|
// Check if there's already an existing swp-event-group in the column
|
|
const existingGroup = eventsLayer.querySelector('swp-event-group') as HTMLElement;
|
|
if (existingGroup) {
|
|
// Check if dropped event overlaps with the group's events
|
|
const groupEvents = Array.from(existingGroup.querySelectorAll('swp-event')) as HTMLElement[];
|
|
let overlapsWithGroup = false;
|
|
|
|
for (const groupEvent of groupEvents) {
|
|
const existingEvent = this.elementToCalendarEvent(groupEvent);
|
|
if (!existingEvent) continue;
|
|
|
|
const overlapType = this.overlapManager.detectOverlap(droppedEvent, existingEvent);
|
|
if (overlapType === OverlapType.COLUMN_SHARING) {
|
|
overlapsWithGroup = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (overlapsWithGroup) {
|
|
// Simply add the dropped event to the existing group
|
|
this.updateElementDataset(droppedElement, droppedEvent);
|
|
this.overlapManager.addToEventGroup(existingGroup, droppedElement);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No existing group or no overlap with existing group - run full overlap detection
|
|
const existingEvents = Array.from(eventsLayer.querySelectorAll('swp-event'))
|
|
.filter(el => el !== droppedElement) as HTMLElement[];
|
|
|
|
// Check if dropped event overlaps with any existing events
|
|
let hasOverlaps = false;
|
|
const overlappingEvents: CalendarEvent[] = [];
|
|
|
|
for (const existingElement of existingEvents) {
|
|
const existingEvent = this.elementToCalendarEvent(existingElement);
|
|
if (!existingEvent) continue;
|
|
|
|
// Skip if it's the same event (comparing IDs)
|
|
if (existingEvent.id === droppedEvent.id) continue;
|
|
|
|
const overlapType = this.overlapManager.detectOverlap(droppedEvent, existingEvent);
|
|
if (overlapType !== OverlapType.NONE) {
|
|
hasOverlaps = true;
|
|
overlappingEvents.push(existingEvent);
|
|
}
|
|
}
|
|
|
|
// Add dropped event LAST so it appears rightmost in flexbox
|
|
overlappingEvents.push(droppedEvent);
|
|
|
|
// Only re-render if there are actual overlaps
|
|
if (!hasOverlaps) {
|
|
// No overlaps - just update the dropped element's dataset with new times
|
|
this.updateElementDataset(droppedElement, droppedEvent);
|
|
return;
|
|
}
|
|
|
|
// There are overlaps - group and re-render overlapping events
|
|
const overlapGroups = this.overlapManager.groupOverlappingEvents(overlappingEvents);
|
|
|
|
// Remove overlapping events from DOM
|
|
const overlappingEventIds = new Set(overlappingEvents.map(e => e.id));
|
|
existingEvents
|
|
.filter(el => overlappingEventIds.has(el.dataset.eventId || ''))
|
|
.forEach(el => el.remove());
|
|
droppedElement.remove();
|
|
|
|
// Re-render overlapping events with proper grouping
|
|
overlapGroups.forEach(group => {
|
|
if (group.type === OverlapType.COLUMN_SHARING && group.events.length > 1) {
|
|
this.renderColumnSharingGroup(group, eventsLayer);
|
|
} else if (group.type === OverlapType.STACKING && group.events.length > 1) {
|
|
this.renderStackedEvents(group, eventsLayer);
|
|
} else {
|
|
group.events.forEach(event => {
|
|
const eventElement = this.createEventElement(event);
|
|
this.positionEvent(eventElement, event);
|
|
eventsLayer.appendChild(eventElement);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update element's dataset with new times after successful drop
|
|
*/
|
|
private updateElementDataset(element: HTMLElement, event: CalendarEvent): void {
|
|
element.dataset.start = event.start;
|
|
element.dataset.end = event.end;
|
|
|
|
// 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 using its NEW position after drag
|
|
*/
|
|
private elementToCalendarEventWithNewPosition(element: HTMLElement, targetColumn: string): CalendarEvent | null {
|
|
const eventId = element.dataset.eventId;
|
|
const title = element.dataset.title;
|
|
const type = element.dataset.type;
|
|
const originalDuration = element.dataset.originalDuration;
|
|
|
|
if (!eventId || !title || !type) {
|
|
return null;
|
|
}
|
|
|
|
// Calculate new start/end times based on current position
|
|
const currentTop = parseFloat(element.style.top) || 0;
|
|
const durationMinutes = originalDuration ? parseInt(originalDuration) : 60;
|
|
|
|
// Convert position to time
|
|
const gridSettings = calendarConfig.getGridSettings();
|
|
const hourHeight = gridSettings.hourHeight;
|
|
const dayStartHour = gridSettings.dayStartHour;
|
|
|
|
// Calculate minutes from grid start
|
|
const minutesFromGridStart = (currentTop / hourHeight) * 60;
|
|
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
|
|
const actualEndMinutes = actualStartMinutes + durationMinutes;
|
|
|
|
// Create ISO date strings for the target column
|
|
const targetDate = new Date(targetColumn + 'T00:00:00');
|
|
const startDate = new Date(targetDate);
|
|
startDate.setMinutes(startDate.getMinutes() + actualStartMinutes);
|
|
|
|
const endDate = new Date(targetDate);
|
|
endDate.setMinutes(endDate.getMinutes() + actualEndMinutes);
|
|
|
|
return {
|
|
id: eventId,
|
|
title: title,
|
|
start: startDate.toISOString(),
|
|
end: endDate.toISOString(),
|
|
type: type,
|
|
allDay: false,
|
|
syncStatus: 'synced',
|
|
metadata: {
|
|
duration: durationMinutes
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert DOM element to CalendarEvent for overlap detection
|
|
*/
|
|
private elementToCalendarEvent(element: HTMLElement): CalendarEvent | null {
|
|
const eventId = element.dataset.eventId;
|
|
const title = element.dataset.title;
|
|
const start = element.dataset.start;
|
|
const end = element.dataset.end;
|
|
const type = element.dataset.type;
|
|
const duration = element.dataset.duration;
|
|
|
|
if (!eventId || !title || !start || !end || !type) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: eventId,
|
|
title: title,
|
|
start: start,
|
|
end: end,
|
|
type: type,
|
|
allDay: false,
|
|
syncStatus: 'synced', // Default to synced for existing events
|
|
metadata: {
|
|
duration: duration ? parseInt(duration) : 60
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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}T${eventTime.split(' - ')[0]}:00`;
|
|
allDayEvent.dataset.end = `${targetDate}T${eventTime.split(' - ')[1]}:00`;
|
|
allDayEvent.dataset.type = clone.dataset.type || 'work';
|
|
allDayEvent.dataset.duration = eventDuration;
|
|
allDayEvent.textContent = eventTitle;
|
|
|
|
// 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();
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Convert dragged clone to all-day event preview
|
|
*/
|
|
private convertToAllDayPreview(targetDate: string): 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);
|
|
|
|
}
|
|
|
|
/**
|
|
* Move all-day event to a new date container
|
|
*/
|
|
private moveAllDayToNewDate(targetDate: string): void {
|
|
if (!this.draggedClone) return;
|
|
|
|
const calendarHeader = document.querySelector('swp-calendar-header');
|
|
if (!calendarHeader) return;
|
|
|
|
// Find the all-day container
|
|
const allDayContainer = calendarHeader.querySelector('swp-allday-container');
|
|
if (!allDayContainer) return;
|
|
|
|
// Calculate new 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;
|
|
}
|
|
});
|
|
|
|
// Update grid column position
|
|
(this.draggedClone as HTMLElement).style.gridColumn = columnIndex.toString();
|
|
|
|
// Move to all-day container if not already there
|
|
if (this.draggedClone.parentElement !== allDayContainer) {
|
|
allDayContainer.appendChild(this.draggedClone);
|
|
}
|
|
|
|
}
|
|
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) {
|
|
// Group events by overlap type
|
|
const overlapGroups = this.overlapManager.groupOverlappingEvents(columnEvents);
|
|
|
|
overlapGroups.forEach(group => {
|
|
if (group.type === OverlapType.COLUMN_SHARING && group.events.length > 1) {
|
|
// Create flexbox container for column sharing
|
|
this.renderColumnSharingGroup(group, eventsLayer);
|
|
} else if (group.type === OverlapType.STACKING && group.events.length > 1) {
|
|
// Render stacked events
|
|
this.renderStackedEvents(group, eventsLayer);
|
|
} else {
|
|
// Render normal single events
|
|
group.events.forEach(event => {
|
|
this.renderEvent(event, eventsLayer);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Debug: Verify events were actually added
|
|
const renderedEvents = eventsLayer.querySelectorAll('swp-event, swp-event-group');
|
|
} else {
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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.eventsOverlap(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;
|
|
allDayEvent.dataset.end = event.end;
|
|
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, container: Element): void {
|
|
const eventElement = document.createElement('swp-event');
|
|
eventElement.dataset.eventId = event.id;
|
|
eventElement.dataset.title = event.title;
|
|
eventElement.dataset.start = event.start;
|
|
eventElement.dataset.end = event.end;
|
|
eventElement.dataset.type = event.type;
|
|
eventElement.dataset.duration = event.metadata?.duration?.toString() || '60';
|
|
|
|
// Calculate 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.
|
|
|
|
// 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 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 data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
|
|
<swp-event-title>${event.title}</swp-event-title>
|
|
`;
|
|
|
|
|
|
container.appendChild(eventElement);
|
|
}
|
|
|
|
protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
|
|
const startDate = new Date(event.start);
|
|
const endDate = new Date(event.end);
|
|
|
|
const gridSettings = calendarConfig.getGridSettings();
|
|
const dayStartHour = gridSettings.dayStartHour;
|
|
const hourHeight = gridSettings.hourHeight;
|
|
|
|
// Calculate minutes from midnight
|
|
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
|
|
const endMinutes = endDate.getHours() * 60 + endDate.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 startDate = new Date(event.start);
|
|
const endDate = new Date(event.end);
|
|
const startDateKey = DateCalculator.formatISODate(startDate);
|
|
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(startDate);
|
|
|
|
while (currentDate <= endDate) {
|
|
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 events overlap in columns
|
|
*/
|
|
private eventsOverlap(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);
|
|
}
|
|
|
|
/**
|
|
* Render column sharing group with flexbox container
|
|
*/
|
|
protected renderColumnSharingGroup(group: any, container: Element): void {
|
|
const groupContainer = this.overlapManager.createEventGroup(group.events, group.position);
|
|
|
|
// Render each event in the group
|
|
group.events.forEach((event: CalendarEvent) => {
|
|
const eventElement = this.createEventElement(event);
|
|
this.overlapManager.addToEventGroup(groupContainer, eventElement);
|
|
});
|
|
|
|
container.appendChild(groupContainer);
|
|
|
|
// Emit event for debugging/logging
|
|
eventBus.emit('overlap:group-created', {
|
|
type: 'column_sharing',
|
|
eventCount: group.events.length,
|
|
events: group.events.map((e: CalendarEvent) => e.id)
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render stacked events with margin-left offset
|
|
*/
|
|
protected renderStackedEvents(group: any, container: Element): void {
|
|
// Sort events by duration - longer events render first (background), shorter events on top
|
|
// This way shorter events are more visible and get higher z-index
|
|
const sortedEvents = [...group.events].sort((a, b) => {
|
|
const durationA = new Date(a.end).getTime() - new Date(a.start).getTime();
|
|
const durationB = new Date(b.end).getTime() - new Date(b.start).getTime();
|
|
return durationB - durationA; // Longer duration first (background)
|
|
});
|
|
|
|
let underlyingElement: HTMLElement | null = null;
|
|
|
|
sortedEvents.forEach((event: CalendarEvent, index: number) => {
|
|
const eventElement = this.createEventElement(event);
|
|
this.positionEvent(eventElement, event);
|
|
|
|
if (index === 0) {
|
|
// First (longest duration) event renders normally at full width - UNCHANGED
|
|
container.appendChild(eventElement);
|
|
underlyingElement = eventElement;
|
|
} else {
|
|
// Shorter events are stacked with margin-left offset and higher z-index
|
|
// Each subsequent event gets more margin: 15px, 30px, 45px, etc.
|
|
if (underlyingElement) {
|
|
this.overlapManager.createStackedEvent(eventElement, underlyingElement, index);
|
|
}
|
|
container.appendChild(eventElement);
|
|
// DO NOT update underlyingElement - keep it as the longest event
|
|
}
|
|
});
|
|
|
|
// Emit event for debugging/logging
|
|
eventBus.emit('overlap:events-stacked', {
|
|
type: 'stacking',
|
|
eventCount: group.events.length,
|
|
events: group.events.map((e: CalendarEvent) => e.id)
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create event element without positioning
|
|
*/
|
|
protected createEventElement(event: CalendarEvent): HTMLElement {
|
|
const eventElement = document.createElement('swp-event');
|
|
eventElement.dataset.eventId = event.id;
|
|
eventElement.dataset.title = event.title;
|
|
eventElement.dataset.start = event.start;
|
|
eventElement.dataset.end = event.end;
|
|
eventElement.dataset.type = event.type;
|
|
eventElement.dataset.duration = event.metadata?.duration?.toString() || '60';
|
|
|
|
// Format time for display using unified method
|
|
const startTime = this.formatTime(event.start);
|
|
const endTime = this.formatTime(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 data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
|
|
<swp-event-title>${event.title}</swp-event-title>
|
|
`;
|
|
|
|
return eventElement;
|
|
}
|
|
|
|
/**
|
|
* Position event element
|
|
*/
|
|
protected positionEvent(eventElement: HTMLElement, event: CalendarEvent): void {
|
|
const position = this.calculateEventPosition(event);
|
|
eventElement.style.position = 'absolute';
|
|
eventElement.style.top = `${position.top + 1}px`;
|
|
eventElement.style.height = `${position.height - 3}px`;
|
|
eventElement.style.left = '2px';
|
|
eventElement.style.right = '2px';
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 eventDate = new Date(event.start);
|
|
const eventDateStr = DateCalculator.formatISODate(eventDate);
|
|
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;
|
|
}
|
|
} |