vevet
Version:
Vevet is a JavaScript library for creative development that simplifies crafting rich interactions like split text animations, carousels, marquees, preloading, and more.
530 lines (426 loc) • 13.4 kB
text/typescript
import { TRequiredProps } from '@/internal/requiredProps';
import {
ICursorCallbacksMap,
ICursorFullCoords,
ICursorHoveredElement,
ICursorMutableProps,
ICursorStaticProps,
ICursorType,
} from './types';
import { Module } from '@/base/Module';
import { Raf } from '../Raf';
import { addEventListener } from '@/utils/listeners';
import { initVevet } from '@/global/initVevet';
import { lerp } from '@/utils/math';
import { createCursorStyles } from './styles';
export * from './types';
/**
* A customizable custom cursor component with smooth animations and hover interactions.
* Supports dynamic appearance changes and enhanced user interaction.
*
* [Documentation](https://antonbobrov.github.io/vevet/docs/components/Cursor)
*
* @group Components
*/
export class Cursor<
CallbacksMap extends ICursorCallbacksMap = ICursorCallbacksMap,
StaticProps extends ICursorStaticProps = ICursorStaticProps,
MutableProps extends ICursorMutableProps = ICursorMutableProps,
> extends Module<CallbacksMap, StaticProps, MutableProps> {
/** Get default static properties */
public _getStatic(): TRequiredProps<StaticProps> {
return {
...super._getStatic(),
container: window,
hideNative: false,
} as TRequiredProps<StaticProps>;
}
/** Get default mutable properties */
public _getMutable(): TRequiredProps<MutableProps> {
return {
...super._getMutable(),
enabled: true,
width: 50,
height: 50,
lerp: 0.2,
autoStop: true,
} as TRequiredProps<MutableProps>;
}
/**
* Classname prefix for styling elements.
*/
get prefix() {
return `${initVevet().prefix}cursor`;
}
/** The cursor container */
get container() {
return this.props.container;
}
/** Returns the DOM parent for the cursor element. */
get domContainer() {
if (this.container instanceof Window) {
return initVevet().body;
}
return this.container as HTMLElement;
}
/** The outer element of the custom cursor */
protected _outer!: HTMLElement;
/**
* The outer element of the custom cursor.
* This is the visual element that represents the cursor on screen.
*/
get outer() {
return this._outer;
}
/** The inner element of the custom cursor. */
protected _inner!: HTMLElement;
/**
* The inner element of the custom cursor.
* This element is nested inside the outer element and can provide additional styling.
*/
get inner() {
return this._inner;
}
/** The currently hovered element */
protected _hoveredElement?: ICursorHoveredElement;
/**
* The currently hovered element.
* Stores information about the element that the cursor is currently interacting with.
*/
get hoveredElement() {
return this._hoveredElement;
}
set hoveredElement(value) {
this._hoveredElement = value;
}
/** Request animation frame handler */
protected _raf!: Raf;
/** The current coordinates */
protected _coords: ICursorFullCoords;
/**
* The current coordinates (x, y, width, height).
* These are updated during cursor movement.
*/
get coords() {
return this._coords;
}
/** Target coordinates of the cursor (without smooth interpolation). */
protected _targetCoords: ICursorFullCoords;
/** Target coordinates of the cursor (without smooth interpolation). */
get targetCoords() {
const { hoveredElement, props } = this;
let { x, y } = this._targetCoords;
let { width, height } = props;
let padding = 0;
if (hoveredElement) {
const bounding = hoveredElement.element.getBoundingClientRect();
if (hoveredElement.sticky) {
x = bounding.left + bounding.width / 2;
y = bounding.top + bounding.height / 2;
}
if (hoveredElement.width === 'auto') {
width = bounding.width;
} else if (typeof hoveredElement.width === 'number') {
width = hoveredElement.width;
}
if (hoveredElement.height === 'auto') {
height = bounding.height;
} else if (typeof hoveredElement.height === 'number') {
height = hoveredElement.height;
}
padding = hoveredElement.padding ?? 0;
}
width += padding * 2;
height += padding * 2;
return { x, y, width, height };
}
/** Defines if the cursor has been moved after initialization */
protected _isFirstMove = true;
/** Cursor types */
protected _types: ICursorType[];
/** Active cursor types */
protected _activeTypes: string[];
constructor(props?: StaticProps & MutableProps) {
super(props);
// Set default variables
const { width, height, enabled: isEnabled } = this.props;
this._coords = { x: 0, y: 0, width, height };
this._targetCoords = { x: 0, y: 0, width, height };
this._types = [];
this._activeTypes = [];
// No need to remove styles on destroy
createCursorStyles(this.prefix);
// Setup
this._createElements();
this._setEvents();
// enable by default
this._toggle(isEnabled);
}
/** Creates the custom cursor and appends it to the DOM. */
protected _createElements() {
const { container, domContainer } = this;
const cn = this._cn.bind(this);
// Hide native cursor
if (this.props.hideNative) {
domContainer.style.cursor = 'none';
this._addTempClassName(domContainer, cn('-hide-default'));
}
// Set class names
this._addTempClassName(domContainer, cn('-container'));
// Set container position
if (domContainer !== initVevet().body) {
domContainer.style.position = 'relative';
}
// Create outer element
const outer = document.createElement('div');
domContainer.append(outer);
outer.classList.add(cn(''));
outer.classList.add(
cn(container instanceof Window ? '-in-window' : '-in-element'),
);
outer.classList.add(cn('-disabled'));
// Create inner element
const inner = document.createElement('div');
outer.append(inner);
inner.classList.add(cn('__inner'));
inner.classList.add(cn('-disabled'));
outer.append(inner);
// assign
this._outer = outer;
this._inner = inner;
// Destroy the cursor
this.onDestroy(() => {
domContainer.style.cursor = '';
inner.remove();
outer.remove();
});
}
/** Sets up the various event listeners for the cursor, such as mouse movements and clicks. */
protected _setEvents() {
const { domContainer } = this;
this._raf = new Raf({ enabled: false });
this._raf.on('frame', () => this.render());
const mouseenter = addEventListener(
domContainer,
'mouseenter',
this._handleMouseEnter.bind(this),
);
const mouseleave = addEventListener(
domContainer,
'mouseleave',
this._handleMouseLeave.bind(this),
);
const mousemove = addEventListener(
domContainer,
'mousemove',
this._handleMouseMove.bind(this),
);
const mousedown = addEventListener(
domContainer,
'mousedown',
this._handleMouseDown.bind(this),
);
const mouseup = addEventListener(
domContainer,
'mouseup',
this._handleMouseUp.bind(this),
);
const blur = addEventListener(
window,
'blur',
this._handleWindowBlur.bind(this),
);
this.onDestroy(() => {
this._raf.destroy();
mouseenter();
mouseleave();
mousemove();
mousedown();
mouseup();
blur();
});
}
/** Handles property mutations */
protected _handleProps() {
super._handleProps();
this._toggle(this.props.enabled);
}
/** Enables cursor animation. */
protected _toggle(enabled: boolean) {
const className = this._cn('-disabled');
this.outer.classList.toggle(className, !enabled);
this.inner.classList.toggle(className, !enabled);
this._raf.updateProps({ enabled });
}
/** Handles mouse enter events. */
protected _handleMouseEnter(evt: MouseEvent) {
this._coords.x = evt.clientX;
this._coords.y = evt.clientY;
this._targetCoords.x = evt.clientX;
this._targetCoords.y = evt.clientY;
this.outer.classList.add(this._cn('-visible'));
}
/** Handles mouse leave events. */
protected _handleMouseLeave() {
this.outer.classList.remove(this._cn('-visible'));
}
/** Handles mouse move events. */
protected _handleMouseMove(evt: MouseEvent) {
this._targetCoords.x = evt.clientX;
this._targetCoords.y = evt.clientY;
if (this._isFirstMove) {
this._coords.x = evt.clientX;
this._coords.y = evt.clientY;
this._isFirstMove = false;
}
this.outer.classList.add(this._cn('-visible'));
if (this.props.enabled) {
this._raf.play();
}
}
/** Handles mouse down events. */
protected _handleMouseDown(evt: MouseEvent) {
const className = this._cn('-click');
if (evt.which === 1) {
this.outer.classList.add(className);
this.inner.classList.add(className);
}
}
/** Handles mouse up events. */
protected _handleMouseUp() {
const className = this._cn('-click');
this.outer.classList.remove(className);
this.inner.classList.remove(className);
}
/** Handles window blur events. */
protected _handleWindowBlur() {
this._handleMouseUp();
}
/**
* Registers an element to interact with the cursor, enabling dynamic size and position changes based on hover effects.
* @param settings The settings for the hovered element.
* @param {number} [enterTimeout=100] The timeout before the hover effect is applied.
* @returns Returns a destructor
*/
public attachElement(settings: ICursorHoveredElement, enterTimeout = 100) {
const final: ICursorHoveredElement = {
width: null,
height: null,
...settings,
};
const { element } = final;
let timeout: NodeJS.Timeout | undefined;
const mouseEnter = addEventListener(element, 'mouseenter', () => {
timeout = setTimeout(() => {
this.hoveredElement = { ...final };
if (final.type) {
this._toggleType(final.type, true);
}
}, enterTimeout);
});
const mouseLeave = addEventListener(element, 'mouseleave', () => {
if (timeout) {
clearTimeout(timeout);
}
this.hoveredElement = undefined;
if (final.type) {
this._toggleType(final.type, false);
}
});
const remove = () => {
if (this.hoveredElement?.element === element) {
this.hoveredElement = undefined;
}
mouseEnter();
mouseLeave();
if (timeout) {
clearTimeout(timeout);
}
};
this.onDestroy(() => remove());
return () => remove();
}
/**
* Registers a cursor type.
*/
public attachCursor({ element, type }: ICursorType) {
this._types.push({ element, type });
this._inner.append(element);
}
/** Enable or disable a cursor type */
protected _toggleType(type: string, isEnabled: boolean) {
if (isEnabled) {
this._activeTypes.push(type);
} else {
this._activeTypes = this._activeTypes.filter((item) => type !== item);
}
const activeType =
this._activeTypes.length > 0
? this._activeTypes[this._activeTypes.length - 1]
: null;
this._types.forEach((item) => {
item.element.classList.toggle('active', item.type === activeType);
});
}
/**
* Checks if all coordinates are interpolated.
* @returns {boolean} True if all coordinates are interpolated, false otherwise.
*/
protected get isInterpolated() {
const { coords, targetCoords } = this;
return (
coords.x === targetCoords.x &&
coords.y === targetCoords.y &&
coords.width === targetCoords.width &&
coords.height === targetCoords.height
);
}
/** Renders the cursor. */
public render() {
this._calculate();
this._renderElements();
if (this.props.autoStop && this.isInterpolated) {
this._raf.pause();
}
// Launch render events
this.callbacks.emit('render', undefined);
}
/** Recalculates current coordinates. */
protected _calculate() {
const { targetCoords, _coords: coords } = this;
coords.x = this._lerp(coords.x, targetCoords.x);
coords.y = this._lerp(coords.y, targetCoords.y);
coords.width = this._lerp(coords.width, targetCoords.width);
coords.height = this._lerp(coords.height, targetCoords.height);
}
/**
* Performs linear interpolation.
* @param {number} current The current value.
* @param {number} target The target value.
* @returns {number} The interpolated value.
*/
protected _lerp(current: number, target: number) {
const value = lerp(
current,
target,
this._raf.lerpFactor(this.props.lerp),
0.0001,
);
return value;
}
/** Renders the cursor elements. */
protected _renderElements() {
const { container, domContainer, outer } = this;
let { x, y } = this.coords;
const { width, height } = this.coords;
if (!(container instanceof Window)) {
const bounding = domContainer.getBoundingClientRect();
x -= bounding.left;
y -= bounding.top;
}
// Update DOM coordinates
outer.style.transform = `translate(${x}px, ${y}px)`;
outer.style.setProperty('--cursor-w', `${width}px`);
outer.style.setProperty('--cursor-h', `${height}px`);
}
}