menu-highlight-fix.js 11.1 KB
(function ($) {
	// parseUri 1.2.2
	// (c) Steven Levithan <stevenlevithan.com>
	// MIT License
	// Modified: Added partial URL-decoding support.

	function parseUri (str) {
		var	o   = parseUri.options,
			m   = o.parser[o.strictMode ? "strict" : "loose"].exec(str),
			uri = {},
			i   = 14;

		while (i--) uri[o.key[i]] = m[i] || "";

		uri[o.q.name] = {};
		uri[o.key[12]].replace(o.q.parser, function ($0, $1, $2) {
			if ($1) {
				//Decode percent-encoded query parameters.
				if (o.q.name === 'queryKey') {
					//A space can be encoded either as "%20" or "+". decodeUriComponent doesn't decode plus signs,
					//so we need to do that first.
					$2 = $2.replace('+', ' ');

					$1 = decodeURIComponent($1);
					$2 = decodeURIComponent($2);
				}
				uri[o.q.name][$1] = $2;
			}
		});

		return uri;
	}

	parseUri.options = {
		strictMode: false,
		key: ["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"],
		q:   {
			name:   "queryKey",
			parser: /(?:^|&)([^&=]*)=?([^&]*)/g
		},
		parser: {
			strict: /^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/,
			loose:  /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/
		}
	};

	// --- parseUri ends ---

	var hasRunAtLeastOnce = false;

	function highlightCurrentMenu() {
		hasRunAtLeastOnce = true;

		//Find the menu item whose URL best matches the currently open page.
		var currentUri = parseUri(location.href);
		var bestMatch = {
			uri: null,
			/**
			 * @type {JQuery|null}
			 */
			link: null,
			matchingParams: -1,
			differentParams: 10000,
			isAnchorMatch: false,
			isTopMenu: false,
			level: 0,
			isHighlighted: false
		};

		//Special case: ".../wp-admin/" should match ".../wp-admin/index.php".
		if (currentUri.path.match(/\/wp-admin\/$/)) {
			currentUri.path = currentUri.path + 'index.php';
		}

		//Special case: if post_type is not specified for edit.php and post-new.php,
		//WordPress assumes it is "post". Here we make this explicit.
		if ((currentUri.file === 'edit.php') || (currentUri.file === 'post-new.php')) {
			if (!currentUri.queryKey.hasOwnProperty('post_type')) {
				currentUri.queryKey['post_type'] = 'post';
			}
		}

		var adminMenu = $('#adminmenu');
		adminMenu.find('li > a').each(function (index, link) {
			var $link = $(link);

			//Skip links that have no href or contain nothing but an "#anchor". Both AME and some
			//other plugins (e.g. S2Member 120703) use them as separators.
			if (!$link.is('[href]') || ($link.attr('href').substring(0, 1) === '#')) {
				return;
			}

			var uri = parseUri(link.href);

			//Same as above - use "post" as the default post type.
			if ((uri.file === 'edit.php') || (uri.file === 'post-new.php')) {
				if (!uri.queryKey.hasOwnProperty('post_type')) {
					uri.queryKey['post_type'] = 'post';
				}
			}
			//TODO: Consider using get_current_screen and the current_screen filter to get post types and taxonomies.

			//Check for a close match - everything but query and #anchor.
			var components = ['protocol', 'host', 'port', 'user', 'password', 'path'];
			var isCloseMatch = true;
			for (var i = 0; (i < components.length) && isCloseMatch; i++) {
				isCloseMatch = isCloseMatch && (uri[components[i]] === currentUri[components[i]]);
			}

			if (!isCloseMatch) {
				return; //Skip to the next link.
			}

			//Calculate the number of matching and different query parameters.
			var matchingParams = 0, differentParams = 0, param;
			for (param in uri.queryKey) {
				if (uri.queryKey.hasOwnProperty(param)) {
					if (currentUri.queryKey.hasOwnProperty(param)) {
						//All parameters that are present in *both* URLs must have the same exact values.
						if (uri.queryKey[param] === currentUri.queryKey[param]) {
							matchingParams++;
						} else {
							return; //Skip to the next link.
						}
					} else {
						differentParams++;
					}
				}
			}
			for (param in currentUri.queryKey) {
				if (currentUri.queryKey.hasOwnProperty(param) && !uri.queryKey.hasOwnProperty(param)) {
					differentParams++;
				}
			}

			var isAnchorMatch = uri.anchor === currentUri.anchor;
			var level = $link.parentsUntil(adminMenu, 'li').length;
			var isHighlighted = $link.is('.current, .wp-has-current-submenu');

			//Figure out if the current link is better than the best found so far.
			//To do that, we compare them by several criteria (in order of priority):
			var comparisons = [
				{
					better: (matchingParams > bestMatch.matchingParams),
					equal: (matchingParams === bestMatch.matchingParams)
				},
				{
					better: (differentParams < bestMatch.differentParams),
					equal: (differentParams === bestMatch.differentParams)
				},
				{
					better: (isAnchorMatch && (!bestMatch.isAnchorMatch)),
					equal: (isAnchorMatch === bestMatch.isAnchorMatch)
				},

				//When a menu has multiple submenus, the first submenu usually has the same URL
				//as the parent menu. We want to highlight this item and not just the parent.
				{
					better: ((level > bestMatch.level)
						//Is this link a child of the current best match?
						&& (!bestMatch.link || ($link.closest(bestMatch.link.closest('li')).length > 0))
					),
					equal: (level === bestMatch.level)
				},

				//All else being equal, the item highlighted by WP is probably a better match.
				{
					better: (isHighlighted && !bestMatch.isHighlighted),
					equal: (isHighlighted === bestMatch.isHighlighted)
				}
			];

			var isBetterMatch = false,
				isEquallyGood = true,
				j = 0;

			while (isEquallyGood && !isBetterMatch && (j < comparisons.length)) {
				isBetterMatch = comparisons[j].better;
				isEquallyGood = comparisons[j].equal;
				j++;
			}

			if (isBetterMatch) {
				bestMatch = {
					uri: uri,
					link: $link,
					matchingParams: matchingParams,
					differentParams: differentParams,
					isAnchorMatch: isAnchorMatch,
					level: level,
					isHighlighted: isHighlighted
				}
			}
		});

		var shouldUpdateStickiness = false;

		function isInViewport($thing) {
			if (!$thing || ($thing.length < 1)) {
				return false; //A non-existent element is never visible.
			}

			var element = $thing.get(0);
			if (!element.getBoundingClientRect || !window.innerHeight || !window.innerWidth) {
				return true; //The browser doesn't support the necessary APIs, default to true.
			}

			var rect = element.getBoundingClientRect();
			return (
				(rect.top < window.innerHeight)
				&& (rect.bottom > 0)
				&& (rect.left < window.innerWidth)
				&& (rect.right > 0)
			);
		}

		//Highlight and/or expand the best matching menu.
		if (bestMatch.link !== null) {
			var bestMatchLink = bestMatch.link;
			var linkAndItem = bestMatchLink.add(bestMatchLink.closest('li'));
			var allParentMenus = bestMatchLink.parentsUntil(adminMenu, 'li');
			var topLevelParent = allParentMenus.filter('li.menu-top').last();
			//console.log('Best match is: ', bestMatchLink);

			shouldUpdateStickiness = !isInViewport(bestMatchLink);

			var otherHighlightedMenus = $('li.wp-has-current-submenu, li.menu-top.current', '#adminmenu')
				.not(allParentMenus)
				.not('.ws-ame-has-always-open-submenu');

			var otherHighlightedSubmenus = adminMenu.find('li.current,a.current').not(linkAndItem);

			var isWrongItemHighlighted = !bestMatchLink.hasClass('current') || (otherHighlightedSubmenus.length > 0);
			var isWrongMenuHighlighted = !topLevelParent.is('.wp-has-current-submenu, .current') ||
				(otherHighlightedMenus.length > 0);

			if (isWrongMenuHighlighted) {
				//Account for users who use the Expanded Admin Menus plugin to keep all menus expanded.
				var shouldCloseOtherMenus = !$('div.expand-arrow', '#adminmenu').get(0);
				if (shouldCloseOtherMenus) {
					otherHighlightedMenus
						.add('> a', otherHighlightedMenus)
						.removeClass('wp-menu-open wp-has-current-submenu current')
						.addClass('wp-not-current-submenu');
				}

				var parentMenuAndLink = topLevelParent.add('> a.menu-top', topLevelParent.get(0));
				parentMenuAndLink.removeClass('wp-not-current-submenu');
				if (topLevelParent.hasClass('wp-has-submenu')) {
					parentMenuAndLink.addClass('wp-has-current-submenu wp-menu-open');
				}

				shouldUpdateStickiness = true;

				//Workaround: Prevent the current submenu from "jumping around" when you click an item. This glitch is
				//caused by a `focusin` event handler in common.js. WP adds this handler to all top level menus that
				//are not the current menu. Since we're changing the current menu, we need to also remove this handler.
				if (typeof topLevelParent['off'] === 'function') {
					topLevelParent.off('focusin.adminmenu');
				}
			}

			if (isWrongItemHighlighted) {
				adminMenu.find('.current').removeClass('current');
				linkAndItem.addClass('current');
				shouldUpdateStickiness = true;
			}

			//WordPress adds the "current" class only to the <li> that is the direct parent of the highlighted link.
			//If the highlighted item is a submenu, its top-level parent shouldn't have this class.
			bestMatchLink.closest('li').parentsUntil(adminMenu, 'li').children('a').addBack().removeClass('current');

			bestMatchLink.closest('ul').parentsUntil(adminMenu, 'li').addClass('ame-has-highlighted-item');
		}

		//If a submenu is highlighted, so must be its parent.
		//In some cases, if we decide to stick with the WP-selected highlighted menu,
		//this might not be the case and we'll need to fix it.
		var parentOfHighlightedMenu = $('.wp-submenu a.current', '#adminmenu').closest('.menu-top').first();
		parentOfHighlightedMenu
			.add('> a.menu-top', parentOfHighlightedMenu)
			.removeClass('wp-not-current-submenu')
			.addClass('wp-has-current-submenu wp-menu-open');

		if (shouldUpdateStickiness) {
			//Note: WordPress switches the admin menu between `position: fixed` and `position: relative` depending on
			//how tall it is compared to the browser window. Opening a different submenu can change the menu's height,
			//so we must trigger the position update to avoid bugs. If we don't, we can end up with a very tall menu
			//that's not scrollable (due to being stuck with `position: fixed`).
			if ((typeof window['stickyMenu'] === 'object') && (typeof window['stickyMenu']['update'] === 'function')) {
				window.stickyMenu.update();
			} else {
				//As of WP core revision 29599 (2014-10-05) the `stickyMenu` object no longer exists
				//and the replacement (`setPinMenu` in common.js) is not accessible from an outside scope.
				//We'll resort to faking a resize event to make WP update the menu height and state.
				$(document).trigger('wp-window-resized');
			}
		}
	}

	//Other scripts can trigger this feature by using a custom event.
	$(document).on('adminMenuEditor:highlightCurrentMenu', highlightCurrentMenu);

	$(function () {
		if (!hasRunAtLeastOnce) {
			highlightCurrentMenu();
		}
	});
})(jQuery);