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:
@@ -74,7 +74,7 @@ h2 {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: none;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(88, 166, 255, 0.45) rgba(33, 38, 45, 0.65);
|
scrollbar-color: rgba(88, 166, 255, 0.45) rgba(33, 38, 45, 0.65);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
<div
|
<div
|
||||||
class="stack-carousel-shell"
|
class="stack-carousel-shell"
|
||||||
data-disable-bg-pointer
|
data-disable-bg-pointer
|
||||||
(mouseenter)="pauseAutoScroll()"
|
(mouseenter)="onHoverStart()"
|
||||||
(mouseleave)="resumeAutoScroll()"
|
(mouseleave)="onHoverEnd()"
|
||||||
(focusin)="pauseAutoScroll()"
|
(focusin)="onHoverStart()"
|
||||||
(focusout)="onCarouselFocusOut($event)"
|
(focusout)="onCarouselFocusOut($event)"
|
||||||
(touchstart)="pauseAutoScroll()"
|
(touchstart)="onHoverStart()"
|
||||||
(touchend)="resumeAutoScroll()"
|
(touchend)="onHoverEnd()"
|
||||||
|
(click)="onInteractionClick()"
|
||||||
>
|
>
|
||||||
<button class="carousel-btn" type="button" aria-label="Previous technologies" (click)="scrollStack(-1)">
|
<button class="carousel-btn" type="button" aria-label="Previous technologies" (click)="scrollStack(-1)">
|
||||||
<span aria-hidden="true">←</span>
|
<span aria-hidden="true">←</span>
|
||||||
|
|||||||
@@ -19,9 +19,16 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
|
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
|
||||||
private autoScrollFrameId: number | null = null;
|
private autoScrollFrameId: number | null = null;
|
||||||
private startAutoScrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
private startAutoScrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private delayedResumeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private delayedResumeAt = 0;
|
||||||
private lastAutoScrollTime = 0;
|
private lastAutoScrollTime = 0;
|
||||||
private virtualScrollLeft = 0;
|
private virtualScrollLeft = 0;
|
||||||
|
private currentAutoScrollSpeedPxPerSecond = 0;
|
||||||
|
private targetAutoScrollSpeedPxPerSecond = 0;
|
||||||
|
private isPausePendingStop = false;
|
||||||
private readonly autoScrollSpeedPxPerSecond = 72;
|
private readonly autoScrollSpeedPxPerSecond = 72;
|
||||||
|
private readonly speedRampDurationMs = 420;
|
||||||
|
private readonly speedStopThresholdPxPerSecond = 0.5;
|
||||||
isAutoScrolling = false;
|
isAutoScrolling = false;
|
||||||
showLeftFade = false;
|
showLeftFade = false;
|
||||||
showRightFade = false;
|
showRightFade = false;
|
||||||
@@ -66,6 +73,7 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
this.startAutoScrollTimeout = null;
|
this.startAutoScrollTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.clearScheduledResume();
|
||||||
this.pauseAutoScroll();
|
this.pauseAutoScroll();
|
||||||
this.isAutoScrolling = false;
|
this.isAutoScrolling = false;
|
||||||
}
|
}
|
||||||
@@ -84,43 +92,80 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
this.updateFadeState();
|
this.updateFadeState();
|
||||||
}
|
}
|
||||||
|
|
||||||
pauseAutoScroll(): void {
|
onHoverStart(): void {
|
||||||
if (this.autoScrollFrameId === null) {
|
this.pauseAutoScroll();
|
||||||
return;
|
this.clearScheduledResume();
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelAnimationFrame(this.autoScrollFrameId);
|
onHoverEnd(): void {
|
||||||
this.autoScrollFrameId = null;
|
this.scheduleResume(1000);
|
||||||
this.lastAutoScrollTime = 0;
|
}
|
||||||
|
|
||||||
|
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 {
|
resumeAutoScroll(): void {
|
||||||
|
this.isPausePendingStop = false;
|
||||||
|
this.targetAutoScrollSpeedPxPerSecond = this.autoScrollSpeedPxPerSecond;
|
||||||
|
this.syncAutoScrollState();
|
||||||
|
|
||||||
if (this.autoScrollFrameId !== null) {
|
if (this.autoScrollFrameId !== null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isAutoScrolling = true;
|
|
||||||
|
|
||||||
const step = (timestamp: number) => {
|
const step = (timestamp: number) => {
|
||||||
const carousel = this.stackCarousel?.nativeElement;
|
const carousel = this.stackCarousel?.nativeElement;
|
||||||
if (!carousel) {
|
if (!carousel) {
|
||||||
this.autoScrollFrameId = null;
|
this.autoScrollFrameId = null;
|
||||||
this.lastAutoScrollTime = 0;
|
this.lastAutoScrollTime = 0;
|
||||||
this.virtualScrollLeft = 0;
|
this.virtualScrollLeft = 0;
|
||||||
|
this.currentAutoScrollSpeedPxPerSecond = 0;
|
||||||
|
this.targetAutoScrollSpeedPxPerSecond = 0;
|
||||||
|
this.isPausePendingStop = false;
|
||||||
this.isAutoScrolling = false;
|
this.isAutoScrolling = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.lastAutoScrollTime === 0) {
|
if (this.lastAutoScrollTime === 0) {
|
||||||
const loopWidth = this.getLoopWidth(carousel);
|
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;
|
const deltaMs = this.lastAutoScrollTime > 0 ? Math.min(timestamp - this.lastAutoScrollTime, 40) : 16;
|
||||||
this.lastAutoScrollTime = timestamp;
|
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.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);
|
this.autoScrollFrameId = requestAnimationFrame(step);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,7 +177,7 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
const shell = event.currentTarget as HTMLElement | null;
|
const shell = event.currentTarget as HTMLElement | null;
|
||||||
|
|
||||||
if (!shell?.contains(next)) {
|
if (!shell?.contains(next)) {
|
||||||
this.resumeAutoScroll();
|
this.scheduleResume(1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,16 +187,60 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
wrapper?.classList.add('is-fallback');
|
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);
|
const loopWidth = this.getLoopWidth(carousel);
|
||||||
if (loopWidth <= 0) {
|
if (loopWidth <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.virtualScrollLeft += (this.autoScrollSpeedPxPerSecond * deltaMs) / 1000;
|
const nextVirtualScrollLeft = this.virtualScrollLeft + (speedPxPerSecond * deltaMs) / 1000;
|
||||||
if (this.virtualScrollLeft >= loopWidth) {
|
|
||||||
this.virtualScrollLeft %= loopWidth;
|
// 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;
|
carousel.scrollLeft = this.virtualScrollLeft;
|
||||||
}
|
}
|
||||||
@@ -182,5 +271,11 @@ export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
|||||||
private getScrollStep(carousel: HTMLElement): number {
|
private getScrollStep(carousel: HTMLElement): number {
|
||||||
return Math.max(carousel.clientWidth * 0.82, 260);
|
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