improve auto-scrolling and add pause when hovering/clicking
All checks were successful
publish.yml / publish (push) Successful in 1m4s
All checks were successful
publish.yml / publish (push) Successful in 1m4s
This commit is contained in:
@@ -19,9 +19,16 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
|
||||
private autoScrollFrameId: number | null = null;
|
||||
private startAutoScrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private delayedResumeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private delayedResumeAt = 0;
|
||||
private lastAutoScrollTime = 0;
|
||||
private virtualScrollLeft = 0;
|
||||
private currentAutoScrollSpeedPxPerSecond = 0;
|
||||
private targetAutoScrollSpeedPxPerSecond = 0;
|
||||
private isPausePendingStop = false;
|
||||
private readonly autoScrollSpeedPxPerSecond = 72;
|
||||
private readonly speedRampDurationMs = 420;
|
||||
private readonly speedStopThresholdPxPerSecond = 0.5;
|
||||
isAutoScrolling = false;
|
||||
showLeftFade = false;
|
||||
showRightFade = false;
|
||||
@@ -66,6 +73,7 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
this.startAutoScrollTimeout = null;
|
||||
}
|
||||
|
||||
this.clearScheduledResume();
|
||||
this.pauseAutoScroll();
|
||||
this.isAutoScrolling = false;
|
||||
}
|
||||
@@ -84,43 +92,80 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
this.updateFadeState();
|
||||
}
|
||||
|
||||
pauseAutoScroll(): void {
|
||||
if (this.autoScrollFrameId === null) {
|
||||
return;
|
||||
}
|
||||
onHoverStart(): void {
|
||||
this.pauseAutoScroll();
|
||||
this.clearScheduledResume();
|
||||
}
|
||||
|
||||
cancelAnimationFrame(this.autoScrollFrameId);
|
||||
this.autoScrollFrameId = null;
|
||||
this.lastAutoScrollTime = 0;
|
||||
onHoverEnd(): void {
|
||||
this.scheduleResume(1000);
|
||||
}
|
||||
|
||||
onInteractionClick(): void {
|
||||
this.pauseAutoScroll();
|
||||
this.scheduleResume(2000);
|
||||
}
|
||||
|
||||
pauseAutoScroll(): void {
|
||||
this.targetAutoScrollSpeedPxPerSecond = 0;
|
||||
this.isPausePendingStop = true;
|
||||
this.syncAutoScrollState();
|
||||
|
||||
if (this.autoScrollFrameId === null) {
|
||||
this.currentAutoScrollSpeedPxPerSecond = 0;
|
||||
this.lastAutoScrollTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
resumeAutoScroll(): void {
|
||||
this.isPausePendingStop = false;
|
||||
this.targetAutoScrollSpeedPxPerSecond = this.autoScrollSpeedPxPerSecond;
|
||||
this.syncAutoScrollState();
|
||||
|
||||
if (this.autoScrollFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isAutoScrolling = true;
|
||||
|
||||
const step = (timestamp: number) => {
|
||||
const carousel = this.stackCarousel?.nativeElement;
|
||||
if (!carousel) {
|
||||
this.autoScrollFrameId = null;
|
||||
this.lastAutoScrollTime = 0;
|
||||
this.virtualScrollLeft = 0;
|
||||
this.currentAutoScrollSpeedPxPerSecond = 0;
|
||||
this.targetAutoScrollSpeedPxPerSecond = 0;
|
||||
this.isPausePendingStop = false;
|
||||
this.isAutoScrolling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.lastAutoScrollTime === 0) {
|
||||
const loopWidth = this.getLoopWidth(carousel);
|
||||
this.virtualScrollLeft = loopWidth > 0 ? carousel.scrollLeft % loopWidth : 0;
|
||||
this.virtualScrollLeft = loopWidth > 0 ? carousel.scrollLeft : 0;
|
||||
}
|
||||
|
||||
const deltaMs = this.lastAutoScrollTime > 0 ? Math.min(timestamp - this.lastAutoScrollTime, 40) : 16;
|
||||
this.lastAutoScrollTime = timestamp;
|
||||
|
||||
this.autoAdvanceStack(carousel, deltaMs);
|
||||
this.updateAutoScrollSpeed(deltaMs);
|
||||
|
||||
if (this.currentAutoScrollSpeedPxPerSecond > this.speedStopThresholdPxPerSecond) {
|
||||
this.autoAdvanceStack(carousel, deltaMs, this.currentAutoScrollSpeedPxPerSecond);
|
||||
}
|
||||
|
||||
this.updateFadeState(carousel);
|
||||
this.syncAutoScrollState();
|
||||
|
||||
if (this.isPausePendingStop && this.currentAutoScrollSpeedPxPerSecond <= this.speedStopThresholdPxPerSecond) {
|
||||
this.currentAutoScrollSpeedPxPerSecond = 0;
|
||||
this.targetAutoScrollSpeedPxPerSecond = 0;
|
||||
this.lastAutoScrollTime = 0;
|
||||
this.autoScrollFrameId = null;
|
||||
this.isPausePendingStop = false;
|
||||
this.syncAutoScrollState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.autoScrollFrameId = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
@@ -132,7 +177,7 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
const shell = event.currentTarget as HTMLElement | null;
|
||||
|
||||
if (!shell?.contains(next)) {
|
||||
this.resumeAutoScroll();
|
||||
this.scheduleResume(1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,16 +187,60 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
wrapper?.classList.add('is-fallback');
|
||||
}
|
||||
|
||||
private autoAdvanceStack(carousel: HTMLElement, deltaMs: number): void {
|
||||
private scheduleResume(delayMs: number): void {
|
||||
const nextResumeAt = Date.now() + delayMs;
|
||||
if (nextResumeAt <= this.delayedResumeAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.delayedResumeAt = nextResumeAt;
|
||||
|
||||
if (this.delayedResumeTimeout !== null) {
|
||||
clearTimeout(this.delayedResumeTimeout);
|
||||
}
|
||||
|
||||
this.delayedResumeTimeout = setTimeout(() => {
|
||||
this.delayedResumeTimeout = null;
|
||||
this.delayedResumeAt = 0;
|
||||
this.resumeAutoScroll();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private clearScheduledResume(): void {
|
||||
if (this.delayedResumeTimeout !== null) {
|
||||
clearTimeout(this.delayedResumeTimeout);
|
||||
this.delayedResumeTimeout = null;
|
||||
}
|
||||
|
||||
this.delayedResumeAt = 0;
|
||||
}
|
||||
|
||||
private updateAutoScrollSpeed(deltaMs: number): void {
|
||||
if (deltaMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const speedDelta = this.targetAutoScrollSpeedPxPerSecond - this.currentAutoScrollSpeedPxPerSecond;
|
||||
if (Math.abs(speedDelta) <= this.speedStopThresholdPxPerSecond) {
|
||||
this.currentAutoScrollSpeedPxPerSecond = this.targetAutoScrollSpeedPxPerSecond;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxStep = (this.autoScrollSpeedPxPerSecond * deltaMs) / this.speedRampDurationMs;
|
||||
const appliedStep = Math.sign(speedDelta) * Math.min(Math.abs(speedDelta), maxStep);
|
||||
this.currentAutoScrollSpeedPxPerSecond += appliedStep;
|
||||
}
|
||||
|
||||
private autoAdvanceStack(carousel: HTMLElement, deltaMs: number, speedPxPerSecond: number): void {
|
||||
const loopWidth = this.getLoopWidth(carousel);
|
||||
if (loopWidth <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.virtualScrollLeft += (this.autoScrollSpeedPxPerSecond * deltaMs) / 1000;
|
||||
if (this.virtualScrollLeft >= loopWidth) {
|
||||
this.virtualScrollLeft %= loopWidth;
|
||||
}
|
||||
const nextVirtualScrollLeft = this.virtualScrollLeft + (speedPxPerSecond * deltaMs) / 1000;
|
||||
|
||||
// Normalize only when moving past the duplicated track boundary to avoid abrupt remaps on quick re-entry.
|
||||
this.virtualScrollLeft = nextVirtualScrollLeft >= loopWidth ? nextVirtualScrollLeft - loopWidth : nextVirtualScrollLeft;
|
||||
|
||||
carousel.scrollLeft = this.virtualScrollLeft;
|
||||
}
|
||||
@@ -182,5 +271,11 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||
private getScrollStep(carousel: HTMLElement): number {
|
||||
return Math.max(carousel.clientWidth * 0.82, 260);
|
||||
}
|
||||
|
||||
private syncAutoScrollState(): void {
|
||||
this.isAutoScrolling =
|
||||
this.targetAutoScrollSpeedPxPerSecond > this.speedStopThresholdPxPerSecond ||
|
||||
this.currentAutoScrollSpeedPxPerSecond > this.speedStopThresholdPxPerSecond;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user