import { animate, keyframes, state, style, transition, trigger } from '@angular/animations'; import { NgStyle } from '@angular/common'; import { AfterViewInit, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, QueryList, SimpleChanges, TemplateRef, ViewChild, ViewChildren } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AngularResizeEventModule, ResizedEvent } from 'angular-resize-event'; import { Subject, debounceTime } from 'rxjs'; @Component({ selector: 'carousel', templateUrl: './carousel.component.html', styleUrls: ['./carousel.component.scss'], standalone: true, imports: [ BrowserModule, AngularResizeEventModule ], animations: [ trigger('direction', [ state('vertical', style({})), state('horizontal', style({})), state('hidden', style({ opacity: 0, })), state('unhidden', style({ opacity: 1, })), transition('* => hidden', animate(`{{directionTime}}ms`)), transition('hidden => unhidden', animate(`{{directionTime}}ms`)), transition('unhidden => *', animate('10ms')), ]) ] }) export class CarouselComponent implements OnInit, AfterViewInit, OnChanges { @Output() afterInitSelf: EventEmitter> = new EventEmitter>(); @Input() slides?: T[]; @ContentChild(TemplateRef) template?: TemplateRef; @ViewChild('carouselWindow') window!: ElementRef; @ViewChildren('slideElement') slideElements!: QueryList; @Input() initIndex?: number; current: number = 0; intersectionObserver?: IntersectionObserver; visibleSlides?: IntersectingSlide[]; increasing: boolean = true; vertical: boolean = true; angularAnimating: boolean = false; animation: string = 'vertical'; directionTime: number = 0; @Input() numVisible: number = 1; @Input() offset: number = 0; containerDirectionLength: number = 0; private debouncedOnchange: Subject = new Subject(); constructor(private cdr: ChangeDetectorRef) { this.visibleSlides = []; this.debouncedOnchange.pipe(debounceTime(500)).subscribe(() => this.realOnChange()); if (this.initIndex) { this.current = this.initIndex; } } ngOnInit(): void { this.afterInitSelf.next(this); this.intersectionObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { const runIntersectionHandling = () => { const entryIndex = parseInt(entry.target.getAttribute('data-slideIndex')!); if (!entryIndex && entryIndex != 0) { return; } const entryIntersectingSlide = this.visibleSlides?.find(s => s.index == entryIndex); if (!entryIntersectingSlide) { return; } entryIntersectingSlide.currentlyIntersecting = entry.isIntersecting; }; runIntersectionHandling(); this.onChange(); }) }) } numExtraNext() { return this.numVisible + this.offset - 1; } numExtraPrev() { return 0 + this.offset; } ngOnChanges(changes: SimpleChanges): void { if(changes['numVisible'] || changes['offset']) { this.reinitializeVisibleSlides(); } } ngAfterViewInit(): void { this.slideElements.changes.subscribe((comps: QueryList) => { comps.forEach((comp) => this.handleNewDomSlide(comp)) }); setTimeout(() => { this.initialize(); this.cdr.detectChanges(); }, 0); } initialize() { for (let i = 0; i <= this.numExtraNext() && i < this.slides!.length; i++) { this.visibleSlides?.push({ index: i, currentlyIntersecting: false, }) } } handleNewDomSlide(ref: ElementRef) { this.intersectionObserver?.observe(ref.nativeElement); } setIndex(slideIndex: number) { this.current = slideIndex; this.reinitializeVisibleSlides(); this.onChange(); } reinitializeVisibleSlides() { this.visibleSlides!.length = 0; const start = Math.max(0, this.current - this.numExtraPrev()); const end = Math.min(this.current + this.numExtraNext(), this.slides!.length - 1); for (let i = start; i <= end; i++) { this.visibleSlides?.push({ index: i, currentlyIntersecting: false, }); } this.onChange(); } next(): void { this.increasing = true; if (this.slides && this.current + 1 < this.slides?.length) { this.current += 1; if (this.current + this.numExtraNext() < this.slides.length) { if (!this.visibleSlides?.find(e => e.index == this.current + this.numExtraNext())) { this.visibleSlides?.push({ index: this.current + this.numExtraNext(), currentlyIntersecting: false, }); } } this.onChange(); } } prev(): void { this.increasing = false; if (this.current - 1 >= 0) { this.current -= 1; if (this.current - this.numExtraPrev() >= 0) { if (!this.visibleSlides?.find(e => e.index == this.current - this.numExtraPrev())) { this.visibleSlides?.push({ index: this.current - this.numExtraPrev(), currentlyIntersecting: false, }); } } this.onChange(); } } onChange() { this.debouncedOnchange.next(); } realOnChange() { const safetyFactor = (this.numVisible == 1 ? 1 : 2); const intersecting = this.visibleSlides?.filter(e => e.currentlyIntersecting).sort((e1, e2) => e1.index - e2.index); if (intersecting && intersecting.length > 0) { const lowestIntersecting = intersecting![0]; const highestIntersecting = intersecting![intersecting!.length - 1]; const min = Math.min(lowestIntersecting.index - safetyFactor, this.current - this.numExtraPrev()); const max = Math.max(highestIntersecting.index + safetyFactor, this.current + this.numExtraNext()); this.visibleSlides = this.visibleSlides?.filter(e => e.index >= min && e.index <= max); } } translation() { const step = this.containerDirectionLength / this.numVisible; const translation = -((this.current - this.offset) * step); if (this.vertical) { return `0 ${translation}px` } else { return `${translation}px 0` } } templateValue() { const len = this.slides?.length; return `repeat(${len}, ${this.containerDirectionLength / this.numVisible}px)`; } style() { let style: any = {}; if (this.vertical) { style['grid-template-rows'] = this.templateValue(); style['grid-auto-flow'] = 'column'; } else { style['grid-template-columns'] = this.templateValue(); style['grid-auto-flow'] = 'row'; } if (!this.angularAnimating) { style.transition = '500ms'; } return style; } onAnimationEnd(event: any) { if (event.toState == 'hidden') { this.vertical = !this.vertical; this.animation = 'unhidden'; } else if (event.toState == 'unhidden') { this.animation = this.vertical ? 'vertical' : 'horizontal'; } else { this.angularAnimating = false; } } onAnimationStart(event: any) { this.angularAnimating = true; } changeDirection() { this.animation = 'hidden'; } carouselResize(event: ResizedEvent) { if (this.vertical) { this.containerDirectionLength = event.newRect.height; } else { this.containerDirectionLength = event.newRect.width; } } } interface IntersectingSlide { index: number; currentlyIntersecting: boolean; }