import { eventBus } from '../core/EventBus'; import { CoreEvents } from '../constants/CoreEvents'; import { CalendarConfig } from '../core/CalendarConfig'; import { ResizeEndEventPayload } from '../types/EventTypes'; type SwpEventEl = HTMLElement & { updateHeight?: (h: number) => void }; export class ResizeHandleManager { private cachedEvents: SwpEventEl[] = []; private isResizing = false; private targetEl: SwpEventEl | null = null; // Resize zone tracking (like DragDropManager hover tracking) private isResizeZoneTrackingActive = false; private currentTrackedEvent: SwpEventEl | null = null; private startY = 0; private startDurationMin = 0; private direction: 'grow' | 'shrink' = 'grow'; private hourHeightPx: number; private snapMin: number; private minDurationMin: number; private animationId: number | null = null; private currentHeight = 0; private targetHeight = 0; // cleanup private unsubscribers: Array<() => void> = []; private pointerCaptured = false; private prevZ?: string; private config: CalendarConfig; constructor(config: CalendarConfig) { this.config = config; const grid = this.config.getGridSettings(); this.hourHeightPx = grid.hourHeight; this.snapMin = grid.snapInterval; this.minDurationMin = this.snapMin; // Use snap interval as minimum duration } public initialize(): void { this.refreshEventCache(); this.attachHandles(); this.attachGlobalListeners(); this.subToBus(); } public destroy(): void { document.removeEventListener('pointerdown', this.onPointerDown, true); document.removeEventListener('pointermove', this.onPointerMove, true); document.removeEventListener('pointerup', this.onPointerUp, true); this.unsubscribers.forEach(u => u()); } private minutesPerPx(): number { return 60 / this.hourHeightPx; } private pxFromMinutes(min: number): number { return (min / 60) * this.hourHeightPx; } private roundSnap(min: number, dir: 'grow' | 'shrink'): number { const q = min / this.snapMin; return (dir === 'grow' ? Math.ceil(q) : Math.floor(q)) * this.snapMin; } private refreshEventCache(): void { this.cachedEvents = Array.from( document.querySelectorAll('swp-day-columns swp-event') ); } private attachHandles(): void { // ensure a single handle per event this.cachedEvents.forEach(el => { if (!el.querySelector(':scope > swp-resize-handle')) { const handle = document.createElement('swp-resize-handle'); handle.setAttribute('aria-label', 'Resize event'); handle.setAttribute('role', 'separator'); el.appendChild(handle); } }); } private attachGlobalListeners(): void { // Use same pattern as DragDropManager - mouseenter to activate tracking const calendarContainer = document.querySelector('swp-calendar-container'); if (calendarContainer) { calendarContainer.addEventListener('mouseenter', (e) => { const target = e.target as HTMLElement; const eventElement = target.closest('swp-event'); if (eventElement && !this.isResizing) { this.isResizeZoneTrackingActive = true; this.currentTrackedEvent = eventElement; } }, true); // Capture phase } document.addEventListener('pointerdown', this.onPointerDown, true); document.addEventListener('pointermove', this.onPointerMove, true); document.addEventListener('pointerup', this.onPointerUp, true); } private subToBus(): void { const sub = (ev: string, fn: () => void) => { eventBus.on(ev, fn); this.unsubscribers.push(() => eventBus.off(ev, fn)); }; const refresh = () => { this.refreshEventCache(); this.attachHandles(); }; [CoreEvents.GRID_RENDERED, CoreEvents.EVENTS_RENDERED, CoreEvents.EVENT_CREATED, CoreEvents.EVENT_UPDATED, CoreEvents.EVENT_DELETED].forEach(ev => sub(ev, refresh)); } private checkResizeZone(e: PointerEvent): void { if (!this.isResizeZoneTrackingActive || !this.currentTrackedEvent || this.isResizing) return; const rect = this.currentTrackedEvent.getBoundingClientRect(); const mouseX = e.clientX; const mouseY = e.clientY; // Check if mouse is still within event bounds const isInBounds = mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom; if (!isInBounds) { // Mouse left event - deactivate tracking this.hideResizeIndicator(this.currentTrackedEvent); this.isResizeZoneTrackingActive = false; this.currentTrackedEvent = null; return; } // Check if in resize zone (bottom 15px) const distanceFromBottom = rect.bottom - mouseY; const isInResizeZone = distanceFromBottom >= 0 && distanceFromBottom <= 15; if (isInResizeZone) { this.showResizeIndicator(this.currentTrackedEvent); } else { this.hideResizeIndicator(this.currentTrackedEvent); } } private showResizeIndicator(el: SwpEventEl): void { el.setAttribute('data-resize-hover', 'true'); } private hideResizeIndicator(el: SwpEventEl): void { el.removeAttribute('data-resize-hover'); } private onPointerDown = (e: PointerEvent) => { const handle = (e.target as HTMLElement).closest('swp-resize-handle'); if (!handle) return; const el = handle.parentElement as SwpEventEl; this.targetEl = el; this.isResizing = true; this.startY = e.clientY; // udled start-varighed fra højde const startHeight = el.offsetHeight; this.startDurationMin = Math.max( this.minDurationMin, Math.round(startHeight * this.minutesPerPx()) ); this.prevZ = (el.closest('swp-event-group') ?? el).style.zIndex; (el.closest('swp-event-group') ?? el).style.zIndex = '1000'; (e.target as Element).setPointerCapture?.(e.pointerId); this.pointerCaptured = true; document.documentElement.classList.add('swp--resizing'); e.preventDefault(); }; private onPointerMove = (e: PointerEvent) => { // Check resize zone if not resizing if (!this.isResizing) { this.checkResizeZone(e); return; } // Continue with resize logic if (!this.targetEl) return; const dy = e.clientY - this.startY; this.direction = dy >= 0 ? 'grow' : 'shrink'; // Calculate raw height from pixel delta (no snapping - 100% smooth like drag & drop) const startHeight = this.pxFromMinutes(this.startDurationMin); const rawHeight = startHeight + dy; const minHeight = this.pxFromMinutes(this.minDurationMin); this.targetHeight = Math.max(minHeight, rawHeight); // Raw height, no snap if (this.animationId == null) { this.currentHeight = this.targetEl.offsetHeight; this.animate(); } }; private animate = () => { if (!this.isResizing || !this.targetEl) { this.animationId = null; return; } const diff = this.targetHeight - this.currentHeight; if (Math.abs(diff) > 0.5) { this.currentHeight += diff * 0.35; this.targetEl.updateHeight?.(this.currentHeight); this.animationId = requestAnimationFrame(this.animate); } else { this.currentHeight = this.targetHeight; this.targetEl.updateHeight?.(this.currentHeight); this.animationId = null; } }; private onPointerUp = (e: PointerEvent) => { if (!this.isResizing || !this.targetEl) return; if (this.animationId != null) cancelAnimationFrame(this.animationId); this.animationId = null; // Snap to grid on pointer up (like DragDropManager does on mouseUp) const currentHeight = this.targetEl.offsetHeight; const snapDistancePx = this.pxFromMinutes(this.snapMin); const snappedHeight = Math.round(currentHeight / snapDistancePx) * snapDistancePx; const minHeight = this.pxFromMinutes(this.minDurationMin); const finalHeight = Math.max(minHeight, snappedHeight) - 3; // lille gap til grid-linjer this.targetEl.updateHeight?.(finalHeight); // Emit resize:end event for re-stacking const eventId = this.targetEl.dataset.eventId || ''; const resizeEndPayload: ResizeEndEventPayload = { eventId, element: this.targetEl, finalHeight }; eventBus.emit('resize:end', resizeEndPayload); const group = this.targetEl.closest('swp-event-group') ?? this.targetEl; group.style.zIndex = this.prevZ ?? ''; this.prevZ = undefined; this.isResizing = false; this.targetEl = null; if (this.pointerCaptured) { try { (e.target as Element).releasePointerCapture?.(e.pointerId); } catch {} this.pointerCaptured = false; } document.documentElement.classList.remove('swp--resizing'); this.refreshEventCache(); }; }