Calendar/src/renderers/EventRenderer.ts
Janus C. H. Knudsen 73e284660f Adds EventId type for robust event ID handling
Introduces type-safe EventId with centralized normalization logic for clone and standard event IDs

Refactors event ID management across multiple components to use consistent ID transformation methods
Improves type safety and reduces potential ID-related bugs in drag-and-drop and event rendering
2025-12-03 14:43:25 +01:00

386 lines
14 KiB
TypeScript

// Event rendering strategy interface and implementations
import { ICalendarEvent } from '../types/CalendarTypes';
import { IColumnInfo } from '../types/ColumnDataSource';
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';
import { EventId } from '../types/EventId';
/**
* Interface for event rendering strategies
*
* Note: renderEvents now receives columns with pre-filtered events,
* not a flat array of events. Each column contains its own events.
*/
export interface IEventRenderer {
renderEvents(columns: IColumnInfo[], container: HTMLElement): void;
clearEvents(container?: HTMLElement): void;
renderSingleColumnEvents?(column: IColumnBounds, events: ICalendarEvent[]): void;
handleDragStart?(payload: IDragStartEventPayload): void;
handleDragMove?(payload: IDragMoveEventPayload): void;
handleDragAutoScroll?(eventId: string, snappedY: number): void;
handleDragEnd?(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
* Only updates visual position and time - date stays the same
*/
public handleDragMove(payload: IDragMoveEventPayload): void {
const swpEvent = payload.draggedClone as SwpEventElement;
swpEvent.updatePosition(payload.snappedY);
}
/**
* Handle column change during drag
* Only moves the element visually - no data updates here
* Data updates happen on drag:end in EventRenderingService
*/
public handleColumnChange(payload: IDragColumnChangeEventPayload): void {
const eventsLayer = payload.newColumn.element.querySelector('swp-events-layer');
if (eventsLayer && payload.draggedClone.parentElement !== eventsLayer) {
eventsLayer.appendChild(payload.draggedClone);
}
}
/**
* 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.identifier,
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 = `clone-${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(originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: IColumnBounds, finalY: number): void {
// Only fade out and remove if it's a swp-event (not swp-allday-event)
// AllDayManager handles removal of swp-allday-event elements
if (originalElement.tagName === 'SWP-EVENT') {
this.fadeOutAndRemove(originalElement);
}
draggedClone.dataset.eventId = EventId.from(draggedClone.dataset.eventId!);
// 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;
// Clean up any remaining day event clones
const dayEventClone = document.querySelector(`swp-event[data-event-id="${draggedClone.dataset.eventId}"]`);
if (dayEventClone) {
dayEventClone.remove();
}
}
/**
* 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(columns: IColumnInfo[], container: HTMLElement): void {
// Find column DOM elements in the container
const columnElements = this.getColumns(container);
// Render events for each column using pre-filtered events from IColumnInfo
columns.forEach((columnInfo, index) => {
const columnElement = columnElements[index];
if (!columnElement) return;
// Filter out all-day events - they should be handled by AllDayEventRenderer
const timedEvents = columnInfo.events.filter(event => !event.allDay);
const eventsLayer = columnElement.querySelector('swp-events-layer') as HTMLElement;
if (eventsLayer && timedEvents.length > 0) {
this.renderColumnEvents(timedEvents, eventsLayer);
}
});
}
/**
* Render events for a single column
* Note: events are already filtered for this column
*/
public renderSingleColumnEvents(column: IColumnBounds, events: ICalendarEvent[]): void {
// Filter out all-day events
const timedEvents = events.filter(event => !event.allDay);
const eventsLayer = column.element.querySelector('swp-events-layer') as HTMLElement;
if (eventsLayer && timedEvents.length > 0) {
this.renderColumnEvents(timedEvents, 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[];
}
}