Calendar/src/managers/AllDayManager.ts

596 lines
20 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 { AllDayLayoutEngine } from '../utils/AllDayLayoutEngine';
import { ColumnDetectionUtils } from '../utils/ColumnDetectionUtils';
import { CalendarEvent } from '../types/CalendarTypes';
import {
DragMouseEnterHeaderEventPayload,
DragStartEventPayload,
DragMoveEventPayload,
DragEndEventPayload,
DragColumnChangeEventPayload
} from '../types/EventTypes';
import { DragOffset, MousePosition } from '../types/DragDropTypes';
/**
* AllDayManager - Handles all-day row height animations and management
* Uses AllDayLayoutEngine for all overlap detection and layout calculation
*/
export class AllDayManager {
private allDayEventRenderer: AllDayEventRenderer;
private layoutEngine: AllDayLayoutEngine | null = null;
// State tracking for differential updates
private currentLayouts: Map<string, string> = new Map();
private currentAllDayEvents: CalendarEvent[] = [];
private currentWeekDates: string[] = [];
constructor() {
this.allDayEventRenderer = new AllDayEventRenderer();
this.setupEventListeners();
}
/**
* Setup event listeners for drag conversions
*/
private setupEventListeners(): void {
eventBus.on('drag:mouseenter-header', (event) => {
const { targetDate, mousePosition, originalElement, cloneElement } = (event as CustomEvent<DragMouseEnterHeaderEventPayload>).detail;
console.log('🔄 AllDayManager: Received drag:mouseenter-header', {
targetDate,
originalElementId: originalElement?.dataset?.eventId,
originalElementTag: originalElement?.tagName
});
if (targetDate && cloneElement) {
this.handleConvertToAllDay(targetDate, cloneElement);
}
this.checkAndAnimateAllDayHeight();
});
eventBus.on('drag:mouseleave-header', (event) => {
const { originalElement, cloneElement } = (event as CustomEvent).detail;
console.log('🚪 AllDayManager: Received drag:mouseleave-header', {
originalElementId: originalElement?.dataset?.eventId
});
this.checkAndAnimateAllDayHeight();
});
// Listen for drag operations on all-day events
eventBus.on('drag:start', (event) => {
const { draggedElement, mouseOffset } = (event as CustomEvent<DragStartEventPayload>).detail;
// Check if this is an all-day event by checking if it's in all-day container
const isAllDayEvent = draggedElement.closest('swp-allday-container');
if (!isAllDayEvent) return; // Not an all-day event
const eventId = draggedElement.dataset.eventId;
console.log('🎯 AllDayManager: Starting drag for all-day event', { eventId });
this.handleDragStart(draggedElement, eventId || '', mouseOffset);
});
eventBus.on('drag:column-change', (event) => {
const { draggedElement, mousePosition } = (event as CustomEvent<DragColumnChangeEventPayload>).detail;
// Check if there's an all-day clone for this event
const eventId = draggedElement.dataset.eventId;
const dragClone = document.querySelector(`swp-allday-container swp-event[data-event-id="clone-${eventId}"]`) as HTMLElement;
if (!dragClone.hasAttribute('data-allday')) {
return;
}
// If we find an all-day clone, handle the drag move
if (dragClone) {
console.log('🔄 AllDayManager: Found all-day clone, handling drag:column-change', {
eventId,
cloneId: dragClone.dataset.eventId
});
this.handleColumnChange(dragClone, mousePosition);
}
});
eventBus.on('drag:end', (event) => {
const { draggedElement, mousePosition, finalPosition, target } = (event as CustomEvent<DragEndEventPayload>).detail;
if (target != 'swp-day-header') // we are not inside the swp-day-header, so just ignore.
return;
const eventId = draggedElement.dataset.eventId;
console.log('🎬 AllDayManager: Received drag:end', {
eventId: eventId,
finalPosition
});
const dragClone = document.querySelector(`swp-allday-container swp-event[data-event-id="clone-${eventId}"]`);
console.log('🎯 AllDayManager: Ending drag for all-day event', { eventId });
this.handleDragEnd(draggedElement, dragClone as HTMLElement, { column: finalPosition.column || '', y: 0 });
});
// Listen for drag cancellation to recalculate height
eventBus.on('drag:cancelled', (event) => {
const { draggedElement, reason } = (event as CustomEvent).detail;
console.log('🚫 AllDayManager: Drag cancelled', {
eventId: draggedElement?.dataset?.eventId,
reason
});
// Recalculate all-day height since clones may have been removed
this.checkAndAnimateAllDayHeight();
});
// Listen for height check requests from EventRendererManager
eventBus.on('allday:checkHeight', () => {
console.log('📏 AllDayManager: Received allday:checkHeight request');
this.checkAndAnimateAllDayHeight();
});
}
private getAllDayContainer(): HTMLElement | null {
return document.querySelector('swp-calendar-header swp-allday-container');
}
private getCalendarHeader(): HTMLElement | null {
return document.querySelector('swp-calendar-header');
}
private getHeaderSpacer(): HTMLElement | null {
return document.querySelector('swp-header-spacer');
}
/**
* 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;
// Read CSS variable directly from style property or default to 0
const currentHeightStr = root.style.getPropertyValue('--all-day-row-height') || '0px';
const currentHeight = parseInt(currentHeightStr) || 0;
const heightDifference = targetHeight - currentHeight;
return { targetHeight, currentHeight, heightDifference };
}
/**
* 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) {
this.animateToRows(0);
return;
}
const allDayEvents = container.querySelectorAll('swp-event');
// Calculate required rows - 0 if no events (will collapse)
let maxRows = 0;
if (allDayEvents.length > 0) {
// Find the HIGHEST row number in use (not count of unique rows)
let highestRow = 0;
(Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => {
const gridRow = parseInt(event.style.gridRow) || 1;
highestRow = Math.max(highestRow, gridRow);
});
// Max rows = highest row number (e.g. if row 3 is used, height = 3 rows)
maxRows = highestRow;
console.log('🔍 AllDayManager: Height calculation FIXED', {
totalEvents: allDayEvents.length,
highestRowFound: highestRow,
maxRows
});
}
// 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: 150,
easing: 'ease-out',
fill: 'forwards'
})
];
// Add spacer animation if spacer exists, but don't use fill: 'forwards'
if (headerSpacer) {
const root = document.documentElement;
const headerHeightStr = root.style.getPropertyValue('--header-height');
const headerHeight = parseInt(headerHeightStr);
const currentSpacerHeight = headerHeight + currentHeight;
const targetSpacerHeight = headerHeight + targetHeight;
animations.push(
headerSpacer.animate([
{ height: `${currentSpacerHeight}px` },
{ height: `${targetSpacerHeight}px` }
], {
duration: 150,
easing: 'ease-out'
// No fill: 'forwards' - let CSS calc() take over after animation
})
);
}
// 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');
});
}
/**
* Store current layouts from DOM for comparison
*/
private storeCurrentLayouts(): void {
this.currentLayouts.clear();
const container = this.getAllDayContainer();
if (!container) return;
container.querySelectorAll('swp-event').forEach(element => {
const htmlElement = element as HTMLElement;
const eventId = htmlElement.dataset.eventId;
const gridArea = htmlElement.style.gridArea;
if (eventId && gridArea) {
this.currentLayouts.set(eventId, gridArea);
}
});
console.log('📋 AllDayManager: Stored current layouts', {
count: this.currentLayouts.size,
layouts: Array.from(this.currentLayouts.entries())
});
}
/**
* Set current events and week dates (called by EventRendererManager)
*/
public setCurrentEvents(events: CalendarEvent[], weekDates: string[]): void {
this.currentAllDayEvents = events;
this.currentWeekDates = weekDates;
console.log('📝 AllDayManager: Set current events', {
eventCount: events.length,
weekDatesCount: weekDates.length
});
}
/**
* Calculate layout for ALL all-day events using AllDayLayoutEngine
* This is the correct method that processes all events together for proper overlap detection
*/
public calculateAllDayEventsLayout(events: CalendarEvent[], weekDates: string[]): Map<string, {
startColumn: number;
endColumn: number;
row: number;
columnSpan: number;
gridArea: string;
}> {
console.log('🔍 AllDayManager: calculateAllDayEventsLayout - Processing all events together', {
eventCount: events.length,
events: events.map(e => ({ id: e.id, title: e.title, start: e.start.toISOString().split('T')[0], end: e.end.toISOString().split('T')[0] })),
weekDates
});
// Store current state
this.currentAllDayEvents = events;
this.currentWeekDates = weekDates;
// Initialize layout engine with provided week dates
this.layoutEngine = new AllDayLayoutEngine(weekDates);
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
const layouts = this.layoutEngine.calculateLayout(events);
// Convert to expected return format
const result = new Map<string, {
startColumn: number;
endColumn: number;
row: number;
columnSpan: number;
gridArea: string;
}>();
layouts.forEach((layout, eventId) => {
result.set(eventId, {
startColumn: layout.startColumn,
endColumn: layout.endColumn,
row: layout.row,
columnSpan: layout.columnSpan,
gridArea: layout.gridArea
});
console.log('✅ AllDayManager: Calculated layout for event', {
eventId,
title: events.find(e => e.id === eventId)?.title,
gridArea: layout.gridArea,
layout: layout
});
});
return result;
}
/**
* Handle conversion of timed event to all-day event - SIMPLIFIED
* During drag: Place in row 1 only, calculate column from targetDate
*/
private handleConvertToAllDay(targetDate: string, cloneElement: HTMLElement): void {
console.log('🔄 AllDayManager: Converting to all-day (row 1 only during drag)', {
eventId: cloneElement.dataset.eventId,
targetDate
});
// Get all-day container, request creation if needed
let allDayContainer = this.getAllDayContainer();
2025-09-16 23:09:56 +02:00
// Calculate target column from targetDate using ColumnDetectionUtils
const targetColumn = ColumnDetectionUtils.getColumnIndexFromDate(targetDate);
cloneElement.removeAttribute('style');
cloneElement.classList.add('all-day-style');
cloneElement.style.gridRow = '1';
cloneElement.style.gridColumn = targetColumn.toString();
cloneElement.dataset.allday = 'true'; // Set the all-day attribute for filtering
// Add to container
allDayContainer?.appendChild(cloneElement);
console.log('✅ AllDayManager: Converted to all-day style (simple row 1)', {
eventId: cloneElement.dataset.eventId,
gridColumn: targetColumn,
gridRow: 1
});
}
/**
* Handle drag start for all-day events
*/
private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: DragOffset): 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 - SPECIALIZED FOR ALL-DAY CONTAINER
*/
private handleColumnChange(dragClone: HTMLElement, mousePosition: MousePosition): void {
// Get the all-day container to understand its grid structure
const allDayContainer = this.getAllDayContainer();
if (!allDayContainer) return;
// Calculate target column using ColumnDetectionUtils
const targetColumn = ColumnDetectionUtils.getColumnIndexFromX(mousePosition.x);
// Update clone position - ALWAYS keep in row 1 during drag
// Use simple grid positioning that matches all-day container structure
dragClone.style.gridColumn = targetColumn.toString();
dragClone.style.gridRow = '1'; // Force row 1 during drag
dragClone.style.gridArea = `1 / ${targetColumn} / 2 / ${targetColumn + 1}`;
console.log('🔄 AllDayManager: Updated all-day drag clone position', {
eventId: dragClone.dataset.eventId,
targetColumn,
gridRow: 1,
gridArea: dragClone.style.gridArea,
mouseX: mousePosition.x
});
}
/**
* Handle drag end for all-day events - WITH DIFFERENTIAL UPDATES
*/
private handleDragEnd(originalElement: HTMLElement, dragClone: HTMLElement, finalPosition: { column: string; y: number }): void {
console.log('🎯 AllDayManager: Starting drag end with differential updates', {
eventId: dragClone.dataset.eventId,
finalColumn: finalPosition.column
});
// 1. Store current layouts BEFORE any changes
this.storeCurrentLayouts();
// 2. Normalize clone ID
const cloneId = dragClone.dataset.eventId;
if (cloneId?.startsWith('clone-')) {
dragClone.dataset.eventId = cloneId.replace('clone-', '');
}
// 3. Create temporary array with existing events + the dropped event
const droppedEventId = dragClone.dataset.eventId || '';
const droppedEventDate = dragClone.dataset.allDayDate || finalPosition.column;
const droppedEvent: CalendarEvent = {
id: droppedEventId,
title: dragClone.dataset.title || dragClone.textContent || '',
start: new Date(droppedEventDate),
end: new Date(droppedEventDate),
type: 'work',
allDay: true,
syncStatus: 'synced'
};
// Use current events + dropped event for calculation
const tempEvents = [...this.currentAllDayEvents, droppedEvent];
// 4. Calculate new layouts for ALL events
const newLayouts = this.calculateAllDayEventsLayout(tempEvents, this.currentWeekDates);
// 5. Apply differential updates - only update events that changed
let changedCount = 0;
newLayouts.forEach((layout, eventId) => {
const oldGridArea = this.currentLayouts.get(eventId);
const newGridArea = layout.gridArea;
if (oldGridArea !== newGridArea) {
changedCount++;
const element = document.querySelector(`[data-event-id="${eventId}"]`) as HTMLElement;
if (element) {
console.log('🔄 AllDayManager: Updating event position', {
eventId,
oldGridArea,
newGridArea
});
// Add transition class for smooth animation
element.classList.add('transitioning');
element.style.gridArea = newGridArea;
element.style.gridRow = layout.row.toString();
element.style.gridColumn = `${layout.startColumn} / ${layout.endColumn + 1}`;
// Remove transition class after animation
setTimeout(() => element.classList.remove('transitioning'), 200);
}
}
});
// 6. Clean up drag styles from the dropped clone
dragClone.classList.remove('dragging');
dragClone.style.zIndex = '';
dragClone.style.cursor = '';
dragClone.style.opacity = '';
// 7. Restore original element opacity
originalElement.style.opacity = '';
// 8. Check if height adjustment is needed
this.checkAndAnimateAllDayHeight();
console.log('✅ AllDayManager: Completed differential drag end', {
eventId: droppedEventId,
totalEvents: newLayouts.size,
changedEvents: changedCount,
finalGridArea: newLayouts.get(droppedEventId)?.gridArea
});
}
/**
* Get existing all-day events from DOM
* Since we don't have direct access to EventManager, we'll get events from the current DOM
*/
private getExistingAllDayEvents(): CalendarEvent[] {
const allDayContainer = this.getAllDayContainer();
if (!allDayContainer) {
return [];
}
const existingElements = allDayContainer.querySelectorAll('swp-event');
const events: CalendarEvent[] = [];
existingElements.forEach(element => {
const htmlElement = element as HTMLElement;
const eventId = htmlElement.dataset.eventId;
const title = htmlElement.dataset.title || htmlElement.textContent || '';
const allDayDate = htmlElement.dataset.allDayDate;
if (eventId && allDayDate) {
events.push({
id: eventId,
title: title,
start: new Date(allDayDate),
end: new Date(allDayDate),
type: 'work',
allDay: true,
syncStatus: 'synced'
});
}
});
return events;
}
private getVisibleDatesFromDOM(): string[] {
const dayHeaders = document.querySelectorAll('swp-calendar-header swp-day-header');
const weekDates: string[] = [];
dayHeaders.forEach(header => {
const dateAttr = header.getAttribute('data-date');
if (dateAttr) {
weekDates.push(dateAttr);
}
});
return weekDates;
}
}