Refactors drag and drop event handling for smoother updates. Tracks the current mouse position during drag operations to improve the accuracy of position updates. Adjusts edge scrolling behavior.
379 lines
13 KiB
TypeScript
379 lines
13 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, DragMouseEnterColumnEventPayload } 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;
|
|
handleConvertAllDayToTimed?(payload: DragMouseEnterColumnEventPayload): 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.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: DragMoveEventPayload): void {
|
|
|
|
console.log('handleDragMove', payload)
|
|
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: DragColumnChangeEventPayload): 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: DragMouseEnterColumnEventPayload): 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: 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;
|
|
}
|
|
}
|