Adds edge scroll functionality for drag interactions

Implements EdgeScrollManager to enable automatic scrolling during drag operations

Introduces new scroll management system that:
- Detects mouse proximity to container edges
- Provides variable scroll speed based on mouse position
- Compensates dragged elements during scrolling

Enhances drag-and-drop user experience with smooth scrolling
This commit is contained in:
Janus C. H. Knudsen 2025-12-10 19:12:38 +01:00
parent 8b95f2735f
commit 10d8a444d8
5 changed files with 219 additions and 2 deletions

View file

@ -46,7 +46,22 @@ export class DragDropManager {
constructor(
private eventBus: IEventBus,
private gridConfig: IGridConfig
) {}
) {
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`;
});
}
/**
* Initialize drag-drop on a container element
@ -165,6 +180,9 @@ export class DragDropManager {
const columnRect = columnElement.getBoundingClientRect();
const targetY = e.clientY - columnRect.top - mouseOffset.y;
// Reset scroll compensation
this.scrollDeltaY = 0;
// Initialize drag state
this.dragState = {
eventId,

View file

@ -0,0 +1,186 @@
/**
* EdgeScrollManager - Auto-scroll when dragging near edges
* Uses time-based scrolling with 2-zone system for variable speed
*
* Copied from V1 with minor adaptations for V2 event names.
*/
import { IEventBus } from '../types/CalendarTypes';
import { CoreEvents } from '../constants/CoreEvents';
export class EdgeScrollManager {
private scrollableContent: HTMLElement | null = null;
private timeGrid: HTMLElement | null = null;
private draggedElement: HTMLElement | null = null;
private scrollRAF: number | null = null;
private mouseY = 0;
private isDragging = false;
private isScrolling = false;
private lastTs = 0;
private rect: DOMRect | null = null;
private initialScrollTop = 0;
private scrollListener: ((e: Event) => void) | null = null;
// Constants
private readonly OUTER_ZONE = 100;
private readonly INNER_ZONE = 50;
private readonly SLOW_SPEED_PXS = 140;
private readonly FAST_SPEED_PXS = 640;
constructor(private eventBus: IEventBus) {
this.subscribeToEvents();
document.addEventListener('pointermove', this.trackMouse);
}
init(scrollableContent: HTMLElement): void {
this.scrollableContent = scrollableContent;
this.timeGrid = scrollableContent.querySelector('swp-time-grid');
// Disable smooth scroll for instant auto-scroll
this.scrollableContent.style.scrollBehavior = 'auto';
// Add scroll listener to detect actual scrolling
this.scrollListener = this.handleScroll.bind(this);
this.scrollableContent.addEventListener('scroll', this.scrollListener, { passive: true });
}
private trackMouse = (e: PointerEvent): void => {
if (this.isDragging) {
this.mouseY = e.clientY;
}
};
private subscribeToEvents(): void {
this.eventBus.on(CoreEvents.EVENT_DRAG_START, (event: Event) => {
const payload = (event as CustomEvent).detail;
this.draggedElement = payload.element;
this.startDrag();
});
this.eventBus.on(CoreEvents.EVENT_DRAG_END, () => this.stopDrag());
this.eventBus.on(CoreEvents.EVENT_DRAG_CANCEL, () => this.stopDrag());
}
private startDrag(): void {
this.isDragging = true;
this.isScrolling = false;
this.lastTs = performance.now();
if (this.scrollableContent) {
this.initialScrollTop = this.scrollableContent.scrollTop;
}
if (this.scrollRAF === null) {
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
}
}
private stopDrag(): void {
this.isDragging = false;
if (this.isScrolling) {
this.isScrolling = false;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {});
}
if (this.scrollRAF !== null) {
cancelAnimationFrame(this.scrollRAF);
this.scrollRAF = null;
}
this.rect = null;
this.lastTs = 0;
this.initialScrollTop = 0;
}
private handleScroll(): void {
if (!this.isDragging || !this.scrollableContent) return;
const currentScrollTop = this.scrollableContent.scrollTop;
const scrollDelta = Math.abs(currentScrollTop - this.initialScrollTop);
if (scrollDelta > 1 && !this.isScrolling) {
this.isScrolling = true;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STARTED, {});
}
}
private scrollTick(ts: number): void {
const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0;
this.lastTs = ts;
if (!this.scrollableContent) {
this.stopDrag();
return;
}
// Cache rect for performance
if (!this.rect) {
this.rect = this.scrollableContent.getBoundingClientRect();
}
let vy = 0;
if (this.isDragging) {
const distTop = this.mouseY - this.rect.top;
const distBot = this.rect.bottom - this.mouseY;
// Check top edge
if (distTop < this.INNER_ZONE) {
vy = -this.FAST_SPEED_PXS;
} else if (distTop < this.OUTER_ZONE) {
vy = -this.SLOW_SPEED_PXS;
}
// Check bottom edge
else if (distBot < this.INNER_ZONE) {
vy = this.FAST_SPEED_PXS;
} else if (distBot < this.OUTER_ZONE) {
vy = this.SLOW_SPEED_PXS;
}
}
if (vy !== 0 && this.isDragging && this.timeGrid && this.draggedElement) {
const currentScrollTop = this.scrollableContent.scrollTop;
const cloneRect = this.draggedElement.getBoundingClientRect();
const cloneBottom = cloneRect.bottom;
const timeGridRect = this.timeGrid.getBoundingClientRect();
const timeGridBottom = timeGridRect.bottom;
// Check boundaries
const atTop = currentScrollTop <= 0 && vy < 0;
const atBottom = (cloneBottom >= timeGridBottom) && vy > 0;
if (atTop || atBottom) {
if (this.isScrolling) {
this.isScrolling = false;
this.initialScrollTop = this.scrollableContent.scrollTop;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {});
}
if (this.isDragging) {
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
}
} else {
// Apply scroll
const scrollDelta = vy * dt;
this.scrollableContent.scrollTop += scrollDelta;
this.rect = null;
// Emit tick for DragDropManager compensation
this.eventBus.emit(CoreEvents.EDGE_SCROLL_TICK, { scrollDelta });
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
}
} else {
if (this.isScrolling) {
this.isScrolling = false;
this.initialScrollTop = this.scrollableContent.scrollTop;
this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {});
}
if (this.isDragging) {
this.scrollRAF = requestAnimationFrame((ts) => this.scrollTick(ts));
} else {
this.stopDrag();
}
}
}
}