import { eventBus } from '../core/EventBus'; import { CoreEvents } from '../constants/CoreEvents'; import { calendarConfig } from '../core/CalendarConfig'; 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 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; constructor() { const grid = calendarConfig.getGridSettings(); this.hourHeightPx = grid.hourHeight; this.snapMin = grid.snapInterval; this.minDurationMin = grid.minEventDuration ?? this.snapMin; } public initialize(): void { this.refreshEventCache(); this.attachGlobalListeners(); this.attachHandles(); 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 { 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 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'); // fx user-select: none; cursor: ns-resize e.preventDefault(); }; private onPointerMove = (e: PointerEvent) => { if (!this.isResizing || !this.targetEl) return; const dy = e.clientY - this.startY; this.direction = dy >= 0 ? 'grow' : 'shrink'; const deltaMin = dy * this.minutesPerPx(); const rawMin = this.startDurationMin + deltaMin; const clamped = Math.max(this.minDurationMin, rawMin); const snappedMin = this.roundSnap(clamped, this.direction); this.targetHeight = this.pxFromMinutes(snappedMin); 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; // sikker slut-snap this.targetEl.updateHeight?.(this.targetHeight - 3); // lille gap til grid-linjer 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(); }; }