2025-12-10 17:07:03 +01:00
|
|
|
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,
|
2025-12-10 17:18:37 +01:00
|
|
|
IDragCancelPayload,
|
2025-12-10 23:11:11 +01:00
|
|
|
IDragColumnChangePayload,
|
|
|
|
|
IDragEnterHeaderPayload,
|
|
|
|
|
IDragMoveHeaderPayload,
|
|
|
|
|
IDragLeaveHeaderPayload
|
2025-12-10 17:07:03 +01:00
|
|
|
} from '../types/DragTypes';
|
|
|
|
|
|
|
|
|
|
interface DragState {
|
|
|
|
|
eventId: string;
|
|
|
|
|
element: HTMLElement;
|
|
|
|
|
ghostElement: HTMLElement;
|
|
|
|
|
startY: number;
|
|
|
|
|
mouseOffset: IMousePosition;
|
|
|
|
|
columnElement: HTMLElement;
|
2025-12-10 17:18:37 +01:00
|
|
|
currentColumn: HTMLElement;
|
2025-12-10 17:07:03 +01:00
|
|
|
targetY: number;
|
|
|
|
|
currentY: number;
|
|
|
|
|
animationId: number;
|
2025-12-11 18:11:11 +01:00
|
|
|
sourceDateKey: string; // Source column date (where drag started)
|
|
|
|
|
sourceResourceId?: string; // Source column resource (where drag started)
|
2025-12-10 17:07:03 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
2025-12-10 17:18:37 +01:00
|
|
|
private container: HTMLElement | null = null;
|
2025-12-10 23:11:11 +01:00
|
|
|
private inHeader = false;
|
2025-12-10 17:07:03 +01:00
|
|
|
|
|
|
|
|
private readonly DRAG_THRESHOLD = 5;
|
|
|
|
|
private readonly INTERPOLATION_FACTOR = 0.3;
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private eventBus: IEventBus,
|
|
|
|
|
private gridConfig: IGridConfig
|
2025-12-10 19:12:38 +01:00
|
|
|
) {
|
|
|
|
|
this.setupScrollListener();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setupScrollListener(): void {
|
|
|
|
|
this.eventBus.on(CoreEvents.EDGE_SCROLL_TICK, (e) => {
|
|
|
|
|
if (!this.dragState) return;
|
|
|
|
|
const { scrollDelta } = (e as CustomEvent<{ scrollDelta: number }>).detail;
|
|
|
|
|
|
|
|
|
|
// Element skal flytte med scroll for at forblive under musen
|
|
|
|
|
// (elementets top er relativ til kolonnen, som scroller med viewport)
|
|
|
|
|
this.dragState.targetY += scrollDelta;
|
|
|
|
|
this.dragState.currentY += scrollDelta;
|
|
|
|
|
this.dragState.element.style.top = `${this.dragState.currentY}px`;
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-12-10 17:07:03 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize drag-drop on a container element
|
|
|
|
|
*/
|
|
|
|
|
init(container: HTMLElement): void {
|
2025-12-10 17:18:37 +01:00
|
|
|
this.container = container;
|
2025-12-10 17:07:03 +01:00
|
|
|
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;
|
2025-12-10 22:16:40 +01:00
|
|
|
|
|
|
|
|
// Ignore if clicking on resize handle
|
|
|
|
|
if (target.closest('swp-resize-handle')) return;
|
|
|
|
|
|
2025-12-10 17:07:03 +01:00
|
|
|
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();
|
|
|
|
|
|
2025-12-11 18:11:11 +01:00
|
|
|
// Get column data (target = current column, source = where drag started)
|
2025-12-10 17:07:03 +01:00
|
|
|
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,
|
2025-12-11 18:11:11 +01:00
|
|
|
resourceId,
|
|
|
|
|
sourceDateKey: this.dragState.sourceDateKey,
|
|
|
|
|
sourceResourceId: this.dragState.sourceResourceId
|
2025-12-10 17:07:03 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_END, payload);
|
|
|
|
|
|
|
|
|
|
// Cleanup
|
|
|
|
|
this.dragState.element.classList.remove('dragging');
|
|
|
|
|
this.dragState = null;
|
2025-12-10 23:11:11 +01:00
|
|
|
this.inHeader = false;
|
2025-12-10 17:07:03 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
2025-12-11 19:05:50 +01:00
|
|
|
|
|
|
|
|
// Calculate absolute Y position using getBoundingClientRect
|
|
|
|
|
const elementRect = element.getBoundingClientRect();
|
|
|
|
|
const columnRect = columnElement.getBoundingClientRect();
|
|
|
|
|
const startY = elementRect.top - columnRect.top;
|
|
|
|
|
|
|
|
|
|
// If event is inside a group, move it to events-layer for correct positioning during drag
|
|
|
|
|
const group = element.closest('swp-event-group');
|
|
|
|
|
if (group) {
|
|
|
|
|
const eventsLayer = columnElement.querySelector('swp-events-layer');
|
|
|
|
|
if (eventsLayer) {
|
|
|
|
|
eventsLayer.appendChild(element);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set consistent positioning for drag (works for both grouped and stacked events)
|
|
|
|
|
element.style.position = 'absolute';
|
|
|
|
|
element.style.top = `${startY}px`;
|
|
|
|
|
element.style.left = '2px';
|
|
|
|
|
element.style.right = '2px';
|
|
|
|
|
element.style.marginLeft = '0'; // Reset stacking margin
|
2025-12-10 17:07:03 +01:00
|
|
|
|
|
|
|
|
// 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 targetY = e.clientY - columnRect.top - mouseOffset.y;
|
|
|
|
|
|
|
|
|
|
// Initialize drag state
|
|
|
|
|
this.dragState = {
|
|
|
|
|
eventId,
|
|
|
|
|
element,
|
|
|
|
|
ghostElement,
|
|
|
|
|
startY,
|
|
|
|
|
mouseOffset,
|
|
|
|
|
columnElement,
|
2025-12-10 17:18:37 +01:00
|
|
|
currentColumn: columnElement,
|
2025-12-10 17:07:03 +01:00
|
|
|
targetY: Math.max(0, targetY),
|
|
|
|
|
currentY: startY,
|
2025-12-11 18:11:11 +01:00
|
|
|
animationId: 0,
|
|
|
|
|
sourceDateKey: columnElement.dataset.date || '',
|
|
|
|
|
sourceResourceId: columnElement.dataset.resourceId
|
2025-12-10 17:07:03 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2025-12-10 23:11:11 +01:00
|
|
|
// Check header zone first
|
|
|
|
|
this.checkHeaderZone(e);
|
|
|
|
|
|
|
|
|
|
// Skip normal grid handling if in header
|
|
|
|
|
if (this.inHeader) return;
|
|
|
|
|
|
2025-12-10 17:18:37 +01:00
|
|
|
// Check for column change
|
|
|
|
|
const columnAtPoint = this.getColumnAtPoint(e.clientX);
|
|
|
|
|
if (columnAtPoint && columnAtPoint !== this.dragState.currentColumn) {
|
|
|
|
|
const payload: IDragColumnChangePayload = {
|
|
|
|
|
eventId: this.dragState.eventId,
|
|
|
|
|
element: this.dragState.element,
|
|
|
|
|
previousColumn: this.dragState.currentColumn,
|
|
|
|
|
newColumn: columnAtPoint,
|
|
|
|
|
currentY: this.dragState.currentY
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_COLUMN_CHANGE, payload);
|
|
|
|
|
this.dragState.currentColumn = columnAtPoint;
|
|
|
|
|
this.dragState.columnElement = columnAtPoint;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-10 17:07:03 +01:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-10 23:11:11 +01:00
|
|
|
/**
|
|
|
|
|
* Check if pointer is in header zone and emit appropriate events
|
|
|
|
|
*/
|
|
|
|
|
private checkHeaderZone(e: PointerEvent): void {
|
|
|
|
|
if (!this.dragState) return;
|
|
|
|
|
|
|
|
|
|
const headerViewport = document.querySelector('swp-header-viewport');
|
|
|
|
|
if (!headerViewport) return;
|
|
|
|
|
|
|
|
|
|
const rect = headerViewport.getBoundingClientRect();
|
|
|
|
|
const isInHeader = e.clientY < rect.bottom;
|
|
|
|
|
|
|
|
|
|
if (isInHeader && !this.inHeader) {
|
|
|
|
|
// Entered header
|
|
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_ENTER_HEADER, payload);
|
|
|
|
|
} else if (!isInHeader && this.inHeader) {
|
|
|
|
|
// Left header
|
|
|
|
|
this.inHeader = false;
|
|
|
|
|
|
|
|
|
|
const payload: IDragLeaveHeaderPayload = {
|
|
|
|
|
eventId: this.dragState.eventId
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_LEAVE_HEADER, payload);
|
|
|
|
|
} else if (isInHeader) {
|
|
|
|
|
// Moving within header
|
|
|
|
|
const column = this.getColumnAtX(e.clientX);
|
|
|
|
|
if (column) {
|
|
|
|
|
const payload: IDragMoveHeaderPayload = {
|
|
|
|
|
eventId: this.dragState.eventId,
|
|
|
|
|
columnIndex: this.getColumnIndex(column),
|
|
|
|
|
dateKey: column.dataset.date || ''
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.eventBus.emit(CoreEvents.EVENT_DRAG_MOVE_HEADER, payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get column index (0-based) for a column element
|
|
|
|
|
*/
|
|
|
|
|
private getColumnIndex(column: HTMLElement): number {
|
|
|
|
|
if (!this.container) return 0;
|
|
|
|
|
const columns = Array.from(this.container.querySelectorAll('swp-day-column'));
|
|
|
|
|
return columns.indexOf(column);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get column at X coordinate (alias for getColumnAtPoint)
|
|
|
|
|
*/
|
|
|
|
|
private getColumnAtX(clientX: number): HTMLElement | null {
|
|
|
|
|
return this.getColumnAtPoint(clientX);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-10 17:18:37 +01:00
|
|
|
/**
|
|
|
|
|
* Find column element at given X coordinate
|
|
|
|
|
*/
|
|
|
|
|
private getColumnAtPoint(clientX: number): HTMLElement | null {
|
|
|
|
|
if (!this.container) return null;
|
|
|
|
|
|
|
|
|
|
const columns = this.container.querySelectorAll('swp-day-column');
|
|
|
|
|
for (const col of columns) {
|
|
|
|
|
const rect = col.getBoundingClientRect();
|
|
|
|
|
if (clientX >= rect.left && clientX <= rect.right) {
|
|
|
|
|
return col as HTMLElement;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-10 17:07:03 +01:00
|
|
|
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;
|
2025-12-10 23:11:11 +01:00
|
|
|
this.inHeader = false;
|
2025-12-10 17:07:03 +01:00
|
|
|
}
|
|
|
|
|
}
|