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:
parent
be4a8af7c4
commit
f697944d75
4 changed files with 658 additions and 676 deletions
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
338
src/managers/DragDropManager.ts
Normal file
338
src/managers/DragDropManager.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = `
|
||||
<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>
|
||||
`;
|
||||
|
||||
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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue