Refines the drag and drop autoscroll functionality to correctly update the position of the dragged element relative to the scrolling container. Calculates the snapped position based on the column's bounding rectangle and scroll movement, ensuring accurate placement during autoscroll. This provides a smoother and more responsive user experience when dragging elements near the edges of the scrollable area.
742 lines
No EOL
24 KiB
TypeScript
742 lines
No EOL
24 KiB
TypeScript
// Event rendering strategy interface and implementations
|
|
|
|
import { CalendarEvent } from '../types/CalendarTypes';
|
|
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
|
|
import { calendarConfig } from '../core/CalendarConfig';
|
|
import { DateCalculator } from '../utils/DateCalculator';
|
|
import { eventBus } from '../core/EventBus';
|
|
import { CoreEvents } from '../constants/CoreEvents';
|
|
|
|
/**
|
|
* Interface for event rendering strategies
|
|
*/
|
|
export interface EventRendererStrategy {
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement): void;
|
|
clearEvents(container?: HTMLElement): void;
|
|
}
|
|
|
|
/**
|
|
* Base class for event renderers with common functionality
|
|
*/
|
|
export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|
protected dateCalculator: DateCalculator;
|
|
|
|
// Drag and drop state
|
|
private draggedClone: HTMLElement | null = null;
|
|
private originalEvent: HTMLElement | null = null;
|
|
|
|
constructor(dateCalculator?: DateCalculator) {
|
|
if (!dateCalculator) {
|
|
DateCalculator.initialize(calendarConfig);
|
|
}
|
|
this.dateCalculator = dateCalculator || new DateCalculator();
|
|
}
|
|
|
|
/**
|
|
* Setup listeners for drag events from DragDropManager
|
|
*/
|
|
protected setupDragEventListeners(): void {
|
|
// Handle drag start
|
|
eventBus.on('drag:start', (event) => {
|
|
const { originalElement, eventId, mouseOffset, column } = (event as CustomEvent).detail;
|
|
this.handleDragStart(originalElement, eventId, mouseOffset, column);
|
|
});
|
|
|
|
// Handle drag move
|
|
eventBus.on('drag:move', (event) => {
|
|
const { eventId, snappedY, column, mouseOffset } = (event as CustomEvent).detail;
|
|
this.handleDragMove(eventId, snappedY, column, mouseOffset);
|
|
});
|
|
|
|
// Handle drag auto-scroll (when dragging near edges triggers scroll)
|
|
eventBus.on('drag:auto-scroll', (event) => {
|
|
const { eventId, snappedY } = (event as CustomEvent).detail;
|
|
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);
|
|
});
|
|
|
|
// Handle drag end
|
|
eventBus.on('drag:end', (event) => {
|
|
const { eventId, originalElement, finalColumn, finalY } = (event as CustomEvent).detail;
|
|
this.handleDragEnd(eventId, originalElement, finalColumn, finalY);
|
|
});
|
|
|
|
// Handle column change
|
|
eventBus.on('drag:column-change', (event) => {
|
|
const { eventId, newColumn } = (event as CustomEvent).detail;
|
|
this.handleColumnChange(eventId, newColumn);
|
|
});
|
|
|
|
// Handle convert to all-day
|
|
eventBus.on('drag:convert-to-allday', (event) => {
|
|
const { eventId, targetDate, headerRenderer } = (event as CustomEvent).detail;
|
|
this.handleConvertToAllDay(eventId, targetDate, headerRenderer);
|
|
});
|
|
|
|
// Handle navigation period change (when slide animation completes)
|
|
eventBus.on(CoreEvents.NAVIGATION_COMPLETED, () => {
|
|
// Animate all-day height after navigation completes
|
|
this.triggerAllDayHeightAnimation();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Trigger all-day height animation without creating new renderer instance
|
|
*/
|
|
private triggerAllDayHeightAnimation(): void {
|
|
import('./HeaderRenderer').then(({ DateHeaderRenderer }) => {
|
|
const headerRenderer = new DateHeaderRenderer();
|
|
headerRenderer.checkAndAnimateAllDayHeight();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cleanup method for proper resource management
|
|
*/
|
|
public destroy(): void {
|
|
this.draggedClone = null;
|
|
this.originalEvent = null;
|
|
}
|
|
|
|
/**
|
|
* Get original event duration from data-duration attribute
|
|
*/
|
|
private getOriginalEventDuration(originalEvent: HTMLElement): number {
|
|
// Find the swp-event-time element with data-duration attribute
|
|
const timeElement = originalEvent.querySelector('swp-event-time');
|
|
if (timeElement) {
|
|
const duration = timeElement.getAttribute('data-duration');
|
|
if (duration) {
|
|
const durationMinutes = parseInt(duration);
|
|
return durationMinutes;
|
|
}
|
|
}
|
|
|
|
// Fallback to 60 minutes if attribute not found
|
|
return 60;
|
|
}
|
|
|
|
/**
|
|
* Create a clone of an event for dragging
|
|
*/
|
|
private createEventClone(originalEvent: HTMLElement): HTMLElement {
|
|
const clone = originalEvent.cloneNode(true) as HTMLElement;
|
|
|
|
// Prefix ID with "clone-"
|
|
const originalId = originalEvent.dataset.eventId;
|
|
if (originalId) {
|
|
clone.dataset.eventId = `clone-${originalId}`;
|
|
}
|
|
|
|
// Get and cache original duration from data-duration attribute
|
|
const originalDurationMinutes = this.getOriginalEventDuration(originalEvent);
|
|
clone.dataset.originalDuration = originalDurationMinutes.toString();
|
|
|
|
|
|
// Style for dragging
|
|
clone.style.position = 'absolute';
|
|
clone.style.zIndex = '999999';
|
|
clone.style.pointerEvents = 'none';
|
|
clone.style.opacity = '0.8';
|
|
|
|
// Keep original dimensions (height stays the same)
|
|
const rect = originalEvent.getBoundingClientRect();
|
|
clone.style.width = rect.width + 'px';
|
|
clone.style.height = rect.height + 'px';
|
|
|
|
return clone;
|
|
}
|
|
|
|
/**
|
|
* Update clone timestamp based on new position
|
|
*/
|
|
private updateCloneTimestamp(clone: HTMLElement, snappedY: number): void {
|
|
const gridSettings = calendarConfig.getGridSettings();
|
|
const hourHeight = gridSettings.hourHeight;
|
|
const dayStartHour = gridSettings.dayStartHour;
|
|
const snapInterval = 15; // TODO: Get from config
|
|
|
|
// Calculate total minutes from top
|
|
const totalMinutesFromTop = (snappedY / hourHeight) * 60;
|
|
const startTotalMinutes = Math.max(
|
|
dayStartHour * 60,
|
|
Math.round((dayStartHour * 60 + totalMinutesFromTop) / snapInterval) * snapInterval
|
|
);
|
|
|
|
// Use cached original duration (no recalculation)
|
|
const cachedDuration = parseInt(clone.dataset.originalDuration || '60');
|
|
const endTotalMinutes = startTotalMinutes + cachedDuration;
|
|
|
|
// Update display
|
|
const timeElement = clone.querySelector('swp-event-time');
|
|
if (timeElement) {
|
|
const newTimeText = `${this.formatTime(startTotalMinutes)} - ${this.formatTime(endTotalMinutes)}`;
|
|
timeElement.textContent = newTimeText;
|
|
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate event duration in minutes from element height
|
|
*/
|
|
private getEventDuration(element: HTMLElement): number {
|
|
const gridSettings = calendarConfig.getGridSettings();
|
|
const hourHeight = gridSettings.hourHeight;
|
|
|
|
// Get height from style or computed
|
|
let heightPx = parseFloat(element.style.height) || 0;
|
|
if (!heightPx) {
|
|
const rect = element.getBoundingClientRect();
|
|
heightPx = rect.height;
|
|
}
|
|
|
|
return Math.round((heightPx / hourHeight) * 60);
|
|
}
|
|
|
|
/**
|
|
* Unified time formatting method - handles both total minutes and Date objects
|
|
*/
|
|
private formatTime(input: number | Date | string): string {
|
|
let hours: number, minutes: number;
|
|
|
|
if (typeof input === 'number') {
|
|
// Total minutes input
|
|
hours = Math.floor(input / 60) % 24;
|
|
minutes = input % 60;
|
|
} else {
|
|
// Date or ISO string input
|
|
const date = typeof input === 'string' ? new Date(input) : input;
|
|
hours = date.getHours();
|
|
minutes = date.getMinutes();
|
|
}
|
|
|
|
const period = hours >= 12 ? 'PM' : 'AM';
|
|
const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
|
|
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
|
}
|
|
|
|
/**
|
|
* Handle drag start event
|
|
*/
|
|
private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void {
|
|
this.originalEvent = originalElement;
|
|
|
|
// Create clone
|
|
this.draggedClone = this.createEventClone(originalElement);
|
|
|
|
// Add to current column
|
|
const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`);
|
|
if (columnElement) {
|
|
columnElement.appendChild(this.draggedClone);
|
|
}
|
|
|
|
// Make original semi-transparent
|
|
originalElement.style.opacity = '0.3';
|
|
originalElement.style.userSelect = 'none';
|
|
|
|
}
|
|
|
|
/**
|
|
* Handle drag move event
|
|
*/
|
|
private handleDragMove(eventId: string, snappedY: number, column: string, mouseOffset: any): void {
|
|
if (!this.draggedClone) return;
|
|
|
|
// Update position
|
|
this.draggedClone.style.top = snappedY + 'px';
|
|
|
|
// Update timestamp display
|
|
this.updateCloneTimestamp(this.draggedClone, snappedY);
|
|
|
|
}
|
|
|
|
/**
|
|
* Handle column change during drag
|
|
*/
|
|
private handleColumnChange(eventId: string, newColumn: string): void {
|
|
if (!this.draggedClone) return;
|
|
|
|
// Move clone to new column
|
|
const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`);
|
|
if (newColumnElement && this.draggedClone.parentElement !== newColumnElement) {
|
|
newColumnElement.appendChild(this.draggedClone);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle drag end event
|
|
*/
|
|
private handleDragEnd(eventId: string, originalElement: HTMLElement, finalColumn: string, finalY: number): void {
|
|
|
|
if (!this.draggedClone || !this.originalEvent) {
|
|
return;
|
|
}
|
|
|
|
// Fade out original
|
|
this.fadeOutAndRemove(this.originalEvent);
|
|
|
|
// Remove clone prefix and normalize clone to be a regular event
|
|
const cloneId = this.draggedClone.dataset.eventId;
|
|
if (cloneId && cloneId.startsWith('clone-')) {
|
|
this.draggedClone.dataset.eventId = cloneId.replace('clone-', '');
|
|
}
|
|
|
|
// Fully normalize the clone to be a regular event
|
|
this.draggedClone.style.pointerEvents = '';
|
|
this.draggedClone.style.opacity = '';
|
|
this.draggedClone.style.userSelect = '';
|
|
this.draggedClone.style.zIndex = '';
|
|
|
|
|
|
// Clean up
|
|
this.draggedClone = null;
|
|
this.originalEvent = null;
|
|
|
|
}
|
|
|
|
/**
|
|
* Handle conversion to all-day event
|
|
*/
|
|
private handleConvertToAllDay(eventId: string, targetDate: string, headerRenderer: any): void {
|
|
if (!this.draggedClone) return;
|
|
|
|
// Only convert once
|
|
if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') return;
|
|
|
|
// Transform clone to all-day format
|
|
this.transformCloneToAllDay(this.draggedClone, targetDate);
|
|
|
|
// Expand header if needed
|
|
headerRenderer.addToAllDay(this.draggedClone.parentElement);
|
|
|
|
}
|
|
|
|
/**
|
|
* Transform clone from timed to all-day event
|
|
*/
|
|
private transformCloneToAllDay(clone: HTMLElement, targetDate: string): void {
|
|
const calendarHeader = document.querySelector('swp-calendar-header');
|
|
if (!calendarHeader) return;
|
|
|
|
// Find all-day container
|
|
const allDayContainer = calendarHeader.querySelector('swp-allday-container');
|
|
if (!allDayContainer) return;
|
|
|
|
// Extract all original event data
|
|
const titleElement = clone.querySelector('swp-event-title');
|
|
const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled';
|
|
|
|
const timeElement = clone.querySelector('swp-event-time');
|
|
const eventTime = timeElement ? timeElement.textContent || '' : '';
|
|
const eventDuration = timeElement ? timeElement.getAttribute('data-duration') || '' : '';
|
|
|
|
// Calculate column index
|
|
const dayHeaders = document.querySelectorAll('swp-day-header');
|
|
let columnIndex = 1;
|
|
dayHeaders.forEach((header, index) => {
|
|
if ((header as HTMLElement).dataset.date === targetDate) {
|
|
columnIndex = index + 1;
|
|
}
|
|
});
|
|
|
|
// Create all-day event with standardized data attributes
|
|
const allDayEvent = document.createElement('swp-allday-event');
|
|
allDayEvent.dataset.eventId = clone.dataset.eventId || '';
|
|
allDayEvent.dataset.title = eventTitle;
|
|
allDayEvent.dataset.start = `${targetDate}T${eventTime.split(' - ')[0]}:00`;
|
|
allDayEvent.dataset.end = `${targetDate}T${eventTime.split(' - ')[1]}:00`;
|
|
allDayEvent.dataset.type = clone.dataset.type || 'work';
|
|
allDayEvent.dataset.duration = eventDuration;
|
|
allDayEvent.textContent = eventTitle;
|
|
|
|
// Position in grid
|
|
(allDayEvent as HTMLElement).style.gridColumn = columnIndex.toString();
|
|
// grid-row will be set by checkAndAnimateAllDayHeight() based on actual position
|
|
|
|
// Remove original clone
|
|
if (clone.parentElement) {
|
|
clone.parentElement.removeChild(clone);
|
|
}
|
|
|
|
// Add to all-day container
|
|
allDayContainer.appendChild(allDayEvent);
|
|
|
|
// Update reference
|
|
this.draggedClone = allDayEvent;
|
|
|
|
// Check if height animation is needed
|
|
this.triggerAllDayHeightAnimation();
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Convert dragged clone to all-day event preview
|
|
*/
|
|
private convertToAllDayPreview(targetDate: string): void {
|
|
if (!this.draggedClone) return;
|
|
|
|
// Only convert once
|
|
if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') {
|
|
return;
|
|
}
|
|
|
|
// Transform clone to all-day format
|
|
this.transformCloneToAllDay(this.draggedClone, targetDate);
|
|
|
|
}
|
|
|
|
/**
|
|
* Move all-day event to a new date container
|
|
*/
|
|
private moveAllDayToNewDate(targetDate: string): void {
|
|
if (!this.draggedClone) return;
|
|
|
|
const calendarHeader = document.querySelector('swp-calendar-header');
|
|
if (!calendarHeader) return;
|
|
|
|
// Find the all-day container
|
|
const allDayContainer = calendarHeader.querySelector('swp-allday-container');
|
|
if (!allDayContainer) return;
|
|
|
|
// Calculate new column index
|
|
const dayHeaders = document.querySelectorAll('swp-day-header');
|
|
let columnIndex = 1;
|
|
dayHeaders.forEach((header, index) => {
|
|
if ((header as HTMLElement).dataset.date === targetDate) {
|
|
columnIndex = index + 1;
|
|
}
|
|
});
|
|
|
|
// Update grid column position
|
|
(this.draggedClone as HTMLElement).style.gridColumn = columnIndex.toString();
|
|
|
|
// Move to all-day container if not already there
|
|
if (this.draggedClone.parentElement !== allDayContainer) {
|
|
allDayContainer.appendChild(this.draggedClone);
|
|
}
|
|
|
|
}
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement): void {
|
|
|
|
// NOTE: Removed clearEvents() to support sliding animation
|
|
// With sliding animation, multiple grid containers exist simultaneously
|
|
// clearEvents() would remove events from all containers, breaking the animation
|
|
// Events are now rendered directly into the new container without clearing
|
|
|
|
// Separate all-day events from regular events
|
|
const allDayEvents = events.filter(event => event.allDay);
|
|
const regularEvents = events.filter(event => !event.allDay);
|
|
|
|
|
|
// Always call renderAllDayEvents to ensure height is set correctly (even to 0)
|
|
this.renderAllDayEvents(allDayEvents, container);
|
|
|
|
// Find columns in the specific container for regular events
|
|
const columns = this.getColumns(container);
|
|
|
|
columns.forEach(column => {
|
|
const columnEvents = this.getEventsForColumn(column, regularEvents);
|
|
|
|
const eventsLayer = column.querySelector('swp-events-layer');
|
|
if (eventsLayer) {
|
|
columnEvents.forEach(event => {
|
|
this.renderEvent(event, eventsLayer);
|
|
});
|
|
|
|
// Debug: Verify events were actually added
|
|
const renderedEvents = eventsLayer.querySelectorAll('swp-event');
|
|
} else {
|
|
}
|
|
});
|
|
}
|
|
|
|
// Abstract methods that subclasses must implement
|
|
protected abstract getColumns(container: HTMLElement): HTMLElement[];
|
|
protected abstract getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[];
|
|
|
|
/**
|
|
* Render all-day events in the header row 2
|
|
*/
|
|
protected renderAllDayEvents(allDayEvents: CalendarEvent[], container: HTMLElement): void {
|
|
|
|
// Find the calendar header
|
|
const calendarHeader = container.querySelector('swp-calendar-header');
|
|
if (!calendarHeader) {
|
|
return;
|
|
}
|
|
|
|
// Find the all-day container (should always exist now)
|
|
const allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement;
|
|
if (!allDayContainer) {
|
|
console.warn('All-day container not found - this should not happen');
|
|
return;
|
|
}
|
|
|
|
// Clear existing events
|
|
allDayContainer.innerHTML = '';
|
|
|
|
if (allDayEvents.length === 0) {
|
|
// No events - container exists but is empty and hidden
|
|
return;
|
|
}
|
|
|
|
// Build date to column mapping
|
|
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
|
|
const dateToColumnMap = new Map<string, number>();
|
|
|
|
dayHeaders.forEach((header, index) => {
|
|
const dateStr = (header as any).dataset.date;
|
|
if (dateStr) {
|
|
dateToColumnMap.set(dateStr, index + 1); // 1-based column index
|
|
}
|
|
});
|
|
|
|
// Calculate grid spans for all events
|
|
const eventSpans = allDayEvents.map(event => ({
|
|
event,
|
|
span: this.calculateEventGridSpan(event, dateToColumnMap)
|
|
})).filter(item => item.span.columnSpan > 0); // Remove events outside visible range
|
|
|
|
// Simple row assignment using overlap detection
|
|
const eventPlacements: Array<{ event: CalendarEvent, span: { startColumn: number, columnSpan: number }, row: number }> = [];
|
|
|
|
eventSpans.forEach(eventItem => {
|
|
let assignedRow = 1;
|
|
|
|
// Find first row where this event doesn't overlap with any existing event
|
|
while (true) {
|
|
const rowEvents = eventPlacements.filter(item => item.row === assignedRow);
|
|
const hasOverlap = rowEvents.some(rowEvent =>
|
|
this.eventsOverlap(eventItem.span, rowEvent.span)
|
|
);
|
|
|
|
if (!hasOverlap) {
|
|
break; // Found available row
|
|
}
|
|
assignedRow++;
|
|
}
|
|
|
|
eventPlacements.push({
|
|
event: eventItem.event,
|
|
span: eventItem.span,
|
|
row: assignedRow
|
|
});
|
|
});
|
|
|
|
// Get max row needed
|
|
const maxRow = Math.max(...eventPlacements.map(item => item.row), 1);
|
|
|
|
// Place events directly in the single container
|
|
eventPlacements.forEach(({ event, span, row }) => {
|
|
// Create the all-day event element
|
|
const allDayEvent = document.createElement('swp-allday-event');
|
|
allDayEvent.textContent = event.title;
|
|
|
|
// Set data attributes directly from CalendarEvent
|
|
allDayEvent.dataset.eventId = event.id;
|
|
allDayEvent.dataset.title = event.title;
|
|
allDayEvent.dataset.start = event.start;
|
|
allDayEvent.dataset.end = event.end;
|
|
allDayEvent.dataset.type = event.type;
|
|
allDayEvent.dataset.duration = event.metadata?.duration?.toString() || '60';
|
|
|
|
// Set grid position (column and row)
|
|
(allDayEvent as HTMLElement).style.gridColumn = span.columnSpan > 1
|
|
? `${span.startColumn} / span ${span.columnSpan}`
|
|
: `${span.startColumn}`;
|
|
(allDayEvent as HTMLElement).style.gridRow = row.toString();
|
|
|
|
// Use event metadata for color if available
|
|
if (event.metadata?.color) {
|
|
(allDayEvent as HTMLElement).style.backgroundColor = event.metadata.color;
|
|
}
|
|
|
|
allDayContainer.appendChild(allDayEvent);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
protected renderEvent(event: CalendarEvent, container: Element): void {
|
|
const eventElement = document.createElement('swp-event');
|
|
eventElement.dataset.eventId = event.id;
|
|
eventElement.dataset.title = event.title;
|
|
eventElement.dataset.start = event.start;
|
|
eventElement.dataset.end = event.end;
|
|
eventElement.dataset.type = event.type;
|
|
eventElement.dataset.duration = event.metadata?.duration?.toString() || '60';
|
|
|
|
// Calculate position based on time
|
|
const position = this.calculateEventPosition(event);
|
|
eventElement.style.position = 'absolute';
|
|
eventElement.style.top = `${position.top + 1}px`;
|
|
eventElement.style.height = `${position.height - 3}px`; //adjusted so bottom does not cover horizontal time lines.
|
|
|
|
// Color is now handled by CSS classes based on data-type attribute
|
|
|
|
// Format time for display using unified method
|
|
const startTime = this.formatTime(event.start);
|
|
const endTime = this.formatTime(event.end);
|
|
|
|
// Calculate duration in minutes
|
|
const startDate = new Date(event.start);
|
|
const endDate = new Date(event.end);
|
|
const durationMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60);
|
|
|
|
// Create event content
|
|
eventElement.innerHTML = `
|
|
<swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
|
|
<swp-event-title>${event.title}</swp-event-title>
|
|
`;
|
|
|
|
|
|
container.appendChild(eventElement);
|
|
}
|
|
|
|
protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
|
|
const startDate = new Date(event.start);
|
|
const endDate = new Date(event.end);
|
|
|
|
const gridSettings = calendarConfig.getGridSettings();
|
|
const dayStartHour = gridSettings.dayStartHour;
|
|
const hourHeight = gridSettings.hourHeight;
|
|
|
|
// Calculate minutes from visible day start
|
|
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
|
|
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
|
|
const dayStartMinutes = dayStartHour * 60;
|
|
|
|
// Calculate top position (subtract day start to align with time axis)
|
|
const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight;
|
|
|
|
// Calculate height
|
|
const durationMinutes = endMinutes - startMinutes;
|
|
const height = (durationMinutes / 60) * hourHeight;
|
|
|
|
|
|
return { top, height };
|
|
}
|
|
|
|
/**
|
|
* Calculate grid column span for event
|
|
*/
|
|
private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map<string, number>): { startColumn: number, columnSpan: number } {
|
|
const startDate = new Date(event.start);
|
|
const endDate = new Date(event.end);
|
|
const startDateKey = DateCalculator.formatISODate(startDate);
|
|
const startColumn = dateToColumnMap.get(startDateKey);
|
|
|
|
if (!startColumn) {
|
|
return { startColumn: 0, columnSpan: 0 }; // Event outside visible range
|
|
}
|
|
|
|
// Calculate span by checking each day
|
|
let endColumn = startColumn;
|
|
const currentDate = new Date(startDate);
|
|
|
|
while (currentDate <= endDate) {
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
const dateKey = DateCalculator.formatISODate(currentDate);
|
|
const col = dateToColumnMap.get(dateKey);
|
|
if (col) {
|
|
endColumn = col;
|
|
} else {
|
|
break; // Event extends beyond visible range
|
|
}
|
|
}
|
|
|
|
const columnSpan = endColumn - startColumn + 1;
|
|
return { startColumn, columnSpan };
|
|
}
|
|
|
|
/**
|
|
* Check if two events overlap in columns
|
|
*/
|
|
private eventsOverlap(event1Span: { startColumn: number, columnSpan: number }, event2Span: { startColumn: number, columnSpan: number }): boolean {
|
|
const event1End = event1Span.startColumn + event1Span.columnSpan - 1;
|
|
const event2End = event2Span.startColumn + event2Span.columnSpan - 1;
|
|
|
|
return !(event1End < event2Span.startColumn || event2End < event1Span.startColumn);
|
|
}
|
|
|
|
clearEvents(container?: HTMLElement): void {
|
|
const selector = 'swp-event';
|
|
const existingEvents = container
|
|
? container.querySelectorAll(selector)
|
|
: document.querySelectorAll(selector);
|
|
|
|
existingEvents.forEach(event => event.remove());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Date-based event renderer
|
|
*/
|
|
export class DateEventRenderer extends BaseEventRenderer {
|
|
constructor(dateCalculator?: DateCalculator) {
|
|
super(dateCalculator);
|
|
this.setupDragEventListeners();
|
|
}
|
|
|
|
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 [];
|
|
}
|
|
|
|
const columnEvents = events.filter(event => {
|
|
const eventDate = new Date(event.start);
|
|
const eventDateStr = DateCalculator.formatISODate(eventDate);
|
|
const matches = eventDateStr === columnDate;
|
|
|
|
|
|
return matches;
|
|
});
|
|
|
|
return columnEvents;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resource-based event renderer
|
|
*/
|
|
export class ResourceEventRenderer extends BaseEventRenderer {
|
|
protected getColumns(container: HTMLElement): HTMLElement[] {
|
|
const columns = container.querySelectorAll('swp-resource-column');
|
|
return Array.from(columns) as HTMLElement[];
|
|
}
|
|
|
|
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
|
|
const resourceName = column.dataset.resource;
|
|
if (!resourceName) return [];
|
|
|
|
const columnEvents = events.filter(event => {
|
|
return event.resource?.name === resourceName;
|
|
});
|
|
|
|
return columnEvents;
|
|
}
|
|
} |