You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

196 lines
5.2 KiB
TypeScript

import { smooth } from '@/utils/smooth';
import useFrame from '@/utils/useFrame';
import { useEffect, useRef } from 'react';
type CursorEffectProps = {
children: React.ReactNode;
className?: string;
effectDistance?: number;
effectForce?: number;
cursorPadding?: number;
cursorBorderRadius?: number;
};
const clamp = (value: number, min: number, max: number) => {
const minOffset = Math.max(value - min, 0);
return Math.min(minOffset / (max - min), 1);
};
const inRect = (x: number, y: number, rect: DOMRect, gap: number = 0) => {
return (
rect.left - gap < x &&
x < rect.right + gap &&
rect.top - gap < y &&
y < rect.bottom + gap
);
};
export function CursorEffect(props: CursorEffectProps) {
const {
children,
className,
effectDistance = 128,
effectForce = 6,
cursorPadding = 16,
cursorBorderRadius = 0,
} = props;
const cursorAffectorRef = useRef<HTMLDivElement>(null);
const cursorAffectoContainerrRef = useRef<HTMLDivElement>(null);
const mousePosition = useRef<{
x: number;
y: number;
targetRect: DOMRect | null;
}>({
x: 0,
y: 0,
targetRect: null,
});
const prevCursorState = useRef<{
x: number;
y: number;
}>({
x: 0,
y: 0,
});
useEffect(() => {
let isTouch = false;
const handleMouseMove = (event: MouseEvent) => {
if (isTouch) return;
let targetRect =
cursorAffectoContainerrRef.current?.getBoundingClientRect() || null;
mousePosition.current = {
x: event.clientX,
y: event.clientY,
targetRect,
};
};
const handleScroll = () => {
if (isTouch) return;
let targetRect =
cursorAffectoContainerrRef.current?.getBoundingClientRect() || null;
mousePosition.current = {
...mousePosition.current,
targetRect,
};
};
const handlePointerDown = (event: PointerEvent) => {
isTouch = event.pointerType === 'touch';
let targetRect =
cursorAffectoContainerrRef.current?.getBoundingClientRect() || null;
mousePosition.current = {
...mousePosition.current,
targetRect,
x: -9999999,
y: -9999999,
};
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('scroll', handleScroll);
};
}, []);
useFrame((delta) => {
let x = 0;
let y = 0;
const gap = effectDistance;
const offsetMax = effectForce;
const mousePos = mousePosition.current;
const rect = mousePosition.current.targetRect;
const isActive = rect && inRect(mousePos.x, mousePos.y, rect, gap);
if (isActive) {
const halfWidth = rect.width / 2;
const targetCenterX = rect.left + halfWidth;
const leftStart = rect.left - gap;
const leftMiddle = rect.left - offsetMax;
const rightMiddle = rect.right + offsetMax;
const rightEnd = rect.right + gap;
const affectX =
clamp(mousePos.y, rect.top - gap, rect.top) *
(1 - clamp(mousePos.y, rect.bottom, rect.bottom + gap));
if (mousePos.x < leftMiddle) {
x = -clamp(mousePos.x, leftStart, leftMiddle);
} else if (mousePos.x < targetCenterX) {
x = -(1 - clamp(mousePos.x, leftMiddle, targetCenterX));
} else if (mousePos.x < rightMiddle) {
x = clamp(mousePos.x, targetCenterX, rightMiddle);
} else if (mousePos.x < rightEnd) {
x = 1 - clamp(mousePos.x, rightMiddle, rightEnd);
}
x = x * affectX;
const halfHeight = rect.height / 2;
const targetCenterY = rect.top + halfHeight;
const topStart = rect.top - gap;
const topMiddle = rect.top - offsetMax;
const bottomMiddle = rect.bottom + offsetMax;
const bottomEnd = rect.bottom + gap;
const affectY =
clamp(mousePos.x, rect.left - gap, rect.left) *
(1 - clamp(mousePos.x, rect.right, rect.right + gap));
if (mousePos.y < topMiddle) {
y = -clamp(mousePos.y, topStart, topMiddle);
} else if (mousePos.y < targetCenterY) {
y = -(1 - clamp(mousePos.y, topMiddle, targetCenterY));
} else if (mousePos.y < bottomMiddle) {
y = clamp(mousePos.y, targetCenterY, bottomMiddle);
} else if (mousePos.y < bottomEnd) {
y = 1 - clamp(mousePos.y, bottomMiddle, bottomEnd);
}
y = y * affectY;
x = x * offsetMax;
y = y * offsetMax;
}
x = smooth(prevCursorState.current.x, x, 0.02 * delta);
y = smooth(prevCursorState.current.y, y, 0.02 * delta);
prevCursorState.current = { x, y };
if (cursorAffectorRef.current) {
cursorAffectorRef.current.style.transform = `translate3D(${x}px, ${y}px, 0)`;
}
});
return (
<div ref={cursorAffectoContainerrRef} className={className}>
<div
ref={cursorAffectorRef}
style={{ width: '100%', height: '100%' }}
data-cursor-effect
data-cursor-padding={cursorPadding}
data-cursor-border-radius={cursorBorderRadius}
>
{children}
</div>
</div>
);
}