diff --git a/src/managers/ResizeHandleManager.ts b/src/managers/ResizeHandleManager.ts index 82c34ab..5d4a18c 100644 --- a/src/managers/ResizeHandleManager.ts +++ b/src/managers/ResizeHandleManager.ts @@ -10,48 +10,52 @@ 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 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: Configuration; - private positionUtils: PositionUtils; + + // Constants for better maintainability + private readonly ANIMATION_SPEED = 0.35; + private readonly Z_INDEX_RESIZING = '1000'; + private readonly EVENT_REFRESH_THRESHOLD = 0.5; - constructor(config: Configuration, positionUtils: PositionUtils) { - this.config = config; - this.positionUtils = positionUtils; + constructor( + private config: Configuration, + private positionUtils: PositionUtils + ) { const grid = this.config.gridSettings; this.snapMin = grid.snapInterval; - this.minDurationMin = this.snapMin; // Use snap interval as minimum duration + this.minDurationMin = this.snapMin; } public initialize(): void { this.refreshEventCache(); this.attachHandles(); this.attachGlobalListeners(); - this.subToBus(); + 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); - this.unsubscribers.forEach(u => u()); } private refreshEventCache(): void { @@ -61,189 +65,202 @@ export class ResizeHandleManager { } 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); + 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 { - // 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)); + 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(); }; - 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)); + + eventsToRefresh.forEach(event => { + eventBus.on(event, refresh); + this.unsubscribers.push(() => eventBus.off(event, 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) => { + private onPointerDown = (e: PointerEvent): void => { 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; + const element = handle.parentElement as SwpEventEl; + this.startResizing(element, e); + }; - // udled start-varighed fra højde - const startHeight = el.offsetHeight; + 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.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; + this.setZIndexForResizing(element); + this.capturePointer(event); document.documentElement.classList.add('swp--resizing'); - e.preventDefault(); + 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 onPointerMove = (e: PointerEvent) => { - // Check resize zone if not resizing - if (!this.isResizing) { - this.checkResizeZone(e); + 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; } - // 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.positionUtils.minutesToPixels(this.startDurationMin); - const rawHeight = startHeight + dy; - const minHeight = this.positionUtils.minutesToPixels(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; + + 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.currentHeight = this.targetHeight; - this.targetEl.updateHeight?.(this.currentHeight); - this.animationId = null; + this.finalizeAnimation(); } }; - private onPointerUp = (e: PointerEvent) => { + 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; - if (this.animationId != null) cancelAnimationFrame(this.animationId); - this.animationId = null; + 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; - // Snap to grid on pointer up (like DragDropManager does on mouseUp) 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; // lille gap til grid-linjer + const finalHeight = Math.max(minHeight, snappedHeight) - 3; // Small gap to grid lines this.targetEl.updateHeight?.(finalHeight); + } + + private emitResizeEndEvent(): void { + if (!this.targetEl) return; - // Emit resize:end event for re-stacking const eventId = this.targetEl.dataset.eventId || ''; const resizeEndPayload: IResizeEndEventPayload = { eventId, element: this.targetEl, - finalHeight + finalHeight: this.targetEl.offsetHeight }; + eventBus.emit('resize:end', resizeEndPayload); + } - const group = this.targetEl.closest('swp-event-group') ?? this.targetEl; - group.style.zIndex = this.prevZ ?? ''; - this.prevZ = undefined; - + private cleanupResizing(event: PointerEvent): void { + this.restoreZIndex(); + this.releasePointer(event); + 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(); //TODO: We should avoid this caching. - }; -} + 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); + } + } +} \ No newline at end of file diff --git a/wwwroot/css/calendar-events-css.css b/wwwroot/css/calendar-events-css.css index 4c80594..5feab37 100644 --- a/wwwroot/css/calendar-events-css.css +++ b/wwwroot/css/calendar-events-css.css @@ -109,8 +109,7 @@ swp-resize-handle { } /* Show handle on hover */ -swp-day-columns swp-event:hover swp-resize-handle, -swp-day-columns swp-event[data-resize-hover="true"] swp-resize-handle { +swp-day-columns swp-event:hover swp-resize-handle { opacity: 1; } @@ -127,11 +126,6 @@ swp-resize-handle::before { 0 0 4px rgba(0, 0, 0, 0.2); } -swp-day-columns swp-event[data-resize-hover="true"] { - cursor: ns-resize; - overflow: visible; -} - /* Global resizing state */ .swp--resizing { user-select: none !important;