HEX
Server: Apache/2.4.65 (Debian)
System: Linux 88f31f35b0b8 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64
User: www-data (33)
PHP: 8.2.29
Disabled: NONE
Upload Files
File: /var/www/html/wp-content/plugins/advanced-ads/src/public/frontend-picker.js
/* eslint-disable @wordpress/no-unused-vars-before-return */
// Highlight elements in frontend, if local storage variable is set.
/* global advads, localStorage */
document.addEventListener( 'DOMContentLoaded', function () {
	/**
	 * Read all localStorage values once — avoids repeated synchronous DOM API calls.
	 */
	function isEnabled() {
		if ( ! advads.supports_localstorage() ) {
			return false;
		}

		const picker = localStorage.getItem( 'advads_frontend_picker' );
		const blogId = localStorage.getItem( 'advads_frontend_blog_id' );
		const startTime = localStorage.getItem( 'advads_frontend_starttime' );
		const optBlogId = globalThis.advads_options.blog_id;

		if ( ! picker ) {
			return false;
		}

		// Check if the frontend picker was started on the current blog.
		if ( optBlogId && blogId && optBlogId !== blogId ) {
			return false;
		}

		// Deactivate if started more than 45 minutes ago.
		if (
			startTime &&
			parseInt( startTime, 10 ) < Date.now() - 45 * 60 * 1000
		) {
			[
				'advads_frontend_action',
				'advads_frontend_element',
				'advads_frontend_picker',
				'advads_prev_url',
				'advads_frontend_pathtype',
				'advads_frontend_boundary',
				'advads_frontend_blog_id',
				'advads_frontend_starttime',
			].forEach( ( key ) => localStorage.removeItem( key ) );

			advads.set_cookie( 'advads_frontend_picker', '', -1 );
			return false;
		}

		return true;
	}

	if ( ! isEnabled() ) {
		return;
	}

	let pickerCur = null;

	// Build the overlay with plain DOM APIs.
	const overlay = document.createElement( 'div' );
	overlay.id = 'advads-picker-overlay';
	Object.assign( overlay.style, {
		position: 'absolute',
		border: 'solid 2px #428bca',
		backgroundColor: 'rgba(66,139,202,0.5)',
		boxSizing: 'border-box',
		zIndex: '1000000',
		pointerEvents: 'none',
		display: 'none',
	} );
	document.body.prepend( overlay );

	if ( 'true' === localStorage.getItem( 'advads_frontend_boundary' ) ) {
		document.body.style.cursor = 'not-allowed';
	}

	// Use a Set for O(1) membership test on every mousemove.
	const pickerNo = new Set( [
		document.body,
		document.documentElement,
		document,
	] );

	/**
	 * Check if we can traverse up the DOM tree.
	 *
	 * @param {HTMLElement} el
	 * @return {boolean} true if the element is a boundary, false otherwise
	 */
	globalThis.advads.is_boundary_reached = function ( el ) {
		if ( 'true' !== localStorage.getItem( 'advads_frontend_boundary' ) ) {
			return false;
		}

		const helpers = document.querySelectorAll(
			'.advads-frontend-picker-boundary-helper'
		);
		const boundaries = new Set(
			Array.from( helpers )
				.map( ( h ) => h.parentElement )
				.filter( Boolean )
		);

		boundaries.forEach( ( b ) => {
			b.style.cursor = 'pointer';
		} );

		// Is el itself a boundary, or does it have no boundary ancestor?
		if ( boundaries.has( el ) ) {
			return true;
		}

		let node = el.parentElement;
		while ( node ) {
			if ( boundaries.has( node ) ) {
				return false;
			}
			node = node.parentElement;
		}
		return true;
	};

	// Determine path function once.
	const fn =
		'xpath' === localStorage.getItem( 'advads_frontend_pathtype' )
			? getXPath
			: getPath;

	/**
	 * Throttle mousemove with requestAnimationFrame — fires 60-100x/sec otherwise.
	 * Each move triggers a full DOM traversal via getPath/getXPath.
	 * rAF limits processing to once per render frame (~16ms).
	 */
	let rafPending = false;
	let lastMouseEvent = null;

	document.addEventListener( 'mousemove', function ( e ) {
		lastMouseEvent = e;
		if ( rafPending ) {
			return;
		}

		rafPending = true;
		globalThis.requestAnimationFrame( function () {
			rafPending = false;

			const ev = lastMouseEvent;
			if ( ! ev ) {
				return;
			}

			if ( ev.target === pickerCur ) {
				return;
			}

			if ( pickerNo.has( ev.target ) ) {
				pickerCur = null;
				overlay.style.display = 'none';
				return;
			}

			pickerCur = ev.target;

			const path = fn( pickerCur );
			if ( ! path ) {
				return;
			}

			// DEBUG only — remove in production.
			// console.log( getPath( pickerCur ), 'getPath' );
			// console.log( getXPath( pickerCur ), 'getXPath' );

			const rect = pickerCur.getBoundingClientRect();
			const scrollX = window.scrollX || window.pageXOffset;
			const scrollY = window.scrollY || window.pageYOffset;

			Object.assign( overlay.style, {
				top: rect.top + scrollY + 'px',
				left: rect.left + scrollX + 'px',
				width: rect.width + 'px',
				height: rect.height + 'px',
				display: 'block',
			} );
		} );
	} );

	// Save on click.
	document.addEventListener( 'click', function () {
		if ( ! pickerCur ) {
			return;
		}
		if ( advads.is_boundary_reached( pickerCur ) ) {
			return;
		}

		const path = fn( pickerCur );
		localStorage.setItem( 'advads_frontend_element', path );
		globalThis.location = localStorage.getItem( 'advads_prev_url' );
	} );
} );

/* --------------------------------------------------------------------------
 * Path helpers — no jQuery dependency.
 * Both use iterative while loops over raw DOM APIs (el.parentNode, el.tagName,
 * el.id, el.className) instead of recursive jQuery calls, eliminating per-node
 * jQuery object allocation, .attr(), .siblings(), .addBack(), .index() overhead.
 * -------------------------------------------------------------------------- */

const OVERLAY_ID = 'advads-picker-overlay';
const MAX_DEPTH = 3;
const HAS_DIGIT = /\d/;

/**
 * Returns true if cls contains any digit character.
 *
 * @param {string} cls
 * @return {boolean} true if the class contains any digit character, false otherwise
 */
function hasDigit( cls ) {
	return HAS_DIGIT.test( cls );
}

/**
 * Get up to `limit` CSS classes from an element's className,
 * skipping any class that contains a digit.
 *
 * @param {string} className Raw className string.
 * @param {number} limit     Max classes to return.
 * @return {string[]} up to `limit` CSS classes from an element's className, skipping any class that contains a digit
 */
function getFilteredClasses( className, limit ) {
	if ( ! className ) {
		return [];
	}

	const result = [];
	for ( const cls of className.split( /[\s\n]+/ ) ) {
		if ( cls && ! hasDigit( cls ) ) {
			result.push( cls );
			if ( result.length === limit ) {
				break;
			}
		}
	}
	return result;
}

/**
 * Count children of `parent` matching `tagName` (and optionally classes),
 * excluding the overlay element. Returns { total, selfIndex }.
 *
 * @param {HTMLElement} parent
 * @param {HTMLElement} self
 * @param {string}      tagName
 * @param {string[]}    classes Optional class list to match against.
 * @return {{ total: number, selfIndex: number }} total number of children matching the tagName and classes, and the index of the self element
 */
function getSiblingIndex( parent, self, tagName, classes ) {
	if ( ! parent ) {
		return { total: 0, selfIndex: -1 };
	}

	const tag = tagName.toLowerCase();
	const hasClasses = classes && classes.length > 0;

	let total = 0;
	let selfIndex = -1;

	for ( const child of parent.children ) {
		if ( child.id === OVERLAY_ID ) {
			continue;
		}
		if ( child.nodeName.toLowerCase() !== tag ) {
			continue;
		}

		if ( hasClasses ) {
			// Check all required classes are present.
			const childClasses = child.className
				? child.className.split( /\s+/ )
				: [];
			const classSet = new Set( childClasses );
			if ( ! classes.every( ( c ) => classSet.has( c ) ) ) {
				continue;
			}
		}

		if ( child === self ) {
			selfIndex = total;
		}
		total++;
	}

	return { total, selfIndex };
}

/**
 * Get a CSS-selector-style path for the element.
 * Walks up to MAX_DEPTH levels, stopping at <html>.
 *
 * @param {HTMLElement} el
 * @return {string} a CSS-selector-style path for the element
 */
function getPath( el ) {
	const parts = [];
	let node = el;

	while (
		node &&
		node.nodeName.toLowerCase() !== 'html' &&
		parts.length < MAX_DEPTH
	) {
		const tag = node.nodeName.toLowerCase();
		const elId = node.id;
		const classes = getFilteredClasses( node.className, 2 );

		let cur = tag;

		if ( elId && ! hasDigit( elId ) ) {
			cur += '#' + elId;
		} else if ( classes.length ) {
			cur += '.' + classes.join( '.' );
		}

		const { total, selfIndex } = getSiblingIndex(
			node.parentElement,
			node,
			tag,
			classes
		);

		if ( total > 1 && selfIndex !== -1 ) {
			// :eq() is 0-based — matches jQuery's selector behaviour.
			cur += ':eq(' + selfIndex + ')';
		}

		parts.unshift( cur );
		node = node.parentElement;
	}

	if ( node && node.nodeName.toLowerCase() === 'html' ) {
		parts.unshift( 'html' );
	}

	return parts.join( ' > ' );
}

/**
 * Get an XPath expression for the element.
 * Walks up to MAX_DEPTH levels, stopping at <body>.
 * XPath indexes are 1-based — selfIndex + 1 corrects the :eq() off-by-one.
 *
 * @param {HTMLElement} el
 * @return {string} an XPath expression for the element
 */
function getXPath( el ) {
	const parts = [];
	let node = el;
	let depth = 0;

	while (
		node &&
		node.nodeName.toLowerCase() !== 'body' &&
		depth < MAX_DEPTH
	) {
		if ( advads.is_boundary_reached( node ) ) {
			break;
		}

		const tag = node.nodeName.toLowerCase();
		const elId = node.id;
		const classes = getFilteredClasses( node.className, 2 );

		let cur = tag;

		if ( elId && ! hasDigit( elId ) ) {
			// @id is unique — return immediately with a rooted //tag[@id="..."] path.
			parts.unshift( cur + '[@id="' + elId + '"]' );
			return '//' + parts.join( '/' );
		}

		if ( classes.length ) {
			depth++;
			const xpathClasses = classes.map(
				( cls ) =>
					'(@class and contains(concat(" ", normalize-space(@class), " "), " ' +
					cls +
					' "))'
			);
			cur += '[' + xpathClasses.join( ' and ' ) + ']';
		}

		const { total, selfIndex } = getSiblingIndex(
			node.parentElement,
			node,
			tag,
			classes
		);

		if ( total > 1 && selfIndex !== -1 ) {
			// XPath uses 1-based indexing — +1 corrects the 0-based selfIndex.
			cur += '[' + ( selfIndex + 1 ) + ']';
		}

		parts.unshift( cur );
		node = node.parentElement;
	}

	return parts.join( '/' );
}