diff --git a/src/factories/ManagerFactory.ts b/src/factories/ManagerFactory.ts index 9a370b2..c3a9c29 100644 --- a/src/factories/ManagerFactory.ts +++ b/src/factories/ManagerFactory.ts @@ -7,7 +7,7 @@ import { ScrollManager } from '../managers/ScrollManager'; import { NavigationManager } from '../managers/NavigationManager'; import { ViewManager } from '../managers/ViewManager'; import { CalendarManager } from '../managers/CalendarManager'; -import { ColumnDetector } from '../managers/ColumnDetector'; +import { DragDropManager } from '../managers/DragDropManager'; /** * Factory for creating and managing calendar managers with proper dependency injection @@ -35,6 +35,7 @@ export class ManagerFactory { navigationManager: NavigationManager; viewManager: ViewManager; calendarManager: CalendarManager; + dragDropManager: DragDropManager; } { console.log('🏭 ManagerFactory: Creating managers with proper DI...'); @@ -45,7 +46,7 @@ export class ManagerFactory { const scrollManager = new ScrollManager(); const navigationManager = new NavigationManager(eventBus, eventRenderer); const viewManager = new ViewManager(eventBus); - const columnDetector = new ColumnDetector(); + const dragDropManager = new DragDropManager(eventBus, config); // CalendarManager depends on all other managers const calendarManager = new CalendarManager( @@ -66,7 +67,8 @@ export class ManagerFactory { scrollManager, navigationManager, viewManager, - calendarManager + calendarManager, + dragDropManager }; } diff --git a/src/managers/ColumnDetector.ts b/src/managers/ColumnDetector.ts deleted file mode 100644 index fec7dff..0000000 --- a/src/managers/ColumnDetector.ts +++ /dev/null @@ -1,669 +0,0 @@ -/** - * ColumnDetector - Bare detect hvilken kolonne musen er over - */ - -import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig'; -import { eventBus } from '../core/EventBus'; - -export class ColumnDetector { - private currentColumn: string | null = null; - private isMouseDown = false; - private lastMousePosition = { x: 0, y: 0 }; - private lastLoggedPosition = { x: 0, y: 0 }; - private draggedClone: HTMLElement | null = null; - private originalEvent: HTMLElement | null = null; - private mouseOffset = { x: 0, y: 0 }; - - // Auto-scroll properties - private scrollContainer: HTMLElement | null = null; - private autoScrollAnimationId: number | null = null; - private scrollSpeed = 10; // pixels per frame - private scrollThreshold = 30; // pixels from edge - private currentMouseY = 0; // Track current mouse Y for scroll updates - - // Konfiguration for snap interval - private snapIntervalMinutes = 15; // 15 minutter - private hourHeightPx = 60; // Fra CSS --hour-height - private get snapDistancePx(): number { - return (this.snapIntervalMinutes / 60) * this.hourHeightPx; // 15/60 * 60 = 15px - } - - constructor() { - this.init(); - } - - /** - * Konfigurer snap interval - */ - public setSnapInterval(minutes: number): void { - this.snapIntervalMinutes = minutes; - console.log(`Snap interval set to ${minutes} minutes (${this.snapDistancePx}px)`); - } - - /** - * Fade out og fjern element fra DOM - */ - private fadeOutAndRemove(element: HTMLElement): void { - element.style.transition = 'opacity 0.3s ease-out'; - element.style.opacity = '0'; - - setTimeout(() => { - element.remove(); - }, 300); - } - - /** - * Fjern "clone-" prefix fra event ID og gendan pointer events - */ - private removeClonePrefix(clone: HTMLElement): void { - const cloneId = clone.dataset.eventId; - if (cloneId && cloneId.startsWith('clone-')) { - const originalId = cloneId.replace('clone-', ''); - clone.dataset.eventId = originalId; - console.log(`Removed clone prefix: ${cloneId} -> ${originalId}`); - } - - // Gendan pointer events så klonen kan dragges igen - clone.style.pointerEvents = ''; - } - - private init(): void { - // Lyt til mouse move på hele body - document.body.addEventListener('mousemove', this.handleMouseMove.bind(this)); - - // Lyt til click på hele body - document.body.addEventListener('click', this.handleClick.bind(this)); - - // Lyt til mouse down og up - document.body.addEventListener('mousedown', this.handleMouseDown.bind(this)); - document.body.addEventListener('mouseup', this.handleMouseUp.bind(this)); - - // Listen for header mouseover events (both day-headers and all-day-containers) - eventBus.on('header:mouseover', (event) => { - const { element, targetDate, headerRenderer } = (event as CustomEvent).detail; - - if (this.isMouseDown && this.draggedClone && targetDate) { - // Scenario 1: Timed event being dragged to header - convert to all-day - if (this.draggedClone.tagName === 'SWP-EVENT') { - console.log('Converting timed event to all-day for date:', targetDate); - headerRenderer.addToAllDay(element); - this.convertToAllDayPreview(targetDate); - } - - // Scenario 2: All-day event being moved to different day - else if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') { - const currentDate = this.draggedClone.parentElement?.getAttribute('data-date'); - if (currentDate !== targetDate) { - console.log('Moving all-day event from', currentDate, 'to', targetDate); - this.moveAllDayToNewDate(targetDate); - } - } - } - }); - } - - - private handleMouseMove(event: MouseEvent): void { - // Track current mouse position for auto-scroll updates - this.currentMouseY = event.clientY; - - // Hvis musen er holdt nede, tjek for snap interval vertikal bevægelse - if (this.isMouseDown) { - const deltaY = Math.abs(event.clientY - this.lastLoggedPosition.y); - - if (deltaY >= this.snapDistancePx) { - console.log(`Mouse dragged ${this.snapIntervalMinutes} minutes (${this.snapDistancePx}px) vertically:`, { - from: this.lastLoggedPosition, - to: { x: event.clientX, y: event.clientY }, - verticalDistance: Math.round(deltaY), - snapInterval: `${this.snapIntervalMinutes} minutes` - }); - this.lastLoggedPosition = { x: event.clientX, y: event.clientY }; - - // Snap klonens position til nærmeste 15-min interval (only for timed events) - if (this.draggedClone && this.draggedClone.parentElement && this.draggedClone.tagName !== 'SWP-ALLDAY-EVENT') { - const columnRect = this.draggedClone.parentElement.getBoundingClientRect(); - const rawRelativeY = event.clientY - columnRect.top - this.mouseOffset.y; - - // Snap til nærmeste 15-min grid - const snappedY = Math.round(rawRelativeY / this.snapDistancePx) * this.snapDistancePx; - this.draggedClone.style.top = snappedY + 'px'; - } - } - - // Kontinuerlig opdatering under auto-scroll for at sikre klonen følger musen (only for timed events) - if (this.draggedClone && this.draggedClone.parentElement && this.autoScrollAnimationId !== null && this.draggedClone.tagName !== 'SWP-ALLDAY-EVENT') { - const columnRect = this.draggedClone.parentElement.getBoundingClientRect(); - const relativeY = event.clientY - columnRect.top - this.mouseOffset.y; - this.draggedClone.style.top = relativeY + 'px'; - } - - // Auto-scroll detection når der er en aktiv clone - if (this.draggedClone) { - this.checkAutoScroll(event); - } - } - - // Find hvilket element musen er over (altid) - { - const elementUnder = document.elementFromPoint(event.clientX, event.clientY); - if (!elementUnder) { - // Ingen element under musen - if (this.currentColumn !== null) { - console.log('Left all columns'); - this.currentColumn = null; - } - return; - } - - // Gå op gennem DOM træet for at finde swp-day-column - let element = elementUnder as HTMLElement; - while (element && element.tagName !== 'SWP-DAY-COLUMN') { - element = element.parentElement as HTMLElement; - if (!element) { - // Ikke i en kolonne - if (this.currentColumn !== null) { - console.log('Left all columns'); - this.currentColumn = null; - } - return; - } - } - - // Vi fandt en kolonne - const date = element.dataset.date; - if (date && date !== this.currentColumn) { - console.log('Entered column:', date); - this.currentColumn = date; - - // Flyt klonen til ny kolonne ved kolonneskift (only for timed events) - if (this.draggedClone && this.isMouseDown && this.draggedClone.tagName !== 'SWP-ALLDAY-EVENT') { - // Flyt klonen til den nye kolonne - const newColumnElement = document.querySelector(`swp-day-column[data-date="${date}"]`); - if (newColumnElement) { - newColumnElement.appendChild(this.draggedClone); - // Opdater Y-position relativt til den nye kolonne - const columnRect = newColumnElement.getBoundingClientRect(); - const relativeY = event.clientY - columnRect.top - this.mouseOffset.y; - this.draggedClone.style.top = relativeY + 'px'; - } - } - } - } - } - - private handleClick(event: MouseEvent): void { - const target = event.target as HTMLElement; - - // Find event element - let eventElement = target; - while (eventElement && eventElement.tagName !== 'SWP-EVENTS-LAYER') { - if (eventElement.tagName === 'SWP-EVENT' || eventElement.tagName === 'SWP-ALLDAY-EVENT') { - break; - } - eventElement = eventElement.parentElement as HTMLElement; - if (!eventElement) return; - } - - // Hvis vi nåede til SWP-EVENTS-LAYER uden at finde et event, så return - if (!eventElement || eventElement.tagName === 'SWP-EVENTS-LAYER') { - return; - } - - // Log event info - const eventId = eventElement.dataset.eventId; - const eventType = eventElement.dataset.type; - console.log('Clicked event:', { - id: eventId, - type: eventType, - element: eventElement, - title: eventElement.textContent - }); - } - - private handleMouseDown(event: MouseEvent): void { - this.isMouseDown = true; - this.lastMousePosition = { x: event.clientX, y: event.clientY }; - this.lastLoggedPosition = { x: event.clientX, y: event.clientY }; - console.log('Mouse down at:', this.lastMousePosition); - - // Tjek om mousedown er på et event - const target = event.target as HTMLElement; - let eventElement = target; - while (eventElement && eventElement.tagName !== 'SWP-EVENTS-LAYER') { - if (eventElement.tagName === 'SWP-EVENT' || eventElement.tagName === 'SWP-ALLDAY-EVENT') { - break; - } - eventElement = eventElement.parentElement as HTMLElement; - if (!eventElement) return; - } - - // Hvis vi nåede til SWP-EVENTS-LAYER uden at finde et event, så return - if (!eventElement || eventElement.tagName === 'SWP-EVENTS-LAYER') { - return; - } - - // Hvis vi fandt et event, lav en clone - if (eventElement) { - // Gem reference til original event - this.originalEvent = eventElement; - this.cloneEvent(eventElement, event); - // Sæt originalen til gennemsigtig og forhindre text selection mens der trækkes - eventElement.style.opacity = '0.6'; - eventElement.style.userSelect = 'none'; - } - } - - private cloneEvent(originalEvent: HTMLElement, mouseEvent: MouseEvent): void { - // Lav en clone - const clone = originalEvent.cloneNode(true) as HTMLElement; - - // Præfiks ID med "clone-" - const originalId = originalEvent.dataset.eventId; - if (originalId) { - clone.dataset.eventId = `clone-${originalId}`; - } - - // Beregn hvor på event'et musen klikkede - const eventRect = originalEvent.getBoundingClientRect(); - this.mouseOffset = { - x: mouseEvent.clientX - eventRect.left, // Stadig nødvendig for cursor placering - y: mouseEvent.clientY - eventRect.top - }; - - // Gør klonen ready til at blive trukket - clone.style.position = 'absolute'; - clone.style.zIndex = '999999'; - clone.style.pointerEvents = 'none'; - - // Sæt størrelse fra det originale event - clone.style.width = eventRect.width + 'px'; - clone.style.height = eventRect.height + 'px'; - - // Find den aktuelle kolonne og placer klonen der - const currentColumnElement = document.querySelector(`swp-day-column[data-date="${this.currentColumn}"]`); - if (currentColumnElement) { - // Sæt initial position relativt til kolonnen - const columnRect = currentColumnElement.getBoundingClientRect(); - const relativeY = mouseEvent.clientY - columnRect.top - this.mouseOffset.y; - clone.style.top = relativeY + 'px'; - - currentColumnElement.appendChild(clone); - } else { - console.error('Could not find current column element:', this.currentColumn); - // Fallback til original placering - originalEvent.parentNode?.insertBefore(clone, originalEvent.nextSibling); - } - - // Gem reference til klonen - this.draggedClone = clone; - - console.log('Cloned event:', { - original: originalId, - clone: clone.dataset.eventId, - offset: this.mouseOffset - }); - } - - private handleMouseUp(event: MouseEvent): void { - this.isMouseDown = false; - console.log('Mouse up at:', { x: event.clientX, y: event.clientY }); - - // Stop auto-scroll - this.stopAutoScroll(); - - // Drop operationen: fade out original og remove clone suffix - if (this.originalEvent && this.draggedClone) { - // Check if clone was converted to all-day (is now in header) - const cloneInHeader = this.draggedClone.closest('swp-calendar-header'); - - if (cloneInHeader) { - console.log('Drop completed: all-day event created'); - // Clone is now an all-day event, just fade out original - this.fadeOutAndRemove(this.originalEvent); - } else { - console.log('Drop in regular area - keeping as timed event'); - // Normal drop: fade out original and keep clone as timed - this.fadeOutAndRemove(this.originalEvent); - this.removeClonePrefix(this.draggedClone); - } - - // Ryd op - this.originalEvent = null; - this.draggedClone = null; - this.scrollContainer = null; - } - - // Cleanup hvis ingen drop (ingen clone var aktiv) - if (this.originalEvent && !this.draggedClone) { - this.originalEvent.style.opacity = ''; - this.originalEvent.style.userSelect = ''; - this.originalEvent = null; - } - } - - /** - * Expand header to show all-day row when clone is dragged into header - */ - private expandHeaderForAllDay(): void { - const root = document.documentElement; - const currentHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0'); - - if (currentHeight === 0) { - root.style.setProperty('--all-day-row-height', `${ALL_DAY_CONSTANTS.SINGLE_ROW_HEIGHT}px`); - console.log('Header expanded for all-day row'); - } - } - - /** - * Check if auto-scroll should be triggered based on mouse position - */ - private checkAutoScroll(event: MouseEvent): void { - // Find scrollable content if not cached - if (!this.scrollContainer) { - this.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement; - if (!this.scrollContainer) { - console.warn('ColumnDetector: Could not find swp-scrollable-content for auto-scroll'); - return; - } - console.log('ColumnDetector: Found scroll container:', this.scrollContainer); - } - - const containerRect = this.scrollContainer.getBoundingClientRect(); - const mouseY = event.clientY; - - // Calculate distances from edges - const distanceFromTop = mouseY - containerRect.top; - const distanceFromBottom = containerRect.bottom - mouseY; - - - // Check if we need to scroll up - if (distanceFromTop <= this.scrollThreshold && distanceFromTop > 0) { - this.startAutoScroll('up'); - console.log(`Auto-scroll up triggered: ${Math.round(distanceFromTop)}px from top edge`); - } - // Check if we need to scroll down - else if (distanceFromBottom <= this.scrollThreshold && distanceFromBottom > 0) { - this.startAutoScroll('down'); - console.log(`Auto-scroll down triggered: ${Math.round(distanceFromBottom)}px from bottom edge`); - } - // Stop scrolling if not in threshold zone - else { - this.stopAutoScroll(); - } - } - - /** - * Start auto-scroll animation in specified direction - */ - private startAutoScroll(direction: 'up' | 'down'): void { - // Don't start if already scrolling in same direction - if (this.autoScrollAnimationId !== null) { - return; - } - - const scroll = () => { - if (!this.scrollContainer || !this.isMouseDown || !this.draggedClone) { - this.stopAutoScroll(); - return; - } - - const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed; - this.scrollContainer.scrollTop += scrollAmount; - - // Update clone position based on current mouse position after scroll (only for timed events) - if (this.draggedClone && this.draggedClone.parentElement && this.draggedClone.tagName !== 'SWP-ALLDAY-EVENT') { - const columnRect = this.draggedClone.parentElement.getBoundingClientRect(); - const relativeY = this.currentMouseY - columnRect.top - this.mouseOffset.y; - this.draggedClone.style.top = relativeY + 'px'; - } - - // Continue animation - this.autoScrollAnimationId = requestAnimationFrame(scroll); - }; - - this.autoScrollAnimationId = requestAnimationFrame(scroll); - } - - /** - * Stop auto-scroll animation - */ - private stopAutoScroll(): void { - if (this.autoScrollAnimationId !== null) { - cancelAnimationFrame(this.autoScrollAnimationId); - this.autoScrollAnimationId = null; - } - } - - /** - * Convert dragged clone to all-day event preview - */ - private convertToAllDayPreview(targetDate: string): void { - if (!this.draggedClone) return; - - // Only convert once - if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') { - return; - } - - // Transform clone to all-day format - this.transformCloneToAllDay(this.draggedClone, targetDate); - - // No need to recalculate height - addToAllDay already handles this - } - - /** - * Transform clone from timed event to all-day event format - */ - private transformCloneToAllDay(clone: HTMLElement, targetDate: string): void { - const calendarHeader = document.querySelector('swp-calendar-header'); - if (!calendarHeader) return; - - // Find or create all-day container for target date - const container = this.findOrCreateAllDayContainer(calendarHeader as HTMLElement, targetDate); - if (!container) return; - - // Extract title from original clone (remove time info) - const titleElement = clone.querySelector('swp-event-title'); - const eventTitle = titleElement ? titleElement.textContent || 'Untitled Event' : 'Untitled Event'; - - // Calculate which column this date corresponds to - const dayHeaders = document.querySelectorAll('swp-day-header'); - let columnIndex = 1; // Default to first column - - dayHeaders.forEach((header, index) => { - if ((header as HTMLElement).dataset.date === targetDate) { - columnIndex = index + 1; // 1-based grid index - } - }); - - // Create new all-day event element - const allDayEvent = document.createElement('swp-allday-event'); - allDayEvent.setAttribute('data-event-id', clone.dataset.eventId || ''); - allDayEvent.setAttribute('data-type', clone.dataset.type || 'work'); - allDayEvent.textContent = eventTitle; - - // Position event in correct column and find available row - (allDayEvent as HTMLElement).style.gridColumn = columnIndex.toString(); - - // Find first available row (simple assignment to row 1 for dropped events) - (allDayEvent as HTMLElement).style.gridRow = '1'; - - // Clear any positioning styles from the original timed event (top, left, position, etc.) - (allDayEvent as HTMLElement).style.top = ''; - (allDayEvent as HTMLElement).style.left = ''; - (allDayEvent as HTMLElement).style.position = ''; - - // Remove the original clone from its current parent - if (clone.parentElement) { - clone.parentElement.removeChild(clone); - } - - // Add new all-day event to container - container.appendChild(allDayEvent); - - // Update reference to point to new element - this.draggedClone = allDayEvent; - - // Recalculate height after adding new all-day event - this.recalculateAllDayHeight(); - } - - /** - * Move all-day event to a new date container - */ - private moveAllDayToNewDate(targetDate: string): void { - if (!this.draggedClone) return; - - const calendarHeader = document.querySelector('swp-calendar-header'); - if (!calendarHeader) return; - - // Find or create container for new date - const newContainer = this.findOrCreateAllDayContainer(calendarHeader as HTMLElement, targetDate); - - // Move the dragged clone to new container - if (newContainer && this.draggedClone.parentElement !== newContainer) { - newContainer.appendChild(this.draggedClone); - - // Recalculate height after moving - this.recalculateAllDayHeight(); - } - } - - /** - * Find existing or create new all-day container for specific date - */ - private findOrCreateAllDayContainer(calendarHeader: HTMLElement, targetDate: string): HTMLElement | null { - // Find day headers to determine column index - const dayHeaders = calendarHeader.querySelectorAll('swp-day-header'); - let columnIndex = -1; - - for (let i = 0; i < dayHeaders.length; i++) { - const dayHeader = dayHeaders[i] as HTMLElement; - if (dayHeader.dataset.date === targetDate) { - columnIndex = i + 1; // 1-based grid index - break; - } - } - - if (columnIndex === -1) { - console.error(`Could not find column for date: ${targetDate}`); - return null; - } - - // Find the all-day container - const container = calendarHeader.querySelector('swp-allday-container'); - if (!container) { - console.warn('ColumnDetector: No swp-allday-container found - HeaderRenderer should create this'); - return null; - } - - return container as HTMLElement; - } - - - /** - * Recalculate all-day row height based on number of rows in use - */ - private recalculateAllDayHeight(): void { - const calendarHeader = document.querySelector('swp-calendar-header') as HTMLElement; - if (!calendarHeader) return; - - // Find all-day container - const allDayContainer = calendarHeader.querySelector('swp-allday-container'); - if (!allDayContainer) { - console.warn('ColumnDetector: No swp-allday-container found for height recalculation'); - return; - } - - // Count highest row used by any event - const events = allDayContainer.querySelectorAll('swp-allday-event'); - let maxRow = 1; - - events.forEach(event => { - const gridRow = (event as HTMLElement).style.gridRow; - if (gridRow) { - const rowNum = parseInt(gridRow); - if (rowNum > maxRow) { - maxRow = rowNum; - } - } - }); - - // Calculate new height - const root = document.documentElement; - const eventHeight = parseInt(getComputedStyle(root).getPropertyValue('--allday-event-height') || '26'); - const calculatedHeight = maxRow * eventHeight; - - // Get current heights for animation - const headerHeight = parseInt(getComputedStyle(root).getPropertyValue('--header-height')); - const currentAllDayHeight = parseInt(getComputedStyle(root).getPropertyValue('--all-day-row-height') || '0'); - const currentTotalHeight = headerHeight + currentAllDayHeight; - const targetTotalHeight = headerHeight + calculatedHeight; - - // Only animate if height actually changes - if (currentAllDayHeight !== calculatedHeight) { - // Find header spacer - const headerSpacer = document.querySelector('swp-header-spacer') as HTMLElement; - - // Animate both header and spacer simultaneously - const animations = [ - calendarHeader.animate([ - { height: `${currentTotalHeight}px` }, - { height: `${targetTotalHeight}px` } - ], { - duration: 150, - easing: 'ease-out', - fill: 'forwards' - }) - ]; - - if (headerSpacer) { - animations.push( - headerSpacer.animate([ - { height: `${currentTotalHeight}px` }, - { height: `${targetTotalHeight}px` } - ], { - duration: 150, - easing: 'ease-out', - fill: 'forwards' - }) - ); - } - - // Wait for all animations to finish before setting CSS variable - Promise.all(animations.map(anim => anim.finished)).then(() => { - root.style.setProperty('--all-day-row-height', `${calculatedHeight}px`); - - // Update grid-template-rows for all swp-allday-containers - const allDayContainers = document.querySelectorAll('swp-allday-container'); - allDayContainers.forEach(container => { - const gridRows = `repeat(${maxRow}, var(--allday-event-height, 26px))`; - (container as HTMLElement).style.gridTemplateRows = gridRows; - }); - - // Notify ScrollManager about header height change - eventBus.emit('header:height-changed'); - }); - - console.log(`Animated all-day height: ${currentAllDayHeight}px → ${calculatedHeight}px (max stack: ${maxRow})`); - } else { - // Height hasn't changed but we still need to update grid-template-rows in case of different row arrangements - const allDayContainers = document.querySelectorAll('swp-allday-container'); - allDayContainers.forEach(container => { - const gridRows = `repeat(${maxRow}, var(--allday-event-height, 26px))`; - (container as HTMLElement).style.gridTemplateRows = gridRows; - }); - - console.log(`All-day height unchanged (${currentAllDayHeight}px) but updated grid-template-rows to ${maxRow} rows`); - } - } - - public destroy(): void { - this.stopAutoScroll(); - document.body.removeEventListener('mousemove', this.handleMouseMove.bind(this)); - document.body.removeEventListener('click', this.handleClick.bind(this)); - document.body.removeEventListener('mousedown', this.handleMouseDown.bind(this)); - document.body.removeEventListener('mouseup', this.handleMouseUp.bind(this)); - } -} \ No newline at end of file diff --git a/src/managers/DragDropManager.ts b/src/managers/DragDropManager.ts new file mode 100644 index 0000000..5859da3 --- /dev/null +++ b/src/managers/DragDropManager.ts @@ -0,0 +1,338 @@ +/** + * DragDropManager - Handles drag and drop interaction logic + * Emits events for visual updates handled by EventRenderer + */ + +import { IEventBus } from '../types/CalendarTypes'; +import { CalendarConfig } from '../core/CalendarConfig'; + +export class DragDropManager { + private eventBus: IEventBus; + private config: CalendarConfig; + + // Mouse tracking + private isMouseDown = false; + private lastMousePosition = { x: 0, y: 0 }; + private lastLoggedPosition = { x: 0, y: 0 }; + private currentMouseY = 0; + private mouseOffset = { x: 0, y: 0 }; + + // Drag state + private draggedEventId: string | null = null; + private originalElement: HTMLElement | null = null; + private currentColumn: string | null = null; + + // Auto-scroll properties + private scrollContainer: HTMLElement | null = null; + private autoScrollAnimationId: number | null = null; + private scrollSpeed = 10; // pixels per frame + private scrollThreshold = 30; // pixels from edge + + // Snap configuration + private snapIntervalMinutes = 15; // Default 15 minutes + private hourHeightPx = 60; // From CSS --hour-height + + private get snapDistancePx(): number { + return (this.snapIntervalMinutes / 60) * this.hourHeightPx; + } + + constructor(eventBus: IEventBus, config: CalendarConfig) { + this.eventBus = eventBus; + this.config = config; + + // Get config values + const gridSettings = config.getGridSettings(); + this.hourHeightPx = gridSettings.hourHeight; + + this.init(); + } + + /** + * Configure snap interval + */ + public setSnapInterval(minutes: number): void { + this.snapIntervalMinutes = minutes; + console.log(`DragDropManager: Snap interval set to ${minutes} minutes (${this.snapDistancePx}px)`); + } + + private init(): void { + // Listen to mouse events on body + document.body.addEventListener('mousemove', this.handleMouseMove.bind(this)); + document.body.addEventListener('mousedown', this.handleMouseDown.bind(this)); + document.body.addEventListener('mouseup', this.handleMouseUp.bind(this)); + + // Listen for header mouseover events + this.eventBus.on('header:mouseover', (event) => { + const { element, targetDate, headerRenderer } = (event as CustomEvent).detail; + + if (this.isMouseDown && this.draggedEventId && targetDate) { + // Emit event to convert to all-day + this.eventBus.emit('drag:convert-to-allday', { + eventId: this.draggedEventId, + targetDate, + element, + headerRenderer + }); + } + }); + } + + private handleMouseDown(event: MouseEvent): void { + this.isMouseDown = true; + this.lastMousePosition = { x: event.clientX, y: event.clientY }; + this.lastLoggedPosition = { x: event.clientX, y: event.clientY }; + + // Check if mousedown is on an event + const target = event.target as HTMLElement; + let eventElement = target; + + while (eventElement && eventElement.tagName !== 'SWP-EVENTS-LAYER') { + if (eventElement.tagName === 'SWP-EVENT' || eventElement.tagName === 'SWP-ALLDAY-EVENT') { + break; + } + eventElement = eventElement.parentElement as HTMLElement; + if (!eventElement) return; + } + + // If we reached SWP-EVENTS-LAYER without finding an event, return + if (!eventElement || eventElement.tagName === 'SWP-EVENTS-LAYER') { + return; + } + + // Found an event - start dragging + if (eventElement) { + this.originalElement = eventElement; + this.draggedEventId = eventElement.dataset.eventId || null; + + // Calculate mouse offset within event + const eventRect = eventElement.getBoundingClientRect(); + this.mouseOffset = { + x: event.clientX - eventRect.left, + y: event.clientY - eventRect.top + }; + + // Detect current column + const column = this.detectColumn(event.clientX, event.clientY); + if (column) { + this.currentColumn = column; + } + + // Emit drag start event + this.eventBus.emit('drag:start', { + originalElement: eventElement, + eventId: this.draggedEventId, + mousePosition: { x: event.clientX, y: event.clientY }, + mouseOffset: this.mouseOffset, + column: this.currentColumn + }); + + console.log('DragDropManager: Drag started', { + eventId: this.draggedEventId, + column: this.currentColumn + }); + } + } + + private handleMouseMove(event: MouseEvent): void { + this.currentMouseY = event.clientY; + + if (this.isMouseDown && this.draggedEventId) { + const deltaY = Math.abs(event.clientY - this.lastLoggedPosition.y); + + // Check for snap interval vertical movement + if (deltaY >= this.snapDistancePx) { + this.lastLoggedPosition = { x: event.clientX, y: event.clientY }; + + // Calculate snapped position + const column = this.detectColumn(event.clientX, event.clientY); + const snappedY = this.calculateSnapPosition(event.clientY); + + // Emit drag move event with snapped position + this.eventBus.emit('drag:move', { + eventId: this.draggedEventId, + mousePosition: { x: event.clientX, y: event.clientY }, + snappedY, + column, + mouseOffset: this.mouseOffset + }); + + console.log(`DragDropManager: Drag moved ${this.snapIntervalMinutes} minutes`, { + snappedY, + column + }); + } + + // Check for auto-scroll + this.checkAutoScroll(event); + + // Check for column change + const newColumn = this.detectColumn(event.clientX, event.clientY); + if (newColumn && newColumn !== this.currentColumn) { + console.log(`DragDropManager: Column changed from ${this.currentColumn} to ${newColumn}`); + this.currentColumn = newColumn; + + this.eventBus.emit('drag:column-change', { + eventId: this.draggedEventId, + previousColumn: this.currentColumn, + newColumn, + mousePosition: { x: event.clientX, y: event.clientY } + }); + } + } + } + + private handleMouseUp(event: MouseEvent): void { + if (!this.isMouseDown) return; + + this.isMouseDown = false; + + // Stop auto-scroll + this.stopAutoScroll(); + + if (this.draggedEventId && this.originalElement) { + // Calculate final position + const finalColumn = this.detectColumn(event.clientX, event.clientY); + const finalY = this.calculateSnapPosition(event.clientY); + + // Emit drag end event + this.eventBus.emit('drag:end', { + eventId: this.draggedEventId, + originalElement: this.originalElement, + finalPosition: { x: event.clientX, y: event.clientY }, + finalColumn, + finalY + }); + + console.log('DragDropManager: Drag ended', { + eventId: this.draggedEventId, + finalColumn, + finalY + }); + + // Clean up + this.draggedEventId = null; + this.originalElement = null; + this.currentColumn = null; + this.scrollContainer = null; + } + } + + /** + * Calculate snapped Y position based on mouse Y + */ + private calculateSnapPosition(mouseY: number): number { + // Find the column element to get relative position + const columnElement = this.currentColumn + ? document.querySelector(`swp-day-column[data-date="${this.currentColumn}"]`) + : null; + + if (!columnElement) return mouseY; + + const columnRect = columnElement.getBoundingClientRect(); + const relativeY = mouseY - columnRect.top - this.mouseOffset.y; + + // Snap to nearest interval + const snappedY = Math.round(relativeY / this.snapDistancePx) * this.snapDistancePx; + + // Ensure non-negative + return Math.max(0, snappedY); + } + + /** + * Detect which column the mouse is over + */ + private detectColumn(mouseX: number, mouseY: number): string | null { + const element = document.elementFromPoint(mouseX, mouseY); + if (!element) return null; + + // Walk up DOM tree to find swp-day-column + let current = element as HTMLElement; + while (current && current.tagName !== 'SWP-DAY-COLUMN') { + current = current.parentElement as HTMLElement; + if (!current) return null; + } + + return current.dataset.date || null; + } + + /** + * Check if auto-scroll should be triggered + */ + private checkAutoScroll(event: MouseEvent): void { + // Find scrollable content if not cached + if (!this.scrollContainer) { + this.scrollContainer = document.querySelector('swp-scrollable-content') as HTMLElement; + if (!this.scrollContainer) { + console.warn('DragDropManager: Could not find swp-scrollable-content for auto-scroll'); + return; + } + } + + const containerRect = this.scrollContainer.getBoundingClientRect(); + const mouseY = event.clientY; + + // Calculate distances from edges + const distanceFromTop = mouseY - containerRect.top; + const distanceFromBottom = containerRect.bottom - mouseY; + + // Check if we need to scroll + if (distanceFromTop <= this.scrollThreshold && distanceFromTop > 0) { + this.startAutoScroll('up'); + } else if (distanceFromBottom <= this.scrollThreshold && distanceFromBottom > 0) { + this.startAutoScroll('down'); + } else { + this.stopAutoScroll(); + } + } + + /** + * Start auto-scroll animation + */ + private startAutoScroll(direction: 'up' | 'down'): void { + if (this.autoScrollAnimationId !== null) return; + + const scroll = () => { + if (!this.scrollContainer || !this.isMouseDown) { + this.stopAutoScroll(); + return; + } + + const scrollAmount = direction === 'up' ? -this.scrollSpeed : this.scrollSpeed; + this.scrollContainer.scrollTop += scrollAmount; + + // Emit updated position during scroll + if (this.draggedEventId) { + const snappedY = this.calculateSnapPosition(this.currentMouseY); + this.eventBus.emit('drag:auto-scroll', { + eventId: this.draggedEventId, + snappedY, + scrollTop: this.scrollContainer.scrollTop + }); + } + + this.autoScrollAnimationId = requestAnimationFrame(scroll); + }; + + this.autoScrollAnimationId = requestAnimationFrame(scroll); + } + + /** + * Stop auto-scroll animation + */ + private stopAutoScroll(): void { + if (this.autoScrollAnimationId !== null) { + cancelAnimationFrame(this.autoScrollAnimationId); + this.autoScrollAnimationId = null; + } + } + + /** + * Clean up event listeners + */ + public destroy(): void { + this.stopAutoScroll(); + document.body.removeEventListener('mousemove', this.handleMouseMove.bind(this)); + document.body.removeEventListener('mousedown', this.handleMouseDown.bind(this)); + document.body.removeEventListener('mouseup', this.handleMouseUp.bind(this)); + } +} \ No newline at end of file diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index de38ab0..fdf9b7b 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -4,6 +4,7 @@ import { CalendarEvent } from '../types/CalendarTypes'; import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig'; import { CalendarConfig } from '../core/CalendarConfig'; import { DateCalculator } from '../utils/DateCalculator'; +import { eventBus } from '../core/EventBus'; /** * Interface for event rendering strategies @@ -18,9 +19,312 @@ export interface EventRendererStrategy { */ export abstract class BaseEventRenderer implements EventRendererStrategy { protected dateCalculator: DateCalculator; + protected config: CalendarConfig; + + // Drag and drop state + private draggedClone: HTMLElement | null = null; + private originalEvent: HTMLElement | null = null; constructor(config: CalendarConfig) { + this.config = config; this.dateCalculator = new DateCalculator(config); + this.setupDragEventListeners(); + } + + /** + * Setup listeners for drag events from DragDropManager + */ + private setupDragEventListeners(): void { + // Handle drag start + eventBus.on('drag:start', (event) => { + const { originalElement, eventId, mouseOffset, column } = (event as CustomEvent).detail; + this.handleDragStart(originalElement, eventId, mouseOffset, column); + }); + + // Handle drag move + eventBus.on('drag:move', (event) => { + const { eventId, snappedY, column, mouseOffset } = (event as CustomEvent).detail; + this.handleDragMove(eventId, snappedY, column, mouseOffset); + }); + + // Handle drag end + eventBus.on('drag:end', (event) => { + const { eventId, originalElement, finalColumn, finalY } = (event as CustomEvent).detail; + this.handleDragEnd(eventId, originalElement, finalColumn, finalY); + }); + + // Handle column change + eventBus.on('drag:column-change', (event) => { + const { eventId, newColumn } = (event as CustomEvent).detail; + this.handleColumnChange(eventId, newColumn); + }); + + // Handle convert to all-day + eventBus.on('drag:convert-to-allday', (event) => { + const { eventId, targetDate, headerRenderer } = (event as CustomEvent).detail; + this.handleConvertToAllDay(eventId, targetDate, headerRenderer); + }); + } + + /** + * Get original event duration from data-duration attribute + */ + private getOriginalEventDuration(originalEvent: HTMLElement): number { + // Find the swp-event-time element with data-duration attribute + const timeElement = originalEvent.querySelector('swp-event-time'); + if (timeElement) { + const duration = timeElement.getAttribute('data-duration'); + if (duration) { + const durationMinutes = parseInt(duration); + console.log(`EventRenderer: Read duration ${durationMinutes} minutes from data-duration attribute`); + return durationMinutes; + } + } + + // Fallback to 60 minutes if attribute not found + console.warn('EventRenderer: No data-duration found, using fallback 60 minutes'); + return 60; + } + + /** + * Create a clone of an event for dragging + */ + private createEventClone(originalEvent: HTMLElement): HTMLElement { + const clone = originalEvent.cloneNode(true) as HTMLElement; + + // Prefix ID with "clone-" + const originalId = originalEvent.dataset.eventId; + if (originalId) { + clone.dataset.eventId = `clone-${originalId}`; + } + + // Get and cache original duration from data-duration attribute + const originalDurationMinutes = this.getOriginalEventDuration(originalEvent); + clone.dataset.originalDuration = originalDurationMinutes.toString(); + + console.log(`EventRenderer: Clone created with ${originalDurationMinutes} minutes duration from data-duration`); + + // Style for dragging + clone.style.position = 'absolute'; + clone.style.zIndex = '999999'; + clone.style.pointerEvents = 'none'; + clone.style.opacity = '0.8'; + + // Keep original dimensions (height stays the same) + const rect = originalEvent.getBoundingClientRect(); + clone.style.width = rect.width + 'px'; + clone.style.height = rect.height + 'px'; + + return clone; + } + + /** + * Update clone timestamp based on new position + */ + private updateCloneTimestamp(clone: HTMLElement, snappedY: number): void { + const gridSettings = this.config.getGridSettings(); + const hourHeight = gridSettings.hourHeight; + const dayStartHour = gridSettings.dayStartHour; + const snapInterval = 15; // TODO: Get from config + + // Calculate total minutes from top + const totalMinutesFromTop = (snappedY / hourHeight) * 60; + const startTotalMinutes = Math.max( + dayStartHour * 60, + Math.round((dayStartHour * 60 + totalMinutesFromTop) / snapInterval) * snapInterval + ); + + // Use cached original duration (no recalculation) + const cachedDuration = parseInt(clone.dataset.originalDuration || '60'); + const endTotalMinutes = startTotalMinutes + cachedDuration; + + // Update display + const timeElement = clone.querySelector('swp-event-time'); + if (timeElement) { + const newTimeText = `${this.formatTime(startTotalMinutes)} - ${this.formatTime(endTotalMinutes)}`; + timeElement.textContent = newTimeText; + + console.log(`EventRenderer: Updated timestamp to ${newTimeText} (${cachedDuration} min duration)`); + } + } + + /** + * Calculate event duration in minutes from element height + */ + private getEventDuration(element: HTMLElement): number { + const gridSettings = this.config.getGridSettings(); + const hourHeight = gridSettings.hourHeight; + + // Get height from style or computed + let heightPx = parseFloat(element.style.height) || 0; + if (!heightPx) { + const rect = element.getBoundingClientRect(); + heightPx = rect.height; + } + + return Math.round((heightPx / hourHeight) * 60); + } + + /** + * Format time from total minutes + */ + private formatTime(totalMinutes: number): string { + const hours = Math.floor(totalMinutes / 60) % 24; + const minutes = totalMinutes % 60; + const period = hours >= 12 ? 'PM' : 'AM'; + const displayHours = hours % 12 || 12; + return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`; + } + + /** + * Handle drag start event + */ + private handleDragStart(originalElement: HTMLElement, eventId: string, mouseOffset: any, column: string): void { + this.originalEvent = originalElement; + + // Create clone + this.draggedClone = this.createEventClone(originalElement); + + // Add to current column + const columnElement = document.querySelector(`swp-day-column[data-date="${column}"]`); + if (columnElement) { + columnElement.appendChild(this.draggedClone); + } + + // Make original semi-transparent + originalElement.style.opacity = '0.3'; + originalElement.style.userSelect = 'none'; + + console.log('EventRenderer: Drag started, clone created'); + } + + /** + * Handle drag move event + */ + private handleDragMove(eventId: string, snappedY: number, column: string, mouseOffset: any): void { + if (!this.draggedClone) return; + + // Update position + this.draggedClone.style.top = snappedY + 'px'; + + // Update timestamp display + this.updateCloneTimestamp(this.draggedClone, snappedY); + + console.log('EventRenderer: Clone position and timestamp updated'); + } + + /** + * Handle column change during drag + */ + private handleColumnChange(eventId: string, newColumn: string): void { + if (!this.draggedClone) return; + + // Move clone to new column + const newColumnElement = document.querySelector(`swp-day-column[data-date="${newColumn}"]`); + if (newColumnElement && this.draggedClone.parentElement !== newColumnElement) { + newColumnElement.appendChild(this.draggedClone); + console.log(`EventRenderer: Clone moved to column ${newColumn}`); + } + } + + /** + * Handle drag end event + */ + private handleDragEnd(eventId: string, originalElement: HTMLElement, finalColumn: string, finalY: number): void { + if (!this.draggedClone || !this.originalEvent) return; + + // Fade out original + this.fadeOutAndRemove(this.originalEvent); + + // Remove clone prefix and enable pointer events + const cloneId = this.draggedClone.dataset.eventId; + if (cloneId && cloneId.startsWith('clone-')) { + this.draggedClone.dataset.eventId = cloneId.replace('clone-', ''); + } + this.draggedClone.style.pointerEvents = ''; + this.draggedClone.style.opacity = ''; + + // Clean up + this.draggedClone = null; + this.originalEvent = null; + + console.log('EventRenderer: Drag completed'); + } + + /** + * Handle conversion to all-day event + */ + private handleConvertToAllDay(eventId: string, targetDate: string, headerRenderer: any): void { + if (!this.draggedClone) return; + + // Only convert once + if (this.draggedClone.tagName === 'SWP-ALLDAY-EVENT') return; + + // Transform clone to all-day format + this.transformCloneToAllDay(this.draggedClone, targetDate); + + // Expand header if needed + headerRenderer.addToAllDay(this.draggedClone.parentElement); + + console.log(`EventRenderer: Converted to all-day event for date ${targetDate}`); + } + + /** + * Transform clone from timed to all-day event + */ + private transformCloneToAllDay(clone: HTMLElement, targetDate: string): void { + const calendarHeader = document.querySelector('swp-calendar-header'); + if (!calendarHeader) return; + + // Find all-day container + const allDayContainer = calendarHeader.querySelector('swp-allday-container'); + if (!allDayContainer) return; + + // Extract title + const titleElement = clone.querySelector('swp-event-title'); + const eventTitle = titleElement ? titleElement.textContent || 'Untitled' : 'Untitled'; + + // Calculate column index + const dayHeaders = document.querySelectorAll('swp-day-header'); + let columnIndex = 1; + dayHeaders.forEach((header, index) => { + if ((header as HTMLElement).dataset.date === targetDate) { + columnIndex = index + 1; + } + }); + + // Create all-day event + const allDayEvent = document.createElement('swp-allday-event'); + allDayEvent.dataset.eventId = clone.dataset.eventId || ''; + allDayEvent.dataset.type = clone.dataset.type || 'work'; + allDayEvent.textContent = eventTitle; + + // Position in grid + (allDayEvent as HTMLElement).style.gridColumn = columnIndex.toString(); + (allDayEvent as HTMLElement).style.gridRow = '1'; + + // Remove original clone + if (clone.parentElement) { + clone.parentElement.removeChild(clone); + } + + // Add to all-day container + allDayContainer.appendChild(allDayEvent); + + // Update reference + this.draggedClone = allDayEvent; + } + + /** + * Fade out and remove element + */ + private fadeOutAndRemove(element: HTMLElement): void { + element.style.transition = 'opacity 0.3s ease-out'; + element.style.opacity = '0'; + + setTimeout(() => { + element.remove(); + }, 300); } renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void { console.log('BaseEventRenderer: renderEvents called with', events.length, 'events'); @@ -186,14 +490,21 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { // Color is now handled by CSS classes based on data-type attribute // Format time for display - const startTime = this.dateCalculator.formatTime(new Date(event.start)); - const endTime = this.dateCalculator.formatTime(new Date(event.end)); + const startTime = this.formatTimeFromISOString(event.start); + const endTime = this.formatTimeFromISOString(event.end); + + // Calculate duration in minutes + const startDate = new Date(event.start); + const endDate = new Date(event.end); + const durationMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60); // Create event content eventElement.innerHTML = ` - ${startTime} - ${endTime} + ${startTime} - ${endTime} ${event.title} `; + + console.log(`BaseEventRenderer: Rendered "${event.title}" with ${durationMinutes} minutes duration`); container.appendChild(eventElement); @@ -240,7 +551,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy { return { top, height }; } - protected formatTime(isoString: string): string { + protected formatTimeFromISOString(isoString: string): string { const date = new Date(isoString); const hours = date.getHours(); const minutes = date.getMinutes();