Calendar/src/managers/AllDayManager.ts

480 lines
15 KiB
TypeScript
Raw Normal View History

// All-day row height management and animations
import { eventBus } from '../core/EventBus';
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
import { CalendarEvent } from '../types/CalendarTypes';
/**
* 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;
private allDayEventRenderer: AllDayEventRenderer;
constructor() {
// Bind methods for event listeners
this.checkAndAnimateAllDayHeight = this.checkAndAnimateAllDayHeight.bind(this);
this.allDayEventRenderer = new AllDayEventRenderer();
// Listen for drag-to-allday conversions
this.setupEventListeners();
}
/**
* Setup event listeners for drag conversions
*/
private setupEventListeners(): void {
eventBus.on('drag:convert-to-allday_event', (event) => {
const { targetDate, originalElement } = (event as CustomEvent).detail;
console.log('🔄 AllDayManager: Received drag:convert-to-allday_event', {
targetDate,
originalElementId: originalElement?.dataset?.eventId,
originalElementTag: originalElement?.tagName
});
this.handleConvertToAllDay(targetDate, originalElement);
});
// Listen for requests to ensure all-day container exists
eventBus.on('allday:ensure-container', () => {
console.log('🏗️ AllDayManager: Received request to ensure all-day container exists');
this.ensureAllDayContainer();
});
// Listen for header mouseleave to recalculate all-day container height
eventBus.on('header:mouseleave', () => {
console.log('🔄 AllDayManager: Received header:mouseleave, recalculating height');
this.checkAndAnimateAllDayHeight();
});
// Listen for drag operations on all-day events
eventBus.on('drag:start', (event) => {
const { eventId, mouseOffset } = (event as CustomEvent).detail;
// Check if this is an all-day event
const originalElement = document.querySelector(`swp-allday-container swp-allday-event[data-event-id="${eventId}"]`);
if (!originalElement) return; // Not an all-day event
console.log('🎯 AllDayManager: Starting drag for all-day event', { eventId });
this.handleDragStart(originalElement as HTMLElement, eventId, mouseOffset);
});
eventBus.on('drag:move', (event) => {
const { eventId, mousePosition } = (event as CustomEvent).detail;
// Only handle for all-day events
const dragClone = document.querySelector(`swp-allday-container swp-allday-event[data-event-id="clone-${eventId}"]`);
if (dragClone) {
this.handleDragMove(dragClone as HTMLElement, mousePosition);
}
});
eventBus.on('drag:end', (event) => {
const { eventId, finalColumn, finalY, dropTarget } = (event as CustomEvent).detail;
if (dropTarget != 'SWP-DAY-HEADER')//we are not inside the swp-day-header, so just ignore.
return;
console.log('🎬 AllDayManager: Received drag:end', {
eventId: eventId,
finalColumn: finalColumn,
finalY: finalY
});
// Check if this was an all-day event
const originalElement = document.querySelector(`swp-allday-container swp-allday-event[data-event-id="${eventId}"]`);
const dragClone = document.querySelector(`swp-allday-container swp-allday-event[data-event-id="clone-${eventId}"]`);
console.log('🎯 AllDayManager: Ending drag for all-day event', { eventId });
this.handleDragEnd(originalElement as HTMLElement, dragClone as HTMLElement, finalColumn);
});
}
/**
* 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');
});
}
/**
* Handle conversion of timed event to all-day event
*/
private handleConvertToAllDay(targetDate: string, originalElement: HTMLElement): void {
// Extract event data from original element
const eventId = originalElement.dataset.eventId;
const title = originalElement.dataset.title || originalElement.textContent || 'Untitled';
const type = originalElement.dataset.type || 'work';
const startStr = originalElement.dataset.start;
const endStr = originalElement.dataset.end;
if (!eventId || !startStr || !endStr) {
console.error('Original element missing required data (eventId, start, end)');
return;
}
// Create CalendarEvent for all-day conversion - preserve original times
const originalStart = new Date(startStr);
const originalEnd = new Date(endStr);
// Set date to target date but keep original time
const targetStart = new Date(targetDate);
targetStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), originalStart.getMilliseconds());
const targetEnd = new Date(targetDate);
targetEnd.setHours(originalEnd.getHours(), originalEnd.getMinutes(), originalEnd.getSeconds(), originalEnd.getMilliseconds());
const calendarEvent: CalendarEvent = {
id: `clone-${eventId}`,
title: title,
start: targetStart,
end: targetEnd,
type: type,
allDay: true,
syncStatus: 'synced',
metadata: {
duration: originalElement.dataset.duration || '60'
}
};
// Check if all-day clone already exists for this event ID
const existingAllDayEvent = document.querySelector(`swp-allday-container swp-allday-event[data-event-id="clone-${eventId}"]`);
2025-09-16 23:09:56 +02:00
if (existingAllDayEvent) {
// All-day event already exists, just ensure clone is hidden
const dragClone = document.querySelector(`swp-event[data-event-id="clone-${eventId}"]`);
if (dragClone) {
(dragClone as HTMLElement).style.display = 'none';
}
return;
}
// Use renderer to create and add all-day event
const allDayElement = this.allDayEventRenderer.renderAllDayEvent(calendarEvent, targetDate);
if (allDayElement) {
2025-09-16 23:09:56 +02:00
// Hide drag clone completely
const dragClone = document.querySelector(`swp-event[data-event-id="clone-${eventId}"]`);
if (dragClone) {
(dragClone as HTMLElement).style.display = 'none';
}
// Animate height change
this.checkAndAnimateAllDayHeight();
}
}
2025-09-16 23:09:56 +02:00
/**
* Update row height when all-day events change
*/
public updateRowHeight(): void {
this.checkAndAnimateAllDayHeight();
}
/**
* Ensure all-day container exists, create if needed
*/
public ensureAllDayContainer(): HTMLElement | null {
console.log('🔍 AllDayManager: Checking if all-day container exists...');
// Try to get existing container first
let container = this.getAllDayContainer();
if (!container) {
this.allDayEventRenderer.clearCache(); // Clear cache to force re-check
const header = this.getCalendarHeader();
container = document.createElement('swp-allday-container');
header?.appendChild(container);
this.cachedAllDayContainer = container;
}
return container;
}
/**
* Handle drag start for all-day events
*/
private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any): void {
// Create clone
const clone = originalElement.cloneNode(true) as HTMLElement;
clone.dataset.eventId = `clone-${eventId}`;
// Get container
const container = this.getAllDayContainer();
if (!container) return;
// Add clone to container
container.appendChild(clone);
// Copy positioning from original
clone.style.gridColumn = originalElement.style.gridColumn;
clone.style.gridRow = originalElement.style.gridRow;
// Add dragging style
clone.classList.add('dragging');
clone.style.zIndex = '1000';
clone.style.cursor = 'grabbing';
// Make original semi-transparent
originalElement.style.opacity = '0.3';
console.log('✅ AllDayManager: Created drag clone for all-day event', {
eventId,
cloneId: clone.dataset.eventId,
gridColumn: clone.style.gridColumn,
gridRow: clone.style.gridRow
});
}
/**
* Handle drag move for all-day events
*/
private handleDragMove(dragClone: HTMLElement, mousePosition: any): void {
// Calculate grid column based on mouse position
const dayHeaders = document.querySelectorAll('swp-day-header');
let targetColumn = 1;
dayHeaders.forEach((header, index) => {
const rect = header.getBoundingClientRect();
if (mousePosition.x >= rect.left && mousePosition.x <= rect.right) {
targetColumn = index + 1;
}
});
// Update clone position
dragClone.style.gridColumn = targetColumn.toString();
console.log('🔄 AllDayManager: Updated drag clone position', {
eventId: dragClone.dataset.eventId,
targetColumn,
mouseX: mousePosition.x
});
}
/**
* Handle drag end for all-day events
*/
private handleDragEnd(originalElement: HTMLElement, dragClone: HTMLElement, finalPosition: any): void {
// Remove original element
originalElement?.remove();
// Normalize clone
const cloneId = dragClone.dataset.eventId;
if (cloneId?.startsWith('clone-')) {
dragClone.dataset.eventId = cloneId.replace('clone-', '');
}
// Remove dragging styles
dragClone.classList.remove('dragging');
dragClone.style.zIndex = '';
dragClone.style.cursor = '';
dragClone.style.opacity = '';
// Recalculate all-day container height
this.checkAndAnimateAllDayHeight();
console.log('✅ AllDayManager: Completed drag operation for all-day event', {
eventId: dragClone.dataset.eventId,
finalColumn: dragClone.style.gridColumn
});
}
/**
* Clean up cached elements and resources
*/
public destroy(): void {
this.clearCache();
}
}