import { eventBus } from '../core/EventBus'; import { CoreEvents } from '../constants/CoreEvents'; import { Configuration } from '../configurations/CalendarConfig'; import { IResizeEndEventPayload } from '../types/EventTypes'; import { PositionUtils } from '../utils/PositionUtils'; type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void }; export class ResizeHandleManager { private cachedEvents: SwpEventEl[] = []; private isResizing = false; private targetEl: SwpEventEl | null = null; private startY = 0; private startDurationMin = 0; private direction: 'grow' | 'shrink' = 'grow'; private snapMin: number; private minDurationMin: number; private animationId: number | null = null; private currentHeight = 0; private targetHeight = 0; private unsubscribers: Array<() => void> = []; private pointerCaptured = false; private prevZ?: string; // Constants for better maintainability private readonly ANIMATION_SPEED = 0.35; private readonly Z_INDEX_RESIZING = '1000'; private readonly EVENT_REFRESH_THRESHOLD = 0.5; constructor( private config: Configuration, private positionUtils: PositionUtils ) { const grid = this.config.gridSettings; this.snapMin = grid.snapInterval; this.minDurationMin = this.snapMin; } public initialize(): void { this.refreshEventCache(); this.attachHandles(); this.attachGlobalListeners(); this.subscribeToEventBus(); } public destroy(): void { this.removeEventListeners(); this.unsubscribers.forEach(unsubscribe => unsubscribe()); this.unsubscribers = []; } private removeEventListeners(): void { document.removeEventListener('pointerdown', this.onPointerDown, true); document.removeEventListener('pointermove', this.onPointerMove, true); document.removeEventListener('pointerup', this.onPointerUp, true); } private refreshEventCache(): void { this.cachedEvents = Array.from( document.querySelectorAll('swp-day-columns swp-event') ); } private attachHandles(): void { this.cachedEvents.forEach(element => { if (!element.querySelector(':scope > swp-resize-handle')) { const handle = this.createResizeHandle(); element.appendChild(handle); } }); } private createResizeHandle(): HTMLElement { const handle = document.createElement('swp-resize-handle'); handle.setAttribute('aria-label', 'Resize event'); handle.setAttribute('role', 'separator'); return handle; } private attachGlobalListeners(): void { document.addEventListener('pointerdown', this.onPointerDown, true); document.addEventListener('pointermove', this.onPointerMove, true); document.addEventListener('pointerup', this.onPointerUp, true); } private subscribeToEventBus(): void { const eventsToRefresh = [ CoreEvents.GRID_RENDERED, CoreEvents.EVENTS_RENDERED, CoreEvents.EVENT_CREATED, CoreEvents.EVENT_UPDATED, CoreEvents.EVENT_DELETED ]; const refresh = () => { this.refreshEventCache(); this.attachHandles(); }; eventsToRefresh.forEach(event => { eventBus.on(event, refresh); this.unsubscribers.push(() => eventBus.off(event, refresh)); }); } private onPointerDown = (e: PointerEvent): void => { const handle = (e.target as HTMLElement).closest('swp-resize-handle'); if (!handle) return; const element = handle.parentElement as SwpEventEl; this.startResizing(element, e); }; private startResizing(element: SwpEventEl, event: PointerEvent): void { this.targetEl = element; this.isResizing = true; this.startY = event.clientY; const startHeight = element.offsetHeight; this.startDurationMin = Math.max( this.minDurationMin, Math.round(this.positionUtils.pixelsToMinutes(startHeight)) ); this.setZIndexForResizing(element); this.capturePointer(event); document.documentElement.classList.add('swp--resizing'); event.preventDefault(); } private setZIndexForResizing(element: SwpEventEl): void { const container = element.closest('swp-event-group') ?? element; this.prevZ = container.style.zIndex; container.style.zIndex = this.Z_INDEX_RESIZING; } private capturePointer(event: PointerEvent): void { try { (event.target as Element).setPointerCapture?.(event.pointerId); this.pointerCaptured = true; } catch (error) { console.warn('Pointer capture failed:', error); } } private onPointerMove = (e: PointerEvent): void => { if (!this.isResizing || !this.targetEl) return; this.updateResizeHeight(e.clientY); }; private updateResizeHeight(currentY: number): void { const deltaY = currentY - this.startY; this.direction = deltaY >= 0 ? 'grow' : 'shrink'; const startHeight = this.positionUtils.minutesToPixels(this.startDurationMin); const rawHeight = startHeight + deltaY; const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin); this.targetHeight = Math.max(minHeight, rawHeight); if (this.animationId == null) { this.currentHeight = this.targetEl?.offsetHeight!!; this.animate(); } } private animate = (): void => { if (!this.isResizing || !this.targetEl) { this.animationId = null; return; } const diff = this.targetHeight - this.currentHeight; if (Math.abs(diff) > this.EVENT_REFRESH_THRESHOLD) { this.currentHeight += diff * this.ANIMATION_SPEED; this.targetEl.updateHeight?.(this.currentHeight); this.animationId = requestAnimationFrame(this.animate); } else { this.finalizeAnimation(); } }; private finalizeAnimation(): void { if (!this.targetEl) return; this.currentHeight = this.targetHeight; this.targetEl.updateHeight?.(this.currentHeight); this.animationId = null; } private onPointerUp = (e: PointerEvent): void => { if (!this.isResizing || !this.targetEl) return; this.cleanupAnimation(); this.snapToGrid(); this.emitResizeEndEvent(); this.cleanupResizing(e); }; private cleanupAnimation(): void { if (this.animationId != null) { cancelAnimationFrame(this.animationId); this.animationId = null; } } private snapToGrid(): void { if (!this.targetEl) return; const currentHeight = this.targetEl.offsetHeight; const snapDistancePx = this.positionUtils.minutesToPixels(this.snapMin); const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx; const minHeight = this.positionUtils.minutesToPixels(this.minDurationMin); const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines this.targetEl.updateHeight?.(finalHeight); } private emitResizeEndEvent(): void { if (!this.targetEl) return; const eventId = this.targetEl.dataset.eventId || ''; const resizeEndPayload: IResizeEndEventPayload = { eventId, element: this.targetEl, finalHeight: this.targetEl.offsetHeight }; eventBus.emit('resize:end', resizeEndPayload); } private cleanupResizing(event: PointerEvent): void { this.restoreZIndex(); this.releasePointer(event); this.isResizing = false; this.targetEl = null; document.documentElement.classList.remove('swp--resizing'); this.refreshEventCache(); // TODO: Optimize to avoid full cache refresh } private restoreZIndex(): void { if (!this.targetEl || this.prevZ === undefined) return; const container = this.targetEl.closest('swp-event-group') ?? this.targetEl; container.style.zIndex = this.prevZ; this.prevZ = undefined; } private releasePointer(event: PointerEvent): void { if (!this.pointerCaptured) return; try { (event.target as Element).releasePointerCapture?.(event.pointerId); this.pointerCaptured = false; } catch (error) { console.warn('Pointer release failed:', error); } } }