/** * King Addons Custom Cursor * Provides global custom cursor functionality with hover states, * magnetic effects, and Elementor integration. */ (() => { 'use strict'; const globalScope = window; const DEFAULT_BODY_CLASS = 'ka-custom-cursor-enabled'; /** * Check if device supports pointer (mouse). * Falls back to checking for touch capability. * * @returns {boolean} True if device likely has a mouse pointer. */ const hasPointerDevice = () => { // Check for pointer:fine media query support if (globalScope.matchMedia) { const pointerFine = globalScope.matchMedia('(pointer:fine)'); if (pointerFine.matches) { return true; } // Also check hover capability const canHover = globalScope.matchMedia('(hover:hover)'); if (canHover.matches) { return true; } } // Fallback: assume pointer exists if not a pure touch device // Check if maxTouchPoints is 0 (no touch) or device has mouse events if ('ontouchstart' in globalScope && navigator.maxTouchPoints > 0) { // Touch device - check if it also has mouse (like laptops with touchscreen) if (globalScope.matchMedia && globalScope.matchMedia('(pointer:coarse)').matches) { // Coarse pointer only = likely touch-only device return false; } } return true; }; /** * Custom Cursor API */ const api = { state: null, config: null, listeners: [], raf: null, cursor: null, inner: null, outer: null, label: null, tailContainer: null, initialized: false, /** * Initialize the custom cursor. * * @param {Object} config Configuration from PHP. */ init(config = globalScope.KingAddonsCustomCursorData) { // Prevent double initialization if (this.initialized) { return; } if (!config) { console.warn('[KingAddons CustomCursor] No configuration found.'); return; } if (!config.enabled) { return; } // Wait for document body if (!document.body) { document.addEventListener('DOMContentLoaded', () => this.init(config), { once: true }); return; } // Check for pointer device (mouse) if (!hasPointerDevice()) { return; } // Clean up any previous instance this.destroy(); this.config = config; this.initialized = true; // Find or create cursor element this.cursor = document.getElementById('ka-custom-cursor'); if (!this.cursor) { this.cursor = document.createElement('div'); this.cursor.id = 'ka-custom-cursor'; this.cursor.className = 'ka-custom-cursor'; this.cursor.setAttribute('aria-hidden', 'true'); this.cursor.innerHTML = `
`; document.body.appendChild(this.cursor); } // Cache DOM references this.inner = this.cursor.querySelector('.ka-custom-cursor__inner'); this.outer = this.cursor.querySelector('.ka-custom-cursor__outer'); this.label = this.cursor.querySelector('[data-ka-cursor-label]'); this.tailContainer = this.cursor.querySelector('[data-ka-cursor-tail]'); // Initialize state this.state = { x: globalScope.innerWidth / 2, y: globalScope.innerHeight / 2, targetX: globalScope.innerWidth / 2, targetY: globalScope.innerHeight / 2, visible: false, multiplier: 1, colorOverride: null, borderOverride: null, activeMagnet: null, tailDots: [], tailPositions: [], currentState: 'normal', runtimeSize: null, }; // Apply configuration this.applyPreset(); this.buildTail(); this.bindEvents(); // Add body class document.body.classList.add(config.bodyClass || DEFAULT_BODY_CLASS); // Add preset type class this.cursor.classList.add(`ka-custom-cursor--type-${config.preset.type}`); // Add blend mode class if needed if (config.preset.blend_mode && config.preset.blend_mode !== 'normal') { this.cursor.classList.add('ka-custom-cursor--blend'); } // Set initial state and show cursor this.setState('normal'); this.show(); this.renderLoop(); }, /** * Destroy current instance and clean up. */ destroy() { // Remove event listeners if (this.listeners.length) { this.listeners.forEach(({ target, type, handler, options }) => { target.removeEventListener(type, handler, options); }); } this.listeners = []; // Cancel animation frame if (this.raf) { cancelAnimationFrame(this.raf); this.raf = null; } // Reset cursor element if (this.cursor) { this.cursor.removeAttribute('data-ka-state'); this.cursor.classList.remove('ka-custom-cursor--hidden'); } this.initialized = false; }, /** * Bind all event listeners. */ bindEvents() { const add = (target, type, handler, options) => { target.addEventListener(type, handler, options); this.listeners.push({ target, type, handler, options }); }; add(document, 'mousemove', this.handleMove.bind(this), { passive: true }); add(document, 'mouseenter', this.show.bind(this), { passive: true }); add(document, 'mouseleave', this.hide.bind(this), { passive: true }); add(document, 'mousedown', this.handleDown.bind(this), { passive: true }); add(document, 'mouseup', this.handleUp.bind(this), { passive: true }); add(document, 'mouseover', this.handleOver.bind(this), { passive: true }); add(document, 'mouseout', this.handleOut.bind(this), { passive: true }); add(document, 'scroll', this.handleScroll.bind(this), { passive: true }); }, /** * Apply preset configuration to cursor element via CSS variables. */ applyPreset() { const { preset, states, image } = this.config; const setVar = (key, value) => { this.cursor.style.setProperty(key, value); }; // Determine cursor size based on preset type const hasImageSize = image && image.size; const sizeValue = preset.type === 'image' && hasImageSize ? image.size : preset.size; this.state.runtimeSize = sizeValue; // Apply CSS variables setVar('--ka-cursor-size', `${sizeValue}px`); setVar('--ka-cursor-border-width', `${preset.border_width}px`); setVar('--ka-cursor-fill', preset.fill_color); setVar('--ka-cursor-border-color', preset.border_color); setVar('--ka-cursor-opacity', states.normal.opacity); setVar('--ka-cursor-scale', states.normal.scale); setVar('--ka-cursor-blur', `${preset.blur}px`); setVar('--ka-cursor-mix-blend', preset.blend_mode || 'normal'); // Apply image settings for image cursor type if (preset.type === 'image' && image && image.url) { setVar('--ka-cursor-image', `url(${image.url})`); setVar('--ka-cursor-image-offset-x', `${image.hotspot_x || 0}px`); setVar('--ka-cursor-image-offset-y', `${image.hotspot_y || 0}px`); } }, /** * Build tail dots for trail effect. */ buildTail() { if (!this.tailContainer) { return; } this.tailContainer.innerHTML = ''; this.state.tailDots = []; this.state.tailPositions = []; const points = this.config.movement && this.config.movement.tail ? this.config.movement.tail.points || 0 : 0; for (let i = 0; i < points; i++) { const dot = document.createElement('span'); dot.className = 'ka-custom-cursor__tail-dot'; this.tailContainer.appendChild(dot); this.state.tailDots.push(dot); this.state.tailPositions.push({ x: this.state.x, y: this.state.y }); } }, /** * Main render loop using requestAnimationFrame. */ renderLoop() { // Calculate follow speed based on rendering mode const followSpeed = this.config.mode === 'enhanced' ? (this.config.movement?.follow_speed || 0.2) : 1; // Smooth interpolation to target position const dx = this.state.targetX - this.state.x; const dy = this.state.targetY - this.state.y; this.state.x += dx * followSpeed; this.state.y += dy * followSpeed; // Update cursor position via CSS variables const size = this.state.runtimeSize || this.config.preset.size; this.cursor.style.setProperty('--ka-cursor-x', `${this.state.x - size / 2}px`); this.cursor.style.setProperty('--ka-cursor-y', `${this.state.y - size / 2}px`); // Update tail and magnetic effects this.updateTail(); this.updateMagnetic(); // Continue loop this.raf = requestAnimationFrame(this.renderLoop.bind(this)); }, /** * Update tail dots positions. */ updateTail() { if (!this.state.tailDots.length) { return; } // Add current position to the front this.state.tailPositions.unshift({ x: this.state.x, y: this.state.y }); // Trim to max length const maxPoints = this.state.tailDots.length; this.state.tailPositions = this.state.tailPositions.slice(0, maxPoints); // Update each tail dot this.state.tailDots.forEach((dot, index) => { const point = this.state.tailPositions[index] || { x: this.state.x, y: this.state.y }; const scale = Math.max(0.2, 1 - index / (maxPoints + 2)); const opacity = Math.max(0.15, 1 - index / (maxPoints + 1)); dot.style.transform = `translate3d(${point.x}px, ${point.y}px, 0) scale(${scale})`; dot.style.opacity = `${opacity}`; }); }, /** * Update magnetic pull effect on active element. */ updateMagnetic() { if (!this.state.activeMagnet || !this.config.magnetic?.enabled) { return; } const rect = this.state.activeMagnet.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const deltaX = this.state.x - centerX; const deltaY = this.state.y - centerY; const distance = Math.hypot(deltaX, deltaY); const radius = this.config.magnetic.radius || 140; // Reset transform if outside radius if (distance > radius) { this.state.activeMagnet.style.transform = ''; return; } // Get magnetic strength based on behavior attribute const behavior = this.state.activeMagnet.getAttribute('data-ka-magnetic'); let strength = this.config.magnetic.strength || 0.2; switch (behavior) { case 'light': strength = 0.2; break; case 'strong': strength = 0.55; break; case 'follow': strength = 0.75; break; } // Calculate pull based on distance const pull = (1 - Math.min(distance / radius, 1)) * strength; const translateX = deltaX * pull; const translateY = deltaY * pull; this.state.activeMagnet.style.transform = `translate3d(${translateX}px, ${translateY}px, 0)`; }, /** * Handle mouse move event. * * @param {MouseEvent} event Mouse event. */ handleMove(event) { this.state.targetX = event.clientX; this.state.targetY = event.clientY; if (!this.state.visible) { this.show(); } }, /** * Handle mouse down event. */ handleDown() { this.setState('click'); if (this.config.states.click.ripple) { this.cursor.classList.add('ka-custom-cursor--ripple'); globalScope.setTimeout(() => { this.cursor.classList.remove('ka-custom-cursor--ripple'); }, 300); } }, /** * Handle mouse up event. */ handleUp() { this.setState('normal'); }, /** * Handle mouse over event. * * @param {MouseEvent} event Mouse event. */ handleOver(event) { const target = event.target; if (!target) { return; } // Check if target should be excluded if (this.isExcluded(target)) { this.hide(); return; } this.show(); // Find cursor attribute on target or parent const cursorAttr = target.closest(this.config.selectors.attribute); const hoverAttr = cursorAttr ? cursorAttr.getAttribute('data-ka-cursor') : null; const magneticAttr = target.closest(this.config.selectors.magnetic); // Handle magnetic elements if (magneticAttr && this.config.magnetic?.enabled) { this.state.activeMagnet = magneticAttr; } // Get overrides from attributes const colorOverride = cursorAttr?.getAttribute('data-ka-cursor-color') || target.getAttribute?.('data-ka-cursor-color'); const sizeOverride = cursorAttr?.getAttribute('data-ka-cursor-size') || target.getAttribute?.('data-ka-cursor-size'); const labelOverride = cursorAttr?.getAttribute('data-ka-cursor-label'); if (colorOverride) { this.state.colorOverride = colorOverride; } if (sizeOverride) { const parsed = parseFloat(sizeOverride); if (!Number.isNaN(parsed) && parsed > 0) { this.state.multiplier = parsed; } } // Handle hide cursor state if (hoverAttr === 'hide') { this.hide(); return; } // Handle special hover states if (['drag', 'zoom', 'hover'].includes(hoverAttr)) { this.setState('hover', { state: hoverAttr, label: labelOverride }); return; } // Handle hover on links/buttons if (target.closest(this.config.selectors.hover)) { this.setState('hover', { label: labelOverride }); return; } this.setState('normal'); }, /** * Handle mouse out event. * * @param {MouseEvent} event Mouse event. */ handleOut(event) { const related = event.relatedTarget; // Reset magnetic element if mouse left it if (this.state.activeMagnet && event.target === this.state.activeMagnet) { if (!related || !event.target.contains(related)) { event.target.style.transform = ''; this.state.activeMagnet = null; } } // Reset state if mouse left the document or moved to excluded area if (!related || this.isExcluded(related)) { this.setState('normal'); this.state.multiplier = 1; this.state.colorOverride = null; this.state.borderOverride = null; this.state.activeMagnet = null; } }, /** * Handle scroll event. */ handleScroll() { if (!this.state.visible) { return; } // Keep cursor position stable during scroll this.state.targetX = this.state.x; this.state.targetY = this.state.y; }, /** * Set cursor state (normal, hover, click). * * @param {string} name State name. * @param {Object} options Optional options. */ setState(name, options = {}) { this.state.currentState = name; this.cursor.dataset.kaState = name; const baseScale = this.config.states.normal.scale || 1; const hoverScale = this.config.states.hover_link.scale || 1.25; const clickScale = this.config.states.click.scale || 0.9; const multiplier = options.sizeMultiplier || this.state.multiplier || 1; const setVar = (key, value) => this.cursor.style.setProperty(key, value); if (['hover', 'drag', 'zoom'].includes(name)) { setVar('--ka-cursor-scale', hoverScale * multiplier); setVar('--ka-cursor-fill', this.state.colorOverride || this.config.states.hover_link.color || this.config.preset.fill_color); setVar('--ka-cursor-border-color', this.state.borderOverride || this.config.states.hover_link.border_color || this.config.preset.border_color); this.setLabel(options.label || this.config.states.hover_link.label); return; } if (name === 'click') { setVar('--ka-cursor-scale', clickScale * multiplier); setVar('--ka-cursor-fill', this.state.colorOverride || this.config.preset.fill_color); setVar('--ka-cursor-border-color', this.state.borderOverride || this.config.preset.border_color); return; } // Normal state setVar('--ka-cursor-scale', baseScale * multiplier); setVar('--ka-cursor-fill', this.config.preset.fill_color); setVar('--ka-cursor-border-color', this.config.preset.border_color); this.state.multiplier = 1; this.state.colorOverride = null; this.state.borderOverride = null; this.setLabel(''); }, /** * Set cursor label text. * * @param {string} text Label text. */ setLabel(text) { if (!this.label) { return; } if (text) { this.label.textContent = text; this.label.classList.add('is-visible'); } else { this.label.textContent = ''; this.label.classList.remove('is-visible'); } }, /** * Check if target element is excluded. * * @param {Element} target Target element. * @returns {boolean} True if excluded. */ isExcluded(target) { if (!target || !this.config.targeting?.excludeSelectors) { return false; } const selectors = this.config.targeting.excludeSelectors .split(',') .map(item => item.trim()) .filter(Boolean); return selectors.some(selector => target.closest(selector)); }, /** * Show the cursor. */ show() { this.state.visible = true; this.cursor.classList.remove('ka-custom-cursor--hidden'); }, /** * Hide the cursor. */ hide() { this.state.visible = false; this.cursor.classList.add('ka-custom-cursor--hidden'); }, /** * Reinitialize cursor (useful for Elementor preview). * * @param {Object} config New configuration. */ reinit(config) { this.destroy(); this.initialized = false; this.init(config); }, }; // Expose API globally globalScope.KingAddonsCustomCursor = api; /** * Initialize cursor when DOM is ready. */ const initWhenReady = () => { if (globalScope.KingAddonsCustomCursorData && globalScope.KingAddonsCustomCursorData.enabled) { api.init(globalScope.KingAddonsCustomCursorData); } }; // Initialize on DOMContentLoaded or immediately if already loaded if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initWhenReady, { once: true }); } else { initWhenReady(); } })();