diff --git a/src/v2/V2CompositionRoot.ts b/src/v2/V2CompositionRoot.ts index fc1d442..8367ec2 100644 --- a/src/v2/V2CompositionRoot.ts +++ b/src/v2/V2CompositionRoot.ts @@ -52,6 +52,7 @@ import { ResourceScheduleService } from './storage/schedules/ResourceScheduleSer // Managers import { DragDropManager } from './managers/DragDropManager'; +import { EdgeScrollManager } from './managers/EdgeScrollManager'; const defaultTimeFormatConfig: ITimeFormatConfig = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, @@ -149,6 +150,7 @@ export function createV2Container(): Container { builder.registerType(ScrollManager).as(); builder.registerType(HeaderDrawerManager).as(); builder.registerType(DragDropManager).as(); + builder.registerType(EdgeScrollManager).as(); // Demo app builder.registerType(DemoApp).as(); diff --git a/src/v2/constants/CoreEvents.ts b/src/v2/constants/CoreEvents.ts index c101d89..f5b7e85 100644 --- a/src/v2/constants/CoreEvents.ts +++ b/src/v2/constants/CoreEvents.ts @@ -37,6 +37,11 @@ export const CoreEvents = { EVENT_DRAG_CANCEL: 'event:drag-cancel', EVENT_DRAG_COLUMN_CHANGE: 'event:drag-column-change', + // Edge scroll + EDGE_SCROLL_TICK: 'edge-scroll:tick', + EDGE_SCROLL_STARTED: 'edge-scroll:started', + EDGE_SCROLL_STOPPED: 'edge-scroll:stopped', + // System events ERROR: 'system:error', diff --git a/src/v2/demo/DemoApp.ts b/src/v2/demo/DemoApp.ts index d29c08d..1d5046a 100644 --- a/src/v2/demo/DemoApp.ts +++ b/src/v2/demo/DemoApp.ts @@ -8,6 +8,7 @@ import { IndexedDBContext } from '../storage/IndexedDBContext'; import { DataSeeder } from '../workers/DataSeeder'; import { ViewConfig } from '../core/ViewConfig'; import { DragDropManager } from '../managers/DragDropManager'; +import { EdgeScrollManager } from '../managers/EdgeScrollManager'; export class DemoApp { private animator!: NavigationAnimator; @@ -23,7 +24,8 @@ export class DemoApp { private headerDrawerManager: HeaderDrawerManager, private indexedDBContext: IndexedDBContext, private dataSeeder: DataSeeder, - private dragDropManager: DragDropManager + private dragDropManager: DragDropManager, + private edgeScrollManager: EdgeScrollManager ) {} async init(): Promise { @@ -55,6 +57,10 @@ export class DemoApp { // Init drag-drop this.dragDropManager.init(this.container); + // Init edge scroll + const scrollableContent = this.container.querySelector('swp-scrollable-content') as HTMLElement; + this.edgeScrollManager.init(scrollableContent); + // Setup event handlers this.setupNavigation(); this.setupDrawerToggle(); diff --git a/src/v2/managers/DragDropManager.ts b/src/v2/managers/DragDropManager.ts index 2b2ecaf..55675ca 100644 --- a/src/v2/managers/DragDropManager.ts +++ b/src/v2/managers/DragDropManager.ts @@ -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, diff --git a/src/v2/managers/EdgeScrollManager.ts b/src/v2/managers/EdgeScrollManager.ts new file mode 100644 index 0000000..5d4e668 --- /dev/null +++ b/src/v2/managers/EdgeScrollManager.ts @@ -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(); + } + } + } +}