diff --git a/src/elements/SwpEventElement.ts b/src/elements/SwpEventElement.ts index 60e7b5d..6f79e60 100644 --- a/src/elements/SwpEventElement.ts +++ b/src/elements/SwpEventElement.ts @@ -2,166 +2,243 @@ import { CalendarEvent } from '../types/CalendarTypes'; import { calendarConfig } from '../core/CalendarConfig'; import { TimeFormatter } from '../utils/TimeFormatter'; import { PositionUtils } from '../utils/PositionUtils'; -import { EventLayout } from '../utils/AllDayLayoutEngine'; import { DateService } from '../utils/DateService'; /** - * Abstract base class for event DOM elements + * Base class for event elements */ -export abstract class BaseEventElement { - protected element: HTMLElement; - protected event: CalendarEvent; +abstract class BaseSwpEventElement extends HTMLElement { protected dateService: DateService; - protected constructor(event: CalendarEvent) { - this.event = event; + constructor() { + super(); const timezone = calendarConfig.getTimezone?.(); this.dateService = new DateService(timezone); - this.element = this.createElement(); - this.setDataAttributes(); } - /** - * Create the underlying DOM element - */ - protected abstract createElement(): HTMLElement; + // ============================================ + // Common Getters/Setters + // ============================================ - /** - * Set standard data attributes on the element - */ - protected setDataAttributes(): void { - this.element.dataset.eventId = this.event.id; - this.element.dataset.title = this.event.title; - this.element.dataset.start = this.dateService.toUTC(this.event.start); - this.element.dataset.end = this.dateService.toUTC(this.event.end); - this.element.dataset.type = this.event.type; - this.element.dataset.duration = this.event.metadata?.duration?.toString() || '60'; + get eventId(): string { + return this.dataset.eventId || ''; + } + set eventId(value: string) { + this.dataset.eventId = value; } - /** - * Get the DOM element - */ - public getElement(): HTMLElement { - return this.element; + get start(): Date { + return new Date(this.dataset.start || ''); + } + set start(value: Date) { + this.dataset.start = this.dateService.toUTC(value); } - /** - * Format time for display using TimeFormatter - */ - protected formatTime(date: Date): string { - return TimeFormatter.formatTime(date); + get end(): Date { + return new Date(this.dataset.end || ''); + } + set end(value: Date) { + this.dataset.end = this.dateService.toUTC(value); } - /** - * Calculate event position for timed events using PositionUtils - */ - protected calculateEventPosition(): { top: number; height: number } { - return PositionUtils.calculateEventPosition(this.event.start, this.event.end); + get title(): string { + return this.dataset.title || ''; + } + set title(value: string) { + this.dataset.title = value; + } + + get type(): string { + return this.dataset.type || 'work'; + } + set type(value: string) { + this.dataset.type = value; } } /** - * Timed event element (swp-event) + * Web Component for timed calendar events (Light DOM) */ -export class SwpEventElement extends BaseEventElement { - private constructor(event: CalendarEvent) { - super(event); - this.createInnerStructure(); - this.applyPositioning(); - } +export class SwpEventElement extends BaseSwpEventElement { - protected createElement(): HTMLElement { - return document.createElement('swp-event'); + /** + * Observed attributes - changes trigger attributeChangedCallback + */ + static get observedAttributes() { + return ['data-start', 'data-end', 'data-title', 'data-type']; } /** - * Create inner HTML structure + * Called when element is added to DOM */ - private createInnerStructure(): void { - const timeRange = TimeFormatter.formatTimeRange(this.event.start, this.event.end); - const durationMinutes = (this.event.end.getTime() - this.event.start.getTime()) / (1000 * 60); + connectedCallback() { + if (!this.hasChildNodes()) { + this.render(); + } + this.applyPositioning(); + } - this.element.innerHTML = ` + /** + * Called when observed attribute changes + */ + attributeChangedCallback(name: string, oldValue: string, newValue: string) { + if (oldValue !== newValue && this.isConnected) { + this.updateDisplay(); + } + } + + // ============================================ + // Public Methods + // ============================================ + + /** + * Update event position during drag + * @param columnDate - The date of the column + * @param snappedY - The Y position in pixels + */ + public updatePosition(columnDate: Date, snappedY: number): void { + // 1. Update visual position + this.style.top = `${snappedY + 1}px`; + + // 2. Calculate new timestamps + const { startMinutes, endMinutes } = this.calculateTimesFromPosition(snappedY); + + // 3. Update data attributes (triggers attributeChangedCallback) + const startDate = this.dateService.createDateAtTime(columnDate, startMinutes); + let endDate = this.dateService.createDateAtTime(columnDate, endMinutes); + + // Handle cross-midnight events + if (endMinutes >= 1440) { + const extraDays = Math.floor(endMinutes / 1440); + endDate = this.dateService.addDays(endDate, extraDays); + } + + this.start = startDate; + this.end = endDate; + } + + /** + * Create a clone for drag operations + */ + public createClone(): SwpEventElement { + const clone = this.cloneNode(true) as SwpEventElement; + + // Apply "clone-" prefix to ID + clone.dataset.eventId = `clone-${this.eventId}`; + + // Cache original duration + const timeEl = this.querySelector('swp-event-time'); + if (timeEl) { + const duration = timeEl.getAttribute('data-duration'); + if (duration) { + clone.dataset.originalDuration = duration; + } + } + + // Set height from original + clone.style.height = this.style.height || `${this.getBoundingClientRect().height}px`; + + return clone; + } + + // ============================================ + // Private Methods + // ============================================ + + /** + * Render inner HTML structure + */ + private render(): void { + const start = this.start; + const end = this.end; + const timeRange = TimeFormatter.formatTimeRange(start, end); + const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); + + this.innerHTML = ` ${timeRange} - ${this.event.title} + ${this.title} `; } /** - * Apply positioning styles + * Update time display when attributes change + */ + private updateDisplay(): void { + const timeEl = this.querySelector('swp-event-time'); + const titleEl = this.querySelector('swp-event-title'); + + if (timeEl && this.dataset.start && this.dataset.end) { + const start = new Date(this.dataset.start); + const end = new Date(this.dataset.end); + const timeRange = TimeFormatter.formatTimeRange(start, end); + timeEl.textContent = timeRange; + + // Update duration attribute + const durationMinutes = (end.getTime() - start.getTime()) / (1000 * 60); + timeEl.setAttribute('data-duration', durationMinutes.toString()); + } + + if (titleEl && this.dataset.title) { + titleEl.textContent = this.dataset.title; + } + } + + /** + * Apply initial positioning based on start/end times */ private applyPositioning(): void { - const position = this.calculateEventPosition(); - this.element.style.top = `${position.top + 1}px`; - this.element.style.height = `${position.height - 3}px`; - this.element.style.left = '2px'; - this.element.style.right = '2px'; + const position = PositionUtils.calculateEventPosition(this.start, this.end); + this.style.top = `${position.top + 1}px`; + this.style.height = `${position.height - 3}px`; + this.style.left = '2px'; + this.style.right = '2px'; } /** - * Factory method to create a SwpEventElement from a CalendarEvent + * Calculate start/end minutes from Y position + */ + private calculateTimesFromPosition(snappedY: number): { startMinutes: number; endMinutes: number } { + const gridSettings = calendarConfig.getGridSettings(); + const { hourHeight, dayStartHour, snapInterval } = gridSettings; + + // Get original duration + const originalDuration = parseInt( + this.dataset.originalDuration || + this.dataset.duration || + '60' + ); + + // Calculate snapped start minutes + const minutesFromGridStart = (snappedY / hourHeight) * 60; + const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart; + const snappedStartMinutes = Math.round(actualStartMinutes / snapInterval) * snapInterval; + + // Calculate end minutes + const endMinutes = snappedStartMinutes + originalDuration; + + return { startMinutes: snappedStartMinutes, endMinutes }; + } + + // ============================================ + // Static Factory Methods + // ============================================ + + /** + * Create SwpEventElement from CalendarEvent */ public static fromCalendarEvent(event: CalendarEvent): SwpEventElement { - return new SwpEventElement(event); - } + const element = document.createElement('swp-event') as SwpEventElement; + const timezone = calendarConfig.getTimezone?.(); + const dateService = new DateService(timezone); - /** - * Create a clone of this SwpEventElement with "clone-" prefix - */ - public createClone(): SwpEventElement { - // Clone the underlying DOM element - const clonedElement = this.element.cloneNode(true) as HTMLElement; + element.dataset.eventId = event.id; + element.dataset.title = event.title; + element.dataset.start = dateService.toUTC(event.start); + element.dataset.end = dateService.toUTC(event.end); + element.dataset.type = event.type; + element.dataset.duration = event.metadata?.duration?.toString() || '60'; - // Create new SwpEventElement instance from the cloned DOM - const clonedSwpEvent = SwpEventElement.fromExistingElement(clonedElement); - - // Apply "clone-" prefix to ID - clonedSwpEvent.updateEventId(`clone-${this.event.id}`); - - // Cache original duration for drag operations - const originalDuration = this.getOriginalEventDuration(); - clonedSwpEvent.element.dataset.originalDuration = originalDuration.toString(); - - // Set height from original element - clonedSwpEvent.element.style.height = this.element.style.height || `${this.element.getBoundingClientRect().height}px`; - - return clonedSwpEvent; - } - - /** - * Factory method to create SwpEventElement from existing DOM element - */ - public static fromExistingElement(element: HTMLElement): SwpEventElement { - // Extract CalendarEvent data from DOM element - const event = this.extractCalendarEventFromElement(element); - - // Create new instance but replace the created element with the existing one - const swpEvent = new SwpEventElement(event); - swpEvent.element = element; - - return swpEvent; - } - - /** - * Update the event ID in both the CalendarEvent and DOM element - */ - private updateEventId(newId: string): void { - this.event.id = newId; - this.element.dataset.eventId = newId; - } - - /** - * Extract original event duration from DOM element - */ - private getOriginalEventDuration(): number { - const timeElement = this.element.querySelector('swp-event-time'); - if (timeElement) { - const duration = timeElement.getAttribute('data-duration'); - if (duration) { - return parseInt(duration); - } - } - return 60; // Fallback + return element; } /** @@ -186,7 +263,6 @@ export class SwpEventElement extends BaseEventElement { * Factory method to convert an all-day HTML element to a timed SwpEventElement */ public static fromAllDayElement(allDayElement: HTMLElement): SwpEventElement { - // Extract data from all-day element's dataset const eventId = allDayElement.dataset.eventId || ''; const title = allDayElement.dataset.title || allDayElement.textContent || 'Untitled'; const type = allDayElement.dataset.type || 'work'; @@ -198,11 +274,9 @@ export class SwpEventElement extends BaseEventElement { throw new Error('All-day element missing start/end dates'); } - // Parse dates and set reasonable 1-hour duration for timed event const originalStart = new Date(startStr); - const duration = durationStr ? parseInt(durationStr) : 60; // Default 1 hour + const duration = durationStr ? parseInt(durationStr) : 60; - // For conversion, use current time or a reasonable default (9 AM) const now = new Date(); const startDate = new Date(originalStart); startDate.setHours(now.getHours() || 9, now.getMinutes() || 0, 0, 0); @@ -210,7 +284,6 @@ export class SwpEventElement extends BaseEventElement { const endDate = new Date(startDate); endDate.setMinutes(endDate.getMinutes() + duration); - // Create CalendarEvent object const calendarEvent: CalendarEvent = { id: eventId, title: title, @@ -224,48 +297,49 @@ export class SwpEventElement extends BaseEventElement { } }; - return new SwpEventElement(calendarEvent); + return SwpEventElement.fromCalendarEvent(calendarEvent); } } /** - * All-day event element (now using unified swp-event tag) + * Web Component for all-day calendar events */ -export class SwpAllDayEventElement extends BaseEventElement { +export class SwpAllDayEventElement extends BaseSwpEventElement { - constructor(event: CalendarEvent) { - super(event); - this.setAllDayAttributes(); - this.createInnerStructure(); - // this.applyGridPositioning(); - } - - protected createElement(): HTMLElement { - return document.createElement('swp-event'); - } - - /** - * Set all-day specific attributes - */ - private setAllDayAttributes(): void { - this.element.dataset.allday = "true"; - this.element.dataset.start = this.dateService.toUTC(this.event.start); - this.element.dataset.end = this.dateService.toUTC(this.event.end); - } - - /** - * Create inner structure (just text content for all-day events) - */ - private createInnerStructure(): void { - this.element.textContent = this.event.title; + connectedCallback() { + if (!this.textContent) { + this.textContent = this.dataset.title || 'Untitled'; + } } /** * Apply CSS grid positioning */ - public applyGridPositioning(layout: EventLayout): void { - const gridArea = `${layout.row} / ${layout.startColumn} / ${layout.row + 1} / ${layout.endColumn + 1}`; - this.element.style.gridArea = gridArea; + public applyGridPositioning(row: number, startColumn: number, endColumn: number): void { + const gridArea = `${row} / ${startColumn} / ${row + 1} / ${endColumn + 1}`; + this.style.gridArea = gridArea; } -} \ No newline at end of file + /** + * Create from CalendarEvent + */ + public static fromCalendarEvent(event: CalendarEvent): SwpAllDayEventElement { + const element = document.createElement('swp-allday-event') as SwpAllDayEventElement; + const timezone = calendarConfig.getTimezone?.(); + const dateService = new DateService(timezone); + + element.dataset.eventId = event.id; + element.dataset.title = event.title; + element.dataset.start = dateService.toUTC(event.start); + element.dataset.end = dateService.toUTC(event.end); + element.dataset.type = event.type; + element.dataset.allDay = 'true'; + element.textContent = event.title; + + return element; + } +} + +// Register custom elements +customElements.define('swp-event', SwpEventElement); +customElements.define('swp-allday-event', SwpAllDayEventElement); \ No newline at end of file diff --git a/src/managers/DragDropManager.ts b/src/managers/DragDropManager.ts index 002f96c..e91b250 100644 --- a/src/managers/DragDropManager.ts +++ b/src/managers/DragDropManager.ts @@ -185,12 +185,9 @@ export class DragDropManager { // Detect current column this.currentColumnBounds = ColumnDetectionUtils.getColumnBounds(currentPosition); - // Create SwpEventElement from existing DOM element and clone it - const originalSwpEvent = SwpEventElement.fromExistingElement(this.draggedElement); - const clonedSwpEvent = originalSwpEvent.createClone(); - - // Get the cloned DOM element - this.draggedClone = clonedSwpEvent.getElement(); + // Cast to SwpEventElement and create clone + const originalSwpEvent = this.draggedElement as SwpEventElement; + this.draggedClone = originalSwpEvent.createClone(); const dragStartPayload: DragStartEventPayload = { draggedElement: this.draggedElement, diff --git a/src/renderers/AllDayEventRenderer.ts b/src/renderers/AllDayEventRenderer.ts index c3b9fe8..2c42d4a 100644 --- a/src/renderers/AllDayEventRenderer.ts +++ b/src/renderers/AllDayEventRenderer.ts @@ -76,10 +76,10 @@ export class AllDayEventRenderer { const container = this.getContainer(); if (!container) return null; - let dayEvent = new SwpAllDayEventElement(event); - dayEvent.applyGridPositioning(layout); + const dayEvent = SwpAllDayEventElement.fromCalendarEvent(event); + dayEvent.applyGridPositioning(layout.row, layout.startColumn, layout.endColumn); - container.appendChild(dayEvent.getElement()); + container.appendChild(dayEvent); } diff --git a/src/renderers/EventRenderer.ts b/src/renderers/EventRenderer.ts index c74b0b5..237e961 100644 --- a/src/renderers/EventRenderer.ts +++ b/src/renderers/EventRenderer.ts @@ -43,86 +43,6 @@ export class DateEventRenderer implements EventRendererStrategy { } - /** - * Update clone timestamp based on new position - */ - private updateCloneTimestamp(payload: DragMoveEventPayload): void { - if (payload.draggedClone.dataset.allDay === "true" || !payload.columnBounds) return; - - const gridSettings = calendarConfig.getGridSettings(); - const { hourHeight, dayStartHour, snapInterval } = gridSettings; - - if (!payload.draggedClone.dataset.originalDuration) { - throw new DOMException("missing clone.dataset.originalDuration"); - } - - // Calculate snapped start minutes - const minutesFromGridStart = (payload.snappedY / hourHeight) * 60; - const snappedStartMinutes = this.calculateSnappedMinutes( - minutesFromGridStart, dayStartHour, snapInterval - ); - - // Calculate end minutes - const originalDuration = parseInt(payload.draggedClone.dataset.originalDuration); - const endTotalMinutes = snappedStartMinutes + originalDuration; - - // Update UI - this.updateTimeDisplay(payload.draggedClone, snappedStartMinutes, endTotalMinutes); - - // Update data attributes - this.updateDateTimeAttributes( - payload.draggedClone, - new Date(payload.columnBounds.date), - snappedStartMinutes, - endTotalMinutes - ); - } - - /** - * Calculate snapped minutes from grid start - */ - private calculateSnappedMinutes(minutesFromGridStart: number, dayStartHour: number, snapInterval: number): number { - const actualStartMinutes = (dayStartHour * 60) + minutesFromGridStart; - return Math.round(actualStartMinutes / snapInterval) * snapInterval; - } - - /** - * Update time display in the UI - */ - private updateTimeDisplay(element: HTMLElement, startMinutes: number, endMinutes: number): void { - const timeElement = element.querySelector('swp-event-time'); - if (!timeElement) return; - - const startTime = this.formatTimeFromMinutes(startMinutes); - const endTime = this.formatTimeFromMinutes(endMinutes); - timeElement.textContent = `${startTime} - ${endTime}`; - } - - /** - * Update data-start and data-end attributes with ISO timestamps - */ - private updateDateTimeAttributes(element: HTMLElement, columnDate: Date, startMinutes: number, endMinutes: number): void { - const startDate = this.dateService.createDateAtTime(columnDate, startMinutes); - - let endDate = this.dateService.createDateAtTime(columnDate, endMinutes); - - // Handle cross-midnight events - if (endMinutes >= 1440) { - const extraDays = Math.floor(endMinutes / 1440); - endDate = this.dateService.addDays(endDate, extraDays); - } - - // Convert to UTC before storing as ISO string - element.dataset.start = this.dateService.toUTC(startDate); - element.dataset.end = this.dateService.toUTC(endDate); - } - - /** - * Format minutes since midnight to time string - */ - private formatTimeFromMinutes(totalMinutes: number): string { - return this.dateService.minutesToTime(totalMinutes); - } /** * Handle drag start event @@ -155,15 +75,12 @@ export class DateEventRenderer implements EventRendererStrategy { * Handle drag move event */ public handleDragMove(payload: DragMoveEventPayload): void { - if (!this.draggedClone) return; - - // Update position - snappedY is already the event top position - // Add +1px to match the initial positioning offset from SwpEventElement - this.draggedClone.style.top = (payload.snappedY + 1) + 'px'; - - // Update timestamp display - this.updateCloneTimestamp(payload); + if (!this.draggedClone || !payload.columnBounds) return; + // Delegate to SwpEventElement to update position and timestamps + const swpEvent = this.draggedClone as SwpEventElement; + const columnDate = new Date(payload.columnBounds.date); + swpEvent.updatePosition(columnDate, payload.snappedY); } /** @@ -191,16 +108,9 @@ export class DateEventRenderer implements EventRendererStrategy { // Recalculate timestamps with new column date const currentTop = parseFloat(this.draggedClone.style.top) || 0; - const mockPayload: DragMoveEventPayload = { - draggedElement: dragColumnChangeEvent.originalElement, - draggedClone: this.draggedClone, - mousePosition: dragColumnChangeEvent.mousePosition, - mouseOffset: { x: 0, y: 0 }, - columnBounds: dragColumnChangeEvent.newColumn, - snappedY: currentTop - }; - - this.updateCloneTimestamp(mockPayload); + const swpEvent = this.draggedClone as SwpEventElement; + const columnDate = new Date(dragColumnChangeEvent.newColumn.date); + swpEvent.updatePosition(columnDate, currentTop); } } @@ -272,8 +182,7 @@ export class DateEventRenderer implements EventRendererStrategy { } private renderEvent(event: CalendarEvent): HTMLElement { - const swpEvent = SwpEventElement.fromCalendarEvent(event); - return swpEvent.getElement(); + return SwpEventElement.fromCalendarEvent(event); } protected calculateEventPosition(event: CalendarEvent): { top: number; height: number } { diff --git a/src/renderers/EventRendererManager.ts b/src/renderers/EventRendererManager.ts index ad35e84..959564b 100644 --- a/src/renderers/EventRendererManager.ts +++ b/src/renderers/EventRendererManager.ts @@ -247,8 +247,7 @@ export class EventRenderingService { // Use SwpEventElement factory to create day event from all-day event - const dayEventElement = SwpEventElement.fromAllDayElement(allDayClone as HTMLElement); - const dayElement = dayEventElement.getElement(); + const dayElement = SwpEventElement.fromAllDayElement(allDayClone as HTMLElement); // Remove the all-day clone - it's no longer needed since we're converting to day event allDayClone.remove(); diff --git a/wwwroot/css/calendar-layout-css.css b/wwwroot/css/calendar-layout-css.css index e6c9fd0..d341193 100644 --- a/wwwroot/css/calendar-layout-css.css +++ b/wwwroot/css/calendar-layout-css.css @@ -321,7 +321,7 @@ swp-allday-column { } /* All-day events in containers */ -swp-allday-container swp-event { +swp-allday-container swp-allday-event { height: 22px !important; /* Fixed height for consistent stacking */ position: relative !important; width: auto !important; @@ -353,7 +353,7 @@ swp-allday-container swp-event { } /* Overflow indicator styling */ -swp-allday-container swp-event.max-event-indicator { +swp-allday-container swp-allday-event.max-event-indicator { background: #e0e0e0 !important; color: #666 !important; border: 1px dashed #999 !important; @@ -364,13 +364,13 @@ swp-allday-container swp-event.max-event-indicator { justify-content: center; } -swp-allday-container swp-event.max-event-indicator:hover { +swp-allday-container swp-allday-event.max-event-indicator:hover { background: #d0d0d0 !important; color: #333 !important; opacity: 1; } -swp-allday-container swp-event.max-event-indicator span { +swp-allday-container swp-allday-event.max-event-indicator span { display: block; width: 100%; text-align: center; @@ -378,23 +378,23 @@ swp-allday-container swp-event.max-event-indicator span { font-weight: normal; } -swp-allday-container swp-event.max-event-overflow-show { +swp-allday-container swp-allday-event.max-event-overflow-show { opacity: 1; transition: opacity 0.3s ease-in-out; } -swp-allday-container swp-event.max-event-overflow-hide { +swp-allday-container swp-allday-event.max-event-overflow-hide { opacity: 0; transition: opacity 0.3s ease-in-out; } /* Hide time element for all-day styled events */ -swp-allday-container swp-event swp-event-time{ +swp-allday-container swp-allday-event swp-event-time{ display: none; } /* Adjust title display for all-day styled events */ -swp-allday-container swp-event swp-event-title { +swp-allday-container swp-allday-event swp-event-title { display: block; font-size: 12px; line-height: 18px;