Calendar/src/renderers/EventRenderer.ts
Janus C. H. Knudsen e83753a7d2 Improves event drag and drop
Enhances the event drag and drop functionality by setting the initial position of the dragged event to prevent it from jumping to the top of the column.
Also adjust event transition for a smoother user experience.
Removes unused resize logic.
2025-10-08 21:50:41 +02:00

353 lines
12 KiB
TypeScript

// Event rendering strategy interface and implementations
import { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
import { SwpEventElement } from '../elements/SwpEventElement';
import { PositionUtils } from '../utils/PositionUtils';
import { ColumnBounds } from '../utils/ColumnDetectionUtils';
import { DragColumnChangeEventPayload, DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
import { DateService } from '../utils/DateService';
import { EventStackManager } from '../managers/EventStackManager';
import { EventLayoutCoordinator, GridGroupLayout, StackedEventLayout } from '../managers/EventLayoutCoordinator';
/**
* 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;
}
/**
* Date-based event renderer
*/
export class DateEventRenderer implements EventRendererStrategy {
private dateService: DateService;
private stackManager: EventStackManager;
private layoutCoordinator: EventLayoutCoordinator;
private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null;
constructor() {
const timezone = calendarConfig.getTimezone?.();
this.dateService = new DateService(timezone);
this.stackManager = new EventStackManager();
this.layoutCoordinator = new EventLayoutCoordinator();
}
private applyDragStyling(element: HTMLElement): void {
element.classList.add('dragging');
element.style.removeProperty("margin-left");
}
/**
* 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 && 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: DragMoveEventPayload): void {
if (!this.draggedClone || !payload.columnBounds) return;
// Delegate to SwpEventElement to update position and timestamps
const swpEvent = this.draggedClone as SwpEventElement;
const columnDate = this.dateService.parseISO(payload.columnBounds.date);
swpEvent.updatePosition(columnDate, payload.snappedY);
}
/**
* 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 swpEvent = this.draggedClone as SwpEventElement;
const columnDate = this.dateService.parseISO(dragColumnChangeEvent.newColumn.date);
swpEvent.updatePosition(columnDate, currentTop);
}
}
/**
* 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;
}
// 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: CalendarEvent[], 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: CalendarEvent[], 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: GridGroupLayout, 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 => {
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: CalendarEvent[], 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: CalendarEvent, 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 = calendarConfig.getGridSettings();
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: CalendarEvent): 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: CalendarEvent): { top: number; height: number } {
// Delegate to PositionUtils for centralized position calculation
return 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: CalendarEvent[]): CalendarEvent[] {
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;
}
}