Adds drag-drop support for calendar events
Introduces comprehensive drag-drop functionality for calendar events - Implements DragDropManager to handle event dragging - Adds new CoreEvents for drag-drop interactions - Supports smooth interpolation and grid snapping - Provides flexible event handling with ghost element strategy
This commit is contained in:
parent
a2b95515fd
commit
159b023f60
6 changed files with 334 additions and 2 deletions
|
|
@ -50,6 +50,9 @@ import { ScheduleOverrideStore } from './storage/schedules/ScheduleOverrideStore
|
||||||
import { ScheduleOverrideService } from './storage/schedules/ScheduleOverrideService';
|
import { ScheduleOverrideService } from './storage/schedules/ScheduleOverrideService';
|
||||||
import { ResourceScheduleService } from './storage/schedules/ResourceScheduleService';
|
import { ResourceScheduleService } from './storage/schedules/ResourceScheduleService';
|
||||||
|
|
||||||
|
// Managers
|
||||||
|
import { DragDropManager } from './managers/DragDropManager';
|
||||||
|
|
||||||
const defaultTimeFormatConfig: ITimeFormatConfig = {
|
const defaultTimeFormatConfig: ITimeFormatConfig = {
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
use24HourFormat: true,
|
use24HourFormat: true,
|
||||||
|
|
@ -145,6 +148,7 @@ export function createV2Container(): Container {
|
||||||
builder.registerType(TimeAxisRenderer).as<TimeAxisRenderer>();
|
builder.registerType(TimeAxisRenderer).as<TimeAxisRenderer>();
|
||||||
builder.registerType(ScrollManager).as<ScrollManager>();
|
builder.registerType(ScrollManager).as<ScrollManager>();
|
||||||
builder.registerType(HeaderDrawerManager).as<HeaderDrawerManager>();
|
builder.registerType(HeaderDrawerManager).as<HeaderDrawerManager>();
|
||||||
|
builder.registerType(DragDropManager).as<DragDropManager>();
|
||||||
|
|
||||||
// Demo app
|
// Demo app
|
||||||
builder.registerType(DemoApp).as<DemoApp>();
|
builder.registerType(DemoApp).as<DemoApp>();
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@ export const CoreEvents = {
|
||||||
EVENT_DELETED: 'event:deleted',
|
EVENT_DELETED: 'event:deleted',
|
||||||
EVENT_SELECTED: 'event:selected',
|
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
|
// System events
|
||||||
ERROR: 'system:error',
|
ERROR: 'system:error',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { HeaderDrawerManager } from '../core/HeaderDrawerManager';
|
||||||
import { IndexedDBContext } from '../storage/IndexedDBContext';
|
import { IndexedDBContext } from '../storage/IndexedDBContext';
|
||||||
import { DataSeeder } from '../workers/DataSeeder';
|
import { DataSeeder } from '../workers/DataSeeder';
|
||||||
import { ViewConfig } from '../core/ViewConfig';
|
import { ViewConfig } from '../core/ViewConfig';
|
||||||
|
import { DragDropManager } from '../managers/DragDropManager';
|
||||||
|
|
||||||
export class DemoApp {
|
export class DemoApp {
|
||||||
private animator!: NavigationAnimator;
|
private animator!: NavigationAnimator;
|
||||||
|
|
@ -21,7 +22,8 @@ export class DemoApp {
|
||||||
private scrollManager: ScrollManager,
|
private scrollManager: ScrollManager,
|
||||||
private headerDrawerManager: HeaderDrawerManager,
|
private headerDrawerManager: HeaderDrawerManager,
|
||||||
private indexedDBContext: IndexedDBContext,
|
private indexedDBContext: IndexedDBContext,
|
||||||
private dataSeeder: DataSeeder
|
private dataSeeder: DataSeeder,
|
||||||
|
private dragDropManager: DragDropManager
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
|
@ -50,6 +52,9 @@ export class DemoApp {
|
||||||
// Init header drawer
|
// Init header drawer
|
||||||
this.headerDrawerManager.init(this.container);
|
this.headerDrawerManager.init(this.container);
|
||||||
|
|
||||||
|
// Init drag-drop
|
||||||
|
this.dragDropManager.init(this.container);
|
||||||
|
|
||||||
// Setup event handlers
|
// Setup event handlers
|
||||||
this.setupNavigation();
|
this.setupNavigation();
|
||||||
this.setupDrawerToggle();
|
this.setupDrawerToggle();
|
||||||
|
|
|
||||||
271
src/v2/managers/DragDropManager.ts
Normal file
271
src/v2/managers/DragDropManager.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/v2/types/DragTypes.ts
Normal file
39
src/v2/types/DragTypes.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -35,10 +35,17 @@ swp-day-columns swp-event {
|
||||||
&.dragging {
|
&.dragging {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 999999;
|
z-index: 999999;
|
||||||
opacity: 0.8;
|
|
||||||
left: 2px;
|
left: 2px;
|
||||||
right: 2px;
|
right: 2px;
|
||||||
width: auto;
|
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 */
|
/* Hover state */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue