Handles dragging of both timed events (converting to all-day) and existing all-day events to different days. Refactors all-day height recalculation to support animated transitions for a smoother user experience when all-day event counts change. Uses event delegation for header mouseover detection. Updates ScrollManager to listen for header height changes.
344 lines
No EOL
13 KiB
TypeScript
344 lines
No EOL
13 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';
|
|
|
|
/**
|
|
* Interface for event rendering strategies
|
|
*/
|
|
export interface EventRendererStrategy {
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void;
|
|
clearEvents(container?: HTMLElement): void;
|
|
}
|
|
|
|
/**
|
|
* Base class for event renderers with common functionality
|
|
*/
|
|
export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|
protected dateCalculator: DateCalculator;
|
|
|
|
constructor(config: CalendarConfig) {
|
|
this.dateCalculator = new DateCalculator(config);
|
|
}
|
|
renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void {
|
|
console.log('BaseEventRenderer: renderEvents called with', events.length, 'events');
|
|
|
|
// 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);
|
|
|
|
console.log(`BaseEventRenderer: Rendering ${allDayEvents.length} all-day events and ${regularEvents.length} regular events`);
|
|
|
|
// Always call renderAllDayEvents to ensure height is set correctly (even to 0)
|
|
this.renderAllDayEvents(allDayEvents, container, config);
|
|
|
|
// Find columns in the specific container for regular events
|
|
const columns = this.getColumns(container);
|
|
console.log(`BaseEventRenderer: Found ${columns.length} columns in container`);
|
|
|
|
columns.forEach(column => {
|
|
const columnEvents = this.getEventsForColumn(column, regularEvents);
|
|
console.log(`BaseEventRenderer: Rendering ${columnEvents.length} regular events in column`);
|
|
|
|
const eventsLayer = column.querySelector('swp-events-layer');
|
|
if (eventsLayer) {
|
|
columnEvents.forEach(event => {
|
|
console.log(`BaseEventRenderer: Rendering event "${event.title}" in events layer`);
|
|
this.renderEvent(event, eventsLayer, config);
|
|
});
|
|
|
|
// Debug: Verify events were actually added
|
|
const renderedEvents = eventsLayer.querySelectorAll('swp-event');
|
|
console.log(`BaseEventRenderer: Events layer now has ${renderedEvents.length} events`);
|
|
} else {
|
|
console.warn('BaseEventRenderer: No events layer found in column');
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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, config: CalendarConfig): void {
|
|
console.log(`BaseEventRenderer: Rendering ${allDayEvents.length} all-day events`);
|
|
|
|
// Find the calendar header
|
|
const calendarHeader = container.querySelector('swp-calendar-header');
|
|
if (!calendarHeader) {
|
|
console.warn('BaseEventRenderer: No calendar header found for all-day events');
|
|
return;
|
|
}
|
|
|
|
// Clear any existing all-day containers first
|
|
const existingContainers = calendarHeader.querySelectorAll('swp-allday-container');
|
|
existingContainers.forEach(container => container.remove());
|
|
|
|
// Track maximum number of stacked events to calculate row height
|
|
let maxStackHeight = 0;
|
|
|
|
// Get day headers to build date map
|
|
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
|
|
const dateToColumnMap = new Map<string, number>();
|
|
const visibleDates: string[] = [];
|
|
|
|
dayHeaders.forEach((header, index) => {
|
|
const dateStr = (header as any).dataset.date;
|
|
if (dateStr) {
|
|
dateToColumnMap.set(dateStr, index + 1); // 1-based column index
|
|
visibleDates.push(dateStr);
|
|
}
|
|
});
|
|
|
|
// Group events by their start column for container creation
|
|
const eventsByStartColumn = new Map<number, CalendarEvent[]>();
|
|
|
|
allDayEvents.forEach(event => {
|
|
const startDate = new Date(event.start);
|
|
const startDateKey = this.dateCalculator.formatISODate(startDate);
|
|
const startColumn = dateToColumnMap.get(startDateKey);
|
|
|
|
if (!startColumn) {
|
|
console.log(`BaseEventRenderer: Event "${event.title}" starts outside visible week`);
|
|
return;
|
|
}
|
|
|
|
// Store event with its start column
|
|
if (!eventsByStartColumn.has(startColumn)) {
|
|
eventsByStartColumn.set(startColumn, []);
|
|
}
|
|
eventsByStartColumn.get(startColumn)!.push(event);
|
|
});
|
|
|
|
// Create containers and render events
|
|
eventsByStartColumn.forEach((events, startColumn) => {
|
|
events.forEach(event => {
|
|
const startDate = new Date(event.start);
|
|
const endDate = new Date(event.end);
|
|
|
|
// Calculate span
|
|
let endColumn = startColumn;
|
|
const currentDate = new Date(startDate);
|
|
|
|
while (currentDate <= endDate) {
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
const dateKey = this.dateCalculator.formatISODate(currentDate);
|
|
const col = dateToColumnMap.get(dateKey);
|
|
if (col) {
|
|
endColumn = col;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const columnSpan = endColumn - startColumn + 1;
|
|
|
|
// Create or find container for this column span
|
|
const containerKey = `${startColumn}-${columnSpan}`;
|
|
let allDayContainer = calendarHeader.querySelector(`swp-allday-container[data-container-key="${containerKey}"]`);
|
|
|
|
if (!allDayContainer) {
|
|
// Create container that spans the appropriate columns
|
|
allDayContainer = document.createElement('swp-allday-container');
|
|
allDayContainer.setAttribute('data-container-key', containerKey);
|
|
(allDayContainer as HTMLElement).style.gridColumn = columnSpan > 1
|
|
? `${startColumn} / span ${columnSpan}`
|
|
: `${startColumn}`;
|
|
(allDayContainer as HTMLElement).style.gridRow = '2';
|
|
calendarHeader.appendChild(allDayContainer);
|
|
}
|
|
|
|
// Create the all-day event element inside container
|
|
const allDayEvent = document.createElement('swp-allday-event');
|
|
allDayEvent.textContent = event.title;
|
|
allDayEvent.setAttribute('data-event-id', event.id);
|
|
allDayEvent.setAttribute('data-type', event.type || 'work');
|
|
|
|
// Use event metadata for color if available
|
|
if (event.metadata?.color) {
|
|
(allDayEvent as HTMLElement).style.backgroundColor = event.metadata.color;
|
|
}
|
|
|
|
console.log(`BaseEventRenderer: All-day event "${event.title}" in container spanning columns ${startColumn} to ${endColumn}`);
|
|
|
|
allDayContainer.appendChild(allDayEvent);
|
|
|
|
// Track max stack height
|
|
const containerEventCount = allDayContainer.querySelectorAll('swp-allday-event').length;
|
|
if (containerEventCount > maxStackHeight) {
|
|
maxStackHeight = containerEventCount;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Calculate and set the all-day row height based on max stack
|
|
const calculatedHeight = maxStackHeight > 0
|
|
? (maxStackHeight * ALL_DAY_CONSTANTS.EVENT_HEIGHT) + ((maxStackHeight - 1) * ALL_DAY_CONSTANTS.EVENT_GAP) + ALL_DAY_CONSTANTS.CONTAINER_PADDING
|
|
: 0; // No height if no events
|
|
|
|
// Only set CSS variable - header-spacer height is handled by CSS calc()
|
|
const root = document.documentElement;
|
|
root.style.setProperty('--all-day-row-height', `${calculatedHeight}px`);
|
|
|
|
console.log(`BaseEventRenderer: Set all-day row height to ${calculatedHeight}px (max stack: ${maxStackHeight})`);
|
|
}
|
|
|
|
protected renderEvent(event: CalendarEvent, container: Element, config: CalendarConfig): void {
|
|
const eventElement = document.createElement('swp-event');
|
|
eventElement.dataset.eventId = event.id;
|
|
eventElement.dataset.type = event.type;
|
|
|
|
// Calculate position based on time
|
|
const position = this.calculateEventPosition(event, config);
|
|
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
|
|
const startTime = this.dateCalculator.formatTime(new Date(event.start));
|
|
const endTime = this.dateCalculator.formatTime(new Date(event.end));
|
|
|
|
// Create event content
|
|
eventElement.innerHTML = `
|
|
<swp-event-time>${startTime} - ${endTime}</swp-event-time>
|
|
<swp-event-title>${event.title}</swp-event-title>
|
|
`;
|
|
|
|
container.appendChild(eventElement);
|
|
|
|
console.log(`BaseEventRenderer: Created event element for "${event.title}":`, {
|
|
top: eventElement.style.top,
|
|
height: eventElement.style.height,
|
|
dataType: eventElement.dataset.type,
|
|
position: eventElement.style.position,
|
|
innerHTML: eventElement.innerHTML
|
|
});
|
|
}
|
|
|
|
protected calculateEventPosition(event: CalendarEvent, config: CalendarConfig): { top: number; height: number } {
|
|
const startDate = new Date(event.start);
|
|
const endDate = new Date(event.end);
|
|
|
|
const gridSettings = config.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;
|
|
|
|
console.log('Event positioning calculation:', {
|
|
eventTime: `${startDate.getHours()}:${startDate.getMinutes()}`,
|
|
startMinutes,
|
|
endMinutes,
|
|
dayStartMinutes,
|
|
dayStartHour,
|
|
hourHeight,
|
|
top,
|
|
height
|
|
});
|
|
|
|
return { top, height };
|
|
}
|
|
|
|
protected formatTime(isoString: string): string {
|
|
const date = new Date(isoString);
|
|
const hours = date.getHours();
|
|
const minutes = date.getMinutes();
|
|
const period = hours >= 12 ? 'PM' : 'AM';
|
|
const displayHour = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours);
|
|
|
|
return `${displayHour}:${minutes.toString().padStart(2, '0')} ${period}`;
|
|
}
|
|
|
|
clearEvents(container?: HTMLElement): void {
|
|
const selector = 'swp-event';
|
|
const existingEvents = container
|
|
? container.querySelectorAll(selector)
|
|
: document.querySelectorAll(selector);
|
|
|
|
if (existingEvents.length > 0) {
|
|
console.log(`BaseEventRenderer: Clearing ${existingEvents.length} events`,
|
|
container ? 'from container' : 'globally');
|
|
}
|
|
existingEvents.forEach(event => event.remove());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Date-based event renderer
|
|
*/
|
|
export class DateEventRenderer extends BaseEventRenderer {
|
|
protected getColumns(container: HTMLElement): HTMLElement[] {
|
|
const columns = container.querySelectorAll('swp-day-column');
|
|
console.log('DateEventRenderer: Found', columns.length, 'day columns in container');
|
|
return Array.from(columns) as HTMLElement[];
|
|
}
|
|
|
|
protected getEventsForColumn(column: HTMLElement, events: CalendarEvent[]): CalendarEvent[] {
|
|
const columnDate = column.dataset.date;
|
|
if (!columnDate) {
|
|
console.log(`DateEventRenderer: Column has no dataset.date`);
|
|
return [];
|
|
}
|
|
|
|
const columnEvents = events.filter(event => {
|
|
const eventDate = new Date(event.start);
|
|
const eventDateStr = this.dateCalculator.formatISODate(eventDate);
|
|
const matches = eventDateStr === columnDate;
|
|
|
|
if (!matches) {
|
|
if(event.title == 'Architecture Planning')
|
|
console.log(`DateEventRenderer: Event ${event.title} (${eventDateStr}) does not match column (${columnDate})`);
|
|
}
|
|
|
|
return matches;
|
|
});
|
|
|
|
console.log(`DateEventRenderer: Column ${columnDate} has ${columnEvents.length} events from ${events.length} total`);
|
|
return columnEvents;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resource-based event renderer
|
|
*/
|
|
export class ResourceEventRenderer extends BaseEventRenderer {
|
|
protected getColumns(container: HTMLElement): HTMLElement[] {
|
|
const columns = container.querySelectorAll('swp-resource-column');
|
|
console.log('ResourceEventRenderer: Found', columns.length, 'resource columns in container');
|
|
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;
|
|
});
|
|
|
|
console.log(`ResourceEventRenderer: Resource ${resourceName} has ${columnEvents.length} events`);
|
|
return columnEvents;
|
|
}
|
|
} |