Implements drag cancellation when the mouse leaves the calendar container during a drag operation. This prevents orphaned drag clones and restores the original event's state, enhancing user experience. All-day events now correctly recalculate their height upon drag cancellation, ensuring accurate rendering after clone removal. Refactors HeaderManager by removing redundant caching of the calendar header element. Adds new mock event data for September and October 2025 to expand testing and demonstration scenarios.
489 lines
No EOL
16 KiB
TypeScript
489 lines
No EOL
16 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 { CalendarEvent } from '../types/CalendarTypes';
|
|
import {
|
|
DragMouseEnterHeaderEventPayload,
|
|
DragStartEventPayload,
|
|
DragMoveEventPayload,
|
|
DragEndEventPayload
|
|
} from '../types/EventTypes';
|
|
|
|
/**
|
|
* 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() {
|
|
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
|
|
});
|
|
|
|
if (cloneElement && cloneElement.classList.contains('all-day-style')) {
|
|
this.handleConvertFromAllDay(cloneElement);
|
|
}
|
|
|
|
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:move', (event) => {
|
|
const { draggedElement, mousePosition } = (event as CustomEvent<DragMoveEventPayload>).detail;
|
|
|
|
// Only handle for all-day events - check if original element is all-day
|
|
const isAllDayEvent = draggedElement.closest('swp-allday-container');
|
|
if (!isAllDayEvent) return;
|
|
|
|
const eventId = draggedElement.dataset.eventId;
|
|
const dragClone = document.querySelector(`swp-allday-container swp-event[data-event-id="clone-${eventId}"]`);
|
|
if (dragClone) {
|
|
this.handleDragMove(dragClone as HTMLElement, 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, finalPosition.column);
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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-event');
|
|
|
|
// Calculate required rows - 0 if no events (will collapse)
|
|
let maxRows = 0;
|
|
|
|
if (allDayEvents.length > 0) {
|
|
// Track which rows are actually used by checking grid positions
|
|
const usedRows = new Set<number>();
|
|
|
|
(Array.from(allDayEvents) as HTMLElement[]).forEach((event: HTMLElement) => {
|
|
const gridRow = parseInt(getComputedStyle(event).gridRowStart) || 1;
|
|
usedRows.add(gridRow);
|
|
});
|
|
|
|
// Max rows = highest row number in use
|
|
maxRows = usedRows.size > 0 ? Math.max(...usedRows) : 0;
|
|
|
|
console.log('🔍 AllDayManager: Height calculation', {
|
|
totalEvents: allDayEvents.length,
|
|
usedRows: Array.from(usedRows).sort(),
|
|
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: 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 using CSS styling
|
|
*/
|
|
private handleConvertToAllDay(targetDate: string, cloneElement: HTMLElement): void {
|
|
console.log('🔄 AllDayManager: Converting to all-day using CSS approach', {
|
|
eventId: cloneElement.dataset.eventId,
|
|
targetDate
|
|
});
|
|
|
|
// Get all-day container, request creation if needed
|
|
let allDayContainer = this.getAllDayContainer();
|
|
if (!allDayContainer) {
|
|
console.log('🔄 AllDayManager: All-day container not found, requesting creation...');
|
|
// Request HeaderManager to create container
|
|
eventBus.emit('header:ensure-allday-container');
|
|
|
|
// Try again after request
|
|
allDayContainer = this.getAllDayContainer();
|
|
if (!allDayContainer) {
|
|
console.error('All-day container still not found after creation request');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Calculate position BEFORE adding to container (to avoid counting clone as existing event)
|
|
const columnIndex = this.getColumnIndexForDate(targetDate);
|
|
const availableRow = this.findAvailableRow(targetDate);
|
|
|
|
// Set all properties BEFORE adding to DOM
|
|
cloneElement.classList.add('all-day-style');
|
|
cloneElement.style.gridColumn = columnIndex.toString();
|
|
cloneElement.style.gridRow = availableRow.toString();
|
|
cloneElement.dataset.allDayDate = targetDate;
|
|
cloneElement.style.display = '';
|
|
|
|
// NOW add to container (after all positioning is calculated)
|
|
allDayContainer.appendChild(cloneElement);
|
|
|
|
console.log('✅ AllDayManager: Converted to all-day style', {
|
|
eventId: cloneElement.dataset.eventId,
|
|
gridColumn: columnIndex,
|
|
gridRow: availableRow
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get column index for a specific date
|
|
*/
|
|
private getColumnIndexForDate(targetDate: string): number {
|
|
const dayHeaders = document.querySelectorAll('swp-day-header');
|
|
let columnIndex = 1;
|
|
dayHeaders.forEach((header, index) => {
|
|
if ((header as HTMLElement).dataset.date === targetDate) {
|
|
columnIndex = index + 1;
|
|
}
|
|
});
|
|
return columnIndex;
|
|
}
|
|
|
|
/**
|
|
* Find available row for all-day event in specific date column
|
|
*/
|
|
private findAvailableRow(targetDate: string): number {
|
|
const container = this.getAllDayContainer();
|
|
if (!container) return 1;
|
|
|
|
const columnIndex = this.getColumnIndexForDate(targetDate);
|
|
const existingEvents = container.querySelectorAll('swp-event');
|
|
const occupiedRows = new Set<number>();
|
|
|
|
existingEvents.forEach(event => {
|
|
const style = getComputedStyle(event);
|
|
const eventStartCol = parseInt(style.gridColumnStart);
|
|
const eventRow = parseInt(style.gridRowStart) || 1;
|
|
|
|
// Only check events in the same column
|
|
if (eventStartCol === columnIndex) {
|
|
occupiedRows.add(eventRow);
|
|
}
|
|
});
|
|
|
|
// Find first available row
|
|
let targetRow = 1;
|
|
while (occupiedRows.has(targetRow)) {
|
|
targetRow++;
|
|
}
|
|
|
|
return targetRow;
|
|
}
|
|
|
|
/**
|
|
* Handle conversion from all-day back to timed event
|
|
*/
|
|
private handleConvertFromAllDay(cloneElement: HTMLElement): void {
|
|
console.log('🔄 AllDayManager: Converting from all-day back to timed', {
|
|
eventId: cloneElement.dataset.eventId
|
|
});
|
|
|
|
// Remove all-day CSS class
|
|
cloneElement.classList.remove('all-day-style');
|
|
|
|
// Reset grid positioning
|
|
cloneElement.style.gridColumn = '';
|
|
cloneElement.style.gridRow = '';
|
|
|
|
// Remove all-day date attribute
|
|
delete cloneElement.dataset.allDayDate;
|
|
|
|
// Move back to appropriate day column (will be handled by drag logic)
|
|
// The drag system will position it correctly
|
|
|
|
console.log('✅ AllDayManager: Converted from all-day back to timed');
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
|
|
// 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 = '';
|
|
|
|
|
|
|
|
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();
|
|
}
|
|
} |