fix(projects): add mobile edge spacing, 1-card layout, and smooth touch drag physics

This commit is contained in:
2026-09-06 13:37:45 +03:30
parent a1cab65d1e
commit c7ef6dff3e
2 changed files with 303 additions and 62 deletions

View File

@@ -12,7 +12,13 @@ export const ProjectsSection: React.FC = () => {
active: false,
moved: false,
startX: 0,
startY: 0,
startScrollLeft: 0,
lastX: 0,
lastTime: 0,
velocity: 0,
pointerId: -1,
directionLocked: null as 'horizontal' | 'vertical' | null,
});
const [isDragging, setIsDragging] = useState(false);
const [scrollProgress, setScrollProgress] = useState(0);
@@ -51,7 +57,7 @@ export const ProjectsSection: React.FC = () => {
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
if (event.button !== 0 && event.pointerType === 'mouse') return;
const viewport = viewportRef.current;
if (!viewport) return;
@@ -60,30 +66,112 @@ export const ProjectsSection: React.FC = () => {
active: true,
moved: false,
startX: event.clientX,
startY: event.clientY,
startScrollLeft: viewport.scrollLeft,
lastX: event.clientX,
lastTime: performance.now(),
velocity: 0,
pointerId: event.pointerId,
directionLocked: null,
};
viewport.setPointerCapture(event.pointerId);
setIsDragging(true);
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current;
if (!viewport || !dragState.current.active) return;
const distance = event.clientX - dragState.current.startX;
if (Math.abs(distance) > 3) dragState.current.moved = true;
viewport.scrollLeft = dragState.current.startScrollLeft - distance;
const deltaX = event.clientX - dragState.current.startX;
const deltaY = event.clientY - dragState.current.startY;
// Detect user intent: horizontal carousel swipe vs vertical page scroll
if (!dragState.current.directionLocked) {
if (Math.abs(deltaX) < 6 && Math.abs(deltaY) < 6) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) {
// User intends to scroll page vertically, release touch
dragState.current.directionLocked = 'vertical';
dragState.current.active = false;
return;
}
// User intends horizontal carousel drag
dragState.current.directionLocked = 'horizontal';
try {
viewport.setPointerCapture(event.pointerId);
} catch {
// Ignore if pointer capture unavailable
}
setIsDragging(true);
}
if (dragState.current.directionLocked === 'horizontal') {
const now = performance.now();
const dt = Math.max(now - dragState.current.lastTime, 1);
const dx = event.clientX - dragState.current.lastX;
dragState.current.velocity = dx / dt;
dragState.current.lastX = event.clientX;
dragState.current.lastTime = now;
if (Math.abs(deltaX) > 4) dragState.current.moved = true;
viewport.scrollLeft = dragState.current.startScrollLeft - deltaX;
}
};
const stopDragging = (event: React.PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current;
if (!viewport || !dragState.current.active) return;
if (!viewport || !dragState.current.active) {
setIsDragging(false);
return;
}
const wasHorizontal = dragState.current.directionLocked === 'horizontal';
const moved = dragState.current.moved;
const velocity = dragState.current.velocity;
const clientX = event.clientX ?? dragState.current.lastX;
const totalDeltaX = clientX - dragState.current.startX;
const pointerId = dragState.current.pointerId;
dragState.current.active = false;
if (viewport.hasPointerCapture(event.pointerId)) {
viewport.releasePointerCapture(event.pointerId);
if (viewport.hasPointerCapture(pointerId)) {
try {
viewport.releasePointerCapture(pointerId);
} catch {
// Ignore
}
}
setIsDragging(false);
if (!wasHorizontal || !moved) return;
// Calculate snap target with velocity fling
const track = viewport.firstElementChild as HTMLElement | null;
const card = track?.firstElementChild as HTMLElement | null;
if (!track || !card) return;
const cardWidth = card.getBoundingClientRect().width;
const gap = Number.parseFloat(window.getComputedStyle(track).columnGap) || 0;
const step = cardWidth + gap;
if (step <= 0) return;
const currentScroll = viewport.scrollLeft;
let targetIndex: number;
if (Math.abs(velocity) > 0.2 || Math.abs(totalDeltaX) > cardWidth * 0.18) {
if (totalDeltaX < 0 || velocity < -0.2) {
targetIndex = Math.ceil(currentScroll / step);
} else {
targetIndex = Math.floor(currentScroll / step);
}
} else {
targetIndex = Math.round(currentScroll / step);
}
const maxIndex = projects.length - 1;
targetIndex = Math.max(0, Math.min(targetIndex, maxIndex));
viewport.scrollTo({
left: targetIndex * step,
behavior: 'smooth',
});
};
const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -102,6 +190,16 @@ export const ProjectsSection: React.FC = () => {
viewport.scrollLeft = (Number(event.target.value) / 100) * maxScroll;
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollByCard(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollByCard(1);
}
};
return (
<section
id="projects"
@@ -152,6 +250,7 @@ export const ProjectsSection: React.FC = () => {
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
onClickCapture={handleClickCapture}
onKeyDown={handleKeyDown}
tabIndex={0}
aria-label="Scrollable project cards"
>
@@ -163,6 +262,8 @@ export const ProjectsSection: React.FC = () => {
src={project.image}
alt={project.title}
fill
sizes="(max-width: 650px) 100vw, (max-width: 1100px) 100vw, 360px"
priority={project.id === 'qlockify'}
draggable={false}
className={styles.projectImage}
/>