import { ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild, } from '@angular/core'; import { AfterViewInit } from '@angular/core'; import { Subject } from 'rxjs'; // modules import { FormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { ObserversModule } from '@angular/cdk/observers'; // models import { ScrollBarOptions, ScrollEvent } from './models'; // enums import { EventType, ScrollEventAction } from './enums'; @Component({ selector: 'app-ca-custom-scrollbar', imports: [FormsModule, CommonModule, ObserversModule], templateUrl: './ca-custom-scrollbar.component.html', styleUrl: './ca-custom-scrollbar.component.scss' }) export class CaCustomScrollbarComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('bar', { static: false }) private bar!: ElementRef; @Output() scrollEvent: EventEmitter = new EventEmitter(); @Input() set scrollBarOptions(value: ScrollBarOptions) { this._scrollBarOptions = value; } @Input() set horizontalScrollHeight(value: number) { this._horizontalScrollHeight = value; } public _scrollBarOptions!: ScrollBarOptions; public _horizontalScrollHeight!: number; private scrollTop: number = 5; public showScrollbar: boolean = false; public scrollHeight: number = 0; private scrollRatio: number = 0; private scrollRatioFull: number = 0; public isMouseDown: boolean = false; private barClickPosition: number = 0; private barClickRestHeight: number = 0; private destroy$ = new Subject(); private calculateSizeHeightTimer?: ReturnType; // Table Horizontal Scroll private tableNotPinedContainer: HTMLDivElement | null = null; public tableNotPinedBoundingRect: DOMRect | null = null; private tableBarClickPosition: number = 0; private tableBarClickRestWidth: number = 0; private tableScrollRatio: number = 0; private tableScrollRatioFull: number = 0; public tableScrollWidth: number = 0; private resizeHandlerCount: number | null = null; constructor( private ngZone: NgZone, private elRef: ElementRef, private cdr: ChangeDetectorRef ) {} ngOnInit(): void { this.ngZone.runOutsideAngular(() => { this.initializeEventListeners(); this.calculateBarSizeAndPosition( this.elRef.nativeElement.children[0] ); }); } ngAfterViewInit(): void { if (this._scrollBarOptions.showHorizontalScrollBar) { this.initializeTableScroll(); this.cdr.detectChanges(); } } private initializeEventListeners(): void { document.addEventListener(EventType.MOUSE_UP, this.onMouseUpHandler); document.addEventListener( EventType.MOUSE_MOVE, this.onMouseMoveHandler ); this.elRef.nativeElement.children[0].addEventListener( EventType.SCROLL, this.setScrollEvent ); window.addEventListener(EventType.RESIZE, this.onResizeHandler); } private initializeTableScroll(): void { this.tableNotPinedContainer = document.querySelector( '.not-pined-columns' ) as HTMLDivElement | null; this.tableNotPinedBoundingRect = this.tableNotPinedContainer?.getBoundingClientRect() || null; } private setScrollEvent = () => { if (!this.isMouseDown) { this.calculateBarSizeAndPosition( this.elRef.nativeElement.children[0] ); } }; public setDraggingStart(event: MouseEvent): void { const style = window.getComputedStyle(this.bar.nativeElement); const matrix = new DOMMatrixReadOnly(style.transform); this.barClickPosition = event.clientY - matrix.m42; this.barClickRestHeight = this.scrollHeight - this.barClickPosition; this.isMouseDown = true; // Table Scroll this.tableBarClickPosition = event.clientX - matrix.m41; this.tableBarClickRestWidth = this.tableScrollWidth - this.tableBarClickPosition; } private calculateBarSizeAndPosition(elem: HTMLElement): void { setTimeout(() => { if (this._scrollBarOptions.showHorizontalScrollBar) { const scrollWrapper: HTMLElement | null = document.querySelector('.not-pined-columns'); this.calculateTableScroll(scrollWrapper); } else { this.calculateRegularScroll(elem); } }, 100); } private calculateTableScroll(scrollWrapper: HTMLElement | null): void { if (!scrollWrapper) { this.tableNotPinedBoundingRect = null; this.showScrollbar = false; this.scrollEvent.emit({ eventAction: ScrollEventAction.IS_SCROLL_SHOWING, isScrollBarShowing: this.showScrollbar, }); return; } const boundingRect = scrollWrapper.getBoundingClientRect(); const tableFullWidth = scrollWrapper.scrollWidth || 0; const tableVisibleWidth = Math.ceil(boundingRect.width || 0); this.tableNotPinedBoundingRect = boundingRect; this.tableScrollRatio = tableVisibleWidth / tableFullWidth; this.tableScrollRatioFull = tableFullWidth / tableVisibleWidth; this.tableScrollWidth = this.tableScrollRatio * tableVisibleWidth; this.showScrollbar = tableFullWidth > tableVisibleWidth; this.cdr.detectChanges(); this.scrollEvent.emit({ eventAction: ScrollEventAction.IS_SCROLL_SHOWING, isScrollBarShowing: this.showScrollbar, }); } private calculateRegularScroll(elem: HTMLElement): void { const contentHeight = elem.scrollHeight - 1; const visibleHeight = window.innerHeight; this.showScrollbar = contentHeight > visibleHeight; this.cdr.detectChanges(); if (!this.showScrollbar) return; this.scrollRatio = visibleHeight / contentHeight; this.scrollRatioFull = contentHeight / visibleHeight; this.scrollTop = elem.scrollTop * this.scrollRatio; if (this.bar) { this.bar.nativeElement.style.transform = `translateY(${this.scrollTop}px)`; } this.scrollHeight = this.scrollRatio * visibleHeight; } private onMouseUpHandler = () => (this.isMouseDown = false); private onResizeHandler = () => { if (!this.isMouseDown) { if (this.resizeHandlerCount !== null) { clearTimeout(this.resizeHandlerCount); } this.resizeHandlerCount = window.setTimeout(() => { this.calculateBarSizeAndPosition( this.elRef.nativeElement.children[0] ); }, 150); } }; private onMouseMoveHandler = (event: MouseEvent) => { if (this.isMouseDown) { if (this._scrollBarOptions.showVerticalScrollBar) { this.handleRegularScroll(event); } else { this.handleTableScroll(event); } } }; private handleRegularScroll(event: MouseEvent): void { const offsetBar = event.clientY - this.barClickPosition; if ( offsetBar > -1 && event.clientY + this.barClickRestHeight < window.innerHeight ) { this.bar.nativeElement.style.transform = `translateY(${offsetBar}px)`; } this.elRef.nativeElement.children[0].scrollTop = (event.clientY - this.barClickPosition) * this.scrollRatioFull; } private handleTableScroll(event: MouseEvent): void { let offsetBar = event.clientX - this.tableBarClickPosition; const maxWidth = this.tableNotPinedBoundingRect?.width || 0; offsetBar = offsetBar < 0 ? 0 : offsetBar; offsetBar = event.clientX + this.tableBarClickRestWidth > maxWidth ? maxWidth - this.tableScrollWidth : offsetBar; this.bar.nativeElement.style.transform = `translateX(${offsetBar}px)`; this.scrollEvent.emit({ eventAction: ScrollEventAction.SCROLLING, scrollPosition: offsetBar * this.tableScrollRatioFull, }); } public projectContentChanged(): void { clearTimeout(this.calculateSizeHeightTimer); this.calculateSizeHeightTimer = setTimeout(() => { this.calculateBarSizeAndPosition( this.elRef.nativeElement.children[0] ); }, 100); } private removeEventListeners(): void { document.removeEventListener(EventType.MOUSE_UP, this.onMouseUpHandler); document.removeEventListener( EventType.MOUSE_MOVE, this.onMouseMoveHandler ); window.removeEventListener(EventType.RESIZE, this.onResizeHandler); } ngOnDestroy(): void { this.removeEventListeners(); this.destroy$.next(); this.destroy$.complete(); } }