import { IEventBus } from '../types/CalendarTypes'; import { IGridConfig } from '../core/IGridConfig'; import { pixelsToMinutes, minutesToPixels, snapToGrid } from '../utils/PositionUtils'; import { DateService } from '../core/DateService'; import { CoreEvents } from '../constants/CoreEvents'; import { IResizeStartPayload, IResizeEndPayload } from '../types/ResizeTypes'; import { SwpEvent } from '../types/SwpEvent'; /** * ResizeManager - Handles resize of calendar events * * Step 1: Handle creation on mouseover (CSS handles visibility) * Step 2: Pointer events + resize start * Step 3: RAF animation for smooth height update * Step 4: Grid snapping + timestamp update */ interface ResizeState { eventId: string; element: HTMLElement; handleElement: HTMLElement; startY: number; startHeight: number; startDurationMinutes: number; pointerId: number; prevZIndex: string; // Animation state currentHeight: number; targetHeight: number; animationId: number | null; } export class ResizeManager { private container: HTMLElement | null = null; private resizeState: ResizeState | null = null; private readonly Z_INDEX_RESIZING = '1000'; private readonly ANIMATION_SPEED = 0.35; private readonly MIN_HEIGHT_MINUTES = 15; constructor( private eventBus: IEventBus, private gridConfig: IGridConfig, private dateService: DateService ) {} /** * Initialize resize functionality on container */ init(container: HTMLElement): void { this.container = container; // Mouseover listener for handle creation (capture phase like V1) container.addEventListener('mouseover', this.handleMouseOver, true); // Pointer listeners for resize (capture phase like V1) document.addEventListener('pointerdown', this.handlePointerDown, true); document.addEventListener('pointermove', this.handlePointerMove, true); document.addEventListener('pointerup', this.handlePointerUp, true); } /** * Create resize handle element */ private createResizeHandle(): HTMLElement { const handle = document.createElement('swp-resize-handle'); handle.setAttribute('aria-label', 'Resize event'); handle.setAttribute('role', 'separator'); return handle; } /** * Handle mouseover - create resize handle if not exists */ private handleMouseOver = (e: Event): void => { const target = e.target as HTMLElement; const eventElement = target.closest('swp-event') as HTMLElement; if (!eventElement || this.resizeState) return; // Check if handle already exists if (!eventElement.querySelector(':scope > swp-resize-handle')) { const handle = this.createResizeHandle(); eventElement.appendChild(handle); } }; /** * Handle pointerdown - start resize if on handle */ private handlePointerDown = (e: PointerEvent): void => { const handle = (e.target as HTMLElement).closest('swp-resize-handle') as HTMLElement; if (!handle) return; const element = handle.parentElement as HTMLElement; if (!element) return; const eventId = element.dataset.eventId || ''; const startHeight = element.offsetHeight; const startDurationMinutes = pixelsToMinutes(startHeight, this.gridConfig); // Store previous z-index const container = element.closest('swp-event-group') as HTMLElement ?? element; const prevZIndex = container.style.zIndex; // Set resize state this.resizeState = { eventId, element, handleElement: handle, startY: e.clientY, startHeight, startDurationMinutes, pointerId: e.pointerId, prevZIndex, // Animation state currentHeight: startHeight, targetHeight: startHeight, animationId: null }; // Elevate z-index container.style.zIndex = this.Z_INDEX_RESIZING; // Capture pointer for smooth tracking try { handle.setPointerCapture(e.pointerId); } catch (err) { console.warn('Pointer capture failed:', err); } // Add global resizing class document.documentElement.classList.add('swp--resizing'); // Emit resize start event this.eventBus.emit(CoreEvents.EVENT_RESIZE_START, { eventId, element, startHeight } as IResizeStartPayload); e.preventDefault(); }; /** * Handle pointermove - update target height during resize */ private handlePointerMove = (e: PointerEvent): void => { if (!this.resizeState) return; const deltaY = e.clientY - this.resizeState.startY; const minHeight = (this.MIN_HEIGHT_MINUTES / 60) * this.gridConfig.hourHeight; const newHeight = Math.max(minHeight, this.resizeState.startHeight + deltaY); // Set target height for animation this.resizeState.targetHeight = newHeight; // Start animation if not running if (this.resizeState.animationId === null) { this.animateHeight(); } }; /** * RAF animation loop for smooth height interpolation */ private animateHeight = (): void => { if (!this.resizeState) return; const diff = this.resizeState.targetHeight - this.resizeState.currentHeight; // Stop animation when close enough if (Math.abs(diff) < 0.5) { this.resizeState.animationId = null; return; } // Interpolate towards target (35% per frame like V1) this.resizeState.currentHeight += diff * this.ANIMATION_SPEED; this.resizeState.element.style.height = `${this.resizeState.currentHeight}px`; // Update timestamp display (snapped) this.updateTimestampDisplay(); // Continue animation this.resizeState.animationId = requestAnimationFrame(this.animateHeight); }; /** * Update timestamp display with snapped end time */ private updateTimestampDisplay(): void { if (!this.resizeState) return; const timeEl = this.resizeState.element.querySelector('swp-event-time'); if (!timeEl) return; // Get start time from element position const top = parseFloat(this.resizeState.element.style.top) || 0; const startMinutesFromGrid = pixelsToMinutes(top, this.gridConfig); const startMinutes = (this.gridConfig.dayStartHour * 60) + startMinutesFromGrid; // Calculate snapped end time from current height const snappedHeight = snapToGrid(this.resizeState.currentHeight, this.gridConfig); const durationMinutes = pixelsToMinutes(snappedHeight, this.gridConfig); const endMinutes = startMinutes + durationMinutes; // Format and update const start = this.minutesToDate(startMinutes); const end = this.minutesToDate(endMinutes); timeEl.textContent = this.dateService.formatTimeRange(start, end); } /** * Convert minutes since midnight to Date */ private minutesToDate(minutes: number): Date { const date = new Date(); date.setHours(Math.floor(minutes / 60) % 24, minutes % 60, 0, 0); return date; }; /** * Handle pointerup - finish resize */ private handlePointerUp = (e: PointerEvent): void => { if (!this.resizeState) return; // Cancel any pending animation if (this.resizeState.animationId !== null) { cancelAnimationFrame(this.resizeState.animationId); } // Release pointer capture try { this.resizeState.handleElement.releasePointerCapture(e.pointerId); } catch (err) { console.warn('Pointer release failed:', err); } // Snap final height to grid this.snapToGridFinal(); // Update timestamp one final time this.updateTimestampDisplay(); // Restore z-index const container = this.resizeState.element.closest('swp-event-group') as HTMLElement ?? this.resizeState.element; container.style.zIndex = this.resizeState.prevZIndex; // Remove global resizing class document.documentElement.classList.remove('swp--resizing'); // Get columnKey and date from parent column const column = this.resizeState.element.closest('swp-day-column') as HTMLElement; const columnKey = column?.dataset.columnKey || ''; const date = column?.dataset.date || ''; // Create SwpEvent from element (reads top/height/eventId from element) const swpEvent = SwpEvent.fromElement( this.resizeState.element, columnKey, date, this.gridConfig ); // Emit resize end event this.eventBus.emit(CoreEvents.EVENT_RESIZE_END, { swpEvent } as IResizeEndPayload); // Reset state this.resizeState = null; }; /** * Snap final height to grid interval */ private snapToGridFinal(): void { if (!this.resizeState) return; const currentHeight = this.resizeState.element.offsetHeight; const snappedHeight = snapToGrid(currentHeight, this.gridConfig); const minHeight = minutesToPixels(this.MIN_HEIGHT_MINUTES, this.gridConfig); const finalHeight = Math.max(minHeight, snappedHeight); this.resizeState.element.style.height = `${finalHeight}px`; this.resizeState.currentHeight = finalHeight; } }