Calendar/src/renderers/EventRenderer.ts

640 lines
22 KiB
TypeScript
Raw Normal View History

2025-08-07 00:15:44 +02:00
// Event rendering strategy interface and implementations
import { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
import { eventBus } from '../core/EventBus';
import { OverlapDetector, OverlapResult } from '../utils/OverlapDetector';
import { SwpEventElement } from '../elements/SwpEventElement';
import { TimeFormatter } from '../utils/TimeFormatter';
import { PositionUtils } from '../utils/PositionUtils';
import { DragOffset, StackLinkData } from '../types/DragDropTypes';
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
import { DateService } from '../utils/DateService';
import { format, setHours, setMinutes, setSeconds, addDays } from 'date-fns';
2025-08-07 00:15:44 +02:00
/**
* Interface for event rendering strategies
*/
export interface EventRendererStrategy {
renderEvents(events: CalendarEvent[], container: HTMLElement): void;
clearEvents(container?: HTMLElement): void;
handleDragStart?(payload: DragStartEventPayload): void;
handleDragMove?(payload: DragMoveEventPayload): void;
handleDragAutoScroll?(eventId: string, snappedY: number): void;
handleDragEnd?(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: ColumnBounds, finalY: number): void;
handleEventClick?(eventId: string, originalElement: HTMLElement): void;
handleColumnChange?(payload: DragColumnChangeEventPayload): void;
handleNavigationCompleted?(): void;
2025-08-07 00:15:44 +02:00
}
2025-10-02 23:11:26 +02:00
// Abstract methods that subclasses must implement
// private getColumns(container: HTMLElement): HTMLElement[];
// private getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
2025-08-07 00:15:44 +02:00
/**
2025-10-02 23:11:26 +02:00
* Date-based event renderer
2025-08-07 00:15:44 +02:00
*/
2025-10-02 23:11:26 +02:00
export class DateEventRenderer implements EventRendererStrategy {
private dateService: DateService;
constructor() {
const timezone = calendarConfig.getTimezone?.() || 'Europe/Copenhagen';
this.dateService = new DateService(timezone);
2025-10-02 23:11:26 +02:00
this.setupDragEventListeners();
}
2025-10-02 23:11:26 +02:00
private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null;
2025-09-09 14:35:21 +02:00
// ============================================
// 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 handleEventOverlaps(events: CalendarEvent[], container: HTMLElement): void {
2025-09-09 14:35:21 +02:00
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>();
2025-09-09 14:35:21 +02:00
// 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;
}
2025-09-09 14:35:21 +02:00
const remainingEvents = events.slice(index + 1);
const overlappingEvents = this.overlapDetector.resolveOverlap(currentEvent, remainingEvents);
2025-09-09 14:35:21 +02:00
if (overlappingEvents.length > 0) {
// Der er overlaps - opret stack links
const result = this.overlapDetector.decorateWithStackLinks(currentEvent, overlappingEvents);
this.renderOverlappingEvents(result, container);
// Marker alle events i overlap gruppen som processeret
overlappingEvents.forEach(event => processedEvents.add(event.id));
2025-09-09 14:35:21 +02:00
} else {
// Intet overlap - render normalt
const element = this.renderEvent(currentEvent);
container.appendChild(element);
processedEvents.add(currentEvent.id);
2025-09-09 14:35:21 +02:00
}
});
}
2025-10-02 23:11:26 +02:00
private applyDragStyling(element: HTMLElement): void {
element.classList.add('dragging');
}
/**
* Update clone timestamp based on new position
*/
private updateCloneTimestamp(payload: DragMoveEventPayload): void {
if (payload.draggedClone.dataset.allDay === "true" || !payload.columnBounds) return;
const gridSettings = calendarConfig.getGridSettings();
const { hourHeight, dayStartHour, snapInterval } = gridSettings;
if (!payload.draggedClone.dataset.originalDuration) {
throw new DOMException("missing clone.dataset.originalDuration");
}
// Calculate snapped start minutes
const minutesFromGridStart = (payload.snappedY / hourHeight) * 60;
const snappedStartMinutes = this.calculateSnappedMinutes(
minutesFromGridStart, dayStartHour, snapInterval
);
// Calculate end minutes
const originalDuration = parseInt(payload.draggedClone.dataset.originalDuration);
const endTotalMinutes = snappedStartMinutes + originalDuration;
// Update UI
this.updateTimeDisplay(payload.draggedClone, snappedStartMinutes, endTotalMinutes);
// Update data attributes
this.updateDateTimeAttributes(
payload.draggedClone,
new Date(payload.columnBounds.date),
snappedStartMinutes,
endTotalMinutes
);
}
/**
* Calculate snapped minutes from grid start
*/
private calculateSnappedMinutes(minutesFromGridStart: number, dayStartHour: number, snapInterval: number): number {
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
return Math.round(actualStartMinutes / snapInterval) * snapInterval;
}
/**
* Update time display in the UI
*/
private updateTimeDisplay(element: HTMLElement, startMinutes: number, endMinutes: number): void {
const timeElement = element.querySelector('swp-event-time');
if (!timeElement) return;
const startTime = this.formatTimeFromMinutes(startMinutes);
const endTime = this.formatTimeFromMinutes(endMinutes);
timeElement.textContent = `${startTime} - ${endTime}`;
}
2025-10-02 23:11:26 +02:00
/**
* Update data-start and data-end attributes with ISO timestamps
*/
private updateDateTimeAttributes(element: HTMLElement, columnDate: Date, startMinutes: number, endMinutes: number): void {
const startDate = this.createDateWithMinutes(columnDate, startMinutes);
let endDate = this.createDateWithMinutes(columnDate, endMinutes);
// Handle cross-midnight events
if (endMinutes >= 1440) {
const extraDays = Math.floor(endMinutes / 1440);
endDate = addDays(endDate, extraDays);
}
element.dataset.start = startDate.toISOString();
element.dataset.end = endDate.toISOString();
}
/**
* Create a date with specific minutes since midnight
*/
private createDateWithMinutes(baseDate: Date, totalMinutes: number): Date {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return setSeconds(setMinutes(setHours(baseDate, hours), minutes), 0);
}
/**
* Format minutes since midnight to time string
*/
private formatTimeFromMinutes(totalMinutes: number): string {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const date = new Date();
date.setHours(hours, minutes, 0, 0);
return format(date, 'HH:mm');
}
/**
* Handle drag start event
*/
public handleDragStart(payload: DragStartEventPayload): void {
this.originalEvent = payload.draggedElement;;
// Use the clone from the payload instead of creating a new one
this.draggedClone = payload.draggedClone;
if (this.draggedClone) {
2025-10-02 23:11:26 +02:00
// 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);
}
}
// Make original semi-transparent
this.originalEvent.style.opacity = '0.3';
this.originalEvent.style.userSelect = 'none';
}
/**
* Handle drag move event
*/
public handleDragMove(payload: DragMoveEventPayload): void {
if (!this.draggedClone) return;
// Update position - snappedY is already the event top position
// Add +1px to match the initial positioning offset from SwpEventElement
this.draggedClone.style.top = (payload.snappedY + 1) + 'px';
// Update timestamp display
this.updateCloneTimestamp(payload);
}
/**
* Handle drag auto-scroll event
*/
public handleDragAutoScroll(eventId: string, snappedY: number): void {
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); //TODO: Commented as, we need to move all this scroll logic til scroll manager away from eventrenderer
}
/**
* Handle column change during drag
*/
public handleColumnChange(dragColumnChangeEvent: DragColumnChangeEventPayload): void {
if (!this.draggedClone) return;
const eventsLayer = dragColumnChangeEvent.newColumn.element.querySelector('swp-events-layer');
if (eventsLayer && this.draggedClone.parentElement !== eventsLayer) {
eventsLayer.appendChild(this.draggedClone);
// Recalculate timestamps with new column date
const currentTop = parseFloat(this.draggedClone.style.top) || 0;
const mockPayload: DragMoveEventPayload = {
draggedElement: dragColumnChangeEvent.originalElement,
draggedClone: this.draggedClone,
mousePosition: dragColumnChangeEvent.mousePosition,
mouseOffset: { x: 0, y: 0 },
columnBounds: dragColumnChangeEvent.newColumn,
snappedY: currentTop
};
this.updateCloneTimestamp(mockPayload);
}
}
/**
* Handle drag end event
*/
public handleDragEnd(eventId: string, originalElement: HTMLElement, draggedClone: HTMLElement, finalColumn: ColumnBounds, finalY: number): void {
if (!draggedClone || !originalElement) {
console.warn('Missing draggedClone or originalElement');
return;
}
// Check om original event var del af en stack
const originalStackLink = originalElement.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: StackLinkData, 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 = SwpEventElement.extractCalendarEventFromElement(element);
if (event) {
stackEvents.push(event);
}
// Fjern elementet
element.remove();
}
});
// Re-render stack events hvis vi fandt nogle
if (stackEvents.length > 0 && container) {
this.handleEventOverlaps(stackEvents, container);
}
} catch (e) {
console.warn('Failed to parse stackLink data:', e);
}
}
// Remove original event from any existing groups first
this.removeEventFromExistingGroups(originalElement);
// Fade out original
// TODO: this should be changed into a subscriber which only after a succesful placement is fired, not just mouseup as this can remove elements that are not placed.
2025-10-02 23:11:26 +02:00
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');
// Behold z-index hvis det er et stacked event
// Data attributes are already updated during drag:move, so no need to update again
// The updateCloneTimestamp method keeps them synchronized throughout the drag operation
2025-09-09 14:35:21 +02:00
// Detect overlaps with other events in the target column and reposition if needed
this.handleDragDropOverlaps(draggedClone, finalColumn);
// Fjern stackLink data fra dropped element
if (draggedClone.dataset.stackLink) {
delete draggedClone.dataset.stackLink;
}
// Clean up instance state (no longer needed since we get elements as parameters)
this.draggedClone = null;
this.originalEvent = null;
}
/**
* Handle navigation completed event
*/
public handleNavigationCompleted(): void {
// Default implementation - can be overridden by subclasses
}
/**
* Handle overlap detection and re-rendering after drag-drop
*/
private handleDragDropOverlaps(droppedElement: HTMLElement, targetColumn: ColumnBounds): void {
const eventsLayer = targetColumn.element.querySelector('swp-events-layer') as HTMLElement;
if (!eventsLayer) return;
// Convert dropped element to CalendarEvent with new position
const droppedEvent = SwpEventElement.extractCalendarEventFromElement(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.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 = SwpEventElement.extractCalendarEventFromElement(element);
if (event) {
events.push(event);
}
});
return events;
}
/**
2025-09-09 14:35:21 +02:00
* Remove event from any existing groups and cleanup empty containers
* In the new system, this is handled automatically by re-rendering overlaps
*/
2025-09-09 14:35:21 +02:00
private removeEventFromExistingGroups(eventElement: HTMLElement): void {
// With the new system, overlap relationships are recalculated on drop
// No need to manually track and remove from groups
}
2025-09-09 14:35:21 +02:00
/**
* Handle conversion to all-day event
*/
/**
* 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 {
// Filter out all-day events - they should be handled by AllDayEventRenderer
const timedEvents = events.filter(event => !event.allDay);
console.log('🎯 EventRenderer: Filtering events', {
totalEvents: events.length,
timedEvents: timedEvents.length,
filteredOutAllDay: events.length - timedEvents.length
});
// Find columns in the specific container for regular events
const columns = this.getColumns(container);
2025-08-13 23:05:58 +02:00
columns.forEach(column => {
const columnEvents = this.getEventsForColumn(column, timedEvents);
2025-08-13 23:05:58 +02:00
const eventsLayer = column.querySelector('swp-events-layer');
if (eventsLayer) {
this.handleEventOverlaps(columnEvents, eventsLayer as HTMLElement);
2025-08-07 00:15:44 +02:00
}
});
}
2025-08-13 23:05:58 +02:00
2025-10-02 23:11:26 +02:00
private renderEvent(event: CalendarEvent): HTMLElement {
const swpEvent = SwpEventElement.fromCalendarEvent(event);
const eventElement = swpEvent.getElement();
2025-09-09 14:35:21 +02:00
// Setup resize handles on first mouseover only
2025-10-02 23:11:26 +02:00
eventElement.addEventListener('mouseover', () => { // TODO: This is not the correct way... we should not add eventlistener on every event
2025-09-09 14:35:21 +02:00
if (eventElement.dataset.hasResizeHandlers !== 'true') {
eventElement.dataset.hasResizeHandlers = 'true';
}
}, { once: true });
2025-09-09 14:35:21 +02:00
return eventElement;
2025-08-07 00:15:44 +02:00
}
protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
// Delegate to PositionUtils for centralized position calculation
return PositionUtils.calculateEventPosition(event.start, event.end);
2025-08-07 00:15:44 +02:00
}
clearEvents(container?: HTMLElement): void {
const selector = 'swp-event, swp-event-group';
const existingEvents = container
? container.querySelectorAll(selector)
: document.querySelectorAll(selector);
2025-08-07 00:15:44 +02:00
existingEvents.forEach(event => event.remove());
}
2025-09-09 14:35:21 +02:00
/**
* Renderer overlappende events baseret OverlapResult
* @param result - OverlapResult med events og stack links
* @param container - Container at rendere i
*/
protected renderOverlappingEvents(result: OverlapResult, container: HTMLElement): void {
2025-09-09 14:35:21 +02:00
// 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;
2025-09-09 14:35:21 +02:00
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
});
2025-09-09 14:35:21 +02:00
// 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);
}
2025-09-09 14:35:21 +02:00
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';
});
}
2025-08-07 00:15:44 +02:00
/**
* Setup drag event listeners - placeholder method
*/
private setupDragEventListeners(): void {
// Drag event listeners are handled by EventRendererManager
// This method exists for compatibility
}
protected getColumns(container: HTMLElement): HTMLElement[] {
const columns = container.querySelectorAll('swp-day-column');
2025-08-13 23:05:58 +02:00
return Array.from(columns) as HTMLElement[];
}
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
const columnDate = column.dataset.date;
if (!columnDate) {
return [];
}
2025-08-13 23:05:58 +02:00
const columnEvents = events.filter(event => {
const eventDateStr = this.dateService.formatISODate(event.start);
const matches = eventDateStr === columnDate;
return matches;
2025-08-13 23:05:58 +02:00
});
return columnEvents;
}
2025-08-07 00:15:44 +02:00
}