// Custom scroll management for calendar week container import { eventBus } from '../core/EventBus'; import { calendarConfig } from '../core/CalendarConfig'; import { EventTypes } from '../constants/EventTypes'; /** * Manages custom scrolling functionality for the calendar */ export class ScrollManager { // Vertical scrolling private rightColumn: HTMLElement | null = null; private scrollHandle: HTMLElement | null = null; private scrollableContent: HTMLElement | null = null; private calendarContainer: HTMLElement | null = null; private timeAxis: HTMLElement | null = null; private resizeObserver: ResizeObserver | null = null; private isDragging: boolean = false; private dragStartY: number = 0; private scrollStartTop: number = 0; private maxScrollTop: number = 0; private handleHeight: number = 40; // Horizontal scrolling private bottomMiddleSpacer: HTMLElement | null = null; private horizontalScrollHandle: HTMLElement | null = null; private weekHeader: HTMLElement | null = null; private isHorizontalDragging: boolean = false; private dragStartX: number = 0; private scrollStartLeft: number = 0; private maxScrollLeft: number = 0; private horizontalHandleWidth: number = 40; constructor() { this.init(); } private init(): void { this.subscribeToEvents(); } private subscribeToEvents(): void { // Initialize scroll when grid is rendered eventBus.on(EventTypes.GRID_RENDERED, () => { console.log('ScrollManager: Received GRID_RENDERED event'); this.setupScrolling(); }); // Handle mouse events for dragging (both vertical and horizontal) document.addEventListener('mousemove', this.handleMouseMove.bind(this)); document.addEventListener('mouseup', this.handleMouseUp.bind(this)); // Handle window resize window.addEventListener('resize', () => { this.updateScrollableHeight(); }); } /** * Setup scrolling functionality after grid is rendered */ private setupScrolling(): void { this.findElements(); if (this.rightColumn && this.scrollableContent && this.calendarContainer) { this.setupResizeObserver(); this.updateScrollableHeight(); this.createScrollHandle(); this.hideNativeScrollbar(); this.setupScrollSynchronization(); this.calculateScrollBounds(); this.updateHandlePosition(); } // Setup horizontal scrolling if (this.bottomMiddleSpacer && this.scrollableContent && this.weekHeader) { this.createHorizontalScrollHandle(); this.setupHorizontalScrollSynchronization(); this.calculateHorizontalScrollBounds(); this.updateHorizontalHandlePosition(); } } /** * Find DOM elements needed for scrolling */ private findElements(): void { this.rightColumn = document.querySelector('swp-right-column'); this.scrollableContent = document.querySelector('swp-scrollable-content'); this.calendarContainer = document.querySelector('swp-calendar-container'); this.timeAxis = document.querySelector('swp-time-axis'); // Horizontal scrolling elements this.bottomMiddleSpacer = document.querySelector('swp-bottom-middle-spacer'); this.weekHeader = document.querySelector('swp-week-header'); console.log('ScrollManager: Found elements:', { rightColumn: !!this.rightColumn, bottomMiddleSpacer: !!this.bottomMiddleSpacer, scrollableContent: !!this.scrollableContent, weekHeader: !!this.weekHeader }); } /** * Create and add scroll handle to right column */ private createScrollHandle(): void { if (!this.rightColumn) return; // Remove existing handle if any const existingHandle = this.rightColumn.querySelector('swp-scroll-handle'); if (existingHandle) { existingHandle.remove(); } // Create new handle this.scrollHandle = document.createElement('swp-scroll-handle'); this.scrollHandle.addEventListener('mousedown', this.handleMouseDown.bind(this)); this.rightColumn.appendChild(this.scrollHandle); } /** * Calculate scroll bounds based on content and container heights */ private calculateScrollBounds(): void { if (!this.scrollableContent || !this.rightColumn) return; const contentHeight = this.scrollableContent.scrollHeight; const containerHeight = this.scrollableContent.clientHeight; // Use container height as track height since right column should match scrollable area const trackHeight = containerHeight; console.log('ScrollManager Debug:'); console.log('- contentHeight (scrollHeight):', contentHeight); console.log('- containerHeight (clientHeight):', containerHeight); console.log('- trackHeight (using containerHeight):', trackHeight); console.log('- scrollableContent element:', this.scrollableContent); this.maxScrollTop = Math.max(0, contentHeight - containerHeight); // Calculate proportional handle height based on content ratio if (contentHeight > 0 && containerHeight > 0) { const visibleRatio = containerHeight / contentHeight; this.handleHeight = Math.max(20, Math.min(trackHeight * visibleRatio, trackHeight - 10)); } else { this.handleHeight = 40; // fallback } console.log('- maxScrollTop:', this.maxScrollTop); console.log('- visibleRatio:', (containerHeight / contentHeight).toFixed(3)); console.log('- calculated handleHeight:', this.handleHeight); // Update handle height in DOM if (this.scrollHandle) { this.scrollHandle.style.height = `${this.handleHeight}px`; } } /** * Handle mouse down on scroll handle */ private handleMouseDown(e: MouseEvent): void { e.preventDefault(); this.isDragging = true; this.dragStartY = e.clientY; if (this.scrollHandle && this.scrollableContent) { this.scrollHandle.classList.add('dragging'); this.scrollStartTop = this.scrollableContent.scrollTop; } } /** * Handle mouse move during drag */ private handleMouseMove(e: MouseEvent): void { // Handle vertical dragging if (this.isDragging && this.scrollHandle && this.scrollableContent) { e.preventDefault(); const deltaY = e.clientY - this.dragStartY; // Use container height as track height const trackHeight = this.scrollableContent.clientHeight - this.handleHeight; // Ensure trackHeight is positive to avoid division by zero if (trackHeight > 0) { const scrollRatio = deltaY / trackHeight; const newScrollTop = this.scrollStartTop + (scrollRatio * this.maxScrollTop); // Clamp scroll position const clampedScrollTop = Math.max(0, Math.min(newScrollTop, this.maxScrollTop)); // Apply scroll to content this.scrollableContent.scrollTop = clampedScrollTop; // Update handle position (this will also trigger time-axis sync via scroll event) this.updateHandlePosition(); } } // Handle horizontal dragging if (this.isHorizontalDragging && this.horizontalScrollHandle && this.scrollableContent) { e.preventDefault(); const deltaX = e.clientX - this.dragStartX; // Use container width as track width const trackWidth = this.scrollableContent.clientWidth - this.horizontalHandleWidth; // Ensure trackWidth is positive to avoid division by zero if (trackWidth > 0) { const scrollRatio = deltaX / trackWidth; const newScrollLeft = this.scrollStartLeft + (scrollRatio * this.maxScrollLeft); // Clamp scroll position const clampedScrollLeft = Math.max(0, Math.min(newScrollLeft, this.maxScrollLeft)); // Apply scroll to content this.scrollableContent.scrollLeft = clampedScrollLeft; // Update handle position (this will also trigger week-header sync via scroll event) this.updateHorizontalHandlePosition(); } } } /** * Handle mouse up to end drag */ private handleMouseUp(e: MouseEvent): void { // Handle vertical drag end if (this.isDragging) { this.isDragging = false; if (this.scrollHandle) { this.scrollHandle.classList.remove('dragging'); } } // Handle horizontal drag end if (this.isHorizontalDragging) { this.isHorizontalDragging = false; if (this.horizontalScrollHandle) { this.horizontalScrollHandle.classList.remove('dragging'); } } } /** * Update handle position based on current scroll */ private updateHandlePosition(): void { if (!this.scrollHandle || !this.scrollableContent) return; const scrollTop = this.scrollableContent.scrollTop; const scrollRatio = this.maxScrollTop > 0 ? scrollTop / this.maxScrollTop : 0; // Use container height as track height const trackHeight = this.scrollableContent.clientHeight - this.handleHeight; const handleTop = Math.max(0, Math.min(scrollRatio * trackHeight, trackHeight)); this.scrollHandle.style.top = `${handleTop}px`; // Debug logging for handle position if (scrollTop % 200 === 0) { // Log every 200px to avoid spam console.log(`ScrollManager: Handle position - scrollTop: ${scrollTop}, ratio: ${scrollRatio.toFixed(3)}, handleTop: ${handleTop.toFixed(1)}, trackHeight: ${trackHeight}`); } } /** * Scroll to specific position */ scrollTo(scrollTop: number): void { if (!this.scrollableContent) return; const clampedScrollTop = Math.max(0, Math.min(scrollTop, this.maxScrollTop)); this.scrollableContent.scrollTop = clampedScrollTop; this.updateHandlePosition(); } /** * Scroll to specific hour */ scrollToHour(hour: number): void { const hourHeight = calendarConfig.get('hourHeight'); const dayStartHour = calendarConfig.get('dayStartHour'); const scrollTop = (hour - dayStartHour) * hourHeight; this.scrollTo(scrollTop); } /** * Setup ResizeObserver to monitor container size changes */ private setupResizeObserver(): void { if (!this.calendarContainer) return; // Clean up existing observer if (this.resizeObserver) { this.resizeObserver.disconnect(); } this.resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { console.log('ScrollManager: Container resized', entry.contentRect); this.updateScrollableHeight(); } }); this.resizeObserver.observe(this.calendarContainer); } /** * Calculate and update scrollable content height dynamically */ private updateScrollableHeight(): void { if (!this.scrollableContent || !this.calendarContainer) return; // Get calendar container height const containerRect = this.calendarContainer.getBoundingClientRect(); // Find navigation height const navigation = document.querySelector('swp-calendar-nav'); const navHeight = navigation ? navigation.getBoundingClientRect().height : 0; // Find week header height const weekHeader = document.querySelector('swp-week-header'); const headerHeight = weekHeader ? weekHeader.getBoundingClientRect().height : 80; // Calculate available height for scrollable content const availableHeight = containerRect.height - headerHeight; // Calculate available width (container width minus time-axis and scrollbar) const availableWidth = containerRect.width - 60 - 20; // 60px time-axis, 20px scrollbar console.log('ScrollManager: Dynamic height calculation'); console.log('- Container height:', containerRect.height); console.log('- Navigation height:', navHeight); console.log('- Header height:', headerHeight); console.log('- Available height:', availableHeight); console.log('- Available width:', availableWidth); // Set the height and width on scrollable content if (availableHeight > 0) { this.scrollableContent.style.height = `${availableHeight}px`; } if (availableWidth > 0) { this.scrollableContent.style.width = `${availableWidth}px`; } // Recalculate scroll bounds after dimension changes setTimeout(() => { this.calculateScrollBounds(); this.calculateHorizontalScrollBounds(); this.updateHandlePosition(); this.updateHorizontalHandlePosition(); }, 0); } /** * Hide native scrollbar while keeping scroll functionality * Note: Scrollbar hiding is now handled in CSS file */ private hideNativeScrollbar(): void { // Scrollbar hiding is now handled in CSS file // No JavaScript needed here anymore } /** * Setup scroll synchronization between scrollable content and time axis */ private setupScrollSynchronization(): void { if (!this.scrollableContent || !this.timeAxis) return; console.log('ScrollManager: Setting up scroll synchronization'); // Throttle scroll events for better performance let scrollTimeout: number | null = null; this.scrollableContent.addEventListener('scroll', () => { if (scrollTimeout) { cancelAnimationFrame(scrollTimeout); } scrollTimeout = requestAnimationFrame(() => { this.syncTimeAxisPosition(); this.updateHandlePosition(); }); }); } /** * Synchronize time axis position with scrollable content */ private syncTimeAxisPosition(): void { if (!this.scrollableContent || !this.timeAxis) return; const scrollTop = this.scrollableContent.scrollTop; // Use transform for smooth performance this.timeAxis.style.transform = `translateY(-${scrollTop}px)`; // Debug logging (can be removed later) if (scrollTop % 100 === 0) { // Only log every 100px to avoid spam console.log(`ScrollManager: Synced time-axis to scrollTop: ${scrollTop}px`); } } /** * Create and add horizontal scroll handle to bottom middle spacer */ private createHorizontalScrollHandle(): void { if (!this.bottomMiddleSpacer) return; // Remove existing handle if any const existingHandle = this.bottomMiddleSpacer.querySelector('swp-horizontal-scroll-handle'); if (existingHandle) { existingHandle.remove(); } // Create new handle this.horizontalScrollHandle = document.createElement('swp-horizontal-scroll-handle'); this.horizontalScrollHandle.addEventListener('mousedown', this.handleHorizontalMouseDown.bind(this)); this.bottomMiddleSpacer.appendChild(this.horizontalScrollHandle); } /** * Calculate horizontal scroll bounds based on content and container widths */ private calculateHorizontalScrollBounds(): void { if (!this.scrollableContent || !this.bottomMiddleSpacer) return; const contentWidth = this.scrollableContent.scrollWidth; const containerWidth = this.scrollableContent.clientWidth; const trackWidth = containerWidth; console.log('ScrollManager Horizontal Debug:'); console.log('- contentWidth (scrollWidth):', contentWidth); console.log('- containerWidth (clientWidth):', containerWidth); console.log('- trackWidth (using containerWidth):', trackWidth); this.maxScrollLeft = Math.max(0, contentWidth - containerWidth); // Calculate proportional handle width based on content ratio if (contentWidth > 0 && containerWidth > 0) { const visibleRatio = containerWidth / contentWidth; this.horizontalHandleWidth = Math.max(20, Math.min(trackWidth * visibleRatio, trackWidth - 10)); } else { this.horizontalHandleWidth = 40; // fallback } console.log('- maxScrollLeft:', this.maxScrollLeft); console.log('- visibleRatio:', (containerWidth / contentWidth).toFixed(3)); console.log('- calculated horizontalHandleWidth:', this.horizontalHandleWidth); // Update handle width in DOM if (this.horizontalScrollHandle) { this.horizontalScrollHandle.style.width = `${this.horizontalHandleWidth}px`; } } /** * Handle mouse down on horizontal scroll handle */ private handleHorizontalMouseDown(e: MouseEvent): void { e.preventDefault(); this.isHorizontalDragging = true; this.dragStartX = e.clientX; if (this.horizontalScrollHandle && this.scrollableContent) { this.horizontalScrollHandle.classList.add('dragging'); this.scrollStartLeft = this.scrollableContent.scrollLeft; } } /** * Update horizontal handle position based on current scroll */ private updateHorizontalHandlePosition(): void { if (!this.horizontalScrollHandle || !this.scrollableContent) return; const scrollLeft = this.scrollableContent.scrollLeft; const scrollRatio = this.maxScrollLeft > 0 ? scrollLeft / this.maxScrollLeft : 0; const trackWidth = this.scrollableContent.clientWidth - this.horizontalHandleWidth; const handleLeft = Math.max(0, Math.min(scrollRatio * trackWidth, trackWidth)); this.horizontalScrollHandle.style.left = `${handleLeft}px`; // Debug logging for handle position if (scrollLeft % 200 === 0) { // Log every 200px to avoid spam console.log(`ScrollManager: Horizontal handle position - scrollLeft: ${scrollLeft}, ratio: ${scrollRatio.toFixed(3)}, handleLeft: ${handleLeft.toFixed(1)}, trackWidth: ${trackWidth}`); } } /** * Setup horizontal scroll synchronization between scrollable content and week header */ private setupHorizontalScrollSynchronization(): void { if (!this.scrollableContent || !this.weekHeader) return; console.log('ScrollManager: Setting up horizontal scroll synchronization'); // Listen to horizontal scroll events this.scrollableContent.addEventListener('scroll', () => { this.syncWeekHeaderPosition(); this.updateHorizontalHandlePosition(); }); } /** * Synchronize week header position with scrollable content horizontal scroll */ private syncWeekHeaderPosition(): void { if (!this.scrollableContent || !this.weekHeader) return; const scrollLeft = this.scrollableContent.scrollLeft; // Use transform for smooth performance this.weekHeader.style.transform = `translateX(-${scrollLeft}px)`; // Debug logging (can be removed later) if (scrollLeft % 100 === 0) { // Only log every 100px to avoid spam console.log(`ScrollManager: Synced week-header to scrollLeft: ${scrollLeft}px`); } } /** * Cleanup resources */ destroy(): void { if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } } }