// Event rendering strategy interface and implementations import { ICalendarEvent } from '../types/CalendarTypes'; import { Configuration } from '../configurations/CalendarConfig'; import { SwpEventElement } from '../elements/SwpEventElement'; import { PositionUtils } from '../utils/PositionUtils'; import { IColumnBounds } from '../utils/ColumnDetectionUtils'; import { IDragColumnChangeEventPayload, IDragMoveEventPayload, IDragStartEventPayload, IDragMouseEnterColumnEventPayload } from '../types/EventTypes'; import { DateService } from '../utils/DateService'; import { EventStackManager } from '../managers/EventStackManager'; import { EventLayoutCoordinator, IGridGroupLayout, IStackedEventLayout } from '../managers/EventLayoutCoordinator'; /** * Interface for event rendering strategies */ export interface IEventRenderer { renderEvents(events: ICalendarEvent[], container: HTMLElement): void; clearEvents(container?: HTMLElement): void; handleDragStart?(payload: IDragStartEventPayload): void; handleDragMove?(payload: IDragMoveEventPayload): void; handleDragAutoScroll?(eventId: string, snappedY: number): void; handleDragEnd?(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: IColumnBounds, finalY: number): void; handleEventClick?(eventId: string, originalElement: HTMLElement): void; handleColumnChange?(payload: IDragColumnChangeEventPayload): void; handleNavigationCompleted?(): void; handleConvertAllDayToTimed?(payload: IDragMouseEnterColumnEventPayload): void; } /** * Date-based event renderer */ export class DateEventRenderer implements IEventRenderer { private dateService: DateService; private stackManager: EventStackManager; private layoutCoordinator: EventLayoutCoordinator; private config: Configuration; private positionUtils: PositionUtils; private draggedClone: HTMLElement | null = null; private originalEvent: HTMLElement | null = null; constructor( dateService: DateService, stackManager: EventStackManager, layoutCoordinator: EventLayoutCoordinator, config: Configuration, positionUtils: PositionUtils ) { this.dateService = dateService; this.stackManager = stackManager; this.layoutCoordinator = layoutCoordinator; this.config = config; this.positionUtils = positionUtils; } private applyDragStyling(element: HTMLElement): void { element.classList.add('dragging'); element.style.removeProperty("margin-left"); } /** * Handle drag start event */ public handleDragStart(payload: IDragStartEventPayload): void { this.originalEvent = payload.originalElement;; // Use the clone from the payload instead of creating a new one this.draggedClone = payload.draggedClone; if (this.draggedClone && payload.columnBounds) { // Apply drag styling this.applyDragStyling(this.draggedClone); // Add to current column's events layer (not directly to column) const eventsLayer = payload.columnBounds.element.querySelector('swp-events-layer'); if (eventsLayer) { eventsLayer.appendChild(this.draggedClone); // Set initial position to prevent "jump to top" effect // Calculate absolute Y position from original element const originalRect = this.originalEvent.getBoundingClientRect(); const columnRect = payload.columnBounds.boundingClientRect; const initialTop = originalRect.top - columnRect.top; this.draggedClone.style.top = `${initialTop}px`; } } // Make original semi-transparent this.originalEvent.style.opacity = '0.3'; this.originalEvent.style.userSelect = 'none'; } /** * Handle drag move event */ public handleDragMove(payload: IDragMoveEventPayload): void { const swpEvent = payload.draggedClone as SwpEventElement; const columnDate = this.dateService.parseISO(payload.columnBounds!!.date); swpEvent.updatePosition(columnDate, payload.snappedY); } /** * Handle column change during drag */ public handleColumnChange(payload: IDragColumnChangeEventPayload): void { const eventsLayer = payload.newColumn.element.querySelector('swp-events-layer'); if (eventsLayer && payload.draggedClone.parentElement !== eventsLayer) { eventsLayer.appendChild(payload.draggedClone); // Recalculate timestamps with new column date const currentTop = parseFloat(payload.draggedClone.style.top) || 0; const swpEvent = payload.draggedClone as SwpEventElement; const columnDate = this.dateService.parseISO(payload.newColumn.date); swpEvent.updatePosition(columnDate, currentTop); } } /** * Handle conversion of all-day event to timed event */ public handleConvertAllDayToTimed(payload: IDragMouseEnterColumnEventPayload): void { console.log('🎯 DateEventRenderer: Converting all-day to timed event', { eventId: payload.calendarEvent.id, targetColumn: payload.targetColumn.date, snappedY: payload.snappedY }); let timedClone = SwpEventElement.fromCalendarEvent(payload.calendarEvent); let position = this.calculateEventPosition(payload.calendarEvent); // Set position at snapped Y //timedClone.style.top = `${snappedY}px`; // Set complete styling for dragged clone (matching normal event rendering) timedClone.style.height = `${position.height - 3}px`; timedClone.style.left = '2px'; timedClone.style.right = '2px'; timedClone.style.width = 'auto'; timedClone.style.pointerEvents = 'none'; // Apply drag styling this.applyDragStyling(timedClone); // Find the events layer in the target column let eventsLayer = payload.targetColumn.element.querySelector('swp-events-layer'); // Add "clone-" prefix to match clone ID pattern timedClone.dataset.eventId = payload.calendarEvent.id; // Remove old all-day clone and replace with new timed clone payload.draggedClone.remove(); payload.replaceClone(timedClone); eventsLayer!!.appendChild(timedClone); } /** * Handle drag end event */ public handleDragEnd(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: IColumnBounds, finalY: number): void { if (!draggedClone || !originalElement) { console.warn('Missing draggedClone or originalElement'); return; } // Fade out original this.fadeOutAndRemove(originalElement); // Remove clone prefix and normalize clone to be a regular event const cloneId = draggedClone.dataset.eventId; if (cloneId && cloneId.startsWith('clone-')) { draggedClone.dataset.eventId = cloneId.replace('clone-', ''); } // Fully normalize the clone to be a regular event draggedClone.classList.remove('dragging'); draggedClone.style.pointerEvents = ''; // Re-enable pointer events // Clean up instance state this.draggedClone = null; this.originalEvent = null; } /** * Handle navigation completed event */ public handleNavigationCompleted(): void { // Default implementation - can be overridden by subclasses } /** * 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: ICalendarEvent[], container: HTMLElement): void { // Filter out all-day events - they should be handled by AllDayEventRenderer const timedEvents = events.filter(event => !event.allDay); // Find columns in the specific container for regular events const columns = this.getColumns(container); columns.forEach(column => { const columnEvents = this.getEventsForColumn(column, timedEvents); const eventsLayer = column.querySelector('swp-events-layer') as HTMLElement; if (eventsLayer) { this.renderColumnEvents(columnEvents, eventsLayer); } }); } /** * Render events in a column using combined stacking + grid algorithm */ private renderColumnEvents(columnEvents: ICalendarEvent[], eventsLayer: HTMLElement): void { if (columnEvents.length === 0) return; // Get layout from coordinator const layout = this.layoutCoordinator.calculateColumnLayout(columnEvents); // Render grid groups layout.gridGroups.forEach(gridGroup => { this.renderGridGroup(gridGroup, eventsLayer); }); // Render stacked events layout.stackedEvents.forEach(stackedEvent => { const element = this.renderEvent(stackedEvent.event); this.stackManager.applyStackLinkToElement(element, stackedEvent.stackLink); this.stackManager.applyVisualStyling(element, stackedEvent.stackLink.stackLevel); eventsLayer.appendChild(element); }); } /** * Render events in a grid container (side-by-side with column sharing) */ private renderGridGroup(gridGroup: IGridGroupLayout, eventsLayer: HTMLElement): void { const groupElement = document.createElement('swp-event-group'); // Add grid column class based on number of columns (not events) const colCount = gridGroup.columns.length; groupElement.classList.add(`cols-${colCount}`); // Add stack level class for margin-left offset groupElement.classList.add(`stack-level-${gridGroup.stackLevel}`); // Position from layout groupElement.style.top = `${gridGroup.position.top}px`; // Add stack-link attribute for drag-drop (group acts as a stacked item) const stackLink = { stackLevel: gridGroup.stackLevel }; this.stackManager.applyStackLinkToElement(groupElement, stackLink); // Apply visual styling (margin-left and z-index) using StackManager this.stackManager.applyVisualStyling(groupElement, gridGroup.stackLevel); // Render each column const earliestEvent = gridGroup.events[0]; gridGroup.columns.forEach((columnEvents: ICalendarEvent[]) => { const columnContainer = this.renderGridColumn(columnEvents, earliestEvent.start); groupElement.appendChild(columnContainer); }); eventsLayer.appendChild(groupElement); } /** * Render a single column within a grid group * Column may contain multiple events that don't overlap */ private renderGridColumn(columnEvents: ICalendarEvent[], containerStart: Date): HTMLElement { const columnContainer = document.createElement('div'); columnContainer.style.position = 'relative'; columnEvents.forEach(event => { const element = this.renderEventInGrid(event, containerStart); columnContainer.appendChild(element); }); return columnContainer; } /** * Render event within a grid container (absolute positioning within column) */ private renderEventInGrid(event: ICalendarEvent, containerStart: Date): HTMLElement { const element = SwpEventElement.fromCalendarEvent(event); // Calculate event height const position = this.calculateEventPosition(event); // Calculate relative top offset if event starts after container start // (e.g., if container starts at 07:00 and event starts at 08:15, offset = 75 min) const timeDiffMs = event.start.getTime() - containerStart.getTime(); const timeDiffMinutes = timeDiffMs / (1000 * 60); const gridSettings = this.config.gridSettings; const relativeTop = timeDiffMinutes > 0 ? (timeDiffMinutes / 60) * gridSettings.hourHeight : 0; // Events in grid columns are positioned absolutely within their column container element.style.position = 'absolute'; element.style.top = `${relativeTop}px`; element.style.height = `${position.height - 3}px`; element.style.left = '0'; element.style.right = '0'; return element; } private renderEvent(event: ICalendarEvent): HTMLElement { const element = SwpEventElement.fromCalendarEvent(event); // Apply positioning (moved from SwpEventElement.applyPositioning) const position = this.calculateEventPosition(event); element.style.position = 'absolute'; element.style.top = `${position.top + 1}px`; element.style.height = `${position.height - 3}px`; element.style.left = '2px'; element.style.right = '2px'; return element; } protected calculateEventPosition(event: ICalendarEvent): { top: number; height: number } { // Delegate to PositionUtils for centralized position calculation return this.positionUtils.calculateEventPosition(event.start, event.end); } clearEvents(container?: HTMLElement): void { const eventSelector = 'swp-event'; const groupSelector = 'swp-event-group'; const existingEvents = container ? container.querySelectorAll(eventSelector) : document.querySelectorAll(eventSelector); const existingGroups = container ? container.querySelectorAll(groupSelector) : document.querySelectorAll(groupSelector); existingEvents.forEach(event => event.remove()); existingGroups.forEach(group => group.remove()); } protected getColumns(container: HTMLElement): HTMLElement[] { const columns = container.querySelectorAll('swp-day-column'); return Array.from(columns) as HTMLElement[]; } protected getEventsForColumn(column: HTMLElement, events: ICalendarEvent[]): ICalendarEvent[] { const columnDate = column.dataset.date; if (!columnDate) { return []; } // Create start and end of day for interval overlap check const columnStart = this.dateService.parseISO(`${columnDate}T00:00:00`); const columnEnd = this.dateService.parseISO(`${columnDate}T23:59:59.999`); const columnEvents = events.filter(event => { // Interval overlap: event overlaps with column day if event.start < columnEnd AND event.end > columnStart const overlaps = event.start < columnEnd && event.end > columnStart; return overlaps; }); return columnEvents; } }