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:
Janus Knudsen 2025-09-12 00:36:02 +02:00
parent e0b83ebd70
commit c07d83d86f
13 changed files with 599 additions and 1306 deletions

View file

@ -0,0 +1,220 @@
// All-day row height management and animations
import { eventBus } from '../core/EventBus';
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
/**
* AllDayManager - Handles all-day row height animations and management
* Separated from HeaderManager for clean responsibility separation
*/
export class AllDayManager {
private cachedAllDayContainer: HTMLElement | null = null;
private cachedCalendarHeader: HTMLElement | null = null;
private cachedHeaderSpacer: HTMLElement | null = null;
constructor() {
// Bind methods for event listeners
this.checkAndAnimateAllDayHeight = this.checkAndAnimateAllDayHeight.bind(this);
}
/**
* 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 calendar header element
*/
private getCalendarHeader(): HTMLElement | null {
if (!this.cachedCalendarHeader) {
this.cachedCalendarHeader = document.querySelector('swp-calendar-header');
}
return this.cachedCalendarHeader;
}
/**
* 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 all-day row to show events
*/
public expandAllDayRow(): void {
const { currentHeight } = this.calculateAllDayHeight(0);
if (currentHeight === 0) {
this.checkAndAnimateAllDayHeight();
}
}
/**
* Collapse all-day row when no events
*/
public collapseAllDayRow(): void {
this.animateToRows(0);
}
/**
* Check current all-day events and animate to correct height
*/
public 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
*/
public animateToRows(targetRows: number): void {
const { targetHeight, currentHeight, heightDifference } = this.calculateAllDayHeight(targetRows);
if (targetHeight === currentHeight) return; // No animation needed
console.log(`🎬 All-day height animation: ${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');
});
}
/**
* Update row height when all-day events change
*/
public updateRowHeight(): void {
this.checkAndAnimateAllDayHeight();
}
/**
* Clean up cached elements and resources
*/
public destroy(): void {
this.clearCache();
}
}

View file

@ -50,32 +50,12 @@ export class HeaderManager {
const target = event.target as HTMLElement;
// Optimized element detection
// Optimized element detection - only handle day headers
const dayHeader = target.closest('swp-day-header');
const allDayContainer = target.closest('swp-allday-container');
if (dayHeader || allDayContainer) {
let hoveredElement: HTMLElement;
let targetDate: string | undefined;
if (dayHeader) {
hoveredElement = dayHeader as HTMLElement;
targetDate = hoveredElement.dataset.date;
} else if (allDayContainer) {
hoveredElement = allDayContainer as HTMLElement;
// Optimized day calculation using cached header rect
const headerRect = calendarHeader.getBoundingClientRect();
const dayHeaders = calendarHeader.querySelectorAll('swp-day-header');
const mouseX = (event as MouseEvent).clientX - headerRect.left;
const dayWidth = headerRect.width / dayHeaders.length;
const dayIndex = Math.max(0, Math.min(dayHeaders.length - 1, Math.floor(mouseX / dayWidth)));
const targetDayHeader = dayHeaders[dayIndex] as HTMLElement;
targetDate = targetDayHeader?.dataset.date;
} else {
return;
}
if (dayHeader) {
const hoveredElement = dayHeader as HTMLElement;
const targetDate = hoveredElement.dataset.date;
// Get header renderer for coordination
const calendarType = calendarConfig.getCalendarMode();

View file

@ -1,264 +0,0 @@
import { calendarConfig } from '../core/CalendarConfig';
import { eventBus } from '../core/EventBus';
import { IEventBus } from '../types/CalendarTypes';
/**
* Resize state interface
*/
interface ResizeState {
element: HTMLElement;
handle: 'top' | 'bottom';
startY: number;
originalTop: number;
originalHeight: number;
originalStartTime: Date;
originalEndTime: Date;
minHeightPx: number;
}
/**
* ResizeManager - Handles event resizing functionality
*/
export class ResizeManager {
private resizeState: ResizeState | null = null;
private readonly MIN_EVENT_DURATION_MINUTES = 15;
constructor(private eventBus: IEventBus) {
// Bind methods for event listeners
this.handleResize = this.handleResize.bind(this);
this.endResize = this.endResize.bind(this);
}
/**
* Setup dynamic resize handles that are only created when needed
* @param eventElement - Event element to add resize handles to
*/
public setupResizeHandles(eventElement: HTMLElement): void {
// Variables to track resize handles
let topHandle: HTMLElement | null = null;
let bottomHandle: HTMLElement | null = null;
console.log('Setting up dynamic resize handles for event:', eventElement.dataset.eventId);
// Create resize handles on first mouseover
eventElement.addEventListener('mouseenter', () => {
if (!topHandle && !bottomHandle) {
topHandle = document.createElement('swp-resize-handle');
topHandle.className = 'swp-resize-handle swp-resize-top';
bottomHandle = document.createElement('swp-resize-handle');
bottomHandle.className = 'swp-resize-handle swp-resize-bottom';
// Add mousedown listeners for resize functionality
topHandle.addEventListener('mousedown', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.startResize(eventElement, 'top', e);
});
bottomHandle.addEventListener('mousedown', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.startResize(eventElement, 'bottom', e);
});
eventElement.appendChild(topHandle);
eventElement.appendChild(bottomHandle);
console.log('Created resize handles for event:', eventElement.dataset.eventId);
}
});
// Show/hide handles based on mouse position
eventElement.addEventListener('mousemove', (e: MouseEvent) => {
if (!topHandle || !bottomHandle) return;
const rect = eventElement.getBoundingClientRect();
const mouseY = e.clientY - rect.top;
const eventHeight = rect.height;
const topZone = eventHeight * 0.2;
const bottomZone = eventHeight * 0.8;
// Show top handle in upper 20%
if (mouseY < topZone) {
topHandle.style.opacity = '1';
bottomHandle.style.opacity = '0';
}
// Show bottom handle in lower 20%
else if (mouseY > bottomZone) {
topHandle.style.opacity = '0';
bottomHandle.style.opacity = '1';
}
// Hide both if mouse is in middle
else {
topHandle.style.opacity = '0';
bottomHandle.style.opacity = '0';
}
});
// Hide handles when mouse leaves event (but only if not in resize mode)
eventElement.addEventListener('mouseleave', () => {
console.log('Mouse LEAVE event:', eventElement.dataset.eventId);
if (!this.resizeState && topHandle && bottomHandle) {
topHandle.style.opacity = '0';
bottomHandle.style.opacity = '0';
console.log('Hidden resize handles for event:', eventElement.dataset.eventId);
}
});
}
/**
* Start resize operation
*/
private startResize(eventElement: HTMLElement, handle: 'top' | 'bottom', e: MouseEvent): void {
const gridSettings = calendarConfig.getGridSettings();
const minHeightPx = (this.MIN_EVENT_DURATION_MINUTES / 60) * gridSettings.hourHeight;
this.resizeState = {
element: eventElement,
handle: handle,
startY: e.clientY,
originalTop: parseFloat(eventElement.style.top),
originalHeight: parseFloat(eventElement.style.height),
originalStartTime: new Date(eventElement.dataset.start || ''),
originalEndTime: new Date(eventElement.dataset.end || ''),
minHeightPx: minHeightPx
};
// Global listeners for resize
document.addEventListener('mousemove', this.handleResize);
document.addEventListener('mouseup', this.endResize);
// Add resize cursor to body
document.body.style.cursor = handle === 'top' ? 'n-resize' : 's-resize';
console.log('Starting resize:', handle, 'element:', eventElement.dataset.eventId);
}
/**
* Handle resize drag
*/
private handleResize(e: MouseEvent): void {
if (!this.resizeState) return;
const deltaY = e.clientY - this.resizeState.startY;
const snappedDelta = this.snapToGrid(deltaY);
const gridSettings = calendarConfig.getGridSettings();
if (this.resizeState.handle === 'top') {
// Resize from top
const newTop = this.resizeState.originalTop + snappedDelta;
const newHeight = this.resizeState.originalHeight - snappedDelta;
// Check minimum height
if (newHeight >= this.resizeState.minHeightPx && newTop >= 0) {
this.resizeState.element.style.top = newTop + 'px';
this.resizeState.element.style.height = newHeight + 'px';
// Update times
const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60;
const newStartTime = this.addMinutes(this.resizeState.originalStartTime, minutesDelta);
this.updateEventDisplay(this.resizeState.element, newStartTime, this.resizeState.originalEndTime);
}
} else {
// Resize from bottom
const newHeight = this.resizeState.originalHeight + snappedDelta;
// Check minimum height
if (newHeight >= this.resizeState.minHeightPx) {
this.resizeState.element.style.height = newHeight + 'px';
// Update times
const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60;
const newEndTime = this.addMinutes(this.resizeState.originalEndTime, minutesDelta);
this.updateEventDisplay(this.resizeState.element, this.resizeState.originalStartTime, newEndTime);
}
}
}
/**
* End resize operation
*/
private endResize(): void {
if (!this.resizeState) return;
// Get final times from element
const finalStart = this.resizeState.element.dataset.start;
const finalEnd = this.resizeState.element.dataset.end;
console.log('Ending resize:', this.resizeState.element.dataset.eventId, 'New times:', finalStart, finalEnd);
// Emit event with new times
this.eventBus.emit('event:resized', {
eventId: this.resizeState.element.dataset.eventId,
newStart: finalStart,
newEnd: finalEnd
});
// Cleanup
document.removeEventListener('mousemove', this.handleResize);
document.removeEventListener('mouseup', this.endResize);
document.body.style.cursor = '';
this.resizeState = null;
}
/**
* Snap delta to grid intervals
*/
private snapToGrid(deltaY: number): number {
const gridSettings = calendarConfig.getGridSettings();
const snapInterval = gridSettings.snapInterval;
const hourHeight = gridSettings.hourHeight;
const snapDistancePx = (snapInterval / 60) * hourHeight;
return Math.round(deltaY / snapDistancePx) * snapDistancePx;
}
/**
* Update event display during resize
*/
private updateEventDisplay(element: HTMLElement, startTime: Date, endTime: Date): void {
// Calculate new duration in minutes
const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
// Update dataset
element.dataset.start = startTime.toISOString();
element.dataset.end = endTime.toISOString();
element.dataset.duration = durationMinutes.toString();
// Update visual time
const timeElement = element.querySelector('swp-event-time');
if (timeElement) {
const startStr = this.formatTime(startTime.toISOString());
const endStr = this.formatTime(endTime.toISOString());
timeElement.textContent = `${startStr} - ${endStr}`;
}
}
/**
* Add minutes to a date
*/
private addMinutes(date: Date, minutes: number): Date {
return new Date(date.getTime() + minutes * 60000);
}
/**
* Format time for display
*/
private formatTime(input: Date | string): string {
let hours: number;
let minutes: number;
if (input instanceof Date) {
hours = input.getHours();
minutes = input.getMinutes();
} 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}`;
}
}