diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index 2739a80..fc1d442 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -50,6 +50,9 @@ import { ScheduleOverrideStore } from './storage/schedules/ScheduleOverrideStore import { ScheduleOverrideService } from './storage/schedules/ScheduleOverrideService'; import { ResourceScheduleService } from './storage/schedules/ResourceScheduleService'; +// Managers +import { DragDropManager } from './managers/DragDropManager'; + const defaultTimeFormatConfig: ITimeFormatConfig = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, use24HourFormat: true, @@ -145,6 +148,7 @@ export function createV2Container(): Container { builder.registerType(TimeAxisRenderer).as(); builder.registerType(ScrollManager).as(); builder.registerType(HeaderDrawerManager).as(); + builder.registerType(DragDropManager).as(); // Demo app builder.registerType(DemoApp).as(); diff --git a/src/v2/constants/CoreEvents.ts b/src/v2/constants/CoreEvents.ts index d4f532f..0472d9a 100644 --- a/src/v2/constants/CoreEvents.ts +++ b/src/v2/constants/CoreEvents.ts @@ -30,6 +30,12 @@ export const CoreEvents = { EVENT_DELETED: 'event:deleted', EVENT_SELECTED: 'event:selected', + // Event drag-drop + EVENT_DRAG_START: 'event:drag-start', + EVENT_DRAG_MOVE: 'event:drag-move', + EVENT_DRAG_END: 'event:drag-end', + EVENT_DRAG_CANCEL: 'event:drag-cancel', + // System events ERROR: 'system:error', diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index d8e535a..d29c08d 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -7,6 +7,7 @@ import { HeaderDrawerManager } from '../core/HeaderDrawerManager'; import { IndexedDBContext } from '../storage/IndexedDBContext'; import { DataSeeder } from '../workers/DataSeeder'; import { ViewConfig } from '../core/ViewConfig'; +import { DragDropManager } from '../managers/DragDropManager'; export class DemoApp { private animator!: NavigationAnimator; @@ -21,7 +22,8 @@ export class DemoApp { private scrollManager: ScrollManager, private headerDrawerManager: HeaderDrawerManager, private indexedDBContext: IndexedDBContext, - private dataSeeder: DataSeeder + private dataSeeder: DataSeeder, + private dragDropManager: DragDropManager ) {} async init(): Promise { @@ -50,6 +52,9 @@ export class DemoApp { // Init header drawer this.headerDrawerManager.init(this.container); + // Init drag-drop + this.dragDropManager.init(this.container); + // Setup event handlers this.setupNavigation(); this.setupDrawerToggle(); diff --git a/src/v2/managers/DragDropManager.ts b/src/v2/managers/DragDropManager.ts new file mode 100644 index 0000000..b60d1c7 --- /dev/null +++ b/src/v2/managers/DragDropManager.ts @@ -0,0 +1,271 @@ +import { IEventBus } from '../types/CalendarTypes'; +import { IGridConfig } from '../core/IGridConfig'; +import { CoreEvents } from '../constants/CoreEvents'; +import { snapToGrid } from '../utils/PositionUtils'; +import { + IMousePosition, + IDragStartPayload, + IDragMovePayload, + IDragEndPayload, + IDragCancelPayload +} from '../types/DragTypes'; + +interface DragState { + eventId: string; + element: HTMLElement; + ghostElement: HTMLElement; + startY: number; + mouseOffset: IMousePosition; + columnElement: HTMLElement; + targetY: number; + currentY: number; + animationId: number; +} + +/** + * DragDropManager - Handles drag-drop for calendar events + * + * Strategy: Drag original element, leave ghost-clone in place + * - mousedown: Store initial state, wait for movement + * - mousemove (>5px): Create ghost, start dragging original + * - mouseup: Snap to grid, remove ghost, emit drag:end + * - cancel: Animate back to startY, remove ghost + */ +export class DragDropManager { + private dragState: DragState | null = null; + private mouseDownPosition: IMousePosition | null = null; + private pendingElement: HTMLElement | null = null; + private pendingMouseOffset: IMousePosition | null = null; + + private readonly DRAG_THRESHOLD = 5; + private readonly INTERPOLATION_FACTOR = 0.3; + + constructor( + private eventBus: IEventBus, + private gridConfig: IGridConfig + ) {} + + /** + * Initialize drag-drop on a container element + */ + init(container: HTMLElement): void { + container.addEventListener('pointerdown', this.handlePointerDown); + document.addEventListener('pointermove', this.handlePointerMove); + document.addEventListener('pointerup', this.handlePointerUp); + } + + private handlePointerDown = (e: PointerEvent): void => { + const target = e.target as HTMLElement; + const eventElement = target.closest('swp-event') as HTMLElement; + + if (!eventElement) return; + + // Store for potential drag + this.mouseDownPosition = { x: e.clientX, y: e.clientY }; + this.pendingElement = eventElement; + + // Calculate mouse offset within element + const rect = eventElement.getBoundingClientRect(); + this.pendingMouseOffset = { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + + // Capture pointer for reliable tracking + eventElement.setPointerCapture(e.pointerId); + }; + + private handlePointerMove = (e: PointerEvent): void => { + // Not in potential drag state + if (!this.mouseDownPosition || !this.pendingElement) { + // Already dragging - update target + if (this.dragState) { + this.updateDragTarget(e); + } + return; + } + + // Check threshold + const deltaX = Math.abs(e.clientX - this.mouseDownPosition.x); + const deltaY = Math.abs(e.clientY - this.mouseDownPosition.y); + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (distance < this.DRAG_THRESHOLD) return; + + // Start drag + this.initializeDrag(this.pendingElement, this.pendingMouseOffset!, e); + this.mouseDownPosition = null; + this.pendingElement = null; + this.pendingMouseOffset = null; + }; + + private handlePointerUp = (_e: PointerEvent): void => { + // Clear pending state + this.mouseDownPosition = null; + this.pendingElement = null; + this.pendingMouseOffset = null; + + if (!this.dragState) return; + + // Stop animation + cancelAnimationFrame(this.dragState.animationId); + + // Snap to grid + const snappedY = snapToGrid(this.dragState.currentY, this.gridConfig); + this.dragState.element.style.top = `${snappedY}px`; + + // Remove ghost + this.dragState.ghostElement.remove(); + + // Get column data + const dateKey = this.dragState.columnElement.dataset.date || ''; + const resourceId = this.dragState.columnElement.dataset.resourceId; + + // Emit drag:end + const payload: IDragEndPayload = { + eventId: this.dragState.eventId, + element: this.dragState.element, + snappedY, + columnElement: this.dragState.columnElement, + dateKey, + resourceId + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload); + + // Cleanup + this.dragState.element.classList.remove('dragging'); + this.dragState = null; + }; + + private initializeDrag(element: HTMLElement, mouseOffset: IMousePosition, e: PointerEvent): void { + const eventId = element.dataset.eventId || ''; + const columnElement = element.closest('swp-day-column') as HTMLElement; + + if (!columnElement) return; + const startY = parseFloat(element.style.top) || 0; + + // Create ghost clone + const ghostElement = element.cloneNode(true) as HTMLElement; + ghostElement.classList.add('drag-ghost'); + ghostElement.style.opacity = '0.3'; + ghostElement.style.pointerEvents = 'none'; + + // Insert ghost before original + element.parentNode?.insertBefore(ghostElement, element); + + // Setup element for dragging + element.classList.add('dragging'); + + // Calculate initial target from mouse position + const columnRect = columnElement.getBoundingClientRect(); + const targetY = e.clientY - columnRect.top - mouseOffset.y; + + // Initialize drag state + this.dragState = { + eventId, + element, + ghostElement, + startY, + mouseOffset, + columnElement, + targetY: Math.max(0, targetY), + currentY: startY, + animationId: 0 + }; + + // Emit drag:start + const payload: IDragStartPayload = { + eventId, + element, + ghostElement, + startY, + mouseOffset, + columnElement + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_START, payload); + + // Start animation loop + this.animateDrag(); + } + + private updateDragTarget(e: PointerEvent): void { + if (!this.dragState) return; + + const columnRect = this.dragState.columnElement.getBoundingClientRect(); + const targetY = e.clientY - columnRect.top - this.dragState.mouseOffset.y; + + this.dragState.targetY = Math.max(0, targetY); + + // Start animation if not running + if (!this.dragState.animationId) { + this.animateDrag(); + } + } + + private animateDrag = (): void => { + if (!this.dragState) return; + + const diff = this.dragState.targetY - this.dragState.currentY; + + // Stop animation when close enough to target + if (Math.abs(diff) <= 0.5) { + this.dragState.animationId = 0; + return; + } + + // Interpolate towards target + this.dragState.currentY += diff * this.INTERPOLATION_FACTOR; + + // 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 + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE, payload); + + // Continue animation + this.dragState.animationId = requestAnimationFrame(this.animateDrag); + }; + + /** + * Cancel drag and animate back to start position + */ + cancelDrag(): void { + if (!this.dragState) return; + + // Stop animation + cancelAnimationFrame(this.dragState.animationId); + + const { element, ghostElement, startY, eventId } = this.dragState; + + // Animate back to start + element.style.transition = 'top 200ms ease-out'; + element.style.top = `${startY}px`; + + // Remove ghost after animation + setTimeout(() => { + ghostElement.remove(); + element.style.transition = ''; + element.classList.remove('dragging'); + }, 200); + + // Emit drag:cancel + const payload: IDragCancelPayload = { + eventId, + element, + startY + }; + + this.eventBus.emit(CoreEvents.EVENT_DRAG_CANCEL, payload); + + this.dragState = null; + } +} diff --git a/src/v2/types/DragTypes.ts b/src/v2/types/DragTypes.ts new file mode 100644 index 0000000..0b72b3a --- /dev/null +++ b/src/v2/types/DragTypes.ts @@ -0,0 +1,39 @@ +/** + * DragTypes - Event payloads for drag-drop operations + */ + +export interface IMousePosition { + x: number; + y: number; +} + +export interface IDragStartPayload { + eventId: string; + element: HTMLElement; // Original element (being dragged) + ghostElement: HTMLElement; // Ghost clone (stays in place) + startY: number; // Original Y position + mouseOffset: IMousePosition; // Click position within element + columnElement: HTMLElement; +} + +export interface IDragMovePayload { + eventId: string; + element: HTMLElement; + currentY: number; // Interpolated Y position (smooth) + columnElement: HTMLElement; +} + +export interface IDragEndPayload { + eventId: string; + element: HTMLElement; + snappedY: number; // Final snapped position + columnElement: HTMLElement; + dateKey: string; // From column dataset + resourceId?: string; // From column dataset (resource mode) +} + +export interface IDragCancelPayload { + eventId: string; + element: HTMLElement; + startY: number; // Position to animate back to +} diff --git a/wwwroot/css/v2/calendar-v2-events.css b/wwwroot/css/v2/calendar-v2-events.css index d6c8295..306a141 100644 --- a/wwwroot/css/v2/calendar-v2-events.css +++ b/wwwroot/css/v2/calendar-v2-events.css @@ -35,10 +35,17 @@ swp-day-columns swp-event { &.dragging { position: absolute; z-index: 999999; - opacity: 0.8; left: 2px; right: 2px; width: auto; + cursor: grabbing; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + /* Ghost clone (stays in original position during drag) */ + &.drag-ghost { + opacity: 0.3; + pointer-events: none; } /* Hover state */