Refactors event positioning and drag-and-drop

Centralizes event position calculations into `PositionUtils` for consistency and reusability across managers and renderers.

Improves drag-and-drop functionality by emitting events for all-day event conversion and streamlining position calculations during drag operations.

Introduces `AllDayManager` and `AllDayEventRenderer` to manage and render all-day events in the calendar header. This allows dragging events to the header to convert them to all-day events.
This commit is contained in:
Janus Knudsen 2025-09-13 00:39:56 +02:00
parent 8b96376d1f
commit 7054c0d40a
9 changed files with 404 additions and 72 deletions

View file

@ -1,6 +1,7 @@
import { CalendarEvent } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
import { TimeFormatter } from '../utils/TimeFormatter';
import { PositionUtils } from '../utils/PositionUtils';
/**
* Abstract base class for event DOM elements
@ -47,22 +48,10 @@ export abstract class BaseEventElement {
}
/**
* Calculate event position for timed events
* Calculate event position for timed events using PositionUtils
*/
protected calculateEventPosition(): { top: number; height: number } {
const gridSettings = calendarConfig.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const hourHeight = gridSettings.hourHeight;
const startMinutes = this.event.start.getHours() * 60 + this.event.start.getMinutes();
const endMinutes = this.event.end.getHours() * 60 + this.event.end.getMinutes();
const dayStartMinutes = dayStartHour * 60;
const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight;
const durationMinutes = endMinutes - startMinutes;
const height = (durationMinutes / 60) * hourHeight;
return { top, height };
return PositionUtils.calculateEventPosition(this.event.start, this.event.end);
}
}

View file

@ -7,6 +7,7 @@ import { NavigationManager } from '../managers/NavigationManager';
import { ViewManager } from '../managers/ViewManager';
import { CalendarManager } from '../managers/CalendarManager';
import { DragDropManager } from '../managers/DragDropManager';
import { AllDayManager } from '../managers/AllDayManager';
/**
* Factory for creating and managing calendar managers with proper dependency injection
@ -35,6 +36,7 @@ export class ManagerFactory {
viewManager: ViewManager;
calendarManager: CalendarManager;
dragDropManager: DragDropManager;
allDayManager: AllDayManager;
} {
// Create managers in dependency order
@ -45,6 +47,7 @@ export class ManagerFactory {
const navigationManager = new NavigationManager(eventBus, eventRenderer);
const viewManager = new ViewManager(eventBus);
const dragDropManager = new DragDropManager(eventBus);
const allDayManager = new AllDayManager();
// CalendarManager depends on all other managers
const calendarManager = new CalendarManager(
@ -64,7 +67,8 @@ export class ManagerFactory {
navigationManager,
viewManager,
calendarManager,
dragDropManager
dragDropManager,
allDayManager
};
}

View file

@ -2,6 +2,8 @@
import { eventBus } from '../core/EventBus';
import { ALL_DAY_CONSTANTS } from '../core/CalendarConfig';
import { AllDayEventRenderer } from '../renderers/AllDayEventRenderer';
import { CalendarEvent } from '../types/CalendarTypes';
/**
* AllDayManager - Handles all-day row height animations and management
@ -11,10 +13,25 @@ export class AllDayManager {
private cachedAllDayContainer: HTMLElement | null = null;
private cachedCalendarHeader: HTMLElement | null = null;
private cachedHeaderSpacer: HTMLElement | null = null;
private allDayEventRenderer: AllDayEventRenderer;
constructor() {
// Bind methods for event listeners
this.checkAndAnimateAllDayHeight = this.checkAndAnimateAllDayHeight.bind(this);
this.allDayEventRenderer = new AllDayEventRenderer();
// Listen for drag-to-allday conversions
this.setupEventListeners();
}
/**
* Setup event listeners for drag conversions
*/
private setupEventListeners(): void {
eventBus.on('drag:convert-to-allday', (event) => {
const { targetDate, originalElement } = (event as CustomEvent).detail;
this.handleConvertToAllDay(targetDate, originalElement);
});
}
/**
@ -204,6 +221,58 @@ export class AllDayManager {
});
}
/**
* Handle conversion of timed event to all-day event
*/
private handleConvertToAllDay(targetDate: string, originalElement: HTMLElement): void {
// Extract event data from original element
const eventId = originalElement.dataset.eventId;
const title = originalElement.dataset.title || originalElement.textContent || 'Untitled';
const type = originalElement.dataset.type || 'work';
const startStr = originalElement.dataset.start;
const endStr = originalElement.dataset.end;
if (!eventId || !startStr || !endStr) {
console.error('Original element missing required data (eventId, start, end)');
return;
}
// Create CalendarEvent for all-day conversion - preserve original times
const originalStart = new Date(startStr);
const originalEnd = new Date(endStr);
// Set date to target date but keep original time
const targetStart = new Date(targetDate);
targetStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), originalStart.getMilliseconds());
const targetEnd = new Date(targetDate);
targetEnd.setHours(originalEnd.getHours(), originalEnd.getMinutes(), originalEnd.getSeconds(), originalEnd.getMilliseconds());
const calendarEvent: CalendarEvent = {
id: eventId,
title: title,
start: targetStart,
end: targetEnd,
type: type,
allDay: true,
syncStatus: 'synced',
metadata: {
duration: originalElement.dataset.duration || '60'
}
};
// Use renderer to create and add all-day event
const allDayElement = this.allDayEventRenderer.renderAllDayEvent(calendarEvent, targetDate);
if (allDayElement) {
// Remove original timed event
originalElement.remove();
// Animate height change
this.checkAndAnimateAllDayHeight();
}
}
/**
* Update row height when all-day events change
*/

View file

@ -6,6 +6,7 @@
import { IEventBus } from '../types/CalendarTypes';
import { calendarConfig } from '../core/CalendarConfig';
import { DateCalculator } from '../utils/DateCalculator';
import { PositionUtils } from '../utils/PositionUtils';
interface CachedElements {
scrollContainer: HTMLElement | null;
@ -93,14 +94,13 @@ export class DragDropManager {
// Listen for header mouseover events
this.eventBus.on('header:mouseover', (event) => {
const { element, targetDate, headerRenderer } = (event as CustomEvent).detail;
const { targetDate, headerRenderer } = (event as CustomEvent).detail;
if (this.draggedEventId && targetDate) {
// Emit event to convert to all-day
this.eventBus.emit('drag:convert-to-allday', {
eventId: this.draggedEventId,
targetDate,
element,
originalElement: this.originalElement,
headerRenderer
});
}
@ -110,7 +110,7 @@ export class DragDropManager {
this.eventBus.on('column:mouseover', (event) => {
const { targetColumn, targetY } = (event as CustomEvent).detail;
if ((event as any).buttons === 1 && this.draggedEventId && this.isAllDayEventBeingDragged()) {
if (this.draggedEventId && this.isAllDayEventBeingDragged()) {
// Emit event to convert to timed
this.eventBus.emit('drag:convert-to-timed', {
eventId: this.draggedEventId,
@ -291,7 +291,7 @@ export class DragDropManager {
}
/**
* Consolidated position calculation method
* Consolidated position calculation method using PositionUtils
*/
private calculateDragPosition(mousePosition: Position): { column: string | null; snappedY: number } {
const column = this.detectColumn(mousePosition.x, mousePosition.y);
@ -310,15 +310,14 @@ export class DragDropManager {
const columnElement = this.getCachedColumnElement(targetColumn);
if (!columnElement) return mouseY;
const columnRect = columnElement.getBoundingClientRect();
const relativeY = mouseY - columnRect.top - this.mouseOffset.y;
const relativeY = PositionUtils.getPositionFromCoordinate(mouseY, columnElement);
// Return free position (no snapping)
return Math.max(0, relativeY);
}
/**
* Optimized snap position calculation with caching (used only on drop)
* Optimized snap position calculation using PositionUtils
*/
private calculateSnapPosition(mouseY: number, column: string | null = null): number {
const targetColumn = column || this.currentColumn;
@ -327,11 +326,8 @@ export class DragDropManager {
const columnElement = this.getCachedColumnElement(targetColumn);
if (!columnElement) return mouseY;
const columnRect = columnElement.getBoundingClientRect();
const relativeY = mouseY - columnRect.top - this.mouseOffset.y;
// Snap to nearest interval using DateCalculator precision
const snappedY = Math.round(relativeY / this.snapDistancePx) * this.snapDistancePx;
// Use PositionUtils for consistent snapping behavior
const snappedY = PositionUtils.getPositionFromCoordinate(mouseY, columnElement);
return Math.max(0, snappedY);
}

View file

@ -3,6 +3,7 @@
import { eventBus } from '../core/EventBus';
import { calendarConfig } from '../core/CalendarConfig';
import { CoreEvents } from '../constants/CoreEvents';
import { PositionUtils } from '../utils/PositionUtils';
/**
* Manages scrolling functionality for the calendar using native scrollbars
@ -96,13 +97,12 @@ export class ScrollManager {
}
/**
* Scroll to specific hour
* Scroll to specific hour using PositionUtils
*/
scrollToHour(hour: number): void {
const gridSettings = calendarConfig.getGridSettings();
const hourHeight = gridSettings.hourHeight;
const dayStartHour = gridSettings.dayStartHour;
const scrollTop = (hour - dayStartHour) * hourHeight;
// Create time string for the hour
const timeString = `${hour.toString().padStart(2, '0')}:00`;
const scrollTop = PositionUtils.timeToPixels(timeString);
this.scrollTo(scrollTop);
}

View file

@ -2,6 +2,7 @@
import { DateCalculator } from '../utils/DateCalculator';
import { calendarConfig } from '../core/CalendarConfig';
import { PositionUtils } from '../utils/PositionUtils';
/**
* Work hours for a specific day
@ -91,7 +92,7 @@ export class WorkHoursManager {
}
/**
* Calculate CSS custom properties for non-work hour overlays (before and after work)
* Calculate CSS custom properties for non-work hour overlays using PositionUtils
*/
calculateNonWorkHoursStyle(workHours: DayWorkHours | 'off'): { beforeWorkHeight: number; afterWorkTop: number } | null {
if (workHours === 'off') {
@ -100,7 +101,6 @@ export class WorkHoursManager {
const gridSettings = calendarConfig.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const dayEndHour = gridSettings.dayEndHour;
const hourHeight = gridSettings.hourHeight;
// Before work: from day start to work start
@ -109,28 +109,28 @@ export class WorkHoursManager {
// After work: from work end to day end
const afterWorkTop = (workHours.end - dayStartHour) * hourHeight;
return {
beforeWorkHeight: Math.max(0, beforeWorkHeight),
afterWorkTop: Math.max(0, afterWorkTop)
return {
beforeWorkHeight: Math.max(0, beforeWorkHeight),
afterWorkTop: Math.max(0, afterWorkTop)
};
}
/**
* Calculate CSS custom properties for work hours overlay (legacy - for backward compatibility)
* Calculate CSS custom properties for work hours overlay using PositionUtils
*/
calculateWorkHoursStyle(workHours: DayWorkHours | 'off'): { top: number; height: number } | null {
if (workHours === 'off') {
return null;
}
const gridSettings = calendarConfig.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const hourHeight = gridSettings.hourHeight;
// Create dummy time strings for start and end of work hours
const startTime = `${workHours.start.toString().padStart(2, '0')}:00`;
const endTime = `${workHours.end.toString().padStart(2, '0')}:00`;
const top = (workHours.start - dayStartHour) * hourHeight;
const height = (workHours.end - workHours.start) * hourHeight;
// Use PositionUtils for consistent position calculation
const position = PositionUtils.calculateEventPosition(startTime, endTime);
return { top, height };
return { top: position.top, height: position.height };
}
/**

View file

@ -1,15 +1,61 @@
// All-day event rendering using factory pattern
import { CalendarEvent } from '../types/CalendarTypes';
import { SwpAllDayEventElement } from '../elements/SwpEventElement';
import { DateCalculator } from '../utils/DateCalculator';
/**
* AllDayEventRenderer - Handles rendering of all-day events in header row
* Uses factory pattern with SwpAllDayEventElement for clean DOM creation
* AllDayEventRenderer - Simple rendering of all-day events
* Handles adding and removing all-day events from the header container
*/
export class AllDayEventRenderer {
private container: HTMLElement | null = null;
constructor() {
this.getContainer();
}
/**
* Get or cache all-day container
*/
private getContainer(): HTMLElement | null {
if (!this.container) {
const header = document.querySelector('swp-calendar-header');
if (header) {
this.container = header.querySelector('swp-allday-container');
}
}
return this.container;
}
/**
* Render an all-day event using factory pattern
*/
public renderAllDayEvent(event: CalendarEvent, targetDate: string): HTMLElement | null {
const container = this.getContainer();
if (!container) return null;
const allDayElement = SwpAllDayEventElement.fromCalendarEvent(event, targetDate);
const element = allDayElement.getElement();
container.appendChild(element);
return element;
}
/**
* Remove an all-day event by ID
*/
public removeAllDayEvent(eventId: string): void {
const container = this.getContainer();
if (!container) return;
const eventElement = container.querySelector(`swp-allday-event[data-event-id="${eventId}"]`);
if (eventElement) {
eventElement.remove();
}
}
/**
* Clear cache when DOM changes
*/
public clearCache(): void {
this.container = null;
}
}

View file

@ -8,6 +8,7 @@ import { CoreEvents } from '../constants/CoreEvents';
import { OverlapDetector, OverlapResult, EventId } from '../utils/OverlapDetector';
import { SwpEventElement, SwpAllDayEventElement } from '../elements/SwpEventElement';
import { TimeFormatter } from '../utils/TimeFormatter';
import { PositionUtils } from '../utils/PositionUtils';
/**
* Interface for event rendering strategies
@ -695,26 +696,8 @@ export abstract class BaseEventRenderer implements EventRendererStrategy {
}
protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } {
const gridSettings = calendarConfig.getGridSettings();
const dayStartHour = gridSettings.dayStartHour;
const hourHeight = gridSettings.hourHeight;
// Calculate minutes from midnight
const startMinutes = event.start.getHours() * 60 + event.start.getMinutes();
const endMinutes = event.end.getHours() * 60 + event.end.getMinutes();
const dayStartMinutes = dayStartHour * 60;
// Calculate top position relative to visible grid start
// If dayStartHour=6 and event starts at 09:00 (540 min), then:
// top = ((540 - 360) / 60) * hourHeight = 3 * hourHeight (3 hours from grid start)
const top = ((startMinutes - dayStartMinutes) / 60) * hourHeight;
// Calculate height based on event duration
const durationMinutes = endMinutes - startMinutes;
const height = (durationMinutes / 60) * hourHeight;
return { top, height };
// Delegate to PositionUtils for centralized position calculation
return PositionUtils.calculateEventPosition(event.start, event.end);
}
clearEvents(container?: HTMLElement): void {