Implements drag and drop functionality

Introduces a DragDropManager to handle event dragging and dropping, replacing the ColumnDetector.

This change centralizes drag and drop logic, improving code organization and maintainability.

The EventRenderer now uses the DragDropManager's events to visually update the calendar during drag operations.

Removes ColumnDetector which is now replaced by the drag and drop manager.
This commit is contained in:
Janus Knudsen 2025-08-27 22:50:13 +02:00
parent be4a8af7c4
commit f697944d75
4 changed files with 658 additions and 676 deletions

View file

@ -7,7 +7,7 @@ import { ScrollManager } from '../managers/ScrollManager';
import { NavigationManager } from '../managers/NavigationManager'; import { NavigationManager } from '../managers/NavigationManager';
import { ViewManager } from '../managers/ViewManager'; import { ViewManager } from '../managers/ViewManager';
import { CalendarManager } from '../managers/CalendarManager'; 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 * Factory for creating and managing calendar managers with proper dependency injection
@ -35,6 +35,7 @@ export class ManagerFactory {
navigationManager: NavigationManager; navigationManager: NavigationManager;
viewManager: ViewManager; viewManager: ViewManager;
calendarManager: CalendarManager; calendarManager: CalendarManager;
dragDropManager: DragDropManager;
} { } {
console.log('🏭 ManagerFactory: Creating managers with proper DI...'); console.log('🏭 ManagerFactory: Creating managers with proper DI...');
@ -45,7 +46,7 @@ export class ManagerFactory {
const scrollManager = new ScrollManager(); const scrollManager = new ScrollManager();
const navigationManager = new NavigationManager(eventBus, eventRenderer); const navigationManager = new NavigationManager(eventBus, eventRenderer);
const viewManager = new ViewManager(eventBus); const viewManager = new ViewManager(eventBus);
const columnDetector = new ColumnDetector(); const dragDropManager = new DragDropManager(eventBus, config);
// CalendarManager depends on all other managers // CalendarManager depends on all other managers
const calendarManager = new CalendarManager( const calendarManager = new CalendarManager(
@ -66,7 +67,8 @@ export class ManagerFactory {
scrollManager, scrollManager,
navigationManager, navigationManager,
viewManager, viewManager,
calendarManager calendarManager,
dragDropManager
}; };
} }

View file

@ -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));
}
}

View file

@ -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));
}
}

View file

@ -4,6 +4,7 @@ import { CalendarEvent } from '../types/CalendarTypes';
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig'; import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
import { CalendarConfig } from '../core/CalendarConfig'; import { CalendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator'; import { DateCalculator } from '../utils/DateCalculator';
import { eventBus } from '../core/EventBus';
/** /**
* Interface for event rendering strategies * Interface for event rendering strategies
@ -18,9 +19,312 @@ export interface EventRendererStrategy {
*/ */
export abstract class BaseEventRenderer implements EventRendererStrategy { export abstract class BaseEventRenderer implements EventRendererStrategy {
protected dateCalculator: DateCalculator; protected dateCalculator: DateCalculator;
protected config: CalendarConfig;
// Drag and drop state
private draggedClone: HTMLElement | null = null;
private originalEvent: HTMLElement | null = null;
constructor(config: CalendarConfig) { constructor(config: CalendarConfig) {
this.config = config;
this.dateCalculator = new DateCalculator(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 { renderEvents(events: CalendarEvent[], container: HTMLElement, config: CalendarConfig): void {
console.log('BaseEventRenderer: renderEvents called with', events.length, 'events'); console.log('BaseEventRenderer: renderEvents called with', events.length, 'events');
@ -186,15 +490,22 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
// Color is now handled by CSS classes based on data-type attribute // Color is now handled by CSS classes based on data-type attribute
// Format time for display // Format time for display
const startTime = this.dateCalculator.formatTime(new Date(event.start)); const startTime = this.formatTimeFromISOString(event.start);
const endTime = this.dateCalculator.formatTime(new Date(event.end)); 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 // Create event content
eventElement.innerHTML = ` eventElement.innerHTML = `
<swp-event-time>${startTime} - ${endTime}</swp-event-time> <swp-event-time data-duration="${durationMinutes}">${startTime} - ${endTime}</swp-event-time>
<swp-event-title>${event.title}</swp-event-title> <swp-event-title>${event.title}</swp-event-title>
`; `;
console.log(`BaseEventRenderer: Rendered "${event.title}" with ${durationMinutes} minutes duration`);
container.appendChild(eventElement); container.appendChild(eventElement);
console.log(`BaseEventRenderer: Created event element for "${event.title}":`, { console.log(`BaseEventRenderer: Created event element for "${event.title}":`, {
@ -240,7 +551,7 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
return { top, height }; return { top, height };
} }
protected formatTime(isoString: string): string { protected formatTimeFromISOString(isoString: string): string {
const date = new Date(isoString); const date = new Date(isoString);
const hours = date.getHours(); const hours = date.getHours();
const minutes = date.getMinutes(); const minutes = date.getMinutes();