diff --git a/src/v2/features/event/EventRenderer.ts b/src/v2/features/event/EventRenderer.ts index e92f7e3..6d9164a 100644 --- a/src/v2/features/event/EventRenderer.ts +++ b/src/v2/features/event/EventRenderer.ts @@ -4,7 +4,7 @@ import { DateService } from '../../core/DateService'; import { IGridConfig } from '../../core/IGridConfig'; import { calculateEventPosition, snapToGrid, pixelsToMinutes } from '../../utils/PositionUtils'; import { CoreEvents } from '../../constants/CoreEvents'; -import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload } from '../../types/DragTypes'; +import { IDragColumnChangePayload, IDragMovePayload, IDragEndPayload, IDragLeaveHeaderPayload } from '../../types/DragTypes'; import { calculateColumnLayout } from './EventLayoutEngine'; import { IGridGroupLayout } from './EventLayoutTypes'; @@ -51,6 +51,11 @@ export class EventRenderer { const payload = (e as CustomEvent).detail; this.handleDragEnd(payload); }); + + this.eventBus.on(CoreEvents.EVENT_DRAG_LEAVE_HEADER, (e) => { + const payload = (e as CustomEvent).detail; + this.handleDragLeaveHeader(payload); + }); } /** @@ -64,6 +69,48 @@ export class EventRenderer { } } + /** + * Handle header item leaving header - create swp-event in grid + */ + private handleDragLeaveHeader(payload: IDragLeaveHeaderPayload): void { + // Only handle when source is header (header item dragged to grid) + if (payload.source !== 'header') return; + if (!payload.targetColumn || !payload.start || !payload.end) return; + + // Turn header item into ghost (stays visible but faded) + if (payload.element) { + payload.element.classList.add('drag-ghost'); + payload.element.style.opacity = '0.3'; + payload.element.style.pointerEvents = 'none'; + } + + // Create event object from header item data + const event: ICalendarEvent = { + id: payload.eventId, + title: payload.title || '', + description: '', + start: payload.start, + end: payload.end, + type: 'customer', + allDay: false, + syncStatus: 'pending' + }; + + // Create swp-event element using existing method + const element = this.createEventElement(event); + + // Add to target column + let eventsLayer = payload.targetColumn.querySelector('swp-events-layer'); + if (!eventsLayer) { + eventsLayer = document.createElement('swp-events-layer'); + payload.targetColumn.appendChild(eventsLayer); + } + eventsLayer.appendChild(element); + + // Mark as dragging so DragDropManager can continue with it + element.classList.add('dragging'); + } + /** * Handle EVENT_UPDATED - re-render affected columns */ diff --git a/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts b/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts index 5fed51b..1fc3864 100644 --- a/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts +++ b/src/v2/features/headerdrawer/HeaderDrawerRenderer.ts @@ -7,7 +7,8 @@ import { DateService } from '../../core/DateService'; import { IDragEnterHeaderPayload, IDragMoveHeaderPayload, - IDragLeaveHeaderPayload + IDragLeaveHeaderPayload, + IDragEndPayload } from '../../types/DragTypes'; /** @@ -199,8 +200,9 @@ export class HeaderDrawerRenderer { this.handleDragLeave(payload); }); - this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => { - this.handleDragEnd(); + this.eventBus.on(CoreEvents.EVENT_DRAG_END, (e) => { + const payload = (e as CustomEvent).detail; + this.handleDragEnd(payload); }); this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => { @@ -290,22 +292,23 @@ export class HeaderDrawerRenderer { } /** - * Handle drag end - finalize the item (it stays in header) - * Note: EventRenderer handles removing the original element from the grid - * via EVENT_DRAG_END with target === 'header' + * Handle drag end - finalize based on drop target */ - private handleDragEnd(): void { - if (!this.currentItem) return; - - // Remove dragging state - this.currentItem.classList.remove('dragging'); - - // Recalculate layout for all items in drawer - this.recalculateDrawerLayout(); - - // Clear references - this.currentItem = null; - this.sourceElement = null; + private handleDragEnd(payload: IDragEndPayload): void { + if (payload.target === 'header') { + // Grid→Header: Finalize the header item (it stays in header) + if (this.currentItem) { + this.currentItem.classList.remove('dragging'); + this.recalculateDrawerLayout(); + this.currentItem = null; + this.sourceElement = null; + } + } else { + // Header→Grid: Remove ghost header item and recalculate + const ghost = document.querySelector(`swp-header-item.drag-ghost[data-event-id="${payload.swpEvent.eventId}"]`); + ghost?.remove(); + this.recalculateDrawerLayout(); + } } /** diff --git a/src/v2/managers/DragDropManager.ts b/src/v2/managers/DragDropManager.ts index 9449c0b..e49b929 100644 --- a/src/v2/managers/DragDropManager.ts +++ b/src/v2/managers/DragDropManager.ts @@ -18,16 +18,17 @@ import { SwpEvent } from '../types/SwpEvent'; interface DragState { eventId: string; element: HTMLElement; - ghostElement: HTMLElement; + ghostElement: HTMLElement | null; // Null for header items startY: number; mouseOffset: IMousePosition; - columnElement: HTMLElement; - currentColumn: HTMLElement; + columnElement: HTMLElement | null; // Null when starting from header + currentColumn: HTMLElement | null; // Null when in header targetY: number; currentY: number; animationId: number; sourceDateKey: string; // Source column date (where drag started) sourceResourceId?: string; // Source column resource (where drag started) + dragSource: 'grid' | 'header'; // Where drag originated } /** @@ -86,23 +87,26 @@ export class DragDropManager { // Ignore if clicking on resize handle if (target.closest('swp-resize-handle')) return; + // Match both swp-event and swp-header-item const eventElement = target.closest('swp-event') as HTMLElement; + const headerItem = target.closest('swp-header-item') as HTMLElement; + const draggable = eventElement || headerItem; - if (!eventElement) return; + if (!draggable) return; // Store for potential drag this.mouseDownPosition = { x: e.clientX, y: e.clientY }; - this.pendingElement = eventElement; + this.pendingElement = draggable; // Calculate mouse offset within element - const rect = eventElement.getBoundingClientRect(); + const rect = draggable.getBoundingClientRect(); this.pendingMouseOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; // Capture pointer for reliable tracking - eventElement.setPointerCapture(e.pointerId); + draggable.setPointerCapture(e.pointerId); }; private handlePointerMove = (e: PointerEvent): void => { @@ -140,12 +144,66 @@ export class DragDropManager { // Stop animation cancelAnimationFrame(this.dragState.animationId); + // Handle based on drag source and target + if (this.dragState.dragSource === 'header') { + // Header item drag end + this.handleHeaderItemDragEnd(); + } else { + // Grid event drag end + this.handleGridEventDragEnd(); + } + + // Cleanup + this.dragState.element.classList.remove('dragging'); + this.dragState = null; + this.inHeader = false; + }; + + /** + * Handle drag end for header items + */ + private handleHeaderItemDragEnd(): void { + if (!this.dragState) return; + + // If dropped in grid (not in header), the swp-event was already created + // by EventRenderer listening to EVENT_DRAG_LEAVE_HEADER + // Just emit drag:end for persistence + + if (!this.inHeader && this.dragState.currentColumn) { + // Dropped in grid - emit drag:end with the new swp-event element + const gridEvent = this.dragState.currentColumn.querySelector( + `swp-event[data-event-id="${this.dragState.eventId}"]` + ) as HTMLElement; + + if (gridEvent) { + const dateKey = this.dragState.currentColumn.dataset.date || ''; + const swpEvent = SwpEvent.fromElement(gridEvent, dateKey, this.gridConfig); + + const payload: IDragEndPayload = { + swpEvent, + sourceDateKey: this.dragState.sourceDateKey, + sourceResourceId: this.dragState.sourceResourceId, + target: 'grid' + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload); + } + } + // If still in header, no persistence needed (stayed in header) + } + + /** + * Handle drag end for grid events + */ + private handleGridEventDragEnd(): void { + if (!this.dragState || !this.dragState.columnElement) return; + // Snap to grid const snappedY = snapToGrid(this.dragState.currentY, this.gridConfig); this.dragState.element.style.top = `${snappedY}px`; // Remove ghost - this.dragState.ghostElement.remove(); + this.dragState.ghostElement?.remove(); // Get dateKey from target column const dateKey = this.dragState.columnElement.dataset.date || ''; @@ -166,19 +224,57 @@ export class DragDropManager { }; this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload); - - // Cleanup - this.dragState.element.classList.remove('dragging'); - this.dragState = null; - this.inHeader = false; - }; + } private initializeDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent): void { const eventId = element.dataset.eventId || ''; + const isHeaderItem = element.tagName.toLowerCase() === 'swp-header-item'; const columnElement = element.closest('swp-day-column') as HTMLElement; - if (!columnElement) return; + // For grid events, we need a column + if (!isHeaderItem && !columnElement) return; + if (isHeaderItem) { + // Header item drag initialization + this.initializeHeaderItemDrag(element, mouseOffset, eventId); + } else { + // Grid event drag initialization + this.initializeGridEventDrag(element, mouseOffset, e, columnElement, eventId); + } + } + + /** + * Initialize drag for a header item (allDay event) + */ + private initializeHeaderItemDrag(element: HTMLElement, mouseOffset: IMousePosition, eventId: string): void { + // Mark as dragging + element.classList.add('dragging'); + + // Initialize drag state for header item + this.dragState = { + eventId, + element, + ghostElement: null, // No ghost for header items + startY: 0, + mouseOffset, + columnElement: null, + currentColumn: null, + targetY: 0, + currentY: 0, + animationId: 0, + sourceDateKey: '', // Will be set from header item data + sourceResourceId: undefined, + dragSource: 'header' + }; + + // Start in header mode + this.inHeader = true; + } + + /** + * Initialize drag for a grid event + */ + private initializeGridEventDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent, columnElement: HTMLElement, eventId: string): void { // Calculate absolute Y position using getBoundingClientRect const elementRect = element.getBoundingClientRect(); const columnRect = columnElement.getBoundingClientRect(); @@ -228,7 +324,8 @@ export class DragDropManager { currentY: startY, animationId: 0, sourceDateKey: columnElement.dataset.date || '', - sourceResourceId: columnElement.dataset.resourceId + sourceResourceId: columnElement.dataset.resourceId, + dragSource: 'grid' }; // Emit drag:start @@ -258,7 +355,14 @@ export class DragDropManager { // Check for column change const columnAtPoint = this.getColumnAtPoint(e.clientX); - if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn) { + + // For header items entering grid, set initial column + if (this.dragState.dragSource === 'header' && columnAtPoint && !this.dragState.currentColumn) { + this.dragState.currentColumn = columnAtPoint; + this.dragState.columnElement = columnAtPoint; + } + + if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn && this.dragState.currentColumn) { const payload: IDragColumnChangePayload = { eventId: this.dragState.eventId, element: this.dragState.element, @@ -272,6 +376,9 @@ export class DragDropManager { this.dragState.columnElement = columnAtPoint; } + // Skip grid position updates if no column yet + if (!this.dragState.columnElement) return; + const columnRect = this.dragState.columnElement.getBoundingClientRect(); const targetY = e.clientY - columnRect.top - this.dragState.mouseOffset.y; @@ -296,30 +403,53 @@ export class DragDropManager { const isInHeader = e.clientY < rect.bottom; if (isInHeader && !this.inHeader) { - // Entered header + // Entered header (from grid) this.inHeader = true; - const payload: IDragEnterHeaderPayload = { - eventId: this.dragState.eventId, - element: this.dragState.element, - sourceColumnIndex: this.getColumnIndex(this.dragState.columnElement), - sourceDate: this.dragState.columnElement.dataset.date || '', - title: this.dragState.element.querySelector('swp-event-title')?.textContent || '', - colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-')), - itemType: 'event', - duration: 1 - }; + if (this.dragState.dragSource === 'grid' && this.dragState.columnElement) { + const payload: IDragEnterHeaderPayload = { + eventId: this.dragState.eventId, + element: this.dragState.element, + sourceColumnIndex: this.getColumnIndex(this.dragState.columnElement), + sourceDate: this.dragState.columnElement.dataset.date || '', + title: this.dragState.element.querySelector('swp-event-title')?.textContent || '', + colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-')), + itemType: 'event', + duration: 1 + }; - this.eventBus.emit(CoreEvents.EVENT_DRAG_ENTER_HEADER, payload); + this.eventBus.emit(CoreEvents.EVENT_DRAG_ENTER_HEADER, payload); + } + // For header source re-entering header, just update inHeader flag } else if (!isInHeader && this.inHeader) { - // Left header + // Left header (entering grid) this.inHeader = false; - const payload: IDragLeaveHeaderPayload = { - eventId: this.dragState.eventId - }; + const targetColumn = this.getColumnAtPoint(e.clientX); - this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload); + if (this.dragState.dragSource === 'header') { + // Header item leaving header → create swp-event in grid + const payload: IDragLeaveHeaderPayload = { + eventId: this.dragState.eventId, + source: 'header', + element: this.dragState.element, + targetColumn: targetColumn || undefined, + start: this.dragState.element.dataset.start ? new Date(this.dragState.element.dataset.start) : undefined, + end: this.dragState.element.dataset.end ? new Date(this.dragState.element.dataset.end) : undefined, + title: this.dragState.element.textContent || '', + colorClass: [...this.dragState.element.classList].find(c => c.startsWith('is-')) + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload); + } else { + // Grid event leaving header → restore to grid + const payload: IDragLeaveHeaderPayload = { + eventId: this.dragState.eventId, + source: 'grid' + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload); + } } else if (isInHeader) { // Moving within header const column = this.getColumnAtX(e.clientX); @@ -338,8 +468,8 @@ export class DragDropManager { /** * Get column index (0-based) for a column element */ - private getColumnIndex(column: HTMLElement): number { - if (!this.container) return 0; + private getColumnIndex(column: HTMLElement | null): number { + if (!this.container || !column) return 0; const columns = Array.from(this.container.querySelectorAll('swp-day-column')); return columns.indexOf(column); } @@ -384,15 +514,17 @@ export class DragDropManager { // Update element position this.dragState.element.style.top = `${this.dragState.currentY}px`; - // Emit drag:move - const payload: IDragMovePayload = { - eventId: this.dragState.eventId, - element: this.dragState.element, - currentY: this.dragState.currentY, - columnElement: this.dragState.columnElement - }; + // Emit drag:move (only if we have a column) + if (this.dragState.columnElement) { + const payload: IDragMovePayload = { + eventId: this.dragState.eventId, + element: this.dragState.element, + currentY: this.dragState.currentY, + columnElement: this.dragState.columnElement + }; - this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE, payload); + this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE, payload); + } // Continue animation this.dragState.animationId = requestAnimationFrame(this.animateDrag); @@ -413,9 +545,9 @@ export class DragDropManager { element.style.transition = 'top 200ms ease-out'; element.style.top = `${startY}px`; - // Remove ghost after animation + // Remove ghost after animation (if exists) setTimeout(() => { - ghostElement.remove(); + ghostElement?.remove(); element.style.transition = ''; element.classList.remove('dragging'); }, 200); diff --git a/src/v2/managers/EventPersistenceManager.ts b/src/v2/managers/EventPersistenceManager.ts index f833630..e71f486 100644 --- a/src/v2/managers/EventPersistenceManager.ts +++ b/src/v2/managers/EventPersistenceManager.ts @@ -41,13 +41,15 @@ export class EventPersistenceManager { } // Update and save - start/end already calculated in SwpEvent - // If dropped in header, mark as allDay + // Set allDay based on drop target: + // - header: allDay = true + // - grid: allDay = false (converts allDay event to timed) const updatedEvent: ICalendarEvent = { ...event, start: swpEvent.start, end: swpEvent.end, resourceId: swpEvent.resourceId ?? event.resourceId, - allDay: payload.target === 'header' ? true : event.allDay, + allDay: payload.target === 'header', syncStatus: 'pending' }; diff --git a/src/v2/types/DragTypes.ts b/src/v2/types/DragTypes.ts index 31de064..32f6dbd 100644 --- a/src/v2/types/DragTypes.ts +++ b/src/v2/types/DragTypes.ts @@ -66,4 +66,12 @@ export interface IDragMoveHeaderPayload { export interface IDragLeaveHeaderPayload { eventId: string; + source: 'grid' | 'header'; // Where drag originated + // Header→grid fields (when source === 'header') + element?: HTMLElement; // Header item element + targetColumn?: HTMLElement; // Target column in grid + start?: Date; // Event start from header item + end?: Date; // Event end from header item + title?: string; + colorClass?: string; }