fix(projects): add mobile edge spacing, 1-card layout, and smooth touch drag physics
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user