Improves drag and drop functionality

Refactors drag and drop to use the original element as the source and introduces edge scrolling.

This change aims to enhance the user experience during drag and drop operations by ensuring the correct element is used as the source, fixing issues, and by automatically scrolling the view when the dragged element reaches the edge of the scrollable area.
This commit is contained in:
Janus C. H. Knudsen 2025-10-12 22:00:02 +02:00
parent 8df1f6c4f1
commit e620c919aa
6 changed files with 120 additions and 121 deletions

View file

@ -23,16 +23,14 @@ export class DragDropManager {
private eventBus: IEventBus;
// Mouse tracking with optimized state
private lastMousePosition: MousePosition = { x: 0, y: 0 };
private currentMouseY = 0;
private mouseDownPosition: MousePosition = { x: 0, y: 0 };
private mouseOffset: MousePosition = { x: 0, y: 0 };
private initialMousePosition: MousePosition = { x: 0, y: 0 };
// Drag state
private draggedElement!: HTMLElement | null;
private originalElement!: HTMLElement | null;
private draggedClone!: HTMLElement | null;
private currentColumnBounds: ColumnBounds | null = null;
private initialColumnBounds: ColumnBounds | null = null; // Track source column
private currentColumn: ColumnBounds | null = null;
private previousColumn: ColumnBounds | null = null;
private isDragStarted = false;
// Hover state
@ -70,7 +68,7 @@ export class DragDropManager {
if (calendarContainer) {
calendarContainer.addEventListener('mouseleave', () => {
if (this.draggedElement && this.isDragStarted) {
if (this.originalElement && this.isDragStarted) {
this.cancelDrag();
}
});
@ -116,11 +114,13 @@ export class DragDropManager {
// Clean up drag state first
this.cleanupDragState();
ColumnDetectionUtils.updateColumnBoundsCache();
this.lastMousePosition = { x: event.clientX, y: event.clientY };
this.initialMousePosition = { x: event.clientX, y: event.clientY };
//this.lastMousePosition = { x: event.clientX, y: event.clientY };
//this.initialMousePosition = { x: event.clientX, y: event.clientY };
// Check if mousedown is on an event
const target = event.target as HTMLElement;
if (target.closest('swp-resize-handle')) return;
let eventElement = target;
while (eventElement && eventElement.tagName !== 'SWP-GRID-CONTAINER') {
@ -131,21 +131,18 @@ export class DragDropManager {
if (!eventElement) return;
}
// Found an event - check if clicking on resize handle first
if (eventElement) {
// Check if click is on resize handle
if (target.closest('swp-resize-handle')) {
return; // Exit early - this is a resize operation, let ResizeHandleManager handle it
}
// Normal drag - prepare for potential dragging
this.draggedElement = eventElement;
this.originalElement = eventElement;
// Calculate mouse offset within event
const eventRect = eventElement.getBoundingClientRect();
this.mouseOffset = {
x: event.clientX - eventRect.left,
y: event.clientY - eventRect.top
};
this.mouseDownPosition = { x: event.clientX, y: event.clientY };
}
}
@ -153,8 +150,8 @@ export class DragDropManager {
* Optimized mouse move handler with consolidated position calculations
*/
private handleMouseMove(event: MouseEvent): void {
this.currentMouseY = event.clientY;
this.lastMousePosition = { x: event.clientX, y: event.clientY };
//this.currentMouseY = event.clientY;
// this.lastMousePosition = { x: event.clientX, y: event.clientY };
// Check for event hover (coordinate-based) - only when mouse button is up
if (this.isHoverTrackingActive && event.buttons === 0) {
@ -165,17 +162,17 @@ export class DragDropManager {
const currentPosition: MousePosition = { x: event.clientX, y: event.clientY };
// Try to initialize drag if not started
if (!this.isDragStarted && this.draggedElement) {
if (!this.tryInitializeDrag(currentPosition)) {
if (!this.isDragStarted && this.originalElement) {
if (!this.initializeDrag(currentPosition)) {
return; // Not enough movement yet
}
}
// Continue drag if started
if (this.isDragStarted && this.draggedElement && this.draggedClone) {
if (this.isDragStarted && this.originalElement && this.draggedClone) {
console.log("Continue drag if started", this.draggedClone);
this.continueDrag(currentPosition);
this.detectAndEmitColumnChange(currentPosition);
this.detectColumnChange(currentPosition);
}
}
}
@ -184,9 +181,9 @@ export class DragDropManager {
* Try to initialize drag based on movement threshold
* Returns true if drag was initialized, false if not enough movement
*/
private tryInitializeDrag(currentPosition: MousePosition): boolean {
const deltaX = Math.abs(currentPosition.x - this.initialMousePosition.x);
const deltaY = Math.abs(currentPosition.y - this.initialMousePosition.y);
private initializeDrag(currentPosition: MousePosition): boolean {
const deltaX = Math.abs(currentPosition.x - this.mouseDownPosition.x);
const deltaY = Math.abs(currentPosition.y - this.mouseDownPosition.y);
const totalMovement = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (totalMovement < this.dragThreshold) {
@ -197,27 +194,23 @@ export class DragDropManager {
this.isDragStarted = true;
// Set high z-index on event-group if exists, otherwise on event itself
const eventGroup = this.draggedElement!.closest<HTMLElement>('swp-event-group');
const eventGroup = this.originalElement!.closest<HTMLElement>('swp-event-group');
if (eventGroup) {
eventGroup.style.zIndex = '9999';
} else {
this.draggedElement!.style.zIndex = '9999';
this.originalElement!.style.zIndex = '9999';
}
// Detect current column and save as initial source column
this.currentColumnBounds = ColumnDetectionUtils.getColumnBounds(currentPosition);
this.initialColumnBounds = this.currentColumnBounds;
// Cast to BaseSwpEventElement and create clone
const originalElement = this.draggedElement as BaseSwpEventElement;
const originalElement = this.originalElement as BaseSwpEventElement;
this.currentColumn = ColumnDetectionUtils.getColumnBounds(currentPosition);
this.draggedClone = originalElement.createClone();
const dragStartPayload: DragStartEventPayload = {
draggedElement: this.draggedElement!,
draggedClone: this.draggedClone,
mousePosition: this.initialMousePosition,
originalElement: this.originalElement!,
draggedClone: this.draggedClone,
mousePosition: this.mouseDownPosition,
mouseOffset: this.mouseOffset,
columnBounds: this.currentColumnBounds
columnBounds: this.currentColumn
};
this.eventBus.emit('drag:start', dragStartPayload);
@ -250,18 +243,18 @@ export class DragDropManager {
/**
* Detect column change and emit event
*/
private detectAndEmitColumnChange(currentPosition: MousePosition): void {
private detectColumnChange(currentPosition: MousePosition): void {
const newColumn = ColumnDetectionUtils.getColumnBounds(currentPosition);
if (newColumn == null) return;
if (newColumn.index !== this.currentColumnBounds?.index) {
const previousColumn = this.currentColumnBounds;
this.currentColumnBounds = newColumn;
if (newColumn.index !== this.currentColumn?.index) {
this.previousColumn = this.currentColumn;
this.currentColumn = newColumn;
const dragColumnChangePayload: DragColumnChangeEventPayload = {
originalElement: this.draggedElement!,
originalElement: this.originalElement!,
draggedClone: this.draggedClone!,
previousColumn,
previousColumn: this.previousColumn,
newColumn,
mousePosition: currentPosition
};
@ -275,7 +268,7 @@ export class DragDropManager {
private handleMouseUp(event: MouseEvent): void {
this.stopDragAnimation();
if (this.draggedElement) {
if (this.originalElement) {
// Only emit drag:end if drag was actually started
if (this.isDragStarted) {
@ -284,13 +277,9 @@ export class DragDropManager {
// Snap to grid on mouse up (like ResizeHandleManager)
const column = ColumnDetectionUtils.getColumnBounds(mousePosition);
if (!column) {
console.warn('No column detected on mouseUp');
return;
}
if (!column) return;
// Get current position and snap it to grid
const currentY = parseFloat(this.draggedClone?.style.top || '0');
const snappedY = this.calculateSnapPosition(mousePosition.y, column);
// Update clone to snapped position immediately
@ -304,22 +293,17 @@ export class DragDropManager {
if (!dropTarget)
throw "dropTarget is null";
console.log('🎯 DragDropManager: Emitting drag:end', {
draggedElement: this.draggedElement.dataset.eventId,
finalColumn: column,
finalY: snappedY,
dropTarget: dropTarget,
isDragStarted: this.isDragStarted
});
const dragEndPayload: DragEndEventPayload = {
originalElement: this.draggedElement,
originalElement: this.originalElement,
draggedClone: this.draggedClone,
sourceColumn: this.initialColumnBounds, // Where drag started
mousePosition,
sourceColumn: this.previousColumn!!,
finalPosition: { column, snappedY }, // Where drag ended
target: dropTarget
};
console.log('DragEndEventPayload', dragEndPayload);
this.eventBus.emit('drag:end', dragEndPayload);
this.cleanupDragState();
@ -327,7 +311,7 @@ export class DragDropManager {
} else {
// This was just a click - emit click event instead
this.eventBus.emit('event:click', {
draggedElement: this.draggedElement,
clickedElement: this.originalElement,
mousePosition: { x: event.clientX, y: event.clientY }
});
}
@ -348,46 +332,24 @@ export class DragDropManager {
* Cancel drag operation when mouse leaves grid container
*/
private cancelDrag(): void {
if (!this.draggedElement) return;
if (!this.originalElement) return;
console.log('🚫 DragDropManager: Cancelling drag - mouse left grid container');
const draggedElement = this.draggedElement;
// 1. Remove all clones
this.cleanupAllClones();
// 2. Restore original element
if (draggedElement) {
draggedElement.style.opacity = '';
draggedElement.style.cursor = '';
}
this.originalElement.style.opacity = '';
this.originalElement.style.cursor = '';
// 3. Emit cancellation event
this.eventBus.emit('drag:cancelled', {
draggedElement: draggedElement,
originalElement: this.originalElement,
reason: 'mouse-left-grid'
});
// 4. Clean up state
this.cleanupDragState();
this.stopDragAnimation();
}
/**
* Consolidated position calculation method using PositionUtils
*/
private calculateDragPosition(mousePosition: MousePosition): { column: ColumnBounds | null; snappedY: number } {
let column = ColumnDetectionUtils.getColumnBounds(mousePosition);
let snappedY = 0;
if (column) {
snappedY = this.calculateSnapPosition(mousePosition.y, column);
return { column, snappedY };
}
return { column, snappedY };
}
/**
* Optimized snap position calculation using PositionUtils
*/
@ -405,7 +367,8 @@ export class DragDropManager {
* Smooth drag animation using requestAnimationFrame
* Emits drag:move events with current draggedClone reference on each frame
*/
private animateDrag(): void {
private animateDrag(): void { //TODO: this can be optimized... WAIT !!!
if (!this.isDragStarted || !this.draggedClone || !this.targetColumn) {
this.dragAnimationId = null;
return;
@ -421,9 +384,9 @@ export class DragDropManager {
// Emit drag:move event with current draggedClone reference
const dragMovePayload: DragMoveEventPayload = {
draggedElement: this.draggedElement!,
originalElement: this.originalElement!,
draggedClone: this.draggedClone, // Always uses current reference
mousePosition: this.lastMousePosition,
mousePosition: this.mouseDownPosition,
snappedY: this.currentY,
columnBounds: this.targetColumn,
mouseOffset: this.mouseOffset
@ -437,9 +400,9 @@ export class DragDropManager {
// Emit final position
const dragMovePayload: DragMoveEventPayload = {
draggedElement: this.draggedElement!,
originalElement: this.originalElement!,
draggedClone: this.draggedClone,
mousePosition: this.lastMousePosition,
mousePosition: this.mouseDownPosition,
snappedY: this.currentY,
columnBounds: this.targetColumn,
mouseOffset: this.mouseOffset
@ -465,8 +428,10 @@ export class DragDropManager {
* Clean up drag state
*/
private cleanupDragState(): void {
this.draggedElement = null;
this.previousColumn = null;
this.originalElement = null;
this.draggedClone = null;
this.currentColumn = null;
this.isDragStarted = false;
}
@ -523,23 +488,20 @@ export class DragDropManager {
const targetColumn = ColumnDetectionUtils.getColumnBounds(position);
if (targetColumn) {
console.log('🎯 DragDropManager: Mouse entered header', { targetDate: targetColumn });
// Extract CalendarEvent from the dragged clone
const calendarEvent = SwpEventElement.extractCalendarEventFromElement(this.draggedClone);
const dragMouseEnterPayload: DragMouseEnterHeaderEventPayload = {
targetColumn: targetColumn,
mousePosition: position,
originalElement: this.draggedElement,
originalElement: this.originalElement,
draggedClone: this.draggedClone,
calendarEvent: calendarEvent,
// Delegate pattern - allows AllDayManager to replace the clone
replaceClone: (newClone: HTMLElement) => {
this.draggedClone = newClone;
this.dragAnimationId === null;
}
};
console.log('DragMouseEnterHeaderEventPayload', dragMouseEnterPayload);
this.eventBus.emit('drag:mouseenter-header', dragMouseEnterPayload);
}
}
@ -573,15 +535,13 @@ export class DragDropManager {
targetColumn: targetColumn,
mousePosition: position,
snappedY: snappedY,
originalElement: this.draggedElement,
originalElement: this.originalElement,
draggedClone: this.draggedClone,
calendarEvent: calendarEvent,
// Delegate pattern - allows EventRenderer to replace the clone
replaceClone: (newClone: HTMLElement) => {
this.draggedClone = newClone;
this.dragAnimationId === null;
this.stopDragAnimation();
console.log("replacing clone with", newClone)
}
};
this.eventBus.emit('drag:mouseenter-column', dragMouseEnterPayload);
@ -609,7 +569,7 @@ export class DragDropManager {
const dragMouseLeavePayload: DragMouseLeaveHeaderEventPayload = {
targetDate: targetColumn.date,
mousePosition: position,
originalElement: this.draggedElement,
originalElement: this.originalElement,
draggedClone: this.draggedClone
};
this.eventBus.emit('drag:mouseleave-header', dragMouseLeavePayload);

View file

@ -4,7 +4,7 @@
*/
import { IEventBus } from '../types/CalendarTypes';
import { DragMoveEventPayload } from '../types/EventTypes';
import { DragMoveEventPayload, DragStartEventPayload } from '../types/EventTypes';
export class EdgeScrollManager {
private scrollableContent: HTMLElement | null = null;
@ -13,6 +13,10 @@ export class EdgeScrollManager {
private isDragging = false;
private lastTs = 0;
private rect: DOMRect | null = null;
private draggedClone: HTMLElement | null = null;
private initialScrollTop = 0;
private initialCloneTop = 0;
private scrollListener: ((e: Event) => void) | null = null;
// Constants - fixed values as per requirements
private readonly OUTER_ZONE = 100; // px from edge (slow zone)
@ -31,6 +35,10 @@ export class EdgeScrollManager {
if (this.scrollableContent) {
// Disable smooth scroll for instant auto-scroll
this.scrollableContent.style.scrollBehavior = 'auto';
// Add scroll listener
this.scrollListener = this.handleScroll.bind(this);
this.scrollableContent.addEventListener('scroll', this.scrollListener, { passive: true });
}
}, 100);
@ -38,12 +46,20 @@ export class EdgeScrollManager {
}
private subscribeToEvents(): void {
// Listen to drag events from DragDropManager
this.eventBus.on('drag:start', () => this.startDrag());
this.eventBus.on('drag:start', (event: Event) => {
let customEvent = event as CustomEvent<DragStartEventPayload>;
this.draggedClone = customEvent.detail.draggedClone;
this.startDrag();
});
this.eventBus.on('drag:move', (event: Event) => {
const customEvent = event as CustomEvent<DragMoveEventPayload>;
let customEvent = event as CustomEvent<DragMoveEventPayload>;
this.draggedClone = customEvent.detail.draggedClone;
this.updateMouseY(customEvent.detail.mousePosition.y);
});
this.eventBus.on('drag:end', () => this.stopDrag());
this.eventBus.on('drag:cancelled', () => this.stopDrag());
}
@ -52,6 +68,16 @@ export class EdgeScrollManager {
console.log('🎬 EdgeScrollManager: Starting drag');
this.isDragging = true;
this.lastTs = performance.now();
// Gem initial scroll position OG clone position
this.initialScrollTop = this.scrollableContent?.scrollTop || 0;
this.initialCloneTop = parseFloat(this.draggedClone?.style.top || '0');
console.log('💾 EdgeScrollManager: Saved initial state', {
initialScrollTop: this.initialScrollTop,
initialCloneTop: this.initialCloneTop
});
if (this.scrollRAF === null) {
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
}
@ -74,6 +100,28 @@ export class EdgeScrollManager {
}
this.rect = null;
this.lastTs = 0;
this.draggedClone = null;
this.initialScrollTop = 0;
this.initialCloneTop = 0;
}
private handleScroll(): void {
if (!this.isDragging || !this.draggedClone || !this.scrollableContent) return;
const currentScrollTop = this.scrollableContent.scrollTop;
const totalScrollDelta = currentScrollTop - this.initialScrollTop;
// Beregn ny position baseret på initial position + total scroll delta
const newTop = this.initialCloneTop + totalScrollDelta;
this.draggedClone.style.top = `${newTop}px`;
console.log('📜 EdgeScrollManager: Scroll event - updated clone', {
initialScrollTop: this.initialScrollTop,
currentScrollTop,
totalScrollDelta,
initialCloneTop: this.initialCloneTop,
newTop
});
}
private scrollTick(ts: number): void {
@ -97,23 +145,15 @@ export class EdgeScrollManager {
// Check top edge
if (distTop < this.INNER_ZONE) {
// Inner zone (0-50px) - fast speed
vy = -this.FAST_SPEED_PXS;
console.log('⬆️ EdgeScrollManager: Top FAST', { distTop, vy });
} else if (distTop < this.OUTER_ZONE) {
// Outer zone (50-100px) - slow speed
vy = -this.SLOW_SPEED_PXS;
console.log('⬆️ EdgeScrollManager: Top SLOW', { distTop, vy });
}
// Check bottom edge
else if (distBot < this.INNER_ZONE) {
// Inner zone (0-50px) - fast speed
vy = this.FAST_SPEED_PXS;
console.log('⬇️ EdgeScrollManager: Bottom FAST', { distBot, vy });
} else if (distBot < this.OUTER_ZONE) {
// Outer zone (50-100px) - slow speed
vy = this.SLOW_SPEED_PXS;
console.log('⬇️ EdgeScrollManager: Bottom SLOW', { distBot, vy });
}
}

View file

@ -40,7 +40,7 @@ export class AllDayEventRenderer {
*/
public handleDragStart(payload: DragStartEventPayload): void {
this.originalEvent = payload.draggedElement;;
this.originalEvent = payload.originalElement;;
this.draggedClone = payload.draggedClone;
if (this.draggedClone) {

View file

@ -56,7 +56,7 @@ export class DateEventRenderer implements EventRendererStrategy {
*/
public handleDragStart(payload: DragStartEventPayload): void {
this.originalEvent = payload.draggedElement;;
this.originalEvent = payload.originalElement;;
// Use the clone from the payload instead of creating a new one
this.draggedClone = payload.draggedClone;

View file

@ -138,11 +138,11 @@ export class EventRenderingService {
this.eventBus.on('drag:start', (event: Event) => {
const dragStartPayload = (event as CustomEvent<DragStartEventPayload>).detail;
if (dragStartPayload.draggedElement.hasAttribute('data-allday')) {
if (dragStartPayload.originalElement.hasAttribute('data-allday')) {
return;
}
if (dragStartPayload.draggedElement && this.strategy.handleDragStart && dragStartPayload.columnBounds) {
if (dragStartPayload.originalElement && this.strategy.handleDragStart && dragStartPayload.columnBounds) {
this.strategy.handleDragStart(dragStartPayload);
}
});
@ -163,6 +163,7 @@ export class EventRenderingService {
private setupDragEndListener(): void {
this.eventBus.on('drag:end', (event: Event) => {
const { originalElement: draggedElement, sourceColumn, finalPosition, target } = (event as CustomEvent<DragEndEventPayload>).detail;
const finalColumn = finalPosition.column;
const finalY = finalPosition.snappedY;

View file

@ -18,7 +18,7 @@ export interface MousePosition {
// Drag start event payload
export interface DragStartEventPayload {
draggedElement: HTMLElement;
originalElement: HTMLElement;
draggedClone: HTMLElement | null;
mousePosition: MousePosition;
mouseOffset: MousePosition;
@ -27,7 +27,7 @@ export interface DragStartEventPayload {
// Drag move event payload
export interface DragMoveEventPayload {
draggedElement: HTMLElement;
originalElement: HTMLElement;
draggedClone: HTMLElement;
mousePosition: MousePosition;
mouseOffset: MousePosition;
@ -39,8 +39,8 @@ export interface DragMoveEventPayload {
export interface DragEndEventPayload {
originalElement: HTMLElement;
draggedClone: HTMLElement | null;
sourceColumn: ColumnBounds | null; // Where drag started
mousePosition: MousePosition;
sourceColumn: ColumnBounds;
finalPosition: {
column: ColumnBounds | null; // Where drag ended
snappedY: number;
@ -55,7 +55,6 @@ export interface DragMouseEnterHeaderEventPayload {
originalElement: HTMLElement | null;
draggedClone: HTMLElement;
calendarEvent: CalendarEvent;
// Delegate pattern - allows subscriber to replace the dragged clone
replaceClone: (newClone: HTMLElement) => void;
}
@ -75,7 +74,6 @@ export interface DragMouseEnterColumnEventPayload {
originalElement: HTMLElement | null;
draggedClone: HTMLElement;
calendarEvent: CalendarEvent;
// Delegate pattern - allows subscriber to replace the dragged clone
replaceClone: (newClone: HTMLElement) => void;
}