Calendar/src/managers/AllDayManager.ts
Janus C. H. Knudsen 6ccc071587 Refactors drag and drop column detection
Improves drag and drop functionality by refactoring column detection to use column bounds instead of dates.
This change enhances the accuracy and efficiency of determining the target column during drag operations.
It also removes redundant code and simplifies the logic in both the DragDropManager and AllDayManager.
2025-09-28 13:25:09 +02:00

494 lines
No EOL
17 KiB
TypeScript

// 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, EventLayout } from '../utils/AllDayLayoutEngine';
import { ColumnBounds, 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 { targetColumn: targetColumnBounds, mousePosition, originalElement, cloneElement } = (event as CustomEvent<DragMouseEnterHeaderEventPayload>).detail;
console.log('🔄 AllDayManager: Received drag:mouseenter-header', {
targetDate: targetColumnBounds,
originalElementId: originalElement?.dataset?.eventId,
originalElementTag: originalElement?.tagName
});
if (targetColumnBounds && cloneElement) {
this.handleConvertToAllDay(targetColumnBounds, 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, draggedClone, 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, draggedClone, mousePosition } = (event as CustomEvent<DragColumnChangeEventPayload>).detail;
if (draggedClone == null)
return;
// Filter: Only handle events where clone IS an all-day event
if (!draggedClone.hasAttribute('data-allday')) {
return; // This is not an all-day event, let EventRendererManager handle it
}
console.log('🔄 AllDayManager: Handling drag:column-change for all-day event', {
eventId: draggedElement.dataset.eventId,
cloneId: draggedClone.dataset.eventId
});
this.handleColumnChange(draggedClone, mousePosition);
});
eventBus.on('drag:end', (event) => {
let draggedElement: DragEndEventPayload = (event as CustomEvent<DragEndEventPayload>).detail;
if (draggedElement.target != 'swp-day-header') // we are not inside the swp-day-header, so just ignore.
return;
this.handleDragEnd(draggedElement);
});
// 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[]): EventLayout[] {
// 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
return this.layoutEngine.calculateLayout(events);
}
/**
* Handle conversion of timed event to all-day event - SIMPLIFIED
* During drag: Place in row 1 only, calculate column from targetDate
*/
private handleConvertToAllDay(targetColumnBounds: ColumnBounds, cloneElement: HTMLElement): void {
console.log('🔄 AllDayManager: Converting to all-day (row 1 only during drag)', {
eventId: cloneElement.dataset.eventId,
targetDate: targetColumnBounds
});
// Get all-day container, request creation if needed
let allDayContainer = this.getAllDayContainer();
cloneElement.removeAttribute('style');
cloneElement.classList.add('all-day-style');
cloneElement.style.gridRow = '1';
cloneElement.style.gridColumn = targetColumnBounds.index.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: targetColumnBounds,
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.getColumnBounds(mousePosition);
if (targetColumn == null)
return;
// Update clone position - ALWAYS keep in row 1 during drag
// Use simple grid positioning that matches all-day container structure
dragClone.style.gridColumn = targetColumn.index.toString();
dragClone.style.gridRow = '1'; // Force row 1 during drag
dragClone.style.gridArea = `1 / ${targetColumn.index} / 2 / ${targetColumn.index + 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(dragEndEvent: DragEndEventPayload): void {
console.log('🎯 AllDayManager: Starting drag end with differential updates', {
dragEndEvent
});
if (dragEndEvent.draggedClone == null)
return;
// 1. Store current layouts BEFORE any changes
this.storeCurrentLayouts();
// 2. Normalize clone ID
const cloneId = dragEndEvent.draggedClone?.dataset.eventId;
if (cloneId?.startsWith('clone-')) {
dragEndEvent.draggedClone.dataset.eventId = cloneId.replace('clone-', '');
}
// 3. Create temporary array with existing events + the dropped event
let eventId = dragEndEvent.draggedClone.dataset.eventId;
let eventDate = dragEndEvent.finalPosition.column?.date;
let eventType = dragEndEvent.draggedClone.dataset.type;
if (eventDate == null || eventId == null || eventType == null)
return;
const droppedEvent: CalendarEvent = {
id: eventId,
title: dragEndEvent.draggedClone.dataset.title || dragEndEvent.draggedClone.textContent || '',
start: new Date(eventDate),
end: new Date(eventDate),
type: eventType,
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) => {
const oldGridArea = this.currentLayouts.get(layout.calenderEvent.id);
const newGridArea = layout.gridArea;
if (oldGridArea !== newGridArea) {
changedCount++;
const element = document.querySelector(`[data-event-id="${layout.calenderEvent.id}"]`) as HTMLElement;
if (element) {
// 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
dragEndEvent.draggedClone.classList.remove('dragging');
dragEndEvent.draggedClone.style.zIndex = '';
dragEndEvent.draggedClone.style.cursor = '';
dragEndEvent.draggedClone.style.opacity = '';
// 7. Restore original element opacity
//originalElement.style.opacity = '';
// 8. Check if height adjustment is needed
this.checkAndAnimateAllDayHeight();
}
}