import { DestroyRef, Directive, effect, inject, input, Renderer2, signal, } from '@angular/core'; /** * Directive that holds scrollbar sync logic and calculations. * Syncs thumb dimensions and position from a scrollable element (scroll + resize), * and handles thumb drag (document mousemove/mouseup). * Used by CaCustomHorizontalScrollbarComponent in its template (exportAs: scrollbarSync). */ @Directive({ selector: '[scrollbarSync]', standalone: true, exportAs: 'scrollbarSync', }) export class ScrollbarSyncDirective { readonly scrollableElement = input(); readonly trackRightOffsetPx = input(0); readonly trackWidthPx = signal(0); readonly thumbWidthPx = signal(0); readonly thumbLeftPx = signal(0); private destroyRef = inject(DestroyRef); private renderer = inject(Renderer2); private scrollListenerCleanup: (() => void) | null = null; private dragListenersCleanup: (() => void) | null = null; private resizeObserver: ResizeObserver | null = null; private pendingThumbSyncRafId: number | null = null; private pendingAttachRafId: number | null = null; private dragStartScrollLeft = 0; private dragStartClientX = 0; constructor() { this.setupScrollableEffect(); } private setupScrollableEffect(): void { effect(() => { this.cleanup(); const scrollableContainer = this.scrollableElement(); if (scrollableContainer) { this.pendingAttachRafId = requestAnimationFrame(() => { this.pendingAttachRafId = null; this.attachToScrollableContainer(scrollableContainer); }); } }); this.destroyRef.onDestroy(() => this.cleanup()); } private attachToScrollableContainer( scrollableContainer: HTMLElement ): void { this.syncThumbFromScrollable(scrollableContainer); const onScroll = () => this.scheduleThumbSync(scrollableContainer); this.scrollListenerCleanup = this.renderer.listen( scrollableContainer, 'scroll', onScroll ); this.resizeObserver?.disconnect(); this.resizeObserver = new ResizeObserver(() => this.scheduleThumbSync(scrollableContainer) ); this.resizeObserver.observe(scrollableContainer); } private scheduleThumbSync(scrollableContainer: HTMLElement): void { if (this.pendingThumbSyncRafId) { cancelAnimationFrame(this.pendingThumbSyncRafId); } this.pendingThumbSyncRafId = requestAnimationFrame(() => { this.pendingThumbSyncRafId = null; this.syncThumbFromScrollable(scrollableContainer); }); } private cleanup(): void { if (this.pendingAttachRafId) { cancelAnimationFrame(this.pendingAttachRafId); this.pendingAttachRafId = null; } this.scrollListenerCleanup?.(); this.scrollListenerCleanup = null; this.resizeObserver?.disconnect(); this.resizeObserver = null; if (this.pendingThumbSyncRafId) { cancelAnimationFrame(this.pendingThumbSyncRafId); this.pendingThumbSyncRafId = null; } this.detachDragListeners(); this.trackWidthPx.set(0); this.thumbWidthPx.set(0); this.thumbLeftPx.set(0); } private handleThumbDrag(mouseEvent: MouseEvent): void { const scrollableContainer = this.scrollableElement(); if (!scrollableContainer) return; const trackWidth = this.trackWidthPx(); const thumbWidth = this.thumbWidthPx(); const thumbMaxLeft = trackWidth - thumbWidth; if (!thumbMaxLeft) return; const { scrollWidth, clientWidth } = scrollableContainer; const maxScrollLeft = scrollWidth - clientWidth; const pointerDeltaX = mouseEvent.clientX - this.dragStartClientX; const scrollDelta = (pointerDeltaX / thumbMaxLeft) * maxScrollLeft; const newScrollLeft = Math.max( 0, Math.min(maxScrollLeft, this.dragStartScrollLeft + scrollDelta) ); this.renderer.setProperty( scrollableContainer, 'scrollLeft', newScrollLeft ); this.syncThumbFromScrollable(scrollableContainer); } private endThumbDrag(): void { this.detachDragListeners(); } private detachDragListeners(): void { this.dragListenersCleanup?.(); this.dragListenersCleanup = null; } public syncThumbFromScrollable(scrollableContainer: HTMLElement): void { const { scrollLeft, scrollWidth, clientWidth } = scrollableContainer; const trackRightOffset = this.trackRightOffsetPx(); const trackWidth = Math.max(0, clientWidth - trackRightOffset); this.trackWidthPx.set(trackWidth); const maxScrollLeft = scrollWidth - clientWidth; if (!maxScrollLeft) { this.thumbWidthPx.set(0); this.thumbLeftPx.set(0); return; } const thumbWidth = Math.min( Math.round((clientWidth / scrollWidth) * trackWidth), trackWidth ); const thumbMaxLeft = trackWidth - thumbWidth; const thumbLeft = Math.round( (scrollLeft / maxScrollLeft) * thumbMaxLeft ); this.thumbWidthPx.set(thumbWidth); this.thumbLeftPx.set(Math.max(0, Math.min(thumbMaxLeft, thumbLeft))); } public onThumbMouseDown(event: MouseEvent): void { event.preventDefault(); const scrollableContainer = this.scrollableElement(); if (!scrollableContainer) return; this.dragStartScrollLeft = scrollableContainer.scrollLeft; this.dragStartClientX = event.clientX; this.detachDragListeners(); const onMouseMove = (e: MouseEvent): void => this.handleThumbDrag(e); const onMouseUp = (): void => this.endThumbDrag(); const unlistenMouseMove = this.renderer.listen( document, 'mousemove', onMouseMove ); const unlistenMouseUp = this.renderer.listen( document, 'mouseup', onMouseUp ); this.dragListenersCleanup = (): void => { unlistenMouseMove(); unlistenMouseUp(); this.dragListenersCleanup = null; }; } }