diff --git a/src/managers/ResizeManager.ts b/src/managers/ResizeManager.ts new file mode 100644 index 0000000..027f7c5 --- /dev/null +++ b/src/managers/ResizeManager.ts @@ -0,0 +1,264 @@ +import { calendarConfig } from '../core/CalendarConfig'; +import { eventBus } from '../core/EventBus'; +import { IEventBus } from '../types/CalendarTypes'; + +/** + * Resize state interface + */ +interface ResizeState { + element: HTMLElement; + handle: 'top' | 'bottom'; + startY: number; + originalTop: number; + originalHeight: number; + originalStartTime: Date; + originalEndTime: Date; + minHeightPx: number; +} + +/** + * ResizeManager - Handles event resizing functionality + */ +export class ResizeManager { + private resizeState: ResizeState | null = null; + private readonly MIN_EVENT_DURATION_MINUTES = 15; + + constructor(private eventBus: IEventBus) { + // Bind methods for event listeners + this.handleResize = this.handleResize.bind(this); + this.endResize = this.endResize.bind(this); + } + + /** + * Setup dynamic resize handles that are only created when needed + * @param eventElement - Event element to add resize handles to + */ + public setupResizeHandles(eventElement: HTMLElement): void { + // Variables to track resize handles + let topHandle: HTMLElement | null = null; + let bottomHandle: HTMLElement | null = null; + + console.log('Setting up dynamic resize handles for event:', eventElement.dataset.eventId); + + // Create resize handles on first mouseover + eventElement.addEventListener('mouseenter', () => { + if (!topHandle && !bottomHandle) { + topHandle = document.createElement('swp-resize-handle'); + topHandle.className = 'swp-resize-handle swp-resize-top'; + + bottomHandle = document.createElement('swp-resize-handle'); + bottomHandle.className = 'swp-resize-handle swp-resize-bottom'; + + // Add mousedown listeners for resize functionality + topHandle.addEventListener('mousedown', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.startResize(eventElement, 'top', e); + }); + + bottomHandle.addEventListener('mousedown', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.startResize(eventElement, 'bottom', e); + }); + + eventElement.appendChild(topHandle); + eventElement.appendChild(bottomHandle); + console.log('Created resize handles for event:', eventElement.dataset.eventId); + } + }); + + // Show/hide handles based on mouse position + eventElement.addEventListener('mousemove', (e: MouseEvent) => { + if (!topHandle || !bottomHandle) return; + + const rect = eventElement.getBoundingClientRect(); + const mouseY = e.clientY - rect.top; + const eventHeight = rect.height; + const topZone = eventHeight * 0.2; + const bottomZone = eventHeight * 0.8; + + // Show top handle in upper 20% + if (mouseY < topZone) { + topHandle.style.opacity = '1'; + bottomHandle.style.opacity = '0'; + } + // Show bottom handle in lower 20% + else if (mouseY > bottomZone) { + topHandle.style.opacity = '0'; + bottomHandle.style.opacity = '1'; + } + // Hide both if mouse is in middle + else { + topHandle.style.opacity = '0'; + bottomHandle.style.opacity = '0'; + } + }); + + // Hide handles when mouse leaves event (but only if not in resize mode) + eventElement.addEventListener('mouseleave', () => { + console.log('Mouse LEAVE event:', eventElement.dataset.eventId); + if (!this.resizeState && topHandle && bottomHandle) { + topHandle.style.opacity = '0'; + bottomHandle.style.opacity = '0'; + console.log('Hidden resize handles for event:', eventElement.dataset.eventId); + } + }); + } + + /** + * Start resize operation + */ + private startResize(eventElement: HTMLElement, handle: 'top' | 'bottom', e: MouseEvent): void { + const gridSettings = calendarConfig.getGridSettings(); + const minHeightPx = (this.MIN_EVENT_DURATION_MINUTES / 60) * gridSettings.hourHeight; + + this.resizeState = { + element: eventElement, + handle: handle, + startY: e.clientY, + originalTop: parseFloat(eventElement.style.top), + originalHeight: parseFloat(eventElement.style.height), + originalStartTime: new Date(eventElement.dataset.start || ''), + originalEndTime: new Date(eventElement.dataset.end || ''), + minHeightPx: minHeightPx + }; + + // Global listeners for resize + document.addEventListener('mousemove', this.handleResize); + document.addEventListener('mouseup', this.endResize); + + // Add resize cursor to body + document.body.style.cursor = handle === 'top' ? 'n-resize' : 's-resize'; + + console.log('Starting resize:', handle, 'element:', eventElement.dataset.eventId); + } + + /** + * Handle resize drag + */ + private handleResize(e: MouseEvent): void { + if (!this.resizeState) return; + + const deltaY = e.clientY - this.resizeState.startY; + const snappedDelta = this.snapToGrid(deltaY); + const gridSettings = calendarConfig.getGridSettings(); + + if (this.resizeState.handle === 'top') { + // Resize from top + const newTop = this.resizeState.originalTop + snappedDelta; + const newHeight = this.resizeState.originalHeight - snappedDelta; + + // Check minimum height + if (newHeight >= this.resizeState.minHeightPx && newTop >= 0) { + this.resizeState.element.style.top = newTop + 'px'; + this.resizeState.element.style.height = newHeight + 'px'; + + // Update times + const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60; + const newStartTime = this.addMinutes(this.resizeState.originalStartTime, minutesDelta); + this.updateEventDisplay(this.resizeState.element, newStartTime, this.resizeState.originalEndTime); + } + } else { + // Resize from bottom + const newHeight = this.resizeState.originalHeight + snappedDelta; + + // Check minimum height + if (newHeight >= this.resizeState.minHeightPx) { + this.resizeState.element.style.height = newHeight + 'px'; + + // Update times + const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60; + const newEndTime = this.addMinutes(this.resizeState.originalEndTime, minutesDelta); + this.updateEventDisplay(this.resizeState.element, this.resizeState.originalStartTime, newEndTime); + } + } + } + + /** + * End resize operation + */ + private endResize(): void { + if (!this.resizeState) return; + + // Get final times from element + const finalStart = this.resizeState.element.dataset.start; + const finalEnd = this.resizeState.element.dataset.end; + + console.log('Ending resize:', this.resizeState.element.dataset.eventId, 'New times:', finalStart, finalEnd); + + // Emit event with new times + this.eventBus.emit('event:resized', { + eventId: this.resizeState.element.dataset.eventId, + newStart: finalStart, + newEnd: finalEnd + }); + + // Cleanup + document.removeEventListener('mousemove', this.handleResize); + document.removeEventListener('mouseup', this.endResize); + document.body.style.cursor = ''; + this.resizeState = null; + } + + /** + * Snap delta to grid intervals + */ + private snapToGrid(deltaY: number): number { + const gridSettings = calendarConfig.getGridSettings(); + const snapInterval = gridSettings.snapInterval; + const hourHeight = gridSettings.hourHeight; + const snapDistancePx = (snapInterval / 60) * hourHeight; + return Math.round(deltaY / snapDistancePx) * snapDistancePx; + } + + /** + * Update event display during resize + */ + private updateEventDisplay(element: HTMLElement, startTime: Date, endTime: Date): void { + // Calculate new duration in minutes + const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60); + + // Update dataset + element.dataset.start = startTime.toISOString(); + element.dataset.end = endTime.toISOString(); + element.dataset.duration = durationMinutes.toString(); + + // Update visual time + const timeElement = element.querySelector('swp-event-time'); + if (timeElement) { + const startStr = this.formatTime(startTime.toISOString()); + const endStr = this.formatTime(endTime.toISOString()); + timeElement.textContent = `${startStr} - ${endStr}`; + } + } + + /** + * Add minutes to a date + */ + private addMinutes(date: Date, minutes: number): Date { + return new Date(date.getTime() + minutes * 60000); + } + + /** + * Format time for display + */ + private formatTime(input: Date | string): string { + let hours: number; + let minutes: number; + + if (input instanceof Date) { + hours = input.getHours(); + minutes = input.getMinutes(); + } else { + // Date or ISO string input + const date = typeof input === 'string' ? new Date(input) : input; + hours = date.getHours(); + minutes = date.getMinutes(); + } + + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours > 12 ? hours - 12 : (hours === 0 ? 12 : hours); + return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; + } +} \ No newline at end of file diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index 5ef03c8..41feb2e 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -6,20 +6,7 @@ import { DateCalculator } from '../utils/DateCalculator'; import { eventBus } from '../core/EventBus'; import { CoreEvents } from '../constants/CoreEvents'; import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector'; - -/** - * Resize state interface - */ -interface ResizeState { - element: HTMLElement; - handle: 'top' | 'bottom'; - startY: number; - originalTop: number; - originalHeight: number; - originalStartTime: Date; - originalEndTime: Date; - minHeightPx: number; -} +import { ResizeManager } from '../managers/ResizeManager'; /** * Interface for event rendering strategies @@ -39,15 +26,15 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { private draggedClone: HTMLElement | null = null; private originalEvent: HTMLElement | null = null; - // Resize state - private resizeState: ResizeState | null = null; - private readonly MIN_EVENT_DURATION_MINUTES = 30; + // Resize manager + private resizeManager: ResizeManager; constructor(dateCalculator?: DateCalculator) { if (!dateCalculator) { DateCalculator.initialize(calendarConfig); } this.dateCalculator = dateCalculator || new DateCalculator(); + this.resizeManager = new ResizeManager(eventBus); } // ============================================ @@ -259,23 +246,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { } } - /** - * Calculate event duration in minutes from element height - */ - private getEventDuration(element: HTMLElement): number { - const gridSettings = calendarConfig.getGridSettings(); - const hourHeight = gridSettings.hourHeight; - - // Get height from style or computed - let heightPx = parseInt(element.style.height) || 0; - if (!heightPx) { - const rect = element.getBoundingClientRect(); - heightPx = rect.height; - } - - return Math.round((heightPx / hourHeight) * 60); - } - /** * Unified time formatting method - handles both total minutes and Date objects */ @@ -378,7 +348,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { if (originalStackLink) { try { const stackData = JSON.parse(originalStackLink); - const stackEventIds: string[] = []; // Saml ALLE event IDs fra hele stack chain const allStackEventIds: Set = new Set(); @@ -510,46 +479,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { }); } - /** - * Handle event double-click for text selection - */ - private handleEventDoubleClick(eventElement: HTMLElement): void { - console.log('handleEventDoubleClick:', eventElement.dataset.eventId); - - // Enable text selection temporarily - eventElement.classList.add('text-selectable'); - - // Auto-select the event text - const selection = window.getSelection(); - if (selection) { - const range = document.createRange(); - range.selectNodeContents(eventElement); - selection.removeAllRanges(); - selection.addRange(range); - } - - // Remove text selection mode when clicking outside - const removeSelectable = (e: Event) => { - // Don't remove if clicking within the same event - if (e.target && eventElement.contains(e.target as Node)) { - return; - } - - eventElement.classList.remove('text-selectable'); - document.removeEventListener('click', removeSelectable); - - // Clear selection - if (selection) { - selection.removeAllRanges(); - } - }; - - // Add click outside listener after a short delay - setTimeout(() => { - document.addEventListener('click', removeSelectable); - }, 100); - } - /** * Handle overlap detection and re-rendering after drag-drop */ @@ -622,19 +551,6 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { // With the new system, overlap relationships are recalculated on drop // No need to manually track and remove from groups } - - /** - * Restore normal event styling (full column width) - */ - private restoreNormalEventStyling(eventElement: HTMLElement): void { - eventElement.style.position = 'absolute'; - eventElement.style.left = '2px'; - eventElement.style.right = '2px'; - eventElement.style.width = ''; - // Behold z-index for stacked events - } - - /** * Update element's dataset with new times after successful drop @@ -1035,17 +951,11 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { // Setup resize handles on first mouseover only eventElement.addEventListener('mouseover', () => { if (eventElement.dataset.hasResizeHandlers !== 'true') { - this.setupDynamicResizeHandles(eventElement); + this.resizeManager.setupResizeHandles(eventElement); eventElement.dataset.hasResizeHandlers = 'true'; } }, { once: true }); - - // Setup double-click for text selection - eventElement.addEventListener('dblclick', (e) => { - e.stopPropagation(); - this.handleEventDoubleClick(eventElement); - }); - + return eventElement; } @@ -1115,220 +1025,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { - /** - * Setup dynamic resize handles that are only created when needed - */ - private setupDynamicResizeHandles(eventElement: HTMLElement): void { - let topHandle: HTMLElement | null = null; - let bottomHandle: HTMLElement | null = null; - - console.log('Setting up dynamic resize handles for event:', eventElement.dataset.eventId); - - // Create handles on mouse enter - eventElement.addEventListener('mouseenter', () => { - console.log('Mouse ENTER event:', eventElement.dataset.eventId); - // Only create if they don't already exist - if (!topHandle || !bottomHandle) { - topHandle = document.createElement('swp-resize-handle'); - topHandle.setAttribute('data-position', 'top'); - topHandle.style.opacity = '0'; - - bottomHandle = document.createElement('swp-resize-handle'); - bottomHandle.setAttribute('data-position', 'bottom'); - bottomHandle.style.opacity = '0'; - - // Add mousedown listeners for resize functionality - topHandle.addEventListener('mousedown', (e: MouseEvent) => { - e.stopPropagation(); // Forhindre normal drag - e.preventDefault(); - this.startResize(eventElement, 'top', e); - }); - - bottomHandle.addEventListener('mousedown', (e: MouseEvent) => { - e.stopPropagation(); // Forhindre normal drag - e.preventDefault(); - this.startResize(eventElement, 'bottom', e); - }); - - // Insert handles at beginning and end - eventElement.insertBefore(topHandle, eventElement.firstChild); - eventElement.appendChild(bottomHandle); - console.log('Created resize handles for event:', eventElement.dataset.eventId); - } - }); - - // Mouse move handler for smart visibility - eventElement.addEventListener('mousemove', (e: MouseEvent) => { - if (!topHandle || !bottomHandle) return; - - const rect = eventElement.getBoundingClientRect(); - const y = e.clientY - rect.top; - const height = rect.height; - - // Show top handle if mouse is in top 12px - if (y <= 12) { - topHandle.style.opacity = '1'; - bottomHandle.style.opacity = '0'; - } - // Show bottom handle if mouse is in bottom 12px - else if (y >= height - 12) { - topHandle.style.opacity = '0'; - bottomHandle.style.opacity = '1'; - } - // Hide both if mouse is in middle - else { - topHandle.style.opacity = '0'; - bottomHandle.style.opacity = '0'; - } - }); - - // Hide handles when mouse leaves event (men kun hvis ikke i resize mode) - eventElement.addEventListener('mouseleave', () => { - console.log('Mouse LEAVE event:', eventElement.dataset.eventId); - if (!this.resizeState && topHandle && bottomHandle) { - topHandle.style.opacity = '0'; - bottomHandle.style.opacity = '0'; - console.log('Hidden resize handles for event:', eventElement.dataset.eventId); - } - }); - } - /** - * Start resize operation - */ - private startResize(eventElement: HTMLElement, handle: 'top' | 'bottom', e: MouseEvent): void { - const gridSettings = calendarConfig.getGridSettings(); - const minHeightPx = (this.MIN_EVENT_DURATION_MINUTES / 60) * gridSettings.hourHeight; - - this.resizeState = { - element: eventElement, - handle: handle, - startY: e.clientY, - originalTop: parseFloat(eventElement.style.top), - originalHeight: parseFloat(eventElement.style.height), - originalStartTime: new Date(eventElement.dataset.start || ''), - originalEndTime: new Date(eventElement.dataset.end || ''), - minHeightPx: minHeightPx - }; - - // Global listeners for resize - document.addEventListener('mousemove', this.handleResize); - document.addEventListener('mouseup', this.endResize); - - // Add resize cursor to body - document.body.style.cursor = handle === 'top' ? 'n-resize' : 's-resize'; - - console.log('Starting resize:', handle, 'element:', eventElement.dataset.eventId); - } - - /** - * Handle resize drag - */ - private handleResize = (e: MouseEvent): void => { - if (!this.resizeState) return; - - const deltaY = e.clientY - this.resizeState.startY; - const snappedDelta = this.snapToGrid(deltaY); - const gridSettings = calendarConfig.getGridSettings(); - - if (this.resizeState.handle === 'top') { - // Resize fra toppen - const newTop = this.resizeState.originalTop + snappedDelta; - const newHeight = this.resizeState.originalHeight - snappedDelta; - - // Check minimum højde - if (newHeight >= this.resizeState.minHeightPx && newTop >= 0) { - this.resizeState.element.style.top = newTop + 'px'; - this.resizeState.element.style.height = newHeight + 'px'; - - // Opdater tidspunkter - const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60; - const newStartTime = this.addMinutes(this.resizeState.originalStartTime, minutesDelta); - this.updateEventDisplay(this.resizeState.element, newStartTime, this.resizeState.originalEndTime); - } - } else { - // Resize fra bunden - const newHeight = this.resizeState.originalHeight + snappedDelta; - - // Check minimum højde - if (newHeight >= this.resizeState.minHeightPx) { - this.resizeState.element.style.height = newHeight + 'px'; - - // Opdater tidspunkter - const minutesDelta = (snappedDelta / gridSettings.hourHeight) * 60; - const newEndTime = this.addMinutes(this.resizeState.originalEndTime, minutesDelta); - this.updateEventDisplay(this.resizeState.element, this.resizeState.originalStartTime, newEndTime); - } - } - } - - /** - * End resize operation - */ - private endResize = (): void => { - if (!this.resizeState) return; - - // Få finale tider fra element - const finalStart = this.resizeState.element.dataset.start; - const finalEnd = this.resizeState.element.dataset.end; - - console.log('Ending resize:', this.resizeState.element.dataset.eventId, 'New times:', finalStart, finalEnd); - - // Emit event med nye tider - eventBus.emit('event:resized', { - eventId: this.resizeState.element.dataset.eventId, - newStart: finalStart, - newEnd: finalEnd - }); - - // Cleanup - document.removeEventListener('mousemove', this.handleResize); - document.removeEventListener('mouseup', this.endResize); - document.body.style.cursor = ''; - this.resizeState = null; - } - - /** - * Snap delta to grid intervals - */ - private snapToGrid(deltaY: number): number { - const gridSettings = calendarConfig.getGridSettings(); - const snapInterval = gridSettings.snapInterval; - const hourHeight = gridSettings.hourHeight; - const snapDistancePx = (snapInterval / 60) * hourHeight; - return Math.round(deltaY / snapDistancePx) * snapDistancePx; - } - - /** - * Update event display during resize - */ - private updateEventDisplay(element: HTMLElement, startTime: Date, endTime: Date): void { - // Beregn ny duration i minutter - const durationMinutes = (endTime.getTime() - startTime.getTime()) / (1000 * 60); - - // Opdater dataset - element.dataset.start = startTime.toISOString(); - element.dataset.end = endTime.toISOString(); - element.dataset.duration = durationMinutes.toString(); - - // Opdater visual tid - const timeElement = element.querySelector('swp-event-time'); - if (timeElement) { - const startStr = this.formatTime(startTime.toISOString()); - const endStr = this.formatTime(endTime.toISOString()); - timeElement.textContent = `${startStr} - ${endStr}`; - - // Opdater også data-duration attribut på time elementet - timeElement.setAttribute('data-duration', durationMinutes.toString()); - } - } - - /** - * Add minutes to a date - */ - private addMinutes(date: Date, minutes: number): Date { - return new Date(date.getTime() + minutes * 60000); - } clearEvents(container?: HTMLElement): void { const selector = 'swp-event, swp-event-group';