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.
293 lines
8.5 KiB
TypeScript
293 lines
8.5 KiB
TypeScript
import useFrame from '@/utils/useFrame';
|
|
import { Fragment, useEffect, useRef } from 'react';
|
|
import styles from './cursor.module.css';
|
|
import { smooth } from '@/utils/smooth';
|
|
|
|
type CursorProps = {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function Cursor(props: CursorProps) {
|
|
const { children } = props;
|
|
const mousePosition = useRef<{
|
|
x: number;
|
|
y: number;
|
|
cursorPadding: number;
|
|
cursorBorderRadius: number;
|
|
targetRect: DOMRect | null;
|
|
target: { link: boolean; button: boolean; cursorEffector: boolean };
|
|
pressed: boolean;
|
|
active: boolean;
|
|
}>({
|
|
x: 0,
|
|
y: 0,
|
|
cursorPadding: 0,
|
|
cursorBorderRadius: 0,
|
|
targetRect: null,
|
|
target: { link: false, button: false, cursorEffector: false },
|
|
pressed: false,
|
|
active: false,
|
|
});
|
|
const cursorRef = useRef<HTMLDivElement>(null);
|
|
const cursorSpotRef = useRef<HTMLDivElement>(null);
|
|
const prevCursorState = useRef<{
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
borderRadius: number;
|
|
}>({
|
|
x: 0,
|
|
y: 0,
|
|
width: 0,
|
|
height: 0,
|
|
borderRadius: 0,
|
|
});
|
|
|
|
useEffect(() => {
|
|
let isReadyForHide = false;
|
|
let isTouch = false;
|
|
|
|
const updateTargetFromPath = (path: any[]) => {
|
|
const isLink = path.find((el) => el?.tagName === 'A');
|
|
const isButton = path.find((el) => el?.tagName === 'BUTTON');
|
|
const isCursorEffector = path.find((el) =>
|
|
el?.getAttribute?.('data-cursor-effect')
|
|
);
|
|
const cursorPadding = +(
|
|
path
|
|
.find((el) => el?.getAttribute?.('data-cursor-padding') !== null)
|
|
?.getAttribute?.('data-cursor-padding') || 16
|
|
);
|
|
|
|
const cursorBorderRadius = +(
|
|
path
|
|
.find(
|
|
(el) => el?.getAttribute?.('data-cursor-border-radius') !== null
|
|
)
|
|
?.getAttribute?.('data-cursor-border-radius') || 0
|
|
);
|
|
|
|
let targetRect = null;
|
|
|
|
if (isLink) {
|
|
targetRect = isLink.getBoundingClientRect();
|
|
}
|
|
|
|
if (isButton) {
|
|
targetRect = isButton.getBoundingClientRect();
|
|
}
|
|
|
|
if (isCursorEffector) {
|
|
targetRect = isCursorEffector.getBoundingClientRect();
|
|
}
|
|
|
|
mousePosition.current = {
|
|
...mousePosition.current,
|
|
cursorPadding,
|
|
cursorBorderRadius,
|
|
targetRect,
|
|
target: {
|
|
link: Boolean(isLink),
|
|
button: Boolean(isButton),
|
|
cursorEffector: Boolean(isCursorEffector),
|
|
},
|
|
};
|
|
};
|
|
|
|
const handleMouseMove = (event: MouseEvent) => {
|
|
if (isTouch) return;
|
|
updateTargetFromPath(event.composedPath());
|
|
|
|
if (!mousePosition.current.active) {
|
|
prevCursorState.current = {
|
|
...prevCursorState.current,
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
};
|
|
}
|
|
|
|
isReadyForHide = false;
|
|
|
|
mousePosition.current = {
|
|
...mousePosition.current,
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
active: true,
|
|
};
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
isReadyForHide = true;
|
|
|
|
setTimeout(() => {
|
|
if (!isReadyForHide) return;
|
|
|
|
console.log('mouse leave');
|
|
|
|
mousePosition.current = {
|
|
...mousePosition.current,
|
|
active: false,
|
|
};
|
|
}, 300);
|
|
};
|
|
|
|
const handleScroll = () => {
|
|
if (isTouch) return;
|
|
updateTargetFromPath(
|
|
document.elementsFromPoint(
|
|
mousePosition.current.x,
|
|
mousePosition.current.y
|
|
)
|
|
);
|
|
};
|
|
|
|
const handleMouseDown = (event: MouseEvent) => {
|
|
if (isTouch) return;
|
|
updateTargetFromPath(event.composedPath());
|
|
mousePosition.current = {
|
|
...mousePosition.current,
|
|
pressed: true,
|
|
};
|
|
};
|
|
|
|
const handleMouseUp = (event: MouseEvent) => {
|
|
if (isTouch) return;
|
|
updateTargetFromPath(event.composedPath());
|
|
mousePosition.current = {
|
|
...mousePosition.current,
|
|
pressed: false,
|
|
};
|
|
};
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
isTouch = event.pointerType === 'touch';
|
|
|
|
if (isTouch) handleMouseLeave();
|
|
};
|
|
|
|
window.addEventListener('pointerdown', handlePointerDown);
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.document.body.addEventListener('mouseleave', handleMouseLeave);
|
|
window.addEventListener('scroll', handleScroll);
|
|
window.addEventListener('mousedown', handleMouseDown);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
|
|
return () => {
|
|
window.removeEventListener('pointerdown', handlePointerDown);
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.document.body.removeEventListener('mouseleave', handleMouseLeave);
|
|
window.removeEventListener('scroll', handleScroll);
|
|
window.removeEventListener('click', handleMouseDown);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, []);
|
|
|
|
useFrame((delta) => {
|
|
let x = mousePosition.current.x;
|
|
let y = mousePosition.current.y;
|
|
let width = 30;
|
|
let height = 30;
|
|
let borderRadius = 0;
|
|
|
|
let gapHover = mousePosition.current.cursorPadding;
|
|
const targetRect = mousePosition.current.targetRect;
|
|
|
|
const isHovered =
|
|
(mousePosition.current.target.link ||
|
|
mousePosition.current.target.button ||
|
|
mousePosition.current.target.cursorEffector) &&
|
|
targetRect !== null;
|
|
|
|
if (!mousePosition.current.active) {
|
|
width = 0;
|
|
height = 0;
|
|
borderRadius = 15;
|
|
} else if (isHovered) {
|
|
const targetCenterX = targetRect.left + targetRect.width / 2;
|
|
const targetCenterY = targetRect.top + targetRect.height / 2;
|
|
|
|
width = targetRect.width + gapHover * 2;
|
|
height = targetRect.height + gapHover * 2;
|
|
|
|
x = x * 0.05 + targetCenterX * 0.95 - width / 2;
|
|
y = y * 0.05 + targetCenterY * 0.95 - height / 2;
|
|
} else {
|
|
width = 30;
|
|
height = 30;
|
|
x = x - width / 2;
|
|
y = y - height / 2;
|
|
borderRadius = 15;
|
|
}
|
|
|
|
if (mousePosition.current.pressed) {
|
|
width -= 10;
|
|
height -= 10;
|
|
x += 5;
|
|
y += 5;
|
|
gapHover -= 5;
|
|
}
|
|
|
|
if (mousePosition.current.cursorBorderRadius) {
|
|
borderRadius = mousePosition.current.cursorBorderRadius + gapHover;
|
|
} else {
|
|
borderRadius = Math.min(width / 2, height / 2);
|
|
}
|
|
|
|
x = smooth(prevCursorState.current.x, x, 0.02 * delta);
|
|
y = smooth(prevCursorState.current.y, y, 0.02 * delta);
|
|
width = smooth(prevCursorState.current.width, width, 0.02 * delta);
|
|
height = smooth(prevCursorState.current.height, height, 0.02 * delta);
|
|
borderRadius = smooth(
|
|
prevCursorState.current.borderRadius,
|
|
borderRadius,
|
|
0.02 * delta
|
|
);
|
|
|
|
prevCursorState.current = {
|
|
...prevCursorState.current,
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
borderRadius,
|
|
};
|
|
|
|
const cursorNode = cursorRef.current;
|
|
const cursorSpotNode = cursorSpotRef.current;
|
|
|
|
if (cursorNode !== null && cursorSpotNode !== null) {
|
|
cursorNode.style.transform = `translate3D(${x}px, ${y}px, 0)`;
|
|
cursorNode.style.width = `${width}px`;
|
|
cursorNode.style.height = `${height}px`;
|
|
cursorNode.style.borderRadius = `${Math.min(borderRadius, 36 + gapHover)}px`;
|
|
|
|
const size = Math.max((width + height) / 2, 48);
|
|
|
|
cursorSpotNode.style.transform = `translate(${mousePosition.current.x - x - size / 2}px, ${mousePosition.current.y - y - size / 2}px)`;
|
|
cursorSpotNode.style.width = `${size}px`;
|
|
cursorSpotNode.style.height = `${size}px`;
|
|
|
|
if (isHovered) {
|
|
if (!cursorNode.classList.contains(styles.hovered)) {
|
|
cursorNode.classList.add(styles.hovered);
|
|
cursorNode.classList.remove(styles.idle);
|
|
}
|
|
} else {
|
|
if (!cursorNode.classList.contains(styles.idle)) {
|
|
cursorNode.classList.add(styles.idle);
|
|
cursorNode.classList.remove(styles.hovered);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return (
|
|
<Fragment>
|
|
<div ref={cursorRef} className={styles.root}>
|
|
<div ref={cursorSpotRef} className={styles.spot} />
|
|
</div>
|
|
{children}
|
|
</Fragment>
|
|
);
|
|
} |