add carousel auto-scroll and removed inactive cursor
All checks were successful
publish.yml / publish (push) Successful in 1m3s
All checks were successful
publish.yml / publish (push) Successful in 1m3s
This commit is contained in:
@@ -16,34 +16,3 @@
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.custom-cursor {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid rgba(147, 197, 253, 0.9);
|
||||
border-radius: 50%;
|
||||
transform: translate3d(-9999px, -9999px, 0);
|
||||
margin-left: -13px;
|
||||
margin-top: -13px;
|
||||
box-shadow: 0 0 24px rgba(56, 189, 248, 0.28);
|
||||
background: radial-gradient(circle, rgba(96, 165, 250, 0.25) 0%, rgba(96, 165, 250, 0) 70%);
|
||||
transition: width 130ms ease, height 130ms ease, margin 130ms ease, border-color 130ms ease;
|
||||
}
|
||||
|
||||
.custom-cursor.pointer-down {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin-left: -19px;
|
||||
margin-top: -19px;
|
||||
border-color: rgba(196, 181, 253, 0.95);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.custom-cursor {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
<div class="background-layer" aria-hidden="true">
|
||||
<canvas #canvas class="particle-canvas"></canvas>
|
||||
@if (isCursorVisible) {
|
||||
<div
|
||||
class="custom-cursor"
|
||||
[class.pointer-down]="isPointerDown"
|
||||
[style.transform]="cursorTransform"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild, inject } from '@angular/core';
|
||||
import { NavigationStart, Router } from '@angular/router';
|
||||
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
|
||||
|
||||
type PointerState = {
|
||||
x: number;
|
||||
@@ -277,11 +276,8 @@ class ParticleEngine {
|
||||
})
|
||||
export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
private readonly router = inject(Router);
|
||||
|
||||
isCursorVisible = InteractiveBackground.detectFinePointer();
|
||||
isPointerDown = false;
|
||||
cursorTransform = 'translate3d(-9999px, -9999px, 0)';
|
||||
|
||||
private particleEngine: ParticleEngine | null = null;
|
||||
private destroyCallbacks: Array<() => void> = [];
|
||||
@@ -293,6 +289,7 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
private activeTouchId: number | null = null;
|
||||
private reducedMotion = false;
|
||||
private mediaQuery!: MediaQueryList;
|
||||
private readonly pointerExcludeSelector = '[data-disable-bg-pointer]';
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
@@ -332,11 +329,6 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
const onTouchCancel = () => this.handlePointerLeave();
|
||||
|
||||
const onMotionChange = (event: MediaQueryListEvent) => this.handleMotionPreferenceChange(event.matches);
|
||||
const routerSubscription = this.router.events.subscribe((event) => {
|
||||
if (event instanceof NavigationStart) {
|
||||
this.resetPointerInteraction();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
@@ -375,7 +367,6 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
() => window.removeEventListener('touchend', onTouchEnd),
|
||||
() => window.removeEventListener('touchcancel', onTouchCancel),
|
||||
() => this.mediaQuery.removeEventListener('change', onMotionChange),
|
||||
() => routerSubscription.unsubscribe(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -435,6 +426,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private handlePointerMove(event: PointerEvent): void {
|
||||
if (this.isPointerExcluded(event.target)) {
|
||||
this.handlePointerLeave();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.pointerType === 'touch' && this.activeTouchId !== null && event.pointerId !== this.activeTouchId) {
|
||||
return;
|
||||
}
|
||||
@@ -443,6 +439,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private handlePointerDown(event: PointerEvent): void {
|
||||
if (this.isPointerExcluded(event.target)) {
|
||||
this.handlePointerLeave();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.pointerType === 'touch') {
|
||||
this.activeTouchId = event.pointerId;
|
||||
}
|
||||
@@ -452,6 +453,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private handlePointerUp(event: PointerEvent): void {
|
||||
if (this.isPointerExcluded(event.target)) {
|
||||
this.handlePointerLeave();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPointerDown = false;
|
||||
|
||||
if (event.pointerType === 'touch') {
|
||||
@@ -460,27 +466,42 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.activeTouchId = null;
|
||||
this.resetPointerInteraction();
|
||||
this.pointerActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetPointerInteraction();
|
||||
this.updatePointer(event.clientX, event.clientY, true);
|
||||
}
|
||||
|
||||
private handleMouseMove(event: MouseEvent): void {
|
||||
if (this.isPointerExcluded(event.target)) {
|
||||
this.handlePointerLeave();
|
||||
return;
|
||||
}
|
||||
|
||||
this.updatePointer(event.clientX, event.clientY, true);
|
||||
}
|
||||
|
||||
private handleMouseDown(event: MouseEvent): void {
|
||||
if (this.isPointerExcluded(event.target)) {
|
||||
this.handlePointerLeave();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPointerDown = true;
|
||||
this.updatePointer(event.clientX, event.clientY, true);
|
||||
}
|
||||
|
||||
private handleMouseUp(): void {
|
||||
this.resetPointerInteraction();
|
||||
this.isPointerDown = false;
|
||||
}
|
||||
|
||||
private handleTouchStart(event: TouchEvent): void {
|
||||
if (this.isPointerExcluded(event.target)) {
|
||||
this.handlePointerLeave();
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.changedTouches.item(0);
|
||||
if (!touch) {
|
||||
return;
|
||||
@@ -492,6 +513,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private handleTouchMove(event: TouchEvent): void {
|
||||
if (this.isPointerExcluded(event.target)) {
|
||||
this.handlePointerLeave();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTouchId === null) {
|
||||
return;
|
||||
}
|
||||
@@ -505,6 +531,11 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private handleTouchEnd(event: TouchEvent): void {
|
||||
if (this.isPointerExcluded(event.target)) {
|
||||
this.handlePointerLeave();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTouchId === null) {
|
||||
return;
|
||||
}
|
||||
@@ -515,25 +546,20 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
this.isPointerDown = false;
|
||||
this.resetPointerInteraction();
|
||||
this.activeTouchId = null;
|
||||
this.pointerActive = false;
|
||||
}
|
||||
|
||||
private handlePointerLeave(): void {
|
||||
this.resetPointerInteraction();
|
||||
}
|
||||
|
||||
private resetPointerInteraction(): void {
|
||||
this.pointerActive = false;
|
||||
this.isPointerDown = false;
|
||||
this.activeTouchId = null;
|
||||
this.cursorTransform = 'translate3d(-9999px, -9999px, 0)';
|
||||
}
|
||||
|
||||
private updatePointer(x: number, y: number, active: boolean): void {
|
||||
this.pointerX = x;
|
||||
this.pointerY = y;
|
||||
this.pointerActive = active;
|
||||
this.cursorTransform = `translate3d(${x}px, ${y}px, 0)`;
|
||||
}
|
||||
|
||||
private findTouch(touches: TouchList, identifier: number): Touch | null {
|
||||
@@ -547,6 +573,14 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
return null;
|
||||
}
|
||||
|
||||
private isPointerExcluded(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return target.closest(this.pointerExcludeSelector) !== null;
|
||||
}
|
||||
|
||||
private syncPointer(): void {
|
||||
this.particleEngine?.setPointer({
|
||||
x: this.pointerX,
|
||||
@@ -591,13 +625,6 @@ export class InteractiveBackground implements AfterViewInit, OnDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
private static detectFinePointer(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(pointer: fine)').matches;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -237,7 +237,19 @@ h2 {
|
||||
background: rgba(33, 38, 45, 0.8);
|
||||
color: #e6edf3;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(2px);
|
||||
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
|
||||
.stack-carousel-shell:hover .carousel-btn,
|
||||
.stack-carousel-shell:focus-within .carousel-btn {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.carousel-btn:hover {
|
||||
|
||||
@@ -34,7 +34,16 @@
|
||||
<h2>Tech Stack</h2>
|
||||
<p>Primary technologies I use to deliver production-ready software.</p>
|
||||
</div>
|
||||
<div class="stack-carousel-shell">
|
||||
<div
|
||||
class="stack-carousel-shell"
|
||||
data-disable-bg-pointer
|
||||
(mouseenter)="pauseAutoScroll()"
|
||||
(mouseleave)="resumeAutoScroll()"
|
||||
(focusin)="pauseAutoScroll()"
|
||||
(focusout)="onCarouselFocusOut($event)"
|
||||
(touchstart)="pauseAutoScroll()"
|
||||
(touchend)="resumeAutoScroll()"
|
||||
>
|
||||
<button class="carousel-btn" type="button" aria-label="Previous technologies" (click)="scrollStack(-1)">
|
||||
<span aria-hidden="true">←</span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, ElementRef, ViewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { CardComponent } from '../../components/card/card';
|
||||
import { PROJECTS, Project } from '../projects/project-data';
|
||||
@@ -17,8 +17,9 @@ interface Skill {
|
||||
templateUrl: './home.html',
|
||||
styleUrl: './home.css',
|
||||
})
|
||||
export class Home {
|
||||
export class Home implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
|
||||
private autoScrollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
readonly hero = {
|
||||
name: 'Tim Kainz',
|
||||
@@ -44,14 +45,56 @@ export class Home {
|
||||
|
||||
readonly projects: Project[] = PROJECTS;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const prefersReducedMotion =
|
||||
typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resumeAutoScroll();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pauseAutoScroll();
|
||||
}
|
||||
|
||||
scrollStack(direction: -1 | 1): void {
|
||||
const carousel = this.stackCarousel?.nativeElement;
|
||||
if (!carousel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardWidth = Math.max(carousel.clientWidth * 0.82, 260);
|
||||
carousel.scrollBy({ left: cardWidth * direction, behavior: 'smooth' });
|
||||
carousel.scrollBy({ left: this.getScrollStep(carousel) * direction, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
pauseAutoScroll(): void {
|
||||
if (!this.autoScrollTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(this.autoScrollTimer);
|
||||
this.autoScrollTimer = null;
|
||||
}
|
||||
|
||||
resumeAutoScroll(): void {
|
||||
if (this.autoScrollTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.autoScrollTimer = setInterval(() => {
|
||||
this.autoAdvanceStack();
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
onCarouselFocusOut(event: FocusEvent): void {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
const shell = event.currentTarget as HTMLElement | null;
|
||||
|
||||
if (!shell?.contains(next)) {
|
||||
this.resumeAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
onLogoError(event: Event): void {
|
||||
@@ -59,4 +102,27 @@ export class Home {
|
||||
const wrapper = image?.closest('.skill-icon');
|
||||
wrapper?.classList.add('is-fallback');
|
||||
}
|
||||
|
||||
private autoAdvanceStack(): void {
|
||||
const carousel = this.stackCarousel?.nativeElement;
|
||||
if (!carousel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScroll = carousel.scrollWidth - carousel.clientWidth;
|
||||
if (maxScroll <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (carousel.scrollLeft >= maxScroll - 4) {
|
||||
carousel.scrollTo({ left: 0, behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
carousel.scrollBy({ left: this.getScrollStep(carousel), behavior: 'smooth' });
|
||||
}
|
||||
|
||||
private getScrollStep(carousel: HTMLElement): number {
|
||||
return Math.max(carousel.clientWidth * 0.82, 260);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user