import { Module, TModuleOnCallbacksProps } from '@/base/Module'; import { initVevet } from '@/global/initVevet'; import { cnAdd, cnRemove, cnToggle } from '@/internal/cn'; import { body, doc } from '@/internal/env'; import { isFiniteNumber } from '@/internal/isFiniteNumber'; import { noopIfDestroyed } from '@/internal/noopIfDestroyed'; import { TRequiredProps } from '@/internal/requiredProps'; import { getTextDirection } from '@/internal/textDirection'; import { toPixels } from '@/utils'; import { addEventListener } from '@/utils/listeners'; import { clamp, lerp } from '@/utils/math'; import { Raf } from '../Raf'; import { LERP_APPROXIMATION } from './constants'; import { CursorHoverElement } from './HoverElement'; import { ICursorHoverElementProps } from './HoverElement/types'; import { CursorPath } from './Path'; import { MUTABLE_PROPS, STATIC_PROPS } from './props'; import { createCursorStyles } from './styles'; import { ICursorCallbacksMap, ICursorFullCoords, ICursorMutableProps, ICursorStaticProps, ICursorTargetCoords, ICursorType, } from './types'; export * from './types'; export type { ICursorHoverElementProps }; type TC = ICursorCallbacksMap; type TS = ICursorStaticProps; type TM = ICursorMutableProps; /** * A customizable custom cursor component with smooth animations and hover interactions. * Supports dynamic appearance changes and enhanced user interaction. * * [Documentation](https://vevetjs.com/docs/Cursor) * * @group Components */ export class Cursor extends Module { /** Get default static properties */ public _getStatic(): TRequiredProps { return { ...super._getStatic(), ...STATIC_PROPS }; } /** Get default mutable properties */ public _getMutable(): TRequiredProps { return { ...super._getMutable(), ...MUTABLE_PROPS }; } /** The outer element of the custom cursor */ private _outer?: HTMLElement; /** The inner element of the custom cursor. */ private _inner?: HTMLElement; /** Attached hover elements */ private _elements: CursorHoverElement[] = []; /** Active hovered element */ private _activeElements: CursorHoverElement[] = []; /** Request animation frame handler */ private _raf?: Raf; /** The current coordinates */ private _coords: ICursorFullCoords; /** Target coordinates of the cursor. Element dimensions are not considered here (in getter - yes). */ private _rawTarget: ICursorTargetCoords; /** Defines if the cursor has been moved after initialization */ private _isFirstMove = true; /** Cursor types */ private _types: ICursorType[]; /** Active cursor types */ private _activeTypes: string[]; /** Cursor Path Instance */ private _path: CursorPath; constructor( props?: TS & TM & TModuleOnCallbacksProps, onCallbacks?: TModuleOnCallbacksProps, ) { super(props, onCallbacks as any); const { enabled: isEnabled } = this.props; const { initialWidth, initialHeight } = this; // Set default variables this._coords = { x: 0, y: 0, width: initialWidth, height: initialHeight, angle: 0, velocity: 0, }; this._rawTarget = { ...this._coords }; this._types = []; this._activeTypes = []; // Create cursor path this._path = new CursorPath(this.hasPath); // No need to remove styles on destroy createCursorStyles(this.prefix); // Setup this._setClassNames(); this._createElements(); this._setEvents(); // enable by default this._toggle(isEnabled); } /** * 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 body; } return this.container as 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. * This element is nested inside the outer element and can provide additional styling. */ get inner() { return this._inner!; } /** Cursor initial width */ get initialWidth() { return toPixels(this.props.width); } /** Cursor initial width */ get initialHeight() { return toPixels(this.props.height); } /** * The current coordinates (x, y, width, height). * These are updated during cursor movement. */ get coords() { return this._coords; } /** * The currently hovered element. * Stores information about the element that the cursor is currently interacting with. */ get hoveredElement(): CursorHoverElement | undefined { const activeElements = this._activeElements; return activeElements[activeElements.length - 1]; } /** Target coordinates of the cursor (without smooth interpolation). */ get targetCoords(): ICursorFullCoords { const { hoveredElement, initialWidth, initialHeight } = this; let { x, y } = this._rawTarget; const { angle, velocity } = this._rawTarget; let width = initialWidth; let height = initialHeight; let padding = 0; if (hoveredElement) { const dimensions = hoveredElement.getDimensions(); width = dimensions.width ?? initialWidth; height = dimensions.height ?? initialHeight; x = dimensions.x ?? x; y = dimensions.y ?? y; padding = dimensions.padding; } width += padding * 2; height += padding * 2; return { x, y, width, height, angle, velocity }; } /** Returns an SVG path element which represents the cursor movement */ get path() { return this._path.path; } /** Check if the cursor has a path */ private get hasPath() { return this.props.behavior === 'path'; } /** Handles property mutations */ protected _handleProps(props: Partial) { super._handleProps(props); this._toggle(this.props.enabled); } /** Sets class names */ private _setClassNames() { const { domContainer } = this; // Hide native cursor if (this.props.hideNative) { domContainer.style.cursor = 'none'; this._addTempClassName(domContainer, this._cn('-hide-default')); } // Set class names this._addTempClassName(domContainer, this._cn('-container')); // Set container position if (domContainer !== body) { domContainer.style.position = 'relative'; } // Reset styles this.onDestroy(() => { domContainer.style.cursor = ''; }); } /** Creates the custom cursor and appends it to the DOM. */ private _createElements() { const { container, domContainer, props } = this; const isWindow = container instanceof Window; const cn = this._cn.bind(this); // Create outer element const outer = doc.createElement('div'); cnAdd(outer, cn('')); cnAdd(outer, cn(isWindow ? '-in-window' : '-in-element')); cnAdd(outer, cn('-disabled')); // Append the outer element to the DOM container if (props.append) { domContainer.append(outer); } // set direction const direction = getTextDirection(outer); cnAdd(outer, cn(`_${direction}`)); // Create inner element const inner = doc.createElement('div'); outer.append(inner); cnAdd(inner, cn('__inner')); cnAdd(inner, cn('-disabled')); outer.append(inner); // assign this._outer = outer; this._inner = inner; // Destroy the cursor this.onDestroy(() => { inner.remove(); outer.remove(); }); } /** Sets up the various event listeners for the cursor, such as mouse movements and clicks. */ private _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(); }); } /** Enables cursor animation. */ private _toggle(enabled: boolean) { const className = this._cn('-disabled'); cnToggle(this.outer, className, !enabled); cnToggle(this.inner, className, !enabled); this._raf?.updateProps({ enabled }); } /** Handles mouse enter events. */ private _handleMouseEnter(evt: MouseEvent) { if (!this.props.enabled) { return; } const { clientX: x, clientY: y } = evt; const target = this._rawTarget; this._coords.x = x; this._coords.y = y; target.x = x; target.y = y; this._path.addPoint(target, true); cnAdd(this.outer, this._cn('-visible')); } /** Handles mouse leave events. */ private _handleMouseLeave() { cnRemove(this.outer, this._cn('-visible')); } /** Handles mouse move events. */ private _handleMouseMove(evt: MouseEvent) { if (!this.props.enabled) { return; } const { clientX: x, clientY: y } = evt; const target = this._rawTarget; const { x: prevX, y: prevY } = target; // Calculate angle const deltaX = prevX - this._coords.x; const deltaY = prevY - this._coords.y; const prevAngle = target.angle; const rawAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; const targetAngle = prevAngle + ((((rawAngle - prevAngle) % 360) + 540) % 360) - 180; // Calculate velocity const velocity = Math.min(Math.sqrt(deltaX ** 2 + deltaY ** 2) * 2, 150) / 150; // Update target coordinates target.x = x; target.y = y; target.angle = targetAngle; target.velocity = velocity; // Update interpolated coords if first move if (this._isFirstMove) { this._coords.x = target.x; this._coords.y = target.y; this._coords.angle = target.angle; this._coords.velocity = target.velocity; this._isFirstMove = false; } // Add path point this._path.addPoint(target); // Handle classnames cnAdd(this.outer, this._cn('-visible')); // Enable animation this._raf?.play(); } /** Handles mouse down events. */ private _handleMouseDown(evt: MouseEvent) { const className = this._cn('-click'); if (evt.which === 1) { cnAdd(this.outer, className); cnAdd(this.inner, className); } } /** Handles mouse up events. */ private _handleMouseUp() { const className = this._cn('-click'); cnRemove(this.outer, className); cnRemove(this.inner, className); } /** Handles window blur events. */ private _handleWindowBlur() { this._handleMouseUp(); } /** * Registers an element to interact with the cursor, enabling dynamic size and position changes based on hover effects. * @returns Returns a destructor */ @noopIfDestroyed public attachHover(settings: ICursorHoverElementProps) { const element = new CursorHoverElement( settings, (data) => this._handleElementEnter(data), (data) => this._handleElementLeave(data), ); this._elements.push(element); const destroy = () => { this._elements = this._elements.filter((i) => i !== element); element.destroy(); }; this.onDestroy(() => destroy()); return () => destroy(); } /** Handle element mouse enter event */ private _handleElementEnter(data: CursorHoverElement) { if (!this.props.enabled) { return; } this._activeElements.push(data); if (data.type) { this._toggleType(data.type, true); } this.callbacks.emit('hoverEnter', data); this._raf?.play(); } /** Handle element mouse leave event */ private _handleElementLeave(data: CursorHoverElement) { this._activeElements = this._activeElements.filter((i) => i !== data); if (data.type) { this._toggleType(data.type, false); } this.callbacks.emit('hoverLeave', data); if (this.props.enabled) { this._raf?.play(); } } /** * Registers a cursor type. */ @noopIfDestroyed public attachCursor({ element, type }: ICursorType) { this._types.push({ element, type }); this._inner?.append(element); } /** Enable or disable a cursor type */ private _toggleType(type: string, isEnabled: boolean) { const targetType = this._types.find((item) => item.type === type); if (isEnabled) { this._activeTypes.push(type); } else { this._activeTypes = this._activeTypes.filter((item) => type !== item); } const activeTypes = this._activeTypes; const activeType = activeTypes.length > 0 ? activeTypes[activeTypes.length - 1] : null; this._types.forEach((item) => { cnToggle(item.element, 'active', item.type === activeType); }); if (targetType) { this.callbacks.emit(isEnabled ? 'typeShow' : 'typeHide', targetType); } if (!activeType) { this.callbacks.emit('noType', undefined); } } /** * Checks if all coordinates are interpolated. * @returns {boolean} True if all coordinates are interpolated, false otherwise. */ private get isInterpolated() { const { coords, targetCoords, props } = this; const isWidthDone = coords.width === targetCoords.width; const isHeightDone = coords.height === targetCoords.height; const isAngleDone = coords.angle === targetCoords.angle; const isVelocityDone = coords.velocity === targetCoords.velocity; const isElementsDone = !this._elements.find( (element) => !element.isInterpolated, ); const isPathDone = this._path.isInterpolated; const isCoordsDone = coords.x === targetCoords.x && coords.y === targetCoords.y; return ( isWidthDone && isHeightDone && isAngleDone && isVelocityDone && isElementsDone && (props.behavior === 'path' ? isPathDone : isCoordsDone) ); } /** Renders the cursor. */ @noopIfDestroyed 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. */ private _calculate() { const { targetCoords: target, _coords: coords } = this; const lerpFactor = this._getLerpFactor(); this._path.lerp(lerpFactor); this._path.minimize(); try { if (this.hasPath) { const pathCoord = this._path.coord; coords.x = pathCoord.x; coords.y = pathCoord.y; } else { throw new Error('No path'); } } catch { coords.x = this._lerp(coords.x, target.x); coords.y = this._lerp(coords.y, target.y); } coords.width = this._lerp(coords.width, target.width); coords.height = this._lerp(coords.height, target.height); coords.angle = this._lerp(coords.angle, target.angle); this._rawTarget.velocity = this._lerp(this._rawTarget.velocity, 0); coords.velocity = this._lerp(coords.velocity, this._rawTarget.velocity); } /** Gets the interpolation factor. */ private _getLerpFactor(input = this.props.lerp) { if (!isFiniteNumber(input)) { return 1; } const lerpFactor = clamp(input, 0, 1); return this._raf!.lerpFactor(lerpFactor); } /** Performs linear interpolation. */ private _lerp(current: number, target: number) { const lerpFactor = this._getLerpFactor(); const value = lerp(current, target, lerpFactor, LERP_APPROXIMATION); return value; } /** Renders the cursor elements. */ private _renderElements() { const { container, domContainer, outer, props, coords } = this; const { width, height } = coords; let { x, y } = coords; if (!(container instanceof Window)) { const bounding = domContainer.getBoundingClientRect(); x -= bounding.left; y -= bounding.top; } // Update DOM coordinates const { style } = outer; style.setProperty('--cursor-w', `${width}px`); style.setProperty('--cursor-h', `${height}px`); style.transform = props.transformModifier({ ...coords, x, y }); // Render element this._elements.forEach((element) => element.render(this._getLerpFactor.bind(this)), ); } }