Refactors calendar event rendering and management
Improves code organization and maintainability by separating concerns related to all-day event rendering, header management, and event resizing. Moves all-day event rendering logic into a dedicated `AllDayEventRenderer` class, utilizing the factory pattern for event element creation. Refactors `AllDayManager` to handle all-day row height animations, separated from `HeaderManager`. Removes the `ResizeManager` and related functionality. These changes aim to reduce code duplication, improve testability, and enhance the overall architecture of the calendar component.
This commit is contained in:
parent
e0b83ebd70
commit
c07d83d86f
13 changed files with 599 additions and 1306 deletions
174
src/renderers/AllDayEventRenderer.ts
Normal file
174
src/renderers/AllDayEventRenderer.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// All-day event rendering using factory pattern
|
||||
|
||||
import { CalendarEvent } from '../types/CalendarTypes';
|
||||
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
|
||||
import { DateCalculator } from '../utils/DateCalculator';
|
||||
|
||||
/**
|
||||
* AllDayEventRenderer - Handles rendering of all-day events in header row
|
||||
* Uses factory pattern with SwpAllDayEventElement for clean DOM creation
|
||||
*/
|
||||
export class AllDayEventRenderer {
|
||||
|
||||
/**
|
||||
* Render all-day events in the header container
|
||||
*/
|
||||
public renderAllDayEvents(events: CalendarEvent[], container: HTMLElement): void {
|
||||
const allDayEvents = events.filter(event => event.allDay);
|
||||
|
||||
// Find the calendar header
|
||||
const calendarHeader = container.querySelector('swp-calendar-header');
|
||||
if (!calendarHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find or create all-day container
|
||||
let allDayContainer = calendarHeader.querySelector('swp-allday-container') as HTMLElement;
|
||||
if (!allDayContainer) {
|
||||
allDayContainer = document.createElement('swp-allday-container');
|
||||
calendarHeader.appendChild(allDayContainer);
|
||||
}
|
||||
|
||||
// Clear existing events
|
||||
allDayContainer.innerHTML = '';
|
||||
|
||||
if (allDayEvents.length === 0) {
|
||||
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 HTMLElement).dataset.date;
|
||||
if (dateStr) {
|
||||
dateToColumnMap.set(dateStr, index + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate grid positioning for events
|
||||
const eventPlacements = this.calculateEventPlacements(allDayEvents, dateToColumnMap);
|
||||
|
||||
// Render events using factory pattern
|
||||
eventPlacements.forEach(({ event, gridColumn, gridRow }) => {
|
||||
const eventDateStr = DateCalculator.formatISODate(event.start);
|
||||
const swpAllDayEvent = SwpAllDayEventElement.fromCalendarEvent(event, eventDateStr);
|
||||
const allDayElement = swpAllDayEvent.getElement();
|
||||
|
||||
// Apply grid positioning
|
||||
(allDayElement as HTMLElement).style.gridColumn = gridColumn;
|
||||
(allDayElement as HTMLElement).style.gridRow = gridRow.toString();
|
||||
|
||||
// Use event metadata for color if available
|
||||
if (event.metadata?.color) {
|
||||
(allDayElement as HTMLElement).style.backgroundColor = event.metadata.color;
|
||||
}
|
||||
|
||||
allDayContainer.appendChild(allDayElement);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate grid positioning for all-day events with overlap detection
|
||||
*/
|
||||
private calculateEventPlacements(events: CalendarEvent[], dateToColumnMap: Map<string, number>) {
|
||||
// Calculate spans for each event
|
||||
const eventItems = events.map(event => {
|
||||
const eventDateStr = DateCalculator.formatISODate(event.start);
|
||||
const endDateStr = DateCalculator.formatISODate(event.end);
|
||||
|
||||
const startColumn = dateToColumnMap.get(eventDateStr);
|
||||
const endColumn = dateToColumnMap.get(endDateStr);
|
||||
|
||||
if (startColumn === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columnSpan = endColumn !== undefined && endColumn >= startColumn
|
||||
? endColumn - startColumn + 1
|
||||
: 1;
|
||||
|
||||
return {
|
||||
event,
|
||||
span: {
|
||||
startColumn: startColumn,
|
||||
columnSpan: columnSpan
|
||||
}
|
||||
};
|
||||
}).filter(item => item !== null) as Array<{
|
||||
event: CalendarEvent;
|
||||
span: { startColumn: number; columnSpan: number };
|
||||
}>;
|
||||
|
||||
// Calculate row placement to avoid overlaps
|
||||
interface EventPlacement {
|
||||
event: CalendarEvent;
|
||||
gridColumn: string;
|
||||
gridRow: number;
|
||||
}
|
||||
|
||||
const eventPlacements: EventPlacement[] = [];
|
||||
|
||||
eventItems.forEach(eventItem => {
|
||||
let assignedRow = 1;
|
||||
|
||||
// Find first available row
|
||||
while (true) {
|
||||
// Check if this row has any conflicts
|
||||
const rowEvents = eventPlacements.filter(p => p.gridRow === assignedRow);
|
||||
|
||||
const hasOverlap = rowEvents.some(rowEvent => {
|
||||
// Parse the existing grid column to check overlap
|
||||
const existingSpan = this.parseGridColumn(rowEvent.gridColumn);
|
||||
return this.spansOverlap(eventItem.span, existingSpan);
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
break; // Found available row
|
||||
}
|
||||
assignedRow++;
|
||||
}
|
||||
|
||||
const gridColumn = eventItem.span.columnSpan > 1
|
||||
? `${eventItem.span.startColumn} / span ${eventItem.span.columnSpan}`
|
||||
: `${eventItem.span.startColumn}`;
|
||||
|
||||
eventPlacements.push({
|
||||
event: eventItem.event,
|
||||
gridColumn,
|
||||
gridRow: assignedRow
|
||||
});
|
||||
});
|
||||
|
||||
return eventPlacements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two column spans overlap
|
||||
*/
|
||||
private spansOverlap(span1: { startColumn: number; columnSpan: number }, span2: { startColumn: number; columnSpan: number }): boolean {
|
||||
const span1End = span1.startColumn + span1.columnSpan - 1;
|
||||
const span2End = span2.startColumn + span2.columnSpan - 1;
|
||||
|
||||
return !(span1End < span2.startColumn || span2End < span1.startColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse grid column string back to span object
|
||||
*/
|
||||
private parseGridColumn(gridColumn: string): { startColumn: number; columnSpan: number } {
|
||||
if (gridColumn.includes('span')) {
|
||||
const parts = gridColumn.split(' / span ');
|
||||
return {
|
||||
startColumn: parseInt(parts[0]),
|
||||
columnSpan: parseInt(parts[1])
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
startColumn: parseInt(gridColumn),
|
||||
columnSpan: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ import { DateCalculator } from '../utils/DateCalculator';
|
|||
import { eventBus } from '../core/EventBus';
|
||||
import { CoreEvents } from '../constants/CoreEvents';
|
||||
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
|
||||
import { ResizeManager } from '../managers/ResizeManager';
|
||||
import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement';
|
||||
|
||||
/**
|
||||
|
|
@ -28,14 +27,12 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
private originalEvent: HTMLElement | null = null;
|
||||
|
||||
// Resize manager
|
||||
private resizeManager: ResizeManager;
|
||||
|
||||
constructor(dateCalculator?: DateCalculator) {
|
||||
if (!dateCalculator) {
|
||||
DateCalculator.initialize(calendarConfig);
|
||||
}
|
||||
this.dateCalculator = dateCalculator || new DateCalculator();
|
||||
this.resizeManager = new ResizeManager(eventBus);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
@ -135,40 +132,13 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
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 convert to timed event
|
||||
eventBus.on('drag:convert-to-timed', (event) => {
|
||||
const { eventId, targetColumn, targetY } = (event as CustomEvent).detail;
|
||||
this.handleConvertToTimed(eventId, targetColumn, targetY);
|
||||
});
|
||||
|
||||
// Handle all-day to timed conversion (when leaving header)
|
||||
eventBus.on('drag:convert-allday-to-timed', (event) => {
|
||||
const { eventId, originalElement } = (event as CustomEvent).detail;
|
||||
this.handleConvertAllDayToTimed(eventId, originalElement);
|
||||
});
|
||||
|
||||
// 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
|
||||
|
|
@ -688,250 +658,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
/**
|
||||
* 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.dataset.displayType === 'allday') 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 by modifying existing element
|
||||
*/
|
||||
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 event data for transformation
|
||||
const titleElement = clone.querySelector('swp-event-title');
|
||||
const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled';
|
||||
|
||||
const timeElement = clone.querySelector('swp-event-time');
|
||||
const eventDuration = timeElement ? timeElement.getAttribute('data-duration') || '' : '';
|
||||
|
||||
// Calculate column index for CSS Grid positioning
|
||||
const dayHeaders = document.querySelectorAll('swp-day-header');
|
||||
let columnIndex = 1;
|
||||
dayHeaders.forEach((header, index) => {
|
||||
if ((header as HTMLElement).dataset.date === targetDate) {
|
||||
columnIndex = index + 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Transform the existing element in-place instead of creating new one
|
||||
// Update dataset for all-day format
|
||||
clone.dataset.displayType = "allday";
|
||||
clone.dataset.allDay = "true";
|
||||
clone.dataset.start = `${targetDate}T00:00:00`;
|
||||
clone.dataset.end = `${targetDate}T23:59:59`;
|
||||
if (eventDuration) {
|
||||
clone.dataset.duration = eventDuration;
|
||||
}
|
||||
|
||||
// Change content to all-day format (just title)
|
||||
clone.innerHTML = eventTitle;
|
||||
|
||||
// Clear timed event positioning
|
||||
clone.style.position = '';
|
||||
clone.style.top = '';
|
||||
clone.style.height = '';
|
||||
clone.style.left = '';
|
||||
clone.style.right = '';
|
||||
|
||||
// Apply CSS grid positioning for all-day
|
||||
clone.style.gridColumn = columnIndex.toString();
|
||||
|
||||
// Move element to all-day container
|
||||
const parent = clone.parentElement;
|
||||
if (parent) {
|
||||
parent.removeChild(clone);
|
||||
}
|
||||
allDayContainer.appendChild(clone);
|
||||
|
||||
// draggedClone reference stays the same since it's the same element
|
||||
|
||||
// Check if height animation is needed
|
||||
this.triggerAllDayHeightAnimation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle conversion from all-day to timed event
|
||||
*/
|
||||
private handleConvertToTimed(eventId: string, targetColumn: string, targetY: number): void {
|
||||
if (!this.draggedClone) return;
|
||||
|
||||
// Only convert if it's an all-day event
|
||||
if (this.draggedClone.dataset.displayType !== 'allday') return;
|
||||
|
||||
// Transform clone to timed format
|
||||
this.transformAllDayToTimed(this.draggedClone, targetColumn, targetY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle all-day to timed conversion by transforming existing element
|
||||
*/
|
||||
private handleConvertAllDayToTimed(eventId: string, originalElement: HTMLElement): void {
|
||||
if (!this.draggedClone) return;
|
||||
|
||||
// Only convert if it's an all-day event
|
||||
if (this.draggedClone.dataset.displayType !== 'allday') return;
|
||||
|
||||
// Transform the existing element instead of creating a new one
|
||||
this.transformAllDayToTimedInPlace(this.draggedClone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform all-day element to timed by modifying existing element in place
|
||||
*/
|
||||
private transformAllDayToTimedInPlace(allDayElement: HTMLElement): void {
|
||||
// Extract event data
|
||||
const eventId = allDayElement.dataset.eventId || '';
|
||||
const eventTitle = allDayElement.dataset.title || allDayElement.textContent || 'Untitled';
|
||||
const eventType = allDayElement.dataset.type || 'work';
|
||||
const duration = parseInt(allDayElement.dataset.duration || '60');
|
||||
|
||||
// Calculate position for timed event (use current time or 9 AM default)
|
||||
const now = new Date();
|
||||
const startHour = now.getHours() || 9;
|
||||
const startMinutes = now.getMinutes() || 0;
|
||||
|
||||
// Transform the existing element in-place instead of creating new one
|
||||
// Update dataset for timed format
|
||||
allDayElement.dataset.displayType = "timed";
|
||||
delete allDayElement.dataset.allDay;
|
||||
|
||||
// Set timed event structure
|
||||
const startTime = this.formatTime(new Date(2000, 0, 1, startHour, startMinutes));
|
||||
const endTime = this.formatTime(new Date(2000, 0, 1, startHour, startMinutes + duration));
|
||||
|
||||
allDayElement.innerHTML = `
|
||||
<swp-event-time data-duration="${duration}">${startTime} - ${endTime}</swp-event-time>
|
||||
<swp-event-title>${eventTitle}</swp-event-title>
|
||||
`;
|
||||
|
||||
// Clear all-day positioning
|
||||
allDayElement.style.gridColumn = '';
|
||||
|
||||
// Apply timed event positioning
|
||||
allDayElement.style.position = 'absolute';
|
||||
allDayElement.style.left = '2px';
|
||||
allDayElement.style.right = '2px';
|
||||
allDayElement.style.top = '100px'; // Default position, will be adjusted by drag system
|
||||
allDayElement.style.height = '57px'; // Default height for 1 hour
|
||||
|
||||
// Find a day column to place the element (try to use today's column)
|
||||
const columns = document.querySelectorAll('swp-day-column');
|
||||
let targetColumn = columns[0]; // fallback
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
columns.forEach(col => {
|
||||
if ((col as HTMLElement).dataset.date === today) {
|
||||
targetColumn = col;
|
||||
}
|
||||
});
|
||||
|
||||
const eventsLayer = targetColumn?.querySelector('swp-events-layer');
|
||||
|
||||
// Move element from all-day container to events layer
|
||||
const parent = allDayElement.parentElement;
|
||||
if (parent) {
|
||||
parent.removeChild(allDayElement);
|
||||
}
|
||||
|
||||
// Add to events layer
|
||||
if (eventsLayer) {
|
||||
eventsLayer.appendChild(allDayElement);
|
||||
}
|
||||
|
||||
// draggedClone reference stays the same since it's the same element
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform clone from all-day to timed event
|
||||
*/
|
||||
private transformAllDayToTimed(allDayClone: HTMLElement, targetColumn: string, targetY: number): void {
|
||||
// Find target column element
|
||||
const columnElement = document.querySelector(`swp-day-column[data-date="${targetColumn}"]`);
|
||||
if (!columnElement) return;
|
||||
|
||||
const eventsLayer = columnElement.querySelector('swp-events-layer');
|
||||
if (!eventsLayer) return;
|
||||
|
||||
// Extract event data from all-day element
|
||||
const eventId = allDayClone.dataset.eventId || '';
|
||||
const eventTitle = allDayClone.dataset.title || allDayClone.textContent || 'Untitled';
|
||||
const eventType = allDayClone.dataset.type || 'work';
|
||||
|
||||
// Calculate time from Y position
|
||||
const gridSettings = calendarConfig.getGridSettings();
|
||||
const hourHeight = gridSettings.hourHeight;
|
||||
const dayStartHour = gridSettings.dayStartHour;
|
||||
const snapInterval = gridSettings.snapInterval;
|
||||
|
||||
// Calculate start time from position
|
||||
const minutesFromGridStart = (targetY / hourHeight) * 60;
|
||||
const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart;
|
||||
const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval;
|
||||
|
||||
// Use default duration or extract from dataset
|
||||
const duration = parseInt(allDayClone.dataset.duration || '60');
|
||||
const endMinutes = snappedStartMinutes + duration;
|
||||
|
||||
// Create dates with target column date
|
||||
const columnDate = new Date(targetColumn + 'T00:00:00');
|
||||
const startDate = new Date(columnDate);
|
||||
startDate.setMinutes(snappedStartMinutes);
|
||||
|
||||
const endDate = new Date(columnDate);
|
||||
endDate.setMinutes(endMinutes);
|
||||
|
||||
// Create CalendarEvent object for helper methods
|
||||
const tempEvent: CalendarEvent = {
|
||||
id: eventId,
|
||||
title: eventTitle,
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
type: eventType,
|
||||
allDay: false,
|
||||
syncStatus: 'synced',
|
||||
metadata: {
|
||||
duration: duration
|
||||
}
|
||||
};
|
||||
|
||||
// Create timed event using factory
|
||||
const swpTimedEvent = SwpEventElement.fromCalendarEvent(tempEvent);
|
||||
const timedEvent = swpTimedEvent.getElement();
|
||||
|
||||
// Set additional drag-specific attributes
|
||||
timedEvent.dataset.originalDuration = duration.toString();
|
||||
|
||||
// Apply drag styling and positioning
|
||||
this.applyDragStyling(timedEvent);
|
||||
const eventHeight = (duration / 60) * hourHeight - 3;
|
||||
timedEvent.style.height = `${eventHeight}px`;
|
||||
timedEvent.style.top = `${targetY}px`;
|
||||
|
||||
// Remove all-day element
|
||||
allDayClone.remove();
|
||||
|
||||
// Add timed event to events layer
|
||||
eventsLayer.appendChild(timedEvent);
|
||||
|
||||
// Update reference
|
||||
this.draggedClone = timedEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fade out and remove element
|
||||
|
|
@ -953,19 +679,15 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
// 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);
|
||||
// Only handle regular (non-all-day) events
|
||||
|
||||
|
||||
// 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 columnEvents = this.getEventsForColumn(column, events);
|
||||
|
||||
const eventsLayer = column.querySelector('swp-events-layer');
|
||||
if (eventsLayer) {
|
||||
|
|
@ -979,101 +701,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
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.spansOverlap(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 all-day event using factory
|
||||
const eventDateStr = DateCalculator.formatISODate(event.start);
|
||||
const swpAllDayEvent = SwpAllDayEventElement.fromCalendarEvent(event, eventDateStr);
|
||||
const allDayEvent = swpAllDayEvent.getElement();
|
||||
|
||||
// Override grid position for spanning events
|
||||
(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): HTMLElement {
|
||||
const swpEvent = SwpEventElement.fromCalendarEvent(event);
|
||||
|
|
@ -1082,7 +709,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
// Setup resize handles on first mouseover only
|
||||
eventElement.addEventListener('mouseover', () => {
|
||||
if (eventElement.dataset.hasResizeHandlers !== 'true') {
|
||||
this.resizeManager.setupResizeHandles(eventElement);
|
||||
eventElement.dataset.hasResizeHandlers = 'true';
|
||||
}
|
||||
}, { once: true });
|
||||
|
|
@ -1113,51 +739,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
|
|||
return { top, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate grid column span for event
|
||||
*/
|
||||
private calculateEventGridSpan(event: CalendarEvent, dateToColumnMap: Map<string, number>): { startColumn: number, columnSpan: number } {
|
||||
const startDateKey = DateCalculator.formatISODate(event.start);
|
||||
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(event.start);
|
||||
|
||||
while (currentDate <= event.end) {
|
||||
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 column spans overlap (for all-day events)
|
||||
*/
|
||||
private spansOverlap(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, swp-event-group';
|
||||
const existingEvents = container
|
||||
|
|
|
|||
|
|
@ -154,8 +154,6 @@ export class GridRenderer {
|
|||
|
||||
headerRenderer.render(calendarHeader, context);
|
||||
|
||||
// Always ensure all-day containers exist for all days
|
||||
headerRenderer.ensureAllDayContainers(calendarHeader);
|
||||
|
||||
// Setup only grid-related event listeners
|
||||
this.setupGridEventListeners();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Header rendering strategy interface and implementations
|
||||
|
||||
import { CalendarConfig, ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
|
||||
import { eventBus } from '../core/EventBus';
|
||||
import { CalendarConfig } from '../core/CalendarConfig';
|
||||
import { ResourceCalendarData } from '../types/CalendarTypes';
|
||||
import { DateCalculator } from '../utils/DateCalculator';
|
||||
|
||||
|
|
@ -10,232 +9,8 @@ import { DateCalculator } from '../utils/DateCalculator';
|
|||
*/
|
||||
export interface HeaderRenderer {
|
||||
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void;
|
||||
addToAllDay(dayHeader: HTMLElement): void;
|
||||
ensureAllDayContainers(calendarHeader: HTMLElement): void;
|
||||
checkAndAnimateAllDayHeight(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class with shared addToAllDay implementation
|
||||
*/
|
||||
export abstract class BaseHeaderRenderer implements HeaderRenderer {
|
||||
// Cached DOM elements to avoid redundant queries
|
||||
private cachedCalendarHeader: HTMLElement | null = null;
|
||||
private cachedAllDayContainer: HTMLElement | null = null;
|
||||
private cachedHeaderSpacer: HTMLElement | null = null;
|
||||
|
||||
abstract render(calendarHeader: HTMLElement, context: HeaderRenderContext): void;
|
||||
|
||||
/**
|
||||
* Get cached calendar header element
|
||||
*/
|
||||
private getCalendarHeader(): HTMLElement | null {
|
||||
if (!this.cachedCalendarHeader) {
|
||||
this.cachedCalendarHeader = document.querySelector('swp-calendar-header');
|
||||
}
|
||||
return this.cachedCalendarHeader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached all-day container element
|
||||
*/
|
||||
private getAllDayContainer(): HTMLElement | null {
|
||||
if (!this.cachedAllDayContainer) {
|
||||
const calendarHeader = this.getCalendarHeader();
|
||||
if (calendarHeader) {
|
||||
this.cachedAllDayContainer = calendarHeader.querySelector('swp-allday-container');
|
||||
}
|
||||
}
|
||||
return this.cachedAllDayContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached header spacer element
|
||||
*/
|
||||
private getHeaderSpacer(): HTMLElement | null {
|
||||
if (!this.cachedHeaderSpacer) {
|
||||
this.cachedHeaderSpacer = document.querySelector('swp-header-spacer');
|
||||
}
|
||||
return this.cachedHeaderSpacer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate all-day height based on number of rows
|
||||
*/
|
||||
private calculateAllDayHeight(targetRows: number): {
|
||||
targetHeight: number;
|
||||
currentHeight: number;
|
||||
heightDifference: number;
|
||||
} {
|
||||
const root = document.documentElement;
|
||||
const targetHeight = targetRows * ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT;
|
||||
const currentHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0');
|
||||
const heightDifference = targetHeight - currentHeight;
|
||||
|
||||
return { targetHeight, currentHeight, heightDifference };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached DOM elements (call when DOM structure changes)
|
||||
*/
|
||||
private clearCache(): void {
|
||||
this.cachedCalendarHeader = null;
|
||||
this.cachedAllDayContainer = null;
|
||||
this.cachedHeaderSpacer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand header to show all-day row
|
||||
*/
|
||||
addToAllDay(dayHeader: HTMLElement): void {
|
||||
const { currentHeight } = this.calculateAllDayHeight(0);
|
||||
|
||||
if (currentHeight === 0) {
|
||||
// Find the calendar header element to animate
|
||||
const calendarHeader = dayHeader.closest('swp-calendar-header') as HTMLElement;
|
||||
if (calendarHeader) {
|
||||
// Ensure container exists BEFORE animation
|
||||
this.createAllDayMainStructure(calendarHeader);
|
||||
this.checkAndAnimateAllDayHeight();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all-day containers exist - always create them during header rendering
|
||||
*/
|
||||
ensureAllDayContainers(calendarHeader: HTMLElement): void {
|
||||
this.createAllDayMainStructure(calendarHeader);
|
||||
}
|
||||
|
||||
checkAndAnimateAllDayHeight(): void {
|
||||
const container = this.getAllDayContainer();
|
||||
if (!container) return;
|
||||
|
||||
const allDayEvents = container.querySelectorAll('swp-allday-event');
|
||||
|
||||
// Calculate required rows - 0 if no events (will collapse)
|
||||
let maxRows = 0;
|
||||
|
||||
if (allDayEvents.length > 0) {
|
||||
// Expand events to all dates they span and group by date
|
||||
const expandedEventsByDate: Record<string, string[]> = {};
|
||||
|
||||
(Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => {
|
||||
const startISO = event.dataset.start || '';
|
||||
const endISO = event.dataset.end || startISO;
|
||||
const eventId = event.dataset.eventId || '';
|
||||
|
||||
// Extract dates from ISO strings
|
||||
const startDate = startISO.split('T')[0]; // YYYY-MM-DD
|
||||
const endDate = endISO.split('T')[0]; // YYYY-MM-DD
|
||||
|
||||
// Loop through all dates from start to end
|
||||
let current = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
while (current <= end) {
|
||||
const dateStr = current.toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
|
||||
if (!expandedEventsByDate[dateStr]) {
|
||||
expandedEventsByDate[dateStr] = [];
|
||||
}
|
||||
expandedEventsByDate[dateStr].push(eventId);
|
||||
|
||||
// Move to next day
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Find max rows needed
|
||||
maxRows = Math.max(
|
||||
...Object.values(expandedEventsByDate).map(ids => ids?.length || 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Animate to required rows (0 = collapse, >0 = expand)
|
||||
this.animateToRows(maxRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate all-day container to specific number of rows
|
||||
*/
|
||||
animateToRows(targetRows: number): void {
|
||||
const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows);
|
||||
|
||||
if (targetHeight === currentHeight) return; // No animation needed
|
||||
|
||||
console.log(`🎬 All-day height animation starting: ${currentHeight}px → ${targetHeight}px (${Math.ceil(currentHeight / ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT)} → ${targetRows} rows)`);
|
||||
|
||||
// Get cached elements
|
||||
const calendarHeader = this.getCalendarHeader();
|
||||
const headerSpacer = this.getHeaderSpacer();
|
||||
const allDayContainer = this.getAllDayContainer();
|
||||
|
||||
if (!calendarHeader || !allDayContainer) return;
|
||||
|
||||
// Get current parent height for animation
|
||||
const currentParentHeight = parseFloat(getComputedStyle(calendarHeader).height);
|
||||
const targetParentHeight = currentParentHeight + heightDifference;
|
||||
|
||||
const animations = [
|
||||
calendarHeader.animate([
|
||||
{ height: `${currentParentHeight}px` },
|
||||
{ height: `${targetParentHeight}px` }
|
||||
], {
|
||||
duration: 300,
|
||||
easing: 'ease-out',
|
||||
fill: 'forwards'
|
||||
})
|
||||
];
|
||||
|
||||
// Add spacer animation if spacer exists
|
||||
if (headerSpacer) {
|
||||
const root = document.documentElement;
|
||||
const currentSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + currentHeight;
|
||||
const targetSpacerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')) + targetHeight;
|
||||
|
||||
animations.push(
|
||||
headerSpacer.animate([
|
||||
{ height: `${currentSpacerHeight}px` },
|
||||
{ height: `${targetSpacerHeight}px` }
|
||||
], {
|
||||
duration: 300,
|
||||
easing: 'ease-out',
|
||||
fill: 'forwards'
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update CSS variable after animation
|
||||
Promise.all(animations.map(anim => anim.finished)).then(() => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--all-day-row-height', `${targetHeight}px`);
|
||||
eventBus.emit('header:height-changed');
|
||||
});
|
||||
}
|
||||
|
||||
private createAllDayMainStructure(calendarHeader: HTMLElement): void {
|
||||
// Check if container already exists
|
||||
let container = calendarHeader.querySelector('swp-allday-container');
|
||||
|
||||
if (!container) {
|
||||
// Create simple all-day container (initially hidden)
|
||||
container = document.createElement('swp-allday-container');
|
||||
calendarHeader.appendChild(container);
|
||||
// Clear cache since DOM structure changed
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public cleanup method for cached elements
|
||||
*/
|
||||
public destroy(): void {
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for header rendering
|
||||
|
|
@ -249,7 +24,7 @@ export interface HeaderRenderContext {
|
|||
/**
|
||||
* Date-based header renderer (original functionality)
|
||||
*/
|
||||
export class DateHeaderRenderer extends BaseHeaderRenderer {
|
||||
export class DateHeaderRenderer implements HeaderRenderer {
|
||||
private dateCalculator!: DateCalculator;
|
||||
|
||||
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void {
|
||||
|
|
@ -279,16 +54,13 @@ export class DateHeaderRenderer extends BaseHeaderRenderer {
|
|||
|
||||
calendarHeader.appendChild(header);
|
||||
});
|
||||
|
||||
// Always create all-day container after rendering headers
|
||||
this.ensureAllDayContainers(calendarHeader);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-based header renderer
|
||||
*/
|
||||
export class ResourceHeaderRenderer extends BaseHeaderRenderer {
|
||||
export class ResourceHeaderRenderer implements HeaderRenderer {
|
||||
render(calendarHeader: HTMLElement, context: HeaderRenderContext): void {
|
||||
const { resourceData } = context;
|
||||
|
||||
|
|
@ -310,8 +82,5 @@ export class ResourceHeaderRenderer extends BaseHeaderRenderer {
|
|||
|
||||
calendarHeader.appendChild(header);
|
||||
});
|
||||
|
||||
// Always create all-day container after rendering headers
|
||||
this.ensureAllDayContainers(calendarHeader);
|
||||
}
|
||||
}
|
||||
|
|
@ -193,9 +193,6 @@ export class NavigationRenderer {
|
|||
header.appendChild(headerElement);
|
||||
});
|
||||
|
||||
// Always ensure all-day containers exist for all days
|
||||
const headerRenderer = CalendarTypeFactory.getHeaderRenderer(calendarConfig.getCalendarMode());
|
||||
headerRenderer.ensureAllDayContainers(header as HTMLElement);
|
||||
|
||||
// Render day columns for target week
|
||||
dates.forEach(date => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue