/** * EdgeScrollManager - Auto-scroll when dragging near viewport edges * * 2-zone system: * - Inner zone (0-50px): Fast scroll (640 px/sec) * - Outer zone (50-100px): Slow scroll (140 px/sec) */ 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 readonly OUTER_ZONE = 100; private readonly INNER_ZONE = 50; private readonly SLOW_SPEED = 140; private readonly FAST_SPEED = 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'); this.scrollableContent.style.scrollBehavior = 'auto'; } 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 = 0; this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0; if (this.scrollRAF === null) { this.scrollRAF = requestAnimationFrame(this.scrollTick); } } private stopDrag(): void { this.isDragging = false; this.setScrollingState(false); if (this.scrollRAF !== null) { cancelAnimationFrame(this.scrollRAF); this.scrollRAF = null; } this.rect = null; this.lastTs = 0; this.initialScrollTop = 0; } private calculateVelocity(): number { if (!this.rect) return 0; const distTop = this.mouseY - this.rect.top; const distBot = this.rect.bottom - this.mouseY; if (distTop < this.INNER_ZONE) return -this.FAST_SPEED; if (distTop < this.OUTER_ZONE) return -this.SLOW_SPEED; if (distBot < this.INNER_ZONE) return this.FAST_SPEED; if (distBot < this.OUTER_ZONE) return this.SLOW_SPEED; return 0; } private isAtBoundary(velocity: number): boolean { if (!this.scrollableContent || !this.timeGrid || !this.draggedElement) return false; const atTop = this.scrollableContent.scrollTop <= 0 && velocity < 0; const atBottom = velocity > 0 && this.draggedElement.getBoundingClientRect().bottom >= this.timeGrid.getBoundingClientRect().bottom; return atTop || atBottom; } private setScrollingState(scrolling: boolean): void { if (this.isScrolling === scrolling) return; this.isScrolling = scrolling; if (scrolling) { this.eventBus.emit(CoreEvents.EDGE_SCROLL_STARTED, {}); } else { this.initialScrollTop = this.scrollableContent?.scrollTop ?? 0; this.eventBus.emit(CoreEvents.EDGE_SCROLL_STOPPED, {}); } } private scrollTick = (ts: number): void => { if (!this.isDragging || !this.scrollableContent) return; const dt = this.lastTs ? (ts - this.lastTs) / 1000 : 0; this.lastTs = ts; this.rect ??= this.scrollableContent.getBoundingClientRect(); const velocity = this.calculateVelocity(); if (velocity !== 0 && !this.isAtBoundary(velocity)) { const scrollDelta = velocity * dt; this.scrollableContent.scrollTop += scrollDelta; this.rect = null; this.eventBus.emit(CoreEvents.EDGE_SCROLL_TICK, { scrollDelta }); this.setScrollingState(true); } else { this.setScrollingState(false); } this.scrollRAF = requestAnimationFrame(this.scrollTick); }; }