Refactors all-day event height calculation to use the `currentLayouts` array, ensuring more accurate and reliable height adjustments. This avoids querying the DOM directly and relies on the existing layout data for improved performance and correctness.
584 lines
No EOL
19 KiB
TypeScript
584 lines
No EOL
19 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: EventLayout[] = [];
|
|
private currentAllDayEvents: CalendarEvent[] = [];
|
|
private currentWeekDates: string[] = [];
|
|
private newLayouts: EventLayout[] = [];
|
|
|
|
// Expand/collapse state
|
|
private isExpanded: boolean = false;
|
|
private actualRowCount: number = 0;
|
|
private readonly MAX_COLLAPSED_ROWS = 4; // Show 4 rows when collapsed (3 events + 1 indicator row)
|
|
|
|
|
|
constructor() {
|
|
this.allDayEventRenderer = new AllDayEventRenderer();
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
/**
|
|
* Setup event listeners for drag conversions
|
|
*/
|
|
private setupEventListeners(): void {
|
|
eventBus.on('drag:mouseenter-header', (event) => {
|
|
const payload = (event as CustomEvent<DragMouseEnterHeaderEventPayload>).detail;
|
|
|
|
console.log('🔄 AllDayManager: Received drag:mouseenter-header', {
|
|
targetDate: payload.targetColumn,
|
|
originalElementId: payload.originalElement?.dataset?.eventId,
|
|
originalElementTag: payload.originalElement?.tagName
|
|
});
|
|
|
|
this.handleConvertToAllDay(payload);
|
|
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 { originalElement: 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 {
|
|
// Calculate required rows - 0 if no events (will collapse)
|
|
let maxRows = 0;
|
|
|
|
if (this.currentLayouts.length > 0) {
|
|
// Find the HIGHEST row number in use from currentLayouts
|
|
let highestRow = 0;
|
|
|
|
this.currentLayouts.forEach((layout) => {
|
|
highestRow = Math.max(highestRow, layout.row);
|
|
});
|
|
|
|
// Max rows = highest row number (e.g. if row 3 is used, height = 3 rows)
|
|
maxRows = highestRow;
|
|
|
|
console.log('🔍 AllDayManager: Height calculation using currentLayouts', {
|
|
totalLayouts: this.currentLayouts.length,
|
|
highestRowFound: highestRow,
|
|
maxRows
|
|
});
|
|
}
|
|
|
|
// Store actual row count
|
|
this.actualRowCount = maxRows;
|
|
|
|
// Determine what to display
|
|
let displayRows = maxRows;
|
|
|
|
if (maxRows > this.MAX_COLLAPSED_ROWS) {
|
|
// Show chevron button
|
|
this.updateChevronButton(true);
|
|
|
|
// Show 4 rows when collapsed (3 events + indicators)
|
|
if (!this.isExpanded) {
|
|
|
|
displayRows = this.MAX_COLLAPSED_ROWS;
|
|
this.updateOverflowIndicators();
|
|
|
|
} else {
|
|
|
|
this.clearOverflowIndicators();
|
|
|
|
}
|
|
} else {
|
|
|
|
// Hide chevron - not needed
|
|
this.updateChevronButton(false);
|
|
this.clearOverflowIndicators();
|
|
}
|
|
|
|
// Animate to required rows (0 = collapse, >0 = expand)
|
|
this.animateToRows(displayRows);
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* 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
|
|
var layoutEngine = new AllDayLayoutEngine(weekDates);
|
|
|
|
// Calculate layout for all events together - AllDayLayoutEngine handles CalendarEvents directly
|
|
return 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(payload: DragMouseEnterHeaderEventPayload): void {
|
|
|
|
if (payload.draggedClone?.dataset == null)
|
|
console.error("payload.cloneElement.dataset.eventId is null");
|
|
|
|
|
|
console.log('🔄 AllDayManager: Converting to all-day (row 1 only during drag)', {
|
|
eventId: payload.draggedClone.dataset.eventId,
|
|
targetDate: payload.targetColumn
|
|
});
|
|
|
|
// Get all-day container, request creation if needed
|
|
let allDayContainer = this.getAllDayContainer();
|
|
|
|
|
|
payload.draggedClone.removeAttribute('style');
|
|
payload.draggedClone.style.gridRow = '1';
|
|
payload.draggedClone.style.gridColumn = payload.targetColumn.index.toString();
|
|
payload.draggedClone.dataset.allday = 'true'; // Set the all-day attribute for filtering
|
|
|
|
// Add to container
|
|
allDayContainer?.appendChild(payload.draggedClone);
|
|
|
|
ColumnDetectionUtils.updateColumnBoundsCache();
|
|
|
|
}
|
|
|
|
|
|
/**
|
|
* 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}`;
|
|
|
|
}
|
|
|
|
/**
|
|
* Handle drag end for all-day events - WITH DIFFERENTIAL UPDATES
|
|
*/
|
|
private handleDragEnd(dragEndEvent: DragEndEventPayload): void {
|
|
|
|
if (dragEndEvent.draggedClone == null)
|
|
return;
|
|
|
|
|
|
// 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
|
|
this.newLayouts = this.calculateAllDayEventsLayout(tempEvents, this.currentWeekDates);
|
|
|
|
// 5. Apply differential updates - only update events that changed
|
|
let changedCount = 0;
|
|
this.newLayouts.forEach((layout) => {
|
|
// Find current layout for this event
|
|
var currentLayout = this.currentLayouts.find(old => old.calenderEvent.id === layout.calenderEvent.id);
|
|
var currentGridArea = currentLayout?.gridArea;
|
|
var newGridArea = layout.gridArea;
|
|
|
|
if (currentGridArea !== newGridArea) {
|
|
changedCount++;
|
|
const element = dragEndEvent.draggedClone;
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (changedCount > 0)
|
|
this.currentLayouts = this.newLayouts;
|
|
|
|
// 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
|
|
dragEndEvent.originalElement.remove(); //TODO: this should be an event that only fade and remove if confirmed dragdrop
|
|
|
|
// 8. Check if height adjustment is needed
|
|
this.checkAndAnimateAllDayHeight();
|
|
|
|
}
|
|
|
|
/**
|
|
* Update chevron button visibility and state
|
|
*/
|
|
private updateChevronButton(show: boolean): void {
|
|
const headerSpacer = this.getHeaderSpacer();
|
|
if (!headerSpacer) return;
|
|
|
|
let chevron = headerSpacer.querySelector('.allday-chevron') as HTMLElement;
|
|
|
|
if (show && !chevron) {
|
|
// Create chevron button
|
|
chevron = document.createElement('button');
|
|
chevron.className = 'allday-chevron collapsed';
|
|
chevron.innerHTML = `
|
|
<svg width="12" height="8" viewBox="0 0 12 8">
|
|
<path d="M1 1.5L6 6.5L11 1.5" stroke="currentColor" stroke-width="2" fill="none"/>
|
|
</svg>
|
|
`;
|
|
chevron.onclick = () => this.toggleExpanded();
|
|
headerSpacer.appendChild(chevron);
|
|
} else if (!show && chevron) {
|
|
// Remove chevron button
|
|
chevron.remove();
|
|
} else if (chevron) {
|
|
// Update chevron state
|
|
chevron.classList.toggle('collapsed', !this.isExpanded);
|
|
chevron.classList.toggle('expanded', this.isExpanded);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle between expanded and collapsed state
|
|
*/
|
|
private toggleExpanded(): void {
|
|
this.isExpanded = !this.isExpanded;
|
|
this.checkAndAnimateAllDayHeight();
|
|
}
|
|
|
|
/**
|
|
* Update overflow indicators for collapsed state
|
|
*/
|
|
private updateOverflowIndicators(): void {
|
|
const container = this.getAllDayContainer();
|
|
if (!container) return;
|
|
|
|
container.querySelectorAll('swp-event').forEach((element) => {
|
|
const event = element as HTMLElement;
|
|
const gridRow = parseInt(event.style.gridRow) || 1;
|
|
|
|
if (gridRow === 4) {
|
|
// Store original content before converting to indicator
|
|
if (!event.dataset.originalTitle) {
|
|
event.dataset.originalTitle = event.dataset.title || event.innerHTML;
|
|
}
|
|
|
|
// Convert row 4 events to indicators
|
|
const overflowCount = this.actualRowCount - 3; // Total overflow rows
|
|
event.classList.add('max-event-overflow');
|
|
event.innerHTML = `<span>+${overflowCount} more</span>`;
|
|
event.onclick = (e) => {
|
|
e.stopPropagation();
|
|
this.toggleExpanded();
|
|
};
|
|
} else if (gridRow > 4) {
|
|
// Hide events beyond row 4
|
|
event.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear overflow indicators and restore normal state
|
|
*/
|
|
private clearOverflowIndicators(): void {
|
|
const container = this.getAllDayContainer();
|
|
if (!container) return;
|
|
|
|
container.querySelectorAll('.max-event-overflow').forEach((event) => {
|
|
const htmlEvent = event as HTMLElement;
|
|
htmlEvent.classList.remove('max-event-overflow');
|
|
htmlEvent.onclick = null;
|
|
|
|
// Restore original title from data-title
|
|
if (htmlEvent.dataset.title) {
|
|
htmlEvent.innerHTML = htmlEvent.dataset.title;
|
|
} else if (htmlEvent.dataset.originalTitle) {
|
|
htmlEvent.innerHTML = htmlEvent.dataset.originalTitle;
|
|
}
|
|
});
|
|
|
|
// Show all hidden events
|
|
container.querySelectorAll('swp-event[style*="display: none"]').forEach((event) => {
|
|
(event as HTMLElement).style.display = '';
|
|
});
|
|
}
|
|
|
|
} |