[ Avaa Bypassed ]




Upload:

Command:

hmhc3928@18.227.183.94: ~ $
//(c) W-Shadow

/*global wsEditorData, defaultMenu, customMenu, _:false */

/**
 * @property wsEditorData
 * @property {boolean} wsEditorData.wsMenuEditorPro
 *
 * @property {object} wsEditorData.blankMenuItem
 * @property {object} wsEditorData.itemTemplates
 * @property {object} wsEditorData.customItemTemplate
 *
 * @property {string} wsEditorData.adminAjaxUrl
 * @property {string} wsEditorData.imagesUrl
 *
 * @property {string} wsEditorData.menuFormatName
 * @property {string} wsEditorData.menuFormatVersion
 *
 * @property {boolean} wsEditorData.hideAdvancedSettings
 * @property {boolean} wsEditorData.showExtraIcons
 * @property {boolean} wsEditorData.dashiconsAvailable
 * @property {string}  wsEditorData.submenuIconsEnabled
 *
 * @property {Object} wsEditorData.showHints
 * @property {string} wsEditorData.hideHintNonce
 *
 * @property {string} wsEditorData.hideAdvancedSettingsNonce
 * @property {string} wsEditorData.getPagesNonce
 * @property {string} wsEditorData.getPageDetailsNonce
 * @property {string} wsEditorData.disableDashboardConfirmationNonce
 *
 * @property {string} wsEditorData.captionShowAdvanced
 * @property {string} wsEditorData.captionHideAdvanced
 *
 * @property {string} wsEditorData.unclickableTemplateId
 * @property {string} wsEditorData.unclickableTemplateClass
 * @property {string} wsEditorData.embeddedPageTemplateId
 *
 * @property {string} wsEditorData.currentUserLogin
 * @property {string|null} wsEditorData.selectedActor
 *
 * @property {object} wsEditorData.actors
 * @property {string[]} wsEditorData.visibleUsers
 *
 * @property {object} wsEditorData.postTypes
 * @property {object} wsEditorData.taxonomies
 *
 * @property {string|null} wsEditorData.selectedMenu
 * @property {string|null} wsEditorData.selectedSubmenu
 *
 * @property {string} wsEditorData.setTestConfigurationNonce
 * @property {string} wsEditorData.testAccessNonce
 *
 * @property {string|null} wsEditorData.deepNestingEnabled
 *
 * @property {object} wsEditorData.auxDataConfig
 *
 * @property {boolean} wsEditorData.isDemoMode
 * @property {boolean} wsEditorData.isMasterMode
 */

wsEditorData.wsMenuEditorPro = !!wsEditorData.wsMenuEditorPro; //Cast to boolean.
var wsIdCounter = 0;

//A bit of black magic/hack to convince my IDE that wsAmeLodash is an alias for lodash.
window.wsAmeLodash = (function() {
	'use strict';
	if (typeof wsAmeLodash !== 'undefined') {
		return wsAmeLodash;
	}
	return _.noConflict();
})();

//These two properties must be objects, not arrays.
jQuery.each(['grant_access', 'hidden_from_actor'], function(unused, key) {
	'use strict';
	if (wsEditorData.blankMenuItem.hasOwnProperty(key) && !jQuery.isPlainObject(wsEditorData.blankMenuItem[key])) {
		wsEditorData.blankMenuItem[key] = {};
	}
});

AmeCapabilityManager = AmeActors;

/**
 * A utility for retrieving post and page titles.
 */
var AmePageTitles = (function($) {
	'use strict';

	var me = {}, cache = {};

	function getCacheKey(pageId, blogId) {
		return blogId + '_' + pageId;
	}

	/**
	 * Add a page title to the cache.
	 *
	 * @param {Number} pageId Post or page ID.
	 * @param {Number} blogId Blog ID.
	 * @param {String} title The title of the post or page.
	 */
	me.add = function(pageId, blogId, title) {
		cache[getCacheKey(pageId, blogId)] = title;
	};

	/**
	 * Get page title.
	 *
	 * Note: This method does not return the title. Instead, it calls the provided callback with the title
	 * as the first argument. The callback will be executed asynchronously if the title hasn't been cached yet.
	 *
	 * @param {Number} pageId
	 * @param {Number} blogId
	 * @param {Function} callback
	 */
	me.get = function(pageId, blogId, callback) {
		var key = getCacheKey(pageId, blogId);
		if (typeof cache[key] !== 'undefined') {
			callback(cache[key], pageId, blogId);
			return;
		}

		$.getJSON(
			wsEditorData.adminAjaxUrl,
			{
				'action' : 'ws_ame_get_page_details',
				'_ajax_nonce' : wsEditorData.getPageDetailsNonce,
				'post_id' : pageId,
				'blog_id' : blogId
			},
			function(details) {
				var title;
				if (typeof details.error !== 'undefined'){
					title = details.error;
				} else if ((typeof details !== 'object') || (typeof details.post_title === 'undefined')) {
					title = '< Server error >';
				} else {
					title = details.post_title;
				}
				cache[key] = title;

				callback(cache[key], pageId, blogId);
			}
		);
	};

	return me;
})(jQuery);

var AmeEditorApi = {};
window.AmeEditorApi = AmeEditorApi;


(function ($, _){
'use strict';

var actorSelectorWidget = new AmeActorSelector(AmeActors, wsEditorData.wsMenuEditorPro);

AmeEditorApi.actorSelectorWidget = actorSelectorWidget;

var itemTemplates = {
	templates: wsEditorData.itemTemplates,

	getTemplateById: function(templateId) {
		if (wsEditorData.itemTemplates.hasOwnProperty(templateId)) {
			return wsEditorData.itemTemplates[templateId];
		} else if ((templateId === '') || (templateId === 'custom')) {
			return wsEditorData.customItemTemplate;
		}
		return null;
	},

	getDefaults: function (templateId) {
		var template = this.getTemplateById(templateId);
		if (template) {
			return template.defaults;
		} else {
			return null;
		}
	},

	getDefaultValue: function (templateId, fieldName) {
		if (fieldName === 'template_id') {
			return null;
		}

		var defaults = this.getDefaults(templateId);
		if (defaults && (typeof defaults[fieldName] !== 'undefined')) {
			return defaults[fieldName];
		}
		return null;
	},

	hasDefaultValue: function(templateId, fieldName) {
		return (this.getDefaultValue(templateId, fieldName) !== null);
	}
};

	/**
	 * @type {AmeMenuPresenter}
	 */
	let menuPresenter;

/**
 * Set an input field to a value. The only difference from jQuery.val() is that
 * setting a checkbox to true/false will check/clear it.
 *
 * @param input
 * @param value
 */
function setInputValue(input, value) {
	if (input.attr('type') === 'checkbox'){
		input.prop('checked', value);
    } else {
        input.val(value);
    }
}

/**
 * Get the value of an input field. The only difference from jQuery.val() is that
 * checked/unchecked checkboxes will return true/false.
 *
 * @param input
 * @return {*}
 */
function getInputValue(input) {
	if (input.attr('type') === 'checkbox'){
		return input.is(':checked');
	}
	return input.val();
}


/*
 * Utility function for generating pseudo-random alphanumeric menu IDs.
 * Rationale: Simpler than atomically auto-incrementing or globally unique IDs.
 */
function randomMenuId(prefix, size){
	prefix = (typeof prefix === 'undefined') ? 'custom_item_' : prefix;
	size = (typeof size === 'undefined') ? 5 : size;

    var suffix = "";
    var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

    for( var i=0; i < size; i++ ) {
        suffix += possible.charAt(Math.floor(Math.random() * possible.length));
    }

    return prefix + suffix;
}
AmeEditorApi.randomMenuId = randomMenuId;

function outputWpMenu(menu){
	const menuCopy = $.extend(true, {}, menu);

	//Remove the current menu data
	menuPresenter.clear();

	//Display the new menu
	const firstColumn = menuPresenter.getColumnImmediate(1);
	const itemList = firstColumn.getVisibleItemList();
	for (let filename in menuCopy){
		if (!menuCopy.hasOwnProperty(filename)){
			continue;
		}
		firstColumn.outputItem(menuCopy[filename], null, itemList);
	}

	//Automatically select the first top-level menu
	if (itemList) {
		itemList.find('.ws_menu:first').trigger('click');
	}
}

/**
 * Load a menu configuration in the editor.
 * Note: All previous settings will be discarded without warning. Unsaved changes will be lost.
 *
 * @param {Object} adminMenu The menu structure to load.
 */
function loadMenuConfiguration(adminMenu) {
	//There are some menu properties that need to be objects, but PHP JSON-encodes empty associative
	//arrays as numeric arrays. We want them to be empty objects instead.
	if (adminMenu.hasOwnProperty('color_presets') && !$.isPlainObject(adminMenu.color_presets)) {
		adminMenu.color_presets = {};
	}

	var objectProperties = ['grant_access', 'hidden_from_actor'];
	//noinspection JSUnusedLocalSymbols
	function fixEmptyObjects(unused, menuItem) {
		for (var i = 0; i < objectProperties.length; i++) {
			var key = objectProperties[i];
			if (menuItem.hasOwnProperty(key) && !$.isPlainObject(menuItem[key])) {
				menuItem[key] = {};
			}
		}
		if (menuItem.hasOwnProperty('items')) {
			$.each(menuItem.items, fixEmptyObjects);
		}
	}
	$.each(adminMenu.tree, fixEmptyObjects);

	//Load color presets from the new configuration.
	if (typeof adminMenu.color_presets === 'object') {
		colorPresets = $.extend(true, {}, adminMenu.color_presets);
	} else {
		colorPresets = {};
	}
	wasPresetDropdownPopulated = false;

	//Load capabilities.
	AmeCapabilityManager.setGrantedCapabilities(_.get(adminMenu, 'granted_capabilities', {}));

	//Load general menu visibility.
	generalComponentVisibility = _.get(adminMenu, 'component_visibility', {});
	AmeEditorApi.refreshComponentVisibility();

	//Display the new admin menu.
	outputWpMenu(adminMenu.tree);

	$(document).trigger('menuConfigurationLoaded.adminMenuEditor', adminMenu);
}

	/**
	 * Check if it's possible to delete a menu item.
	 *
	 * @param {JQuery} containerNode
	 * @returns {boolean}
	 */
	function canDeleteItem(containerNode) {
		if (!containerNode || (containerNode.length < 1)) {
			return false;
		}

		var menuItem = containerNode.data('menu_item');
		var isDefaultItem =
			( menuItem.template_id !== '')
			&& ( menuItem.template_id !== wsEditorData.unclickableTemplateId)
			&& ( menuItem.template_id !== wsEditorData.embeddedPageTemplateId)
			&& (!menuItem.separator);

		var otherCopiesExist = false;
		if (isDefaultItem) {
			//Check if there are any other menus with the same template ID.
			$('#ws_menu_editor').find('.ws_container').each(function() {
				var otherItem = $(this).data('menu_item');
				if ((menuItem !== otherItem) && (menuItem.template_id === otherItem.template_id)) {
					otherCopiesExist = true;
					return false;
				}
				return true;
			});
		}

		return (!isDefaultItem || otherCopiesExist);
	}

	/**
	 * Get or create the submenu container of a menu item.
	 *
	 * @param {JQuery|null} container
	 * @param {AmeEditorColumn} [nextColumn]
	 * @return {JQuery|null}
	 */
	function getSubmenuOf(container, nextColumn) {
		if (!container || (container.length < 1)) {
			return null;
		}

		const submenuId = container.data('submenu_id');
		if (submenuId) {
			let $submenu = $('#' + submenuId).first();
			if ($submenu.length > 0) {
				return $submenu;
			}
		}

		//If a submenu doesn't exist yet, create it in the next column.
		if (nextColumn) {
			return createSubmenuFor(container, nextColumn);
		} else {
			return null;
		}
	}

	/**
	 * Create a submenu container for a menu item.
	 * @param {JQuery} container
	 * @param {AmeEditorColumn} nextColumn
	 * @return {JQuery}
	 */
	function createSubmenuFor(container, nextColumn) {
		const $submenu = nextColumn.buildSubmenuContainer(container.attr('id'));
		nextColumn.appendSubmenuContainer($submenu);
		container.data('submenu_id', $submenu.attr('id'))
		return $submenu;
	}

	/**
	 * @param {Number} level
	 * @param {JQuery|null} predecessor
	 * @param {JQuery|null} [container]
	 * @param {Function} [getNextColumn]
	 * @constructor
	 */
	function AmeEditorColumn(level, predecessor, container, getNextColumn) {
		const self = this;

		this.level = level;
		this.usesSubmenuContainers = (this.level > 1);

		if ((typeof container === 'undefined') || (container === null)) {
			container = $('#ame-submenu-column-template').first().clone();
			container.attr('id', '');
			container.find('.ws_box').first().attr('id', '');
			container.show().insertAfter(predecessor);
		}
		container.data('ame-menu-level', level);
		container.addClass('ame-editor-column-' + level);

		this.container = container;
		this.menuBox = container.find('.ws_box').first();
		this.dropZone = container.children('.ws_dropzone').first();
		this.visibleItemList = null;

		if (!this.usesSubmenuContainers) {
			this.menuBox.addClass('ame-visible-item-list');
		}

		if (typeof getNextColumn !== 'undefined') {
			this.getNextColumn = getNextColumn;
		} else {
			this.getNextColumn = function(callback) {
				callback(null);
			};
		}

		this.container.children('.ws_toolbar').on('click', '.ws_button', function() {
			const $button = $(this);
			let buttonAction = $button.data('ame-button-action') || 'unknown';
			let selectedItem = self.getSelectedItem();
			self.container.trigger(
				'adminMenuEditor:action-' + buttonAction,
				[(selectedItem.length > 0) ? selectedItem : null, self, $button]
			);
			return false;
		});
	}

	/**
	 * Create editor widgets for a menu item and its submenus.
	 *
	 * @param {Object} itemData An object containing menu data.
	 * @param {JQuery|null|number} [insertPosition] Insert the widget after this node. If it's NULL, the widget
	 *  will be added to the end fo the list. If it's -1, the widget will be added to the beginning.
	 * @param {JQuery} [itemList] The container where to insert the widget. Defaults to the currently
	 *  visible item list. For columns that don't use submenu containers, it's always the menuBox.
	 * @return {Object} Object with two fields - 'menu' and 'submenu' - containing the jQuery objects
	 *  of the created widgets.
	 */
	AmeEditorColumn.prototype.outputItem = function(itemData, insertPosition, itemList) {
		if (!itemList) {
			itemList = this.getVisibleItemList();
		}
		const self = this;

		//Create the menu widget
		const isTopLevel = this.level <= 1;
		const $item = buildMenuItem(itemData, isTopLevel);

		if (typeof insertPosition === 'undefined') {
			insertPosition = null;
		}
		if (insertPosition === null) {
			$item.appendTo(itemList);
		} else if (insertPosition === -1) {
			$item.prependTo(itemList);
		} else {
			//phpcs:ignore WordPressVIPMinimum.JS.HTMLExecutingFunctions -- buildMenuItem() should be safe.
			$(insertPosition).after($item);
		}

		const children = (typeof itemData.items !== 'undefined') ? itemData.items : [];
		const hasChildren = !_.isEmpty(children);
		let $submenu = null;

		this.getNextColumn(
			/**
			 * @param {AmeEditorColumn|null} nextColumn
			 */
			function (nextColumn) {
				if (nextColumn) {
					//Create a submenu container even if this item doesn't have children.
					//The user could add submenu items later.
					$submenu = createSubmenuFor($item, nextColumn);

					//Output children.
					if (hasChildren) {
						$.each(children, function (index, item) {
							nextColumn.outputItem(item, null, $submenu);
						});
					}
				} else {
					//TODO: This branch could be optimized by letting the recursive outputItem call know that there is no next column.
					//There is no next column, so any submenu items that belong to this item will be
					//displayed in the same column, below the item.
					if (hasChildren) {
						let $previousItem = $item;
						$.each(children, function (index, child) {
							const result = self.outputItem(child, $previousItem, itemList);
							if (result && result.menu) {
								$previousItem = result.menu;
							}
						});
					}
				}

				//Note: Update the menu only after its children are ready. It needs the submenu items to decide
				//whether to display the access checkbox as checked or indeterminate.
				updateItemEditor($item);
			},
			hasChildren
		);

		//Note that $submenu could still be NULL at this point if the "get next column" callback
		//is called asynchronously.
		return {
			'menu': $item,
			'submenu': $submenu
		};
	};

	/**
	 * Paste a menu item in this column.
	 *
	 * @param {Object} item
	 * @param {JQuery|null|number} [insertPosition] Defaults to inserting the item below the current selection.
	 *  Set to NULL to paste at the end of the list, or -1 to paste at the beginning.
	 * @param {JQuery} [itemList]
	 */
	AmeEditorColumn.prototype.pasteItem = function (item, insertPosition, itemList) {
		if (typeof insertPosition === 'undefined') {
			insertPosition = this.getSelectedItem();
			if (insertPosition.length < 1) {
				insertPosition = null;
			}
		}

		if (!itemList) {
			itemList = this.getVisibleItemList();
		}

		//The user shouldn't need to worry about giving separators a unique filename.
		if (item.separator) {
			item.defaults.file = randomMenuId('separator_');
		}

		//If we're pasting from a sub-menu into the top level, we may need to fix some properties
		//that are blank for sub-menu items but required for top level menus.
		const isTopLevel = this.level <= 1;
		if (isTopLevel) {
			function isNonEmptyString(value) {
				return (typeof value === 'string') && (value !== '');
			}

			if (!isNonEmptyString(getFieldValue(item, 'css_class', ''))) {
				item.css_class = 'menu-top';
			}
			if (!isNonEmptyString(getFieldValue(item, 'icon_url', ''))) {
				item.icon_url = 'dashicons-admin-generic';
			}
			if (!isNonEmptyString(getFieldValue(item, 'hookname', ''))) {
				item.hookname = randomMenuId();
			}
		}

		const result = this.outputItem(item, insertPosition, itemList);

		if (this.level > 1) {
			updateParentAccessUi(itemList);
		}

		return result;
	};

	/**
	 * @return {JQuery|null}
	 */
	AmeEditorColumn.prototype.getVisibleItemList = function() {
		if (this.usesSubmenuContainers) {
			if (this.visibleItemList) {
				return this.visibleItemList;
			}

			const $list = this.menuBox.children('.ws_submenu:visible').first().addClass('ame-visible-item-list');
			if ($list && ($list.length > 0)) {
				this.visibleItemList = $list;
			}
			return $list;
		} else {
			return this.menuBox;
		}
	};

	/**
	 * @param {JQuery|null} $submenu
	 */
	AmeEditorColumn.prototype.setVisibleItemList = function($submenu) {
		//Do nothing if the new list is the same as the old one.
		if (($submenu === this.visibleItemList) || ($submenu && ($submenu.is(this.visibleItemList)))) {
			return;
		}

		if (this.visibleItemList) {
			this.visibleItemList.hide().removeClass('ame-visible-item-list');
		}
		this.visibleItemList = $submenu;

		if (this.visibleItemList) {
			this.visibleItemList.show().addClass('ame-visible-item-list');
		}

		//Each item list/submenu has its own own selected item, so switching to a different item list
		//also effectively changes the selected item.
		this.selectionHasChanged();
	};

	/**
	 * @return {JQuery}
	 */
	AmeEditorColumn.prototype.getAllItemLists = function() {
		if (this.usesSubmenuContainers) {
			return this.menuBox.children('.ws_submenu');
		}
		return this.menuBox;
	};

	/**
	 * @return {JQuery}
	 */
	AmeEditorColumn.prototype.getSelectedItem = function() {
		const list = this.getVisibleItemList();
		if (list && (list.length > 0)) {
			return list.children('.ws_active').first();
		}
		return $([]);
	};

	/**
	 * @param {JQuery} container
	 */
	AmeEditorColumn.prototype.selectItem = function(container) {
		if (container.hasClass('ws_active')) {
			//The menu item is already selected.
			return;
		}

		//Highlight the active item and un-highlight the previous one
		container.addClass('ws_active');
		container.siblings('.ws_active').removeClass('ws_active');

		this.selectionHasChanged(container);
	};

	/**
	 * @param {JQuery|null} [$item]
	 */
	AmeEditorColumn.prototype.selectionHasChanged = function($item) {
		if (typeof $item === 'undefined') {
			$item = this.getSelectedItem();
		}
		if (!$item || ($item.length < 1)) {
			$item = null;
		}

		//Make the "delete" button appear disabled if you can't delete this item.
		this.container.find('.ws_toolbar .ws_delete_menu_button')
			.toggleClass('ws_button_disabled', !canDeleteItem($item))

		const self = this;
		this.getNextColumn(function(nextColumn) {
			if (nextColumn) {
				nextColumn.setVisibleItemList(getSubmenuOf($item, nextColumn));
				if ($item) {
					self.updateSubmenuBoxHeight($item, nextColumn);
				}
			}
		}, false);
	};

	/**
	 * @param {JQuery} selectedMenu
	 * @param {AmeEditorColumn} nextColumn
	 */
	AmeEditorColumn.prototype.updateSubmenuBoxHeight = function updateSubmenuBoxHeight(selectedMenu, nextColumn) {
		if (!nextColumn || (nextColumn === this)) {
			return;
		}
		let mainMenuBox = this.menuBox,
			submenuBox = nextColumn.menuBox,
			submenuDropZone = nextColumn.dropZone;

		//Make the submenu box tall enough to reach the selected item.
		//This prevents the menu tip (if any) from floating in empty space.
		if (selectedMenu.hasClass('ws_menu_separator')) {
			submenuBox.css('min-height', '');
		} else {
			var menuTipHeight = 30,
				empiricalExtraHeight = 4,
				verticalBoxOffset = (submenuBox.offset().top - mainMenuBox.offset().top),
				minSubmenuHeight = (selectedMenu.offset().top - mainMenuBox.offset().top)
					- verticalBoxOffset
					+ menuTipHeight - (submenuDropZone.outerHeight() || 0) + empiricalExtraHeight;
			minSubmenuHeight = Math.max(minSubmenuHeight, 0);
			submenuBox.css('min-height', minSubmenuHeight);
		}
	}

	AmeEditorColumn.prototype.buildSubmenuContainer = function(parentMenuId) {
		//Create a container for menu items.
		const submenu = $('<div class="ws_submenu" style="display:none;"></div>');
		submenu.attr('id', 'ws-submenu-'+(wsIdCounter++));

		if (parentMenuId) {
			submenu.data('parent_menu_id', parentMenuId);
		}

		//Make the submenu sortable
		makeBoxSortable(submenu);

		return submenu;
	};

	AmeEditorColumn.prototype.appendSubmenuContainer = function($submenu) {
		this.usesSubmenuContainers = true;
		$submenu.appendTo(this.menuBox);
	};

	/**
	 * Delete a menu item and all of its children.
	 *
	 * @param {JQuery} container
	 */
	AmeEditorColumn.prototype.destroyItem = function(container) {
		const wasSelected = container.is('.ws_active');

		//Recursively destroy any submenu items.
		const submenuId = container.data('submenu_id');
		if (submenuId) {
			const self = this;
			const $submenu = $('#' + submenuId);
			$submenu.children('.ws_container').each(function() {
				self.destroyItem($(this));
			});
			$submenu.remove();
		}

		//Destroy the item itself.
		container.remove();

		if (wasSelected) {
			this.selectionHasChanged();
		}
	};

	/**
	 * Check if this column can accept a menu item that's being dragged/moved to it.
	 *
	 * @param {JQuery} $itemNode
	 * @returns {boolean}
	 */
	AmeEditorColumn.prototype.canAcceptItem = function($itemNode) {
		const visibleSubmenu = this.getVisibleItemList();
		if (!visibleSubmenu || (visibleSubmenu.length < 1)) {
			return false; //Can't move anything to a non-existent submenu.
		}

		return (
			//It must actually be a menu item.
			$itemNode.hasClass('ws_container')

			//Prevent users from dropping a parent menu on one of its own sub-menus.
			&& !isParentMenuNodeOf($itemNode, visibleSubmenu)
		);
	}

	/**
	 * Remove all items and item lists from this column.
	 *
	 * Note: Does not remove item submenus that are in other columns.
	 */
	AmeEditorColumn.prototype.reset = function() {
		this.menuBox.empty();
		this.visibleItemList = null;
		this.selectionHasChanged(null);
	};

	/**
	 *
	 * @param {JQuery} editorNode
	 * @param {Boolean|null|string} [deepNestingEnabled]
	 * @param {Number} [maxLevels]
	 * @param {Number} [initialLevels]
	 * @constructor
	 */
	function AmeMenuPresenter(editorNode, deepNestingEnabled, maxLevels, initialLevels ) {
		const self = this;
		this.editorNode = editorNode;

		if (typeof deepNestingEnabled === 'string') {
			deepNestingEnabled = (deepNestingEnabled === '1');
		}
		this.isDeepNestingEnabled = (typeof deepNestingEnabled !== 'undefined') ? deepNestingEnabled : null;
		this.nestingQueryPromise = null;

		if (typeof maxLevels === 'undefined') {
			maxLevels = 3;
		}
		if (typeof initialLevels === 'undefined') {
			if (this.isDeepNestingEnabled) {
				//If additional levels are enabled, show the maximum number of levels.
				initialLevels = maxLevels;
			} else {
				//WordPress only supports up to two levels by default.
				initialLevels = Math.min(maxLevels, 2);
			}
		}
		if (initialLevels > this.maxLevels) {
			initialLevels = this.maxLevels;
		}

		this.maxLevels = maxLevels;

		const $topLevelContainer = this.editorNode.find('#ws_menu_box').first().closest('.ws_main_container');
		this.columns = [
			//Empty zeroth column.
			new AmeEditorColumn(0, null, $()),
			//The first column contains top level menus.
			new AmeEditorColumn(1, null, $topLevelContainer, makeNextColumnGetter(1))
		];
		this.currentLevels = this.columns.length - 1;

		function makeNextColumnGetter(ownLevel) {
			if (ownLevel >= self.maxLevels) {
				//This column will never have a next column, so we can just use NULL.
				return function(callback) {
					callback(null);
				};
			}
			return function(callback, createIfNotExists) {
				self.getColumn(ownLevel + 1, callback, createIfNotExists);
			};
		}

		/**
		 * @param {Number} level
		 * @return {AmeEditorColumn}
		 */
		function createColumn(level) {
			if (level > self.maxLevels) {
				throw new Error('Cannot exceed maximum nesting level: ' + self.maxLevels);
			}
			if (typeof self.columns[level] !== 'undefined') {
				throw new Error('Cannot overwrite an existing column ' + level);
			}

			let predecessor;
			if (typeof self.columns[level - 1] !== 'undefined') {
				predecessor = self.columns[level - 1].container;
			} else {
				predecessor = self.columns[self.currentLevels].container;
			}

			let newColumn = new AmeEditorColumn(level, predecessor, null, makeNextColumnGetter(level));
			self.columns.push(newColumn);

			if (level > self.currentLevels) {
				self.currentLevels = level;
			}

			return newColumn;
		}

		/**
		 * Can we create another column?
		 *
		 * @param {Number} level
		 * @param {Function} callback
		 */
		function queryCanCreateColumn(level, callback) {
			if (
				(level > self.maxLevels)                            //Do not exceed the maximum depth.
				|| (typeof self.columns[level] !== 'undefined')     //Do not overwrite existing columns.
			) {
				callback(false);
				return;
			}

			//WordPress core only supports two admin menu levels. We call anything beyond that "deep".
			const isDeep = (level > 2);
			if (!isDeep) {
				callback(true);
				return;
			}
			//Do we already know if we can create deeply nested menus?
			if (self.isDeepNestingEnabled !== null) {
				callback(self.isDeepNestingEnabled);
				return;
			}

			//If we're already waiting for a decision, just add another callback to the queue.
			if (self.nestingQueryPromise !== null) {
				self.nestingQueryPromise.always(function() {
					callback(self.isDeepNestingEnabled);
				});
				return;
			}

			//Let's allow other code/plugins to decide this. Scripts can add deferred objects or promises
			//to an array. All deferred objects must resolve successfully to enable deep nesting.
			let deferreds = [];
			self.editorNode.trigger('adminMenuEditor:queryDeepNesting', [deferreds]);

			if (deferreds.length > 0) {
				self.nestingQueryPromise = $.when.apply($, deferreds)
					.done(function() {
						self.isDeepNestingEnabled = true;
					})
					.fail(function() {
						self.isDeepNestingEnabled = false;
					})
					.always(function() {
						callback(self.isDeepNestingEnabled);
					});
			} else {
				//Deep nesting is disabled by default.
				self.isDeepNestingEnabled = false;
				callback(self.isDeepNestingEnabled);
			}
		}

		/**
		 * Get or create a column. The callback will be called with one argument: either the column object,
		 * or NULL if the column does not exist and could not be created.
		 *
		 * @param {Number} level
		 * @param {Function} callback
		 * @param {Boolean} [createIfNotExists] Defaults to true.
		 */
		this.getColumn = function(level, callback, createIfNotExists) {
			if (typeof this.columns[level] !== 'undefined') {
				callback(this.columns[level]);
				return;
			}

			if (typeof createIfNotExists === 'undefined') {
				createIfNotExists = true;
			}

			if (createIfNotExists) {
				queryCanCreateColumn(level, function (isAllowed) {
					//It could be that another callback has already created the next column,
					//so we need to check again if it exists.
					if (typeof self.columns[level] !== 'undefined') {
						callback(self.columns[level]);
					} else if (isAllowed) {
						callback(createColumn(level));
					} else {
						callback(null);
					}
				});
			} else {
				callback(null);
			}
		};

		/**
		 * Get or create a column. Like getColumn(), but it will default to not creating deeply nested
		 * menu levels unless that feature is already enabled.
		 *
		 * @param {Number} level
		 * @return {AmeEditorColumn|null}
		 */
		this.getColumnImmediate = function(level) {
			if (typeof this.columns[level] !== 'undefined') {
				return this.columns[level];
			}
			if (level > this.maxLevels) {
				return null;
			}

			if ((level <= 2) || (this.isDeepNestingEnabled === true)) {
				return createColumn(level);
			}
			return null;
		};

		/**
		 * Get the column that contains a specific menu item or element.
		 *
		 * @param  {JQuery} container Menu item container, or another element that's inside a column.
		 * @return {AmeEditorColumn|null}
		 */
		this.getItemColumn = function(container) {
			if (!container) {
				return null;
			}
			const level = container.closest('.ws_main_container').data('ame-menu-level');
			if (typeof level === 'undefined') {
				return null;
			}
			return this.getColumnImmediate(level);
		};

		/**
		 * Create editor widgets for a menu item and its submenus and append them all to the DOM.
		 *
		 * @param {Number} level
		 * @param {Object} itemData
		 * @param {JQuery} [afterNode] Insert the widget after this node.
		 */
		this.outputMenuItem = function(level, itemData, afterNode) {
			const column = this.getColumnImmediate(level);
			return column.outputItem(itemData, afterNode);
		}

		/**
		 * Select a menu item and show its submenu.
		 *
		 * @param {JQuery} container
		 */
		this.selectItem = function(container) {
			const thisColumn = this.getColumnImmediate(container.closest('.ws_main_container').data('ame-menu-level'));
			if (thisColumn) {
				thisColumn.selectItem(container);
			}
		};

		/**
		 * Delete a menu item and all of its children.
		 *
		 * @param {JQuery} container
		 */
		this.destroyItem = function(container) {
			const column = this.getItemColumn(container);
			if (column) {
				column.destroyItem(container);
			}
		};

		/**
		 * Delete all items and reset all columns.
		 */
		this.clear = function() {
			for (let level = 0; level < this.columns.length; level++) {
				if (typeof this.columns[level] !== 'undefined') {
					this.columns[level].reset();
				}
			}
		};

		//Initialisation.
		for (let level = this.currentLevels + 1; level <= initialLevels; level++) {
			createColumn(level);
		}
	}

/*
 * Create edit widgets for a top-level menu and its submenus and append them all to the DOM.
 *
 * Inputs :
 *	menu - an object containing menu data
 *	afterNode - if specified, the new menu widget will be inserted after this node. Otherwise,
 *	            it will be added to the end of the list.
 * Outputs :
 *	Object with two fields - 'menu' and 'submenu' - containing the DOM nodes of the created widgets.
 */
function outputTopMenu(menu, afterNode){
	if (!menuPresenter) {
		throw new Error('outputTopMenu cannot be called before the menu presenter has been initialised.');
	}
	return menuPresenter.outputMenuItem(1, menu, afterNode);
}

/**
 * Create an edit widget for a menu item.
 *
 * @param {Object} itemData
 * @param {Boolean} [isTopLevel] Specify if this is a top-level menu or a sub-menu item. Defaults to false (= sub-item).
 * @return {*} The created widget as a jQuery object.
 */
function buildMenuItem(itemData, isTopLevel) {
	isTopLevel = (typeof isTopLevel === 'undefined') ? false : isTopLevel;
	const canHaveSubmenuItems = isTopLevel && !itemData.separator;

	//Create the menu HTML
	var item = $('<div></div>')
		.attr('class', "ws_container")
		.attr('id', 'ws-menu-item-' + (wsIdCounter++))
		.data('menu_item', itemData)
		.data('field_editors_created', false);

	item.addClass(isTopLevel ? 'ws_menu' : 'ws_item');
	if ( itemData.separator ) {
		item.addClass('ws_menu_separator');
	}

	//Add a header and a container for property editors (to improve performance
	//the editors themselves are created later, when the user tries to access them
	//for the first time).
	var contents = [];
	var menuTitle = getFieldValue(itemData, 'menu_title', '');
	if (menuTitle === '') {
		menuTitle = '&nbsp;';
	}

	contents.push(
		'<div class="ws_item_head">',
			itemData.separator ? '' : '<a class="ws_edit_link"> </a><div class="ws_flag_container"> </div>',
			'<input type="checkbox" class="ws_actor_access_checkbox">',
			'<span class="ws_item_title">',
				formatMenuTitle(menuTitle),
			'&nbsp;</span>',

		'</div>',
		'<div class="ws_editbox" style="display: none;"></div>'
	);
	item.append(contents.join(''));

	//Apply flags based on the item's state
	var flags = ['hidden', 'unused', 'custom'];
	for (var i = 0; i < flags.length; i++) {
		setMenuFlag(item, flags[i], getFieldValue(itemData, flags[i], false));
	}

	if ( canHaveSubmenuItems ){
		//Allow the user to drag menu items to top-level menus
		item.droppable({
			'hoverClass' : 'ws_menu_drop_hover',

			'accept' : (function(thing){
				return thing.hasClass('ws_item');
			}),

			'drop' : (function(event, ui){
				const column = menuPresenter.getItemColumn(item);
				if (!column) {
					return;
				}
				const nextColumn = menuPresenter.getColumnImmediate(column.level + 1);
				const submenu = getSubmenuOf(item, nextColumn);
				if (!submenu || !nextColumn) {
					return;
				}

				const droppedItemData = readItemState(ui.draggable);
				const sourceSubmenu = ui.draggable.parent();

				let result = nextColumn.outputItem(droppedItemData, null, submenu);

				if ( !event.ctrlKey ) {
					menuPresenter.destroyItem(ui.draggable);
				}

				updateItemEditor(result.menu);

				//Moving an item can change aggregate menu permissions. Update the UI accordingly.
				updateParentAccessUi(submenu);
				if (sourceSubmenu) {
					updateParentAccessUi(sourceSubmenu);
				}
			})
		});
	}

	return item;
}

function jsTrim(str){
	return str.replace(/^\s+|\s+$/g, "");
}

//Expose this handy tool to our other scripts.
AmeEditorApi.jsTrim = jsTrim;

function stripAllTags(input) {
	//Based on: http://phpjs.org/functions/strip_tags/
	var tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi,
		commentsAndPhpTags = /<!--[\s\S]*?-->|<\?(?:php)?[\s\S]*?\?>/gi;
	return input.replace(commentsAndPhpTags, '').replace(tags, '');
}

function truncateString(input, maxLength, padding) {
	if (typeof padding === 'undefined') {
		padding = '';
	}

	if (input.length > maxLength) {
		input = input.substring(0, maxLength - 1) + padding;
	}

	return input;
}

/**
 * Format menu title for display in HTML.
 * Strips tags and truncates long titles.
 *
 * @param {String} title
 * @returns {String}
 */
function formatMenuTitle(title) {
	title = stripAllTags(title);

	//Compact whitespace.
	title = title.replace(/[\s\t\r\n]+/g, ' ');
	title = jsTrim(title);

	//The max. length was chosen empirically.
	title = truncateString(title, 34, '\u2026');
	return title;
}

AmeEditorApi.formatMenuTitle = formatMenuTitle;

//Editor field spec template.
var baseField = {
	caption : '[No caption]',
    standardCaption : true,
	advanced : false,
	type : 'text',
	defaultValue: '',
	onlyForTopMenus: false,
	addDropdown : false,
	visible: true,

	write: null,
	display: null,

	tooltip: null
};

/*
 * List of all menu fields that have an associated editor
 */
var knownMenuFields = {
	'menu_title' : $.extend({}, baseField, {
		caption : 'Menu title',
		display: function(menuItem, displayValue, input, containerNode) {
			//Update the header as well.
			containerNode.find('.ws_item_title').text(formatMenuTitle(displayValue) + '\xa0');
			return displayValue;
		},
		write: function(menuItem, value, input, containerNode) {
			menuItem.menu_title = value;
			containerNode.find('.ws_item_title').text(stripAllTags(input.val()) + '\xa0');
		}
	}),

	'template_id' : $.extend({}, baseField, {
		caption : 'Target page',
		type : 'select',
		options : (function(){
			//Generate name => id mappings for all item templates + the special "Custom" template.
			var itemTemplateIds = [];
			itemTemplateIds.push([wsEditorData.customItemTemplate.name, '']);

			for (var template_id in wsEditorData.itemTemplates) {
				if (wsEditorData.itemTemplates.hasOwnProperty(template_id)) {
					itemTemplateIds.push([wsEditorData.itemTemplates[template_id].name, template_id]);
				}
			}

			itemTemplateIds.sort(function(a, b) {
				if (a[1] === b[1]) {
					return 0;
				}

				//The "Custom" item is always first.
				if (a[1] === '') {
					return -1;
				} else if (b[1] === '') {
					return 1;
				}

				//Top-level items go before submenus.
				var aIsTop = (a[1].charAt(0) === '>') ? 1 : 0;
				var bIsTop = (b[1].charAt(0) === '>') ? 1 : 0;
				if (aIsTop !== bIsTop) {
					return bIsTop - aIsTop;
				}

				//Everything else is sorted by name, in alphabetical order.
				if (a[0] > b[0]) {
					return 1;
				} else if (a[0] < b[0]) {
					return -1;
				}
				return 0;
			});

			return itemTemplateIds;
		})(),

		write: function(menuItem, value, input, containerNode) {
			var oldTemplateId = menuItem.template_id;

			menuItem.template_id = value;
			menuItem.defaults = itemTemplates.getDefaults(menuItem.template_id);
		    menuItem.custom = (menuItem.template_id === '');

		    // The file/URL of non-custom items is read-only and equal to the default
		    // value. Rationale: simplifies menu generation, prevents some user mistakes.
		    if (menuItem.template_id !== '') {
			    menuItem.file = null;
		    }

		    // The new template might not have default values for some of the fields
		    // currently set to null (= "default"). In those cases, we need to make
		    // the current values explicit.
		    containerNode.find('.ws_edit_field').each(function(index, field){
			    field = $(field);
			    var fieldName = field.data('field_name');
			    var isSetToDefault = (menuItem[fieldName] === null);
			    var hasDefaultValue = itemTemplates.hasDefaultValue(menuItem.template_id, fieldName);

			    if (isSetToDefault && !hasDefaultValue) {
					var oldDefaultValue = itemTemplates.getDefaultValue(oldTemplateId, fieldName);
					if (oldDefaultValue !== null) {
						menuItem[fieldName] = oldDefaultValue;
					}
			    }
		    });
		}
	}),

	'embedded_page_id' : $.extend({}, baseField, {
		caption: 'Embedded page ID',
		defaultValue: 'Select page to display',
		type: 'text',
		addDropdown: 'ws_embedded_page_selector',

		display: function(menuItem, displayValue, input) {
			input.prop('readonly', true);
			var pageId = parseInt(getFieldValue(menuItem, 'embedded_page_id', 0), 10),
				blogId = parseInt(getFieldValue(menuItem, 'embedded_page_blog_id', 1), 10),
				formattedId = 'ID: ' + pageId;

			if (pageId <= 0) {
				return 'Select page =>';
			}

			if (blogId !== 1) {
				formattedId = formattedId + ', blog ID: ' + blogId;
			}
			displayValue = formattedId;

			AmePageTitles.get(pageId, blogId, function(title) {
				//If we retrieved the title via AJAX, the user might have selected a different page in the meantime.
				//Make sure it's still the same page before displaying the title.
				var currentPageId = parseInt(getFieldValue(menuItem, 'embedded_page_id', 0), 10),
					currentBlogId = parseInt(getFieldValue(menuItem, 'embedded_page_blog_id', 1), 10);
				if ((currentPageId !== pageId) || (currentBlogId !== blogId)) {
					return;
				}

				displayValue = title + ' (' + formattedId + ')';
				input.val(displayValue);
			});

			return displayValue;
		},

		write: function() {
			//The user cannot directly edit this field. We deliberately ignore writes.
		},

		visible: function(menuItem) {
			//Only show this field if the "Embed WP page" template is selected.
			return (menuItem.template_id === wsEditorData.embeddedPageTemplateId);
		}
	}),

	'file' : $.extend({}, baseField, {
		caption: 'URL',
		display: function(menuItem, displayValue, input) {
			// The URL/file field is read-only for default menus. Also, since the "file"
			// field is usually set to a page slug or plugin filename for plugin/hook pages,
			// we display the dynamically generated "url" field here (i.e. the actual URL) instead.
			if (menuItem.template_id !== '') {
				input.prop('readonly', true);
				displayValue = itemTemplates.getDefaultValue(menuItem.template_id, 'url');
			} else {
				input.prop('readonly', false);
			}
			return displayValue;
		},

		write: function(menuItem, value) {
			// A menu must always have a non-empty URL. If the user deletes the current value,
			// reset it to the old value.
			if (value === '') {
				value = menuItem.file;
			}
			// Default menus always point to the default file/URL.
			if (menuItem.template_id !== '') {
				value = null;
			}
			menuItem.file = value;
		}
	}),

	'access_level' : $.extend({}, baseField, {
		caption: 'Permissions',
		defaultValue: 'read',
		type: 'access_editor',
		visible: false, //Will be set to visible only in Pro version.

		display: function(menuItem) {
			//Permissions display is a little complicated and could use improvement.
			var requiredCap = getFieldValue(menuItem, 'access_level', '');
			var extraCap = getFieldValue(menuItem, 'extra_capability', '');

			var displayValue = (menuItem.template_id === '') ? '< Custom >' : requiredCap;
			if (extraCap !== '') {
				if (menuItem.template_id === '') {
					displayValue = extraCap;
				} else {
					displayValue = displayValue + '+' + extraCap;
				}
			}

			return displayValue;
		},

		write: function(menuItem) {
			//The required capability can't be directly edited and always equals the default.
			menuItem.access_level = null;
		}
	}),

	//TODO: Never save this field. It just wastes database space.
	'required_capability_read_only' : $.extend({}, baseField, {
		caption: 'Required capability',
		defaultValue: 'none',
		type: 'text',
		tooltip: "Only users who have this capability can see the menu. "+
			"The capability can't be changed because it's usually hard-coded in WordPress or the plugin that created the menu."+
			"<br><br>Use the \"Extra capability\" field to restrict access to this menu.",

		visible: function(menuItem) {
			//Show only in the free version, on non-custom menus.
			return !wsEditorData.wsMenuEditorPro && (menuItem.template_id !== '');
		},

		display: function(menuItem, displayValue, input) {
			input.prop('readonly', true);
			return getFieldValue(menuItem, 'access_level', '');
		},

		write: function(menuItem, value) {
			//The required capability is read-only. Ignore writes.
		}
	}),

	'extra_capability' : $.extend({}, baseField, {
		caption: 'Extra capability',
		defaultValue: 'read',
		type: 'text',
		addDropdown: 'ws_cap_selector',
		tooltip: function(menuItem) {
			if (menuItem.template_id === '') {
				return 'Only users who have this capability can see the menu.';
			}
			return 'An additional capability check that is applied on top of the required capability.';
		},

		display: function(menuItem) {
			var requiredCap = getFieldValue(menuItem, 'access_level', '');
			var extraCap = getFieldValue(menuItem, 'extra_capability', '');

			//On custom menus, show the default required cap when no extra cap is selected.
			//Otherwise there would be no visible capability requirements at all.
			var displayValue = extraCap;
			if ((menuItem.template_id === '') && (extraCap === '')) {
				displayValue = requiredCap;
			}

			return displayValue;
		},

		write: function(menuItem, value) {
			value = jsTrim(value);

			//Reset to default if the user clears the input.
			if (value === '') {
				menuItem.extra_capability = null;
				return;
			}

			menuItem.extra_capability = value;
		}
	}),

	'appearance_heading' : $.extend({}, baseField, {
		caption: 'Appearance',
		advanced : true,
		onlyForTopMenus: false,
		type: 'heading',
		standardCaption: false,
		visible: false //Only visible in the Pro version.
	}),

	'icon_url' : $.extend({}, baseField, {
		caption: 'Icon URL',
		type : 'icon_selector',
		advanced : true,
		defaultValue: 'div',
		onlyForTopMenus: true,

		display: function(menuItem, displayValue, input, containerNode) {
			//Display the current icon in the selector.
			var cssClass = getFieldValue(menuItem, 'css_class', '');
			var iconUrl = getFieldValue(menuItem, 'icon_url', '', containerNode);
			displayValue = iconUrl;

			//When submenu icon visibility is set to "only if manually selected",
			//don't show the default submenu icons.
			var isDefault = (typeof menuItem.icon_url === 'undefined') || (menuItem.icon_url === null);
			if (isDefault && (wsEditorData.submenuIconsEnabled === 'if_custom') && containerNode.hasClass('ws_item')) {
				iconUrl = 'none';
				cssClass = '';
			}

			var selectButton = input.closest('.ws_edit_field').find('.ws_select_icon');
			var cssIcon = selectButton.find('.ws_icon_image');
			var imageIcon = selectButton.find('img');

			var matches = cssClass.match(/\b(ame-)?menu-icon-([^\s]+)\b/);
			var iconFontMatches = iconUrl && iconUrl.match(/^\s*((dashicons|ame-fa)-[a-z0-9\-]+)/);

			//Icon URL takes precedence over icon class.
			if ( iconUrl && iconUrl !== 'none' && iconUrl !== 'div' && !iconFontMatches ) {
				//Regular image icon.
				cssIcon.hide();
				imageIcon.prop('src', iconUrl).show();
			} else if ( iconFontMatches ) {
				cssIcon.removeClass().addClass('ws_icon_image');
				if ( iconFontMatches[2] === 'dashicons' ) {
					//Dashicon.
					cssIcon.addClass('dashicons ' + iconFontMatches[1]);
				} else if ( iconFontMatches[2] === 'ame-fa' ) {
					//FontAwesome icon.
					cssIcon.addClass('ame-fa ' + iconFontMatches[1]);
				}
				imageIcon.hide();
				cssIcon.show();
			} else if ( matches ) {
				//Other CSS-based icon.
				imageIcon.hide();
				var iconClass = (matches[1] ? matches[1] : '') + 'icon-' + matches[2];
				cssIcon.removeClass().addClass('ws_icon_image ' + iconClass).show();
			} else {
				//This menu has no icon at all. This is actually a valid state
				//and WordPress will display a menu like that correctly.
				imageIcon.hide();
				cssIcon.removeClass().addClass('ws_icon_image').show();
			}

			return displayValue;
		}
	}),

	'colors' : $.extend({}, baseField, {
		caption: 'Color scheme',
		defaultValue: 'Default',
		type: 'color_scheme_editor',
		onlyForTopMenus: true,
		visible: false,
		advanced : true,

		display: function(menuItem, displayValue, input, containerNode) {
			var colors = getFieldValue(menuItem, 'colors', {}) || {};
			var colorList = containerNode.find('.ws_color_scheme_display');

			colorList.empty();
			var count = 0, maxColorsToShow = 7;

			$.each(colors, function(name, value) {
				if ( !value || (count >= maxColorsToShow) ) {
					return;
				}

				colorList.append(
					$('<span></span>').addClass('ws_color_display_item').css('background-color', value)
				);
				count++;
			});

			if (count === 0) {
				colorList.append('Default');
			}

			return 'Placeholder. You should never see this.';
		},

		write: function(menuItem) {
			//Menu colors can't be directly edited.
		}
	}),

	'html_heading' : $.extend({}, baseField, {
		caption: 'HTML',
		advanced : true,
		onlyForTopMenus: true,
		type: 'heading',
		standardCaption: false
	}),

	'open_in' : $.extend({}, baseField, {
		caption: 'Open in',
		advanced : true,
		type : 'select',
		options : [
			['Same window or tab', 'same_window'],
			['New window', 'new_window'],
			['Frame', 'iframe']
		],
		defaultValue: 'same_window',
		visible: false
	}),

	'iframe_height' : $.extend({}, baseField, {
		caption: 'Frame height (pixels)',
		advanced : true,
		visible: function(menuItem) {
			return wsEditorData.wsMenuEditorPro && (getFieldValue(menuItem, 'open_in') === 'iframe');
		},

		display: function(menuItem, displayValue, input) {
			input.prop('placeholder', 'Auto');
			if (displayValue === 0 || displayValue === '0') {
				displayValue = '';
			}
			return displayValue;
		},

		write: function(menuItem, value) {
			value = parseInt(value, 10);
			if (isNaN(value) || (value < 0)) {
				value = 0;
			}
			value = Math.round(value);

			if (value > 10000) {
				value = 10000;
			}

			if (value === 0) {
				menuItem.iframe_height = null;
			} else {
				menuItem.iframe_height = value;
			}

		}
	}),

	'css_class' : $.extend({}, baseField, {
		caption: 'CSS classes',
		advanced : true,
		onlyForTopMenus: true
	}),

	'hookname' : $.extend({}, baseField, {
		caption: 'ID attribute',
		advanced : true,
		onlyForTopMenus: true
	}),

	'page_properties_heading' : $.extend({}, baseField, {
		caption: 'Page',
		advanced : true,
		onlyForTopMenus: true,
		type: 'heading',
		standardCaption: false
	}),

	'page_heading' : $.extend({}, baseField, {
		caption: 'Page heading',
		advanced : true,
		onlyForTopMenus: false,
		visible: false
	}),

	'page_title' : $.extend({}, baseField, {
		caption: "Window title",
		standardCaption : true,
		advanced : true
	}),

	'is_always_open' : $.extend({}, baseField, {
		caption: 'Keep this menu expanded',
		advanced : true,
		onlyForTopMenus: true,
		type: 'checkbox',
		standardCaption: false
	})
};

var visibleMenuFieldsByType = {};

AmeEditorApi.getItemDisplayUrl = function(menuItem) {
	var url = getFieldValue(menuItem, 'file', '');
	if (menuItem.template_id !== '') {
		//Use the template URL. It's a preset that can't be overridden.
		var defaultUrl = itemTemplates.getDefaultValue(menuItem.template_id, 'url');
		if (defaultUrl) {
			url = defaultUrl;
		}
	}
	return url;
};

/*
 * Create editors for the visible fields of a menu entry and append them to the specified node.
 */
function buildEditboxFields(fieldContainer, entry, isTopLevel){
	isTopLevel = (typeof isTopLevel === 'undefined') ? false : isTopLevel;

	var basicFields = $('<div class="ws_edit_panel ws_basic"></div>').appendTo(fieldContainer);
    var advancedFields = $('<div class="ws_edit_panel ws_advanced"></div>').appendTo(fieldContainer);

    if ( wsEditorData.hideAdvancedSettings ){
    	advancedFields.css('display', 'none');
    }

	for (var field_name in knownMenuFields){
		if (!knownMenuFields.hasOwnProperty(field_name)) {
			continue;
		}

		var fieldSpec = knownMenuFields[field_name];
		if (fieldSpec.onlyForTopMenus && !isTopLevel) {
			continue;
		}

		var field = buildEditboxField(entry, field_name, fieldSpec);
		if (field){
            if (fieldSpec.advanced){
                advancedFields.append(field);
            } else {
                basicFields.append(field);
            }
		}
	}

	//Add a link that shows/hides advanced fields
	fieldContainer.append(
		$('<div>').addClass('ws_toggle_container').append(
			$('<a></a>', {href: '#'})
				.addClass('ws_toggle_advanced_fields')
				.text(
					wsEditorData.hideAdvancedSettings
						? wsEditorData.captionShowAdvanced
						: wsEditorData.captionHideAdvanced
				)
				.toggle(!!wsEditorData.hideAdvancedSettings) //Conver to boolean because it could be a string ("1" or "0").
		)
	);
}

/*
 * Create an editor for a specified field.
 */
//noinspection JSUnusedLocalSymbols
function buildEditboxField(entry, field_name, field_settings){
	//Build a form field of the appropriate type
	var inputBox;
	var basicTextField = '<input type="text" class="ws_field_value">';
	//noinspection FallthroughInSwitchStatementJS
	switch(field_settings.type){
		case 'select':
			inputBox = $('<select class="ws_field_value">');
			var option = null;
			for( var index = 0; index < field_settings.options.length; index++ ){
				var optionTitle = field_settings.options[index][0];
				var optionValue = field_settings.options[index][1];

				option = $('<option>')
					.val(optionValue)
					.text(optionTitle);
				option.appendTo(inputBox);
			}
			break;

        case 'checkbox':
	        inputBox = $('<label></label>')
		        .append($('<input>', {type: 'checkbox', "class": 'ws_field_value'}))
		        .append(' ')
		        .append($('<span></span>', {"class": 'ws_field_label_text'}).text(field_settings.caption))
            break;

		case 'access_editor':
			inputBox = $('<input type="text" class="ws_field_value" readonly="readonly">')
                .add('<input type="button" class="button ws_launch_access_editor" value="Edit...">');
			break;

		case 'icon_selector':
			//noinspection HtmlUnknownTag
			inputBox = $(basicTextField)
                .add('<button class="button ws_select_icon" title="Select icon"><div class="ws_icon_image dashicons dashicons-admin-generic"></div><img src="" style="display:none;" alt="Icon"></button>');
			break;

		case 'color_scheme_editor':
			inputBox = $('<span class="ws_color_scheme_display">Placeholder</span>')
				.add('<input type="button" class="button ws_open_color_editor" value="Edit...">');
			break;

		case 'heading':
			inputBox = $('<span></span>').text(field_settings.caption);
			break;

		case 'text':
			/* falls through */
		default:
			inputBox = $(basicTextField);
	}


	var className = "ws_edit_field ws_edit_field-"+field_name;
	if (field_settings.addDropdown){
		className += ' ws_has_dropdown';
	}
	if (!field_settings.standardCaption) {
		className += ' ws_no_field_caption';
	}
	if (field_settings.type === 'heading') {
		className += ' ws_field_group_heading';
	}

	var caption = $(); //Empty set by default.
	if (field_settings.standardCaption) {
		var $labelText = $('<span></span>')
			.addClass('ws_field_label_text')
			.text(field_settings.caption + ' ');

		if (field_settings.tooltip !== null) {
			$labelText.append(
				'<a class="ws_field_tooltip_trigger"><div class="dashicons dashicons-info"></div></a>'
			);
		}

		caption = caption.add($labelText).add('<br>'); //Note: add(), not append().
	}
	var editField = $('<div></div>')
		.attr('class', className)
		.append(caption)
		.append(inputBox);

	if (field_settings.addDropdown) {
		//Add a dropdown button
		var dropdownId = field_settings.addDropdown;
		editField.append(
			$('<input type="button" value="&#xf347;">')
				.addClass('button ws_dropdown_button ' + dropdownId + '_trigger')
				.attr('tabindex', '-1')
				.data('dropdownId', dropdownId)
		);
	}

	editField
		.append(
			$('<img class="ws_reset_button" title="Reset to default value" src="" alt="Reset">')
				.attr('src', wsEditorData.imagesUrl + '/transparent16.png')
		).data('field_name', field_name);

	var visible;
	if (typeof field_settings.visible === 'function') {
		visible = field_settings.visible(entry, field_name);
	} else {
		visible = field_settings.visible;
	}
	if (!visible) {
		editField.css('display', 'none');
	}

	return editField;
}

/**
 * Get the parent menu of a menu item.
 *
 * @param containerNode A DOM element as a jQuery object.
 * @return {JQuery} Parent container node, or an empty jQuery set.
 */
function getParentMenuNode(containerNode) {
	var submenu = containerNode.closest('.ws_submenu', '#ws_menu_editor'),
		parentId = submenu.data('parent_menu_id');
	if (parentId) {
		return $('#' + parentId);
	} else {
		return $([]);
	}
}

/**
 * Check if a menu item is the parent of another item or a submenu list.
 *
 * @param {JQuery} menuItem
 * @param {JQuery} something
 * @returns {boolean}
 */
function isParentMenuNodeOf(menuItem, something) {
	const parent = getParentMenuNode(something)
	if (menuItem.is(parent)) {
		return true;
	} else if (parent.length > 0) {
		return isParentMenuNodeOf(menuItem, parent);
	}
	return false;
}

/**
 * Get all submenu items of a menu item.
 *
 * @param {JQuery} containerNode
 * @return {JQuery} A list of submenu item container nodes, or an empty set.
 */
function getSubmenuItemNodes(containerNode) {
	var subMenuId = containerNode.data('submenu_id');
	if (subMenuId) {
		return $('#' + subMenuId).find('.ws_container');
	} else {
		return $([]);
	}
}

/**
 * Apply a callback recursively to a menu item and all of its children, in depth-first order.
 * The callback will be invoked with two arguments: (containerNode, menuItem).
 *
 * @param containerNode
 * @param {Function} callback
 */
function walkMenuTree(containerNode, callback) {
	getSubmenuItemNodes(containerNode).each(function() {
		walkMenuTree($(this), callback);
	});
	callback(containerNode, containerNode.data('menu_item'));
}

/**
 * Update the UI elements that that indicate whether the currently selected
 * actor can access a menu item.
 *
 * @param containerNode
 */
function updateActorAccessUi(containerNode) {
	//Update the permissions checkbox & UI
	var menuItem = containerNode.data('menu_item');
	if (actorSelectorWidget.selectedActor !== null) {
		var hasAccess = actorCanAccessMenu(menuItem, actorSelectorWidget.selectedActor);
		var hasCustomPermissions = actorHasCustomPermissions(menuItem, actorSelectorWidget.selectedActor);

		var isOverrideActive = !hasAccess && getFieldValue(menuItem, 'restrict_access_to_items', false);

		//Check if the parent menu has the "hide all submenus if this is hidden" override in effect.
		var currentChild = containerNode, parentNode, parentItem;
		do {
			parentNode = getParentMenuNode(currentChild);
			parentItem = parentNode.data('menu_item');
			if (
				parentItem
				&& getFieldValue(parentItem, 'restrict_access_to_items', false)
				&& !actorCanAccessMenu(parentItem, actorSelectorWidget.selectedActor)
			) {
				hasAccess = false;
				isOverrideActive = true;
				break;
			}
			currentChild = parentNode;
		} while (parentNode.length > 0);

		var checkbox = containerNode.find('.ws_actor_access_checkbox');
		checkbox.prop('checked', hasAccess);

		//Display the checkbox in an indeterminate state if the actual menu permissions are unknown
		//because it uses meta capabilities.
		var isIndeterminate = (hasAccess === null);
		//Also show it as indeterminate if some items of this menu are hidden and some are visible,
		//or if their permissions don't match this menu's permissions.
		var submenuItems = getSubmenuItemNodes(containerNode);
		if ((submenuItems.length > 0) && !isOverrideActive)  {
			var differentPermissions = false;
			submenuItems.each(function() {
				var item = $(this).data('menu_item');
				if ( !item ) { //Skip placeholder items created by drag & drop operations.
					return true;
				}
				var hasSubmenuAccess = actorCanAccessMenu(item, actorSelectorWidget.selectedActor);
				if (hasSubmenuAccess !== hasAccess) {
					differentPermissions = true;
					return false;
				}
				return true;
			});

			if (differentPermissions) {
				isIndeterminate = true;
			}
		}
		checkbox.prop('indeterminate', isIndeterminate);

		if (isIndeterminate && (hasAccess === null)) {
			setMenuFlag(
				containerNode,
				'uncertain_meta_cap',
				true,
				"This item might be visible.\n"
				+ "The plugin cannot reliably detect if \"" + actorSelectorWidget.selectedDisplayName
				+ "\" has the \"" + getFieldValue(menuItem, 'access_level', '[No capability]')
				+ "\" capability. If you need to hide the item, try checking and then unchecking it."
			);
		} else {
			setMenuFlag(containerNode, 'uncertain_meta_cap', false);
		}

		containerNode.toggleClass('ws_is_hidden_for_actor', !hasAccess);
		containerNode.toggleClass('ws_has_custom_permissions_for_actor', hasCustomPermissions);
		setMenuFlag(containerNode, 'custom_actor_permissions', hasCustomPermissions);
		setMenuFlag(containerNode, 'hidden_from_others', false);
	} else {
		containerNode.removeClass('ws_is_hidden_for_actor ws_has_custom_permissions_for_actor');
		setMenuFlag(containerNode, 'custom_actor_permissions', false);
		setMenuFlag(containerNode, 'uncertain_meta_cap', false);

		var currentUserActor = 'user:' + wsEditorData.currentUserLogin;
		var otherActors = _(wsEditorData.actors).keys().without(currentUserActor, 'special:super_admin').value(),
			hiddenFromCurrentUser = ! actorCanAccessMenu(menuItem, currentUserActor),
			hasAccessToThisItem = _.curry(actorCanAccessMenu, 2)(menuItem),
			hiddenFromOthers = _.every(otherActors, function(actorId) {
				return (hasAccessToThisItem(actorId) === false);
			}),
			visibleForSuperAdmin = AmeActors.isMultisite && actorCanAccessMenu(menuItem, 'special:super_admin');

		setMenuFlag(
			containerNode,
			'hidden_from_others',
			hiddenFromOthers,
			hiddenFromCurrentUser
				? 'Hidden from everyone'
				: ('Hidden from everyone except you' + (visibleForSuperAdmin ? ' and Super Admins' : ''))
		);
	}

	//Update the "hidden" flag.
	setMenuFlag(containerNode, 'hidden', itemHasHiddenFlag(menuItem, actorSelectorWidget.selectedActor));
}

/**
 * Like updateActorAccessUi() except it updates the specified menu's parent, not the menu itself.
 * If the menu has no parent (i.e. it's a top-level menu), this function does nothing.
 *
 * @param containerNode Either a menu item or a submenu container.
 */
function updateParentAccessUi(containerNode) {
	var submenu;
	if ( containerNode.is('.ws_submenu') ) {
		submenu = containerNode;
	} else {
		submenu = containerNode.parent();
	}

	var parentId = submenu.data('parent_menu_id');
	if (parentId) {
		updateActorAccessUi($('#' + parentId));
	}
}

/**
 * Update an edit widget with the current menu item settings.
 *
 * @param {JQuery} containerNode
 */
function updateItemEditor(containerNode) {
	var menuItem = containerNode.data('menu_item');
	var itemSubType = (menuItem.hasOwnProperty('sub_type') ? menuItem['sub_type'] : '');

	//Apply flags based on the item's state.
	var flags = ['hidden', 'unused', 'custom'];
	for (var i = 0; i < flags.length; i++) {
		setMenuFlag(containerNode, flags[i], getFieldValue(menuItem, flags[i], false));
	}

	if (itemSubType) {
		var typeTitle = itemSubType.charAt(0).toUpperCase() + itemSubType.slice(1);
		setMenuFlag(containerNode, 'subtype_' + itemSubType, true, typeTitle);
	}

	//Update the permissions checkbox & other actor-specific UI
	updateActorAccessUi(containerNode);

	//Update all input fields with the current values.
	containerNode.find('.ws_edit_field').each(function(index, field) {
		field = $(field);
		var fieldName = field.data('field_name');
		var input = field.find('.ws_field_value').first();

		var hasADefaultValue = itemTemplates.hasDefaultValue(menuItem.template_id, fieldName);
		var defaultValue = getDefaultValue(menuItem, fieldName, null, containerNode);
		var isDefault = hasADefaultValue && ((typeof menuItem[fieldName] === 'undefined') || (menuItem[fieldName] === null));

        if (fieldName === 'access_level') {
            isDefault = (getFieldValue(menuItem, 'extra_capability', '') === '')
				&& isEmptyObject(menuItem.grant_access)
				&& (!getFieldValue(menuItem, 'restrict_access_to_items', false));
        } else if (fieldName === 'required_capability_read_only') {
        	isDefault = true;
	        hasADefaultValue = true;
        }

		field.toggleClass('ws_has_no_default', !hasADefaultValue);
		field.toggleClass('ws_input_default', isDefault);

		var displayValue = isDefault ? defaultValue : menuItem[fieldName];
		if (knownMenuFields[fieldName].display !== null) {
			displayValue = knownMenuFields[fieldName].display(menuItem, displayValue, input, containerNode);
		}

        setInputValue(input, displayValue);

		//Store the value to help with change detection.
		if (input.length > 0) {
			$.data(input.get(0), 'ame_last_display_value', displayValue);
		}

		var isFieldVisible = _.get(visibleMenuFieldsByType, [itemSubType, fieldName], true);
		if (typeof (knownMenuFields[fieldName].visible) === 'function') {
			isFieldVisible = isFieldVisible && knownMenuFields[fieldName].visible(menuItem, fieldName);
		} else {
			isFieldVisible = isFieldVisible && knownMenuFields[fieldName].visible;
		}
		if (isFieldVisible) {
			field.css('display', '');
		} else {
			field.css('display', 'none');
		}
    });
}

AmeEditorApi.updateParentAccessUi = updateParentAccessUi;
AmeEditorApi.updateItemEditor = updateItemEditor;

function isEmptyObject(obj) {
    for (var prop in obj) {
        if (obj.hasOwnProperty(prop)) {
            return false;
        }
    }
    return true;
}

/**
 * Get the current value of a single menu field.
 *
 * If the specified field is not set, this function will attempt to retrieve it
 * from the "defaults" property of the menu object. If *that* fails, it will return
 * the value of the optional third argument defaultValue.
 *
 * @param {Object} entry
 * @param {string} fieldName
 * @param {*} [defaultValue]
 * @param {JQuery} [containerNode]
 * @return {*}
 */
function getFieldValue(entry, fieldName, defaultValue, containerNode){
	if ( (typeof entry[fieldName] === 'undefined') || (entry[fieldName] === null) ) {
		return getDefaultValue(entry, fieldName, defaultValue, containerNode);
	} else {
		return entry[fieldName];
	}
}

AmeEditorApi.getFieldValue = getFieldValue;

/**
 * Get the default value of a menu field.
 *
 * @param {Object} entry
 * @param {String} fieldName
 * @param {*} [defaultValue]
 * @param {JQuery} [containerNode]
 * @returns {*}
 */
function getDefaultValue(entry, fieldName, defaultValue, containerNode) {
	//By default, a submenu item has the same icon as its parent.
	if ((fieldName === 'icon_url') && containerNode && (wsEditorData.submenuIconsEnabled !== 'never')) {
		var parentContainerNode = getParentMenuNode(containerNode),
			parentMenuItem = parentContainerNode.data('menu_item');
		if (parentMenuItem) {
			return getFieldValue(parentMenuItem, fieldName, defaultValue, parentContainerNode);
		}
	}

	//Use the custom menu title as the page title if the default page title matches the default menu title.
	//Note that if the page title is an empty string (''), WP automatically uses the menu title. So we do the same.
	if ((fieldName === 'page_title') && (entry.template_id !== '')) {
		var defaultPageTitle = itemTemplates.getDefaultValue(entry.template_id, 'page_title'),
			defaultMenuTitle = itemTemplates.getDefaultValue(entry.template_id, 'menu_title'),
			customMenuTitle = entry['menu_title'];

		if (
			(customMenuTitle !== null)
			&& (customMenuTitle !== '')
			&& ((defaultPageTitle === '') || (defaultMenuTitle === defaultPageTitle))
		) {
			return customMenuTitle;
		}
	}

	if (typeof defaultValue === 'undefined') {
		defaultValue = null;
	}

	//Known templates take precedence.
	if ((entry.template_id === '') || (typeof itemTemplates.templates[entry.template_id] !== 'undefined')) {
		var templateDefault = itemTemplates.getDefaultValue(entry.template_id, fieldName);
		return (templateDefault !== null) ? templateDefault : defaultValue;
	}

	if (fieldName === 'template_id') {
		return null;
	}

	//Separators can have their own defaults, independent of templates.
	var hasDefault = (typeof entry.defaults !== 'undefined') && (typeof entry.defaults[fieldName] !== 'undefined');
	if (hasDefault){
		return entry.defaults[fieldName];
	}

	return defaultValue;
}

/*
 * Make a menu container sortable
 */
function makeBoxSortable(menuBox){
	//Make the submenu sortable
	menuBox.sortable({
		items: '> .ws_container',
		cursor: 'move',
		dropOnEmpty: true,
		cancel : '.ws_editbox, .ws_edit_link',

		placeholder: 'ws_container ws_sortable_placeholder',
		forcePlaceholderSize: true,

		connectWith: '.ws_submenu',

		stop: function(even, ui) {
			//Fix incorrect item overlap caused by jQuery.sortable applying the initial z-index as an inline style.
			ui.item.css('z-index', '');

			//Fix submenu container height. It should be tall enough to reach the selected parent menu.
			if (ui.item.hasClass('ws_menu') && ui.item.hasClass('ws_active')) {
				AmeEditorApi.updateSubmenuBoxHeight(ui.item);
			}
		},

		over: function(event, ui) {
			//Provide visual feedback if the user drags an unacceptable item over the list.
			const $list = $(this);
			const targetColumn = menuPresenter.getItemColumn($list);
			if (!targetColumn) {
				return;
			}

			$list.closest('.ws_main_container').toggleClass(
				'ws_invalid_item_drop_target',
				!targetColumn.canAcceptItem(ui.item)
			);
		},

		out: function() {
			$(this).closest('.ws_main_container').removeClass('ws_invalid_item_drop_target');
		},

		receive: function(event, ui) {
			//Receive a menu item from another column.
			const $sender = $(ui.sender);

			const $itemNode = ui.item;
			const targetColumn = menuPresenter.getItemColumn($itemNode);
			const sourceColumn = menuPresenter.getItemColumn($sender);

			if (!targetColumn || !sourceColumn)  {
				$sender.sortable('cancel');
				return;
			}
			if (!targetColumn.canAcceptItem($itemNode)) {
				$sender.sortable('cancel');
				return;
			}

			//The way that inter-column drag & drop actually works is that we copy the item
			//to the target column and then delete the original item. This way all the internal
			//data structures are updated correctly.

			//Remember where the item was dropped in the target column.
			const $previousItem = $itemNode.prev('.ws_container');

			//Move the original item back.
			$sender.sortable('cancel');

			//Copy & paste the item to the target column.
			const droppedItemData = readItemState($itemNode);
			targetColumn.pasteItem(droppedItemData, ($previousItem.length > 0) ? $previousItem : -1);

			//Delete the original. Optionally, the user can hold Ctrl to avoid this
			//(i.e. to copy the item instead of moving it).
			if ( !event.ctrlKey ) {
				sourceColumn.destroyItem($itemNode);
			}
		}
	});
}

/**
 * Iterates over all menu items invoking a callback for each item.
 *
 * The callback will be passed two arguments: the menu item and its UI container node (a jQuery object).
 * You can stop iteration by returning false from the callback.
 *
 * @param {Function} callback
 * @param {boolean} [skipSeparators] Defaults to true. Set to false to include separators in the iteration.
 */
AmeEditorApi.forEachMenuItem = function(callback, skipSeparators) {
	if (typeof skipSeparators === 'undefined') {
		skipSeparators = true;
	}

	$('#ws_menu_editor').find('.ws_container').each(function() {
		var containerNode = $(this);
		if ( !(skipSeparators && containerNode.hasClass('ws_menu_separator')) ) {
			return callback(containerNode.data('menu_item'), containerNode);
		}
	});
};

/**
 * Select the first menu item that has the specified URL.
 *
 * @param {number|string} selectorOrLevel
 * @param {string} url
 * @param {null|Boolean} [expandProperties]
 * @returns {JQuery}
 */
AmeEditorApi.selectMenuItemByUrl = function(selectorOrLevel, url, expandProperties) {
	if (typeof expandProperties === 'undefined') {
		expandProperties = null;
	}

	let level;
	if (selectorOrLevel === '#ws_menu_box') {
		level = 1;
	} else if (selectorOrLevel === '#ws_submenu_box') {
		level = 2;
	} else {
		level = selectorOrLevel;
	}

	const column = menuPresenter.getColumnImmediate(level);
	if (!column) {
		return $([]);
	}

	const box = column.getVisibleItemList();

	const containerNode =
		box.find('.ws_container')
		.filter(function() {
			const itemUrl = AmeEditorApi.getItemDisplayUrl($(this).data('menu_item'));
			return (itemUrl === url);
		})
		.first();

	if (containerNode.length > 0) {
		AmeEditorApi.selectItem(containerNode);

		if (expandProperties !== null) {
			const expandLink = containerNode.find('.ws_edit_link').first();
			if (expandLink.hasClass('ws_edit_link_expanded') !== expandProperties) {
				expandLink.trigger('click');
			}
		}
	}
	return containerNode;
};

/***************************************************************************
                       Parsing & encoding menu inputs
 ***************************************************************************/

/**
 * Encode the current menu structure as JSON
 *
 * @return {String} A JSON-encoded string representing the current menu tree loaded in the editor.
 */
function encodeMenuAsJSON(tree){
	if (typeof tree === 'undefined' || !tree) {
		tree = readMenuTreeState();
	}
	tree.format = {
		name: wsEditorData.menuFormatName,
		version: wsEditorData.menuFormatVersion
	};

	//Compress the admin menu.
	tree = compressMenu(tree);

	return JSON.stringify(tree);
}

function readMenuTreeState(){
	var tree = {};
	var menuPosition = 0;
	var itemsByFilename = {};

	//Gather all menus and their items
	$('#ws_menu_box').find('.ws_menu').each(function() {
		var containerNode = this;
		var menu = readItemState(containerNode, menuPosition++);

		//Attach the current menu to the main structure.
		var filename = getFieldValue(menu, 'file');

		//Give unclickable items unique keys.
		if (menu.template_id === wsEditorData.unclickableTemplateId) {
			ws_paste_count++;
			filename = '#' + wsEditorData.unclickableTemplateClass + '-' + ws_paste_count;
		} else if (menu.template_id === wsEditorData.embeddedPageTemplateId) {
			ws_paste_count++;
			filename = '#embedded-page-' + ws_paste_count;
		}

		//Prevent the user from saving top level items with duplicate URLs.
		//WordPress indexes the submenu array by parent URL and AME uses a {url : menu_data} hashtable internally.
		//Duplicate URLs would cause problems for both.
		if (itemsByFilename.hasOwnProperty(filename)) {
			throw {
				code: 'duplicate_top_level_url',
				message: 'Error: Found a duplicate URL! All top level menus must have unique URLs.',
				duplicates: [itemsByFilename[filename], containerNode]
			};
		}

		tree[filename] = menu;
		itemsByFilename[filename] = containerNode;
	});

	// Ensure items that need auto-generated slugs have unique IDs. The IDs only
	// need to be unique within the same menu configuration, not globally.
	let localIdCounter = 0;
	const usedLocalIds = {};
	function ensureUniqueIdIfNeeded(menuItem) {
		// Recurse into children.
		if (menuItem.items) {
			_.forEach(menuItem.items, ensureUniqueIdIfNeeded);
		}

		const needsUniqueId = (menuItem.template_id === wsEditorData.embeddedPageTemplateId)
			|| (menuItem.open_in === 'iframe');
		const currentLocalId = (typeof menuItem.local_id === 'string') ? menuItem.local_id : '';

		// Assign a new ID if the item needs one and doesn't have it, or if the current ID
		// is a duplicate. IDs can get duplicated if the user copies and pastes items.
		if ((needsUniqueId && (currentLocalId === '')) || usedLocalIds.hasOwnProperty(currentLocalId)) {
			menuItem.local_id = randomMenuId(localIdCounter + 'C', 8);
		}

		if (typeof menuItem.local_id === 'string') {
			usedLocalIds[menuItem.local_id] = true;
			localIdCounter++;
		}
	}
	_.forEach(tree, ensureUniqueIdIfNeeded);

	AmeCapabilityManager.pruneGrantedUserCapabilities();

	var result = {
		tree: tree,
		granted_capabilities: AmeCapabilityManager.getGrantedCapabilities(),
		component_visibility: $.extend(true, {}, generalComponentVisibility)
	};

	$(document).trigger('getMenuConfiguration.adminMenuEditor', result);
	return result;
}

/**
 * Losslessly compress the admin menu configuration.
 *
 * This is a JS port of the ameMenu::compress() function defined in /includes/menu.php.
 *
 * @param {Object} adminMenu
 * @returns {Object}
 */
function compressMenu(adminMenu) {
	var common = {
		properties: _.omit(wsEditorData.blankMenuItem, ['defaults']),
		basic_defaults: _.clone(_.get(wsEditorData.blankMenuItem, 'defaults', {})),
		custom_item_defaults: _.clone(itemTemplates.getTemplateById('').defaults)
	};

	adminMenu.format.compressed = true;
	adminMenu.format.common = common;

	function compressItem(item) {
		//These empty arrays can be dropped.
		if ( _.isEmpty(item['grant_access']) ) {
			delete item['grant_access'];
		}
		if ( _.isEmpty(item['items']) ) {
			delete item['items'];
		}

		//Normal and custom menu items have different defaults.
		//Remove defaults that are the same for all items of that type.
		var defaults = _.get(item, 'custom', false) ? common['custom_item_defaults'] : common['basic_defaults'];
		if ( _.has(item, 'defaults') ) {
			_.forEach(defaults, function(value, key) {
				if (_.has(item['defaults'], key) && (item['defaults'][key] === value)) {
					delete item['defaults'][key];
				}
			});
		}

		//Remove properties that match the common values.
		_.forEach(common['properties'], function(value, key) {
			if (_.has(item, key) && (item[key] === value)) {
				delete item[key];
			}
		});

		return item;
	}

	adminMenu.tree = _.mapValues(adminMenu.tree, function(topMenu) {
		topMenu = compressItem(topMenu);
		if (typeof topMenu.items !== 'undefined') {
			topMenu.items = _.map(topMenu.items, compressItem);
		}
		return topMenu;
	});

	return adminMenu;
}

AmeEditorApi.readMenuTreeState = readMenuTreeState;
AmeEditorApi.encodeMenuAsJson = encodeMenuAsJSON;

/**
 * Extract the current menu item settings from its editor widget.
 *
 * @param itemDiv DOM node containing the editor widget, usually with the .ws_item or .ws_menu class.
 * @param {Number} [position] Menu item position among its sibling menu items. Defaults to zero.
 * @return {Object} A menu object in the tree format.
 */
function readItemState(itemDiv, position){
	position = (typeof position === 'undefined') ? 0 : position;

	itemDiv = $(itemDiv);
	var item = $.extend(true, {}, wsEditorData.blankMenuItem, itemDiv.data('menu_item'), readAllFields(itemDiv));

	item.defaults = itemDiv.data('menu_item').defaults;

	//Save the position data
	item.position = position;
	item.defaults.position = position; //The real default value will later overwrite this

	item.separator = itemDiv.hasClass('ws_menu_separator');
	item.custom = menuHasFlag(itemDiv, 'custom');

	//Gather the menu's sub-items, if any
	item.items = [];
	var subMenuId = itemDiv.data('submenu_id');
	if (subMenuId) {
		var itemPosition = 0;
		$('#' + subMenuId).find('.ws_item').each(function () {
			var sub_item = readItemState(this, itemPosition++);
			item.items.push(sub_item);
		});
	}

	return item;
}

/*
 * Extract the values of all menu/item fields present in a container node
 *
 * Inputs:
 *	container - a jQuery collection representing the node to read.
 */
function readAllFields(container){
	if ( !container.hasClass('ws_container') ){
		container = container.closest('.ws_container');
	}

	if ( !container.data('field_editors_created') ){
		return container.data('menu_item');
	}

	var state = {};

	//Iterate over all fields of the item
	container.find('.ws_edit_field').each(function() {
		var field = $(this);

		//Get the name of this field
		var field_name = field.data('field_name');
		//Skip if unnamed
		if (!field_name) {
			return true;
		}

		//Hackety-hack. The "Page" input is for display purposes and contains more than just the ID. Skip it.
		//Eventually we'll need a better way to handle this.
		if (field_name === 'embedded_page_id') {
			return true;
		}
		//Headings contain no useful data.
		if (field.hasClass('ws_field_group_heading')) {
			return true;
		}

		//Find the field (usually an input or select element).
		var input_box = field.find('.ws_field_value');

		//Save null if default used, custom value otherwise
		if (field.hasClass('ws_input_default')){
			state[field_name] = null;
		} else {
			state[field_name] = getInputValue(input_box);
		}
		return true;
	});

    //Permission settings are not stored in the visible access_level field (that's just for show),
    //so do not attempt to read them from there.
    state.access_level = null;

	return state;
}


/***************************************************************************
 Flag manipulation
 ***************************************************************************/

var item_flags = {
	'custom': 'This is a custom menu item',
	'unused': 'This item was added since the last time you saved menu settings.',
	'hidden': 'Cosmetically hidden',
	'custom_actor_permissions': "The selected role has custom permissions for this item.",
	'hidden_from_others': 'Hidden from everyone except you.',
	'uncertain_meta_cap': 'The plugin cannot detect if this item is visible by default.'
};

function setMenuFlag(item, flag, state, title) {
	title = title || item_flags[flag];
	item = $(item);

	var item_class = 'ws_' + flag;
	var img_class = 'ws_' + flag + '_flag';

	item.toggleClass(item_class, state);
	if (state) {
		//Add the flag image.
		var flag_container = item.find('.ws_flag_container');
		var image = flag_container.find('.' + img_class);
		if (image.length === 0) {
			image = $('<div></div>').addClass('ws_flag').addClass(img_class);
			flag_container.append(image);
		}
		image.attr('title', title);
	} else {
		//Remove the flag image.
		item.find('.' + img_class).remove();
	}
}

function menuHasFlag(item, flag){
	return $(item).hasClass('ws_'+flag);
}

//The "hidden" flag is special. There's both a global version and one that's actor-specific.

/**
 * Check if a menu item is hidden from an actor.
 * This function only checks the "hidden" and "hidden_from_actor" flags, not permissions.
 *
 * @param {Object} menuItem
 * @param {string|null} actor
 * @returns {boolean}
 */
function itemHasHiddenFlag(menuItem, actor) {
	var isHidden = false,
		userActors,
		userPrefix = 'user:',
		userLogin;

	//(Only) A globally hidden item is hidden from everyone.
	if ((actor === null) || menuItem.hidden) {
		return menuItem.hidden;
	}

	if (actor.substr(0, userPrefix.length) === userPrefix) {
		//You can set an exception for a specific user. It takes precedence.
		if (menuItem.hidden_from_actor.hasOwnProperty(actor)) {
			isHidden = menuItem.hidden_from_actor[actor];
		} else {
			//Otherwise the item is hidden only if it is hidden from all of the user's roles.
			userLogin = actorSelectorWidget.selectedActor.substr(userPrefix.length);
			userActors = AmeCapabilityManager.getGroupActorsFor(userLogin);
			for (var i = 0; i < userActors.length; i++) {
				if (menuItem.hidden_from_actor.hasOwnProperty(userActors[i]) && menuItem.hidden_from_actor[userActors[i]]) {
					isHidden = true;
				} else {
					isHidden = false;
					break;
				}
			}
		}
	} else {
		//Roles and the super admin are straightforward.
		isHidden = menuItem.hidden_from_actor.hasOwnProperty(actor) && menuItem.hidden_from_actor[actor];
	}

	return isHidden;
}

/**
 * Toggle menu visibility without changing its permissions.
 *
 * Applies to the selected actor, or all actors if no actor is selected.
 *
 * @param {JQuery} selection A menu container node.
 * @param {boolean} [isHidden] Optional. True = hide the menu, false = show the menu.
 */
function toggleItemHiddenFlag(selection, isHidden) {
	var menuItem = selection.data('menu_item');

	//By default, invert the current state.
	if (typeof isHidden === 'undefined') {
		isHidden = !itemHasHiddenFlag(menuItem, actorSelectorWidget.selectedActor);
	}

	//Mark the menu as hidden/visible
	if (actorSelectorWidget.selectedActor === null) {
		//For ALL roles and users.
		menuItem.hidden = isHidden;
		menuItem.hidden_from_actor = {};
	} else {
		//Just for the current role.
		if (isHidden) {
			menuItem.hidden_from_actor[actorSelectorWidget.selectedActor] = true;
		} else {
			if (actorSelectorWidget.selectedActor.indexOf('user:') === 0) {
				//User-specific exception. Lets you can hide a menu from all admins but leave it visible to yourself.
				menuItem.hidden_from_actor[actorSelectorWidget.selectedActor] = false;
			} else {
				delete menuItem.hidden_from_actor[actorSelectorWidget.selectedActor];
			}
		}

		//When the user un-hides a menu that was globally hidden via the "hidden" flag, we must remove
		//that flag but also make sure the menu stays hidden from other roles.
		if (!isHidden && menuItem.hidden) {
			menuItem.hidden = false;
			$.each(wsEditorData.actors, function(otherActor) {
				if (otherActor !== actorSelectorWidget.selectedActor) {
					menuItem.hidden_from_actor[otherActor] = true;
				}
			});
		}
	}
	setMenuFlag(selection, 'hidden', isHidden);

	//Also mark all of it's submenus as hidden/visible
	var submenuId = selection.data('submenu_id');
	if (submenuId) {
		$('#' + submenuId + ' .ws_item').each(function(){
			toggleItemHiddenFlag($(this), isHidden);
		});
	}
}

/***********************************************************
                  Capability manipulation
 ************************************************************/

function actorCanAccessMenu(menuItem, actor) {
	if (!$.isPlainObject(menuItem.grant_access)) {
		menuItem.grant_access = {};
	}

	//By default, any actor that has the required cap has access to the menu.
	//Users can override this on a per-menu basis.
	var requiredCap = getFieldValue(menuItem, 'access_level', '< Error: access_level is missing! >');
	var actorHasAccess;
	if (menuItem.grant_access.hasOwnProperty(actor)) {
		actorHasAccess = menuItem.grant_access[actor];
	} else {
		actorHasAccess = AmeCapabilityManager.hasCap(actor, requiredCap, menuItem.grant_access);
	}
	return actorHasAccess;
}

AmeEditorApi.actorCanAccessMenu = actorCanAccessMenu;

function actorHasCustomPermissions(menuItem, actor) {
	if (menuItem.grant_access && menuItem.grant_access.hasOwnProperty && menuItem.grant_access.hasOwnProperty(actor)) {
		return (menuItem.grant_access[actor] !== null);
	}
	return false;
}

/**
 * @param containerNode
 * @param {string|Object.<string, boolean>} actor
 * @param {boolean} [allowAccess]
 */
function setActorAccess(containerNode, actor, allowAccess) {
	var menuItem = containerNode.data('menu_item');

	//grant_access comes from PHP, which JSON-encodes empty assoc. arrays as arrays.
	//However, we want it to be a dictionary.
	if (!$.isPlainObject(menuItem.grant_access)) {
		menuItem.grant_access = {};
	}

	if (typeof actor === 'string') {
		menuItem.grant_access[actor] = Boolean(allowAccess);
	} else {
		_.assign(menuItem.grant_access, actor);
	}
}

/**
 * Make a menu item inaccessible to everyone except a particular actor.
 *
 * Will not change access settings for actors that are more specific than the input actor.
 * For example, if the input actor is a "role:", this function will only disable other roles,
 * but will leave "user:" actors untouched.
 *
 * @param {Object} menuItem
 * @param {String} actor
 * @return {Object}
 */
function denyAccessForAllExcept(menuItem, actor) {
	//grant_access comes from PHP, which JSON-encodes empty assoc. arrays as arrays.
	//However, we want it to be a dictionary.
	if (!$.isPlainObject(menuItem.grant_access)) {
		menuItem.grant_access = {};
	}

	$.each(wsEditorData.actors, function(otherActor) {
		//If the input actor is more or equally specific...
		if ((actor === null) || (AmeActorManager.compareActorSpecificity(actor, otherActor) >= 0)) {
			menuItem.grant_access[otherActor] = false;
		}
	});

	if (actor !== null) {
		menuItem.grant_access[actor] = true;
	}
	return menuItem;
}

/***************************************************************************
 Event handlers
 ***************************************************************************/

//Cut & paste stuff
var menu_in_clipboard = null;
var ws_paste_count = 0;

//Color preset stuff.
var colorPresets = {},
	wasPresetDropdownPopulated = false;

//General admin menu visibility.
var generalComponentVisibility = {};

//Combined DOM-ready event handler.
var isDomReadyDone = false;

function ameOnDomReady() {
	if (isDomReadyDone) {
		return;
	}
	isDomReadyDone = true;

	//Some editor elements are only available in the Pro version.
	if (wsEditorData.wsMenuEditorPro) {
		knownMenuFields.open_in.visible = true;
		knownMenuFields.access_level.visible = true;
		knownMenuFields.page_heading.visible = true;
		knownMenuFields.colors.visible = true;
		knownMenuFields.appearance_heading.visible = true;
		knownMenuFields.appearance_heading.onlyForTopMenus = false;
		knownMenuFields.extra_capability.visible = false; //Superseded by the "access_level" field.

		//The Pro version supports submenu icons, but they can be disabled by the user.
		knownMenuFields.icon_url.onlyForTopMenus = (wsEditorData.submenuIconsEnabled === 'never');

		$('.ws_hide_if_pro').hide();
	}

	//Let other plugins filter knownMenuFields and menu fields by type.
	$(document).trigger('filterMenuFields.adminMenuEditor', [knownMenuFields, baseField]);
	$(document).trigger('filterVisibleMenuFields.adminMenuEditor', [visibleMenuFieldsByType]);

	//Make the top menu box sortable (we only need to do this once)
    var mainMenuBox = $('#ws_menu_box');
    makeBoxSortable(mainMenuBox);

	/***************************************************************************
	                  Event handlers for editor widgets
	 ***************************************************************************/
	const menuEditorNode = $('#ws_menu_editor');

	menuPresenter = new AmeMenuPresenter(menuEditorNode, wsEditorData.deepNestingEnabled);

	/**
	 * Select a menu item and show its submenu.
	 *
	 * @param {JQuery|HTMLElement} container Menu container node.
	 */
	function selectItem(container) {
		menuPresenter.selectItem(container);
	}
	AmeEditorApi.selectItem = selectItem;

	//Select the clicked menu item and show its submenu
	menuEditorNode.on('click', '.ws_container', (function () {
		selectItem($(this));
    }));

	function updateSubmenuBoxHeight(selectedMenu) {
		//TODO: Eliminate this duplication. Maybe we could just call the corresponding column method.
		const myColumn = menuPresenter.getColumnImmediate(selectedMenu.closest('.ws_main_container').data('ame-menu-level') || 1);
		const nextColumn = menuPresenter.getColumnImmediate(myColumn.level + 1);
		if (!nextColumn || (nextColumn === myColumn)) {
			return;
		}
		let mainMenuBox = myColumn.menuBox,
			submenuBox = nextColumn.menuBox,
			submenuDropZone = nextColumn.container.find('.ws_dropzone').first();

		//Make the submenu box tall enough to reach the selected item.
		//This prevents the menu tip (if any) from floating in empty space.
		if (selectedMenu.hasClass('ws_menu_separator')) {
			submenuBox.css('min-height', '');
		} else {
			var menuTipHeight = 30,
				empiricalExtraHeight = 4,
				verticalBoxOffset = (submenuBox.offset().top - mainMenuBox.offset().top),
				minSubmenuHeight = (selectedMenu.offset().top - mainMenuBox.offset().top)
					- verticalBoxOffset
					+ menuTipHeight - (submenuDropZone.outerHeight() || 0) + empiricalExtraHeight;
			minSubmenuHeight = Math.max(minSubmenuHeight, 0);
			submenuBox.css('min-height', minSubmenuHeight);
		}
	}

	AmeEditorApi.updateSubmenuBoxHeight = updateSubmenuBoxHeight;

	//Show a notification icon next to the "Permissions" field when the menu item supports extended permissions.
	function updateExtPermissionsIndicator(container, menuItem) {
		var extPermissions = AmeItemAccessEditor.detectExtPermissions(AmeEditorApi.getItemDisplayUrl(menuItem)),
			fieldTitle = container.find('.ws_edit_field-access_level .ws_field_label_text'),
			indicator = fieldTitle.find('.ws_ext_permissions_indicator');

		if (wsEditorData.wsMenuEditorPro && (extPermissions !== null)) {
			if (indicator.length < 1) {
				indicator = $('<div class="dashicons dashicons-info ws_ext_permissions_indicator"></div>');
				fieldTitle.append(" ").append(indicator);
			}
			//Idea: Change the icon based on the kind of permissions available (post type, tags, etc).
			indicator.show().data('ext_permissions', extPermissions);
		} else {
			indicator.hide();
		}
	}

	menuEditorNode.on('adminMenuEditor:fieldChange', function(event, menuItem, fieldName) {
		if ((fieldName === 'template_id') || (fieldName === 'file')) {
			updateExtPermissionsIndicator($(event.target), menuItem);
		}
	});

	//Show/hide a menu's properties
	menuEditorNode.on('click', '.ws_edit_link', (function (event) {
		event.preventDefault();

		var container = $(this).parents('.ws_container').first();
		var box = container.find('.ws_editbox');

		//For performance, the property editors for each menu are only created
		//when the user tries to access access them for the first time.
		if ( !container.data('field_editors_created') ){
			var menuItem = container.data('menu_item');
			buildEditboxFields(box, menuItem, container.hasClass('ws_menu'));
			container.data('field_editors_created', true);
			updateItemEditor(container);
			updateExtPermissionsIndicator(container, menuItem);
		}

		$(this).toggleClass('ws_edit_link_expanded');
		//show/hide the editbox
		if ($(this).hasClass('ws_edit_link_expanded')){
			box.show();
		} else {
			//Make sure changes are applied before the menu is collapsed
			box.find('input').change();
			box.hide();
		}
    }));

    //The "Default" button : Reset to default value when clicked
    menuEditorNode.on('click', '.ws_reset_button', (function () {
        //Find the field div (it holds the field name)
        var field = $(this).parents('.ws_edit_field');
	    var fieldName = field.data('field_name');

		if ( (field.length > 0) && fieldName ) {
			//Extract the default value from the menu item.
            var containerNode = field.closest('.ws_container');
			var menuItem = containerNode.data('menu_item');

			if (fieldName === 'access_level') {
	            //This is a pretty nasty hack.
	            menuItem.grant_access = {};
	            menuItem.extra_capability = null;
				menuItem.restrict_access_to_items = false;
				delete menuItem.had_access_before_hiding;
            }

			if (itemTemplates.hasDefaultValue(menuItem.template_id, fieldName)) {
				menuItem[fieldName] = null;
				updateItemEditor(containerNode);
				updateParentAccessUi(containerNode);
			}
		}
	}));

	//When a field is edited, change it's appearance if it's contents don't match the default value.
    function fieldValueChange(){
	    /* jshint validthis:true */
        var input = $(this);
		var field = input.parents('.ws_edit_field').first();
	    var fieldName = field.data('field_name');

        if ((fieldName === 'access_level') || (fieldName === 'embedded_page_id')) {
            //These fields are read-only and can never be directly edited by the user.
            //Ignore spurious change events.
            return;
        }

	    var containerNode = field.parents('.ws_container').first();
	    var menuItem = containerNode.data('menu_item');

	    var oldValue = menuItem[fieldName];
	    var oldDisplayValue = $.data(this, 'ame_last_display_value');
	    var value = getInputValue(input);
	    var defaultValue = getDefaultValue(menuItem, fieldName, null, containerNode);
        var hasADefaultValue = (defaultValue !== null);

	    //Some fields/templates have no default values.
        field.toggleClass('ws_has_no_default', !hasADefaultValue);
        if (!hasADefaultValue) {
            field.removeClass('ws_input_default');
        }

        // noinspection EqualityComparisonWithCoercionJS It's been like this so long that I'm afraid to change it.
	    if (field.hasClass('ws_input_default') && (value == defaultValue)) {
            value = null; //null = use default.
        }

	    //Ignore changes where the new value is the same as the old one.
	    if ((value === oldValue) || (value === oldDisplayValue)) {
		    return;
	    }

	    //Update the item.
	    if (knownMenuFields[fieldName].write !== null) {
			// phpcs:ignore WordPressVIPMinimum.JS.HTMLExecutingFunctions.write -- Misdetected. Not document.write().
		    knownMenuFields[fieldName].write(menuItem, value, input, containerNode);
	    } else {
		    menuItem[fieldName] = value;
	    }

	    updateItemEditor(containerNode);
	    updateParentAccessUi(containerNode);

	    containerNode.trigger('adminMenuEditor:fieldChange', [menuItem, fieldName]);
    }
	menuEditorNode.on('click change', '.ws_field_value', fieldValueChange);

	//Show/hide advanced fields
	menuEditorNode.on('click', '.ws_toggle_advanced_fields', function(){
		var self = $(this);
		var advancedFields = self.parents('.ws_container').first().find('.ws_advanced');

		if ( advancedFields.is(':visible') ){
			advancedFields.hide();
			self.text(wsEditorData.captionShowAdvanced);
		} else {
			advancedFields.show();
			self.text(wsEditorData.captionHideAdvanced);
		}

		return false;
	});

	//Allow/forbid items in actor-specific views
	menuEditorNode.on('click', 'input.ws_actor_access_checkbox', function() {
		if (actorSelectorWidget.selectedActor === null) {
			return;
		}

		var checked = $(this).is(':checked');
		var containerNode = $(this).closest('.ws_container');

		var menu = containerNode.data('menu_item');
		//Ask for confirmation if the user tries to hide Dashboard -> Home.
		if ( !checked && ((menu.template_id === 'index.php>index.php') || (menu.template_id === '>index.php')) ) {
			updateItemEditor(containerNode); //Resets the checkbox back to the old value.
			confirmDashboardHiding(function(ok) {
				if (ok) {
					setActorAccessForTreeAndUpdateUi(containerNode, actorSelectorWidget.selectedActor, checked);
				}
			});
		} else {
			setActorAccessForTreeAndUpdateUi(containerNode, actorSelectorWidget.selectedActor, checked);
		}
	});

	/**
	 * This confusingly named function sets actor access for the specified menu item
	 * and all of its children (if any). It also updates the UI with the new settings.
	 *
	 * (And it violates SRP in a particularly egregious manner.)
	 *
	 * @param containerNode
	 * @param {String|Object.<String, Boolean>} actor
	 * @param {Boolean} [allowAccess]
	 * @param {Boolean} [skipParentUiRefresh] Whether to skip updating the parent access UI. Defaults to false.
	 */
	function setActorAccessForTreeAndUpdateUi(containerNode, actor, allowAccess, skipParentUiRefresh) {
		setActorAccess(containerNode, actor, allowAccess);

		//Apply the same permissions to sub-menus.
		const subMenuId = containerNode.data('submenu_id');
		if (subMenuId) {
			$('.ws_item', '#' + subMenuId).each(function() {
				const node = $(this);
				setActorAccessForTreeAndUpdateUi(node, actor, allowAccess, true);
			});
		}

		updateItemEditor(containerNode);
		updateActorAccessUi(containerNode);

		if ( !skipParentUiRefresh ) {
			updateParentAccessUi(containerNode);
		}
	}

	/**
	 * Insert a new top level menu after the selected menu or at the end of the list.
	 *
	 * @param {Object} menu
	 */
	function insertMenu(menu) {
		const selection = (typeof getSelectedMenu !== 'undefined') ? getSelectedMenu() : null;
		if (selection && (selection.length > 0) ) {
			outputTopMenu(menu, selection);
		} else {
			outputTopMenu(menu);
		}
	}
	AmeEditorApi.insertMenu = insertMenu;

	/**
	 * Confirm with the user that they want to hide "Dashboard -> Home".
	 *
	 * This particular menu is important because hiding it can cause an "insufficient permissions" error
	 * to be displayed right when someone logs in, making it look like login failed.
	 */
	var permissionConfirmationDialog = $('#ws-ame-dashboard-hide-confirmation').dialog({
		autoOpen: false,
		modal: true,
		closeText: ' ',
		width: 380,
		title: 'Warning'
	});
	var currentConfirmationCallback = function(ok) {};

	/**
	 * Confirm hiding "Dashboard -> Home".
	 *
	 * @param callback Called when the user selects an option. True = confirmed.
	 */
	function confirmDashboardHiding(callback) {
		//The user can disable the confirmation dialog.
		if (!wsEditorData.dashboardHidingConfirmationEnabled) {
			callback(true);
			return;
		}

		currentConfirmationCallback = callback;
		permissionConfirmationDialog.dialog('open');
	}

	$('#ws_confirm_menu_hiding, #ws_cancel_menu_hiding').on('click', function() {
		var confirmed = $(this).is('#ws_confirm_menu_hiding');
		var dontShowAgain = permissionConfirmationDialog.find('.ws_dont_show_again input[type="checkbox"]').is(':checked');

		currentConfirmationCallback(confirmed);
		permissionConfirmationDialog.dialog('close');

		if (dontShowAgain) {
			wsEditorData.dashboardHidingConfirmationEnabled = false;
			//Run an AJAX request to disable the dialog for this user.
			$.post(
				wsEditorData.adminAjaxUrl,
				{
					'action' : 'ws_ame_disable_dashboard_hiding_confirmation',
					'_ajax_nonce' : wsEditorData.disableDashboardConfirmationNonce
				}
			);
		}
	});


	/*************************************************************************
	                  Access editor dialog
	 *************************************************************************/

	AmeItemAccessEditor.setup({
		api: AmeEditorApi,
		actorSelector: actorSelectorWidget,
		postTypes: wsEditorData.postTypes,
		taxonomies: wsEditorData.taxonomies,
		lodash: _,
		isPro: wsEditorData.wsMenuEditorPro,

		save: function(menuItem, containerNode, settings) {
			//Save the new settings.
			menuItem.extra_capability         = settings.extraCapability;
			menuItem.grant_access             = settings.grantAccess;
			menuItem.restrict_access_to_items = settings.restrictAccessToItems;

			//Save granted capabilities.
			var newlyDisabledCaps = {};
			_.forEach(settings.grantedCapabilities, function(capabilities, actor) {
				_.forEach(capabilities, function(grant, capability) {
					if (!_.isArray(grant)) {
						grant = [grant, null, null];
					}

					AmeCapabilityManager.setCap(actor, capability, grant[0], grant[1], grant[2]);

					if (!grant[0]) {
						if (!newlyDisabledCaps.hasOwnProperty(capability)) {
							newlyDisabledCaps[capability] = [];
						}
						newlyDisabledCaps[capability].push(actor);
					}
				});
			});

			AmeEditorApi.forEachMenuItem(function(menuItem, containerNode) {
				//When the user unchecks a capability, uncheck ALL menu items associated with that capability.
				//Anything less won't actually get rid of the capability as enabled menus auto-grant req. caps.
				var requiredCap = getFieldValue(menuItem, 'access_level');
				if (newlyDisabledCaps.hasOwnProperty(requiredCap)) {
					//It's enough to remove custom "allow" settings. The rest happens automatically - items that
					//have no custom per-role settings use capability checks.
					_.forEach(newlyDisabledCaps[requiredCap], function(actor) {
						if (_.get(menuItem.grant_access, actor) === true) {
							delete menuItem.grant_access[actor];
						}
					});
				}

				//Due to changed caps and cascading submenu overrides, changes to one item's permissions
				//can affect other items. Lets just update all items.
				updateActorAccessUi(containerNode);
			});

			//Refresh the UI.
			updateItemEditor(containerNode);
		}
	});

	menuEditorNode.on('click', '.ws_launch_access_editor', function() {
		var containerNode = $(this).parents('.ws_container').first();
		var menuItem = containerNode.data('menu_item');

		AmeItemAccessEditor.open({
			menuItem: menuItem,
			containerNode: containerNode,
			selectedActor: actorSelectorWidget.selectedActor,
			itemHasSubmenus: (!!(containerNode.data('submenu_id')) &&
				$('#' + containerNode.data('submenu_id')).find('.ws_item').length > 0)
		});
	});

	/***************************************************************************
		              General dialog handlers
	 ***************************************************************************/

	$(document).on('click', '.ws_close_dialog', function() {
		$(this).parents('.ui-dialog-content').dialog('close');
	});


	/***************************************************************************
	              Drop-down list for combo-box fields
	 ***************************************************************************/

	var capSelectorDropdown = $('#ws_cap_selector');
	var currentDropdownOwner = null; //The input element that the dropdown is currently associated with.
	var currentDropdownOwnerMenu = null; //The menu item that the above input belongs to.

	var isDropdownBeingHidden = false, isSuggestionClick = false;

	const $extraCapInAccessEditor = $('#ws_extra_capability');

	//Show/hide the capability drop-down list when the trigger button is clicked
	$('#ws_trigger_capability_dropdown').on('mousedown click', onDropdownTriggerClicked);
	menuEditorNode.on('mousedown click', '.ws_cap_selector_trigger', onDropdownTriggerClicked);

	function onDropdownTriggerClicked(event){
		/* jshint validthis:true */
		var inputBox;
		var button = $(this);

		var isInAccessEditor = false;
		isSuggestionClick = false;

		//Find the input associated with the button that was clicked.
		if ( button.attr('id') === 'ws_trigger_capability_dropdown' ) {
			inputBox = $extraCapInAccessEditor;
			isInAccessEditor = true;
		} else {
			inputBox = button.closest('.ws_edit_field').find('.ws_field_value').first();
		}

		//If the user clicks the same button again while the dropdown is already visible,
		//ignore the click. The dropdown will be hidden by its "blur" handler.
		if (event.type === 'mousedown') {
			if ( capSelectorDropdown.is(':visible') && inputBox.is(currentDropdownOwner) ) {
				isDropdownBeingHidden = true;
			}
			return;
		} else if (isDropdownBeingHidden) {
			isDropdownBeingHidden = false; //Ignore the click event.
			return;
		}

		//A jQuery UI dialog widget will prevent focus from leaving the dialog. So if we want
		//the dropdown to be properly focused when displaying it in a dialog, we must make it
		//a child of the dialog's DOM node (and vice versa when it's not in a dialog).
		var parentContainer = $(this).closest('.ui-dialog, #ws_menu_editor');
		if ((parentContainer.length > 0) && (capSelectorDropdown.closest(parentContainer).length === 0)) {
			var oldHeight = capSelectorDropdown.height(); //Height seems to reset when moving to a new parent.
			capSelectorDropdown.detach().appendTo(parentContainer).height(oldHeight);
		}

		//Pre-select the current capability (will clear selection if there's no match).
		capSelectorDropdown.val(inputBox.val()).show();

		//Move the drop-down near the input box.
		var inputPos = inputBox.offset();
		capSelectorDropdown
			.css({
				position: 'absolute',
				zIndex: 1010 //Must be higher than the permissions dialog overlay.
			})
			.offset({
				left: inputPos.left,
				top : inputPos.top + inputBox.outerHeight()
			}).
			width(inputBox.outerWidth());

		currentDropdownOwner = inputBox;

		currentDropdownOwnerMenu = null;
		if (isInAccessEditor) {
			currentDropdownOwnerMenu = AmeItemAccessEditor.getCurrentMenuItem();
		} else {
			currentDropdownOwnerMenu = currentDropdownOwner.closest('.ws_container').data('menu_item');
		}

		capSelectorDropdown.focus();

		capSuggestionFeature.show();
	}

	//Also show it when the user presses the down arrow in the input field (doesn't work in Opera).
	$extraCapInAccessEditor.bind('keyup', function(event){
		if ( event.which === 40 ){
			$('#ws_trigger_capability_dropdown').trigger('click');
		}
	});

	function hideCapSelector() {
		capSelectorDropdown.hide();
		capSuggestionFeature.hide();
		isSuggestionClick = false;
	}

	//Event handlers for the drop-down lists themselves
	var dropdownNodes = $('.ws_dropdown');

	// Hide capability drop-down when it loses focus.
	dropdownNodes.on('blur', function(){
		if (!isSuggestionClick) {
			hideCapSelector();
		}
	});

	dropdownNodes.on('keydown', function(event){

		//Hide it when the user presses Esc
		if ( event.which === 27 ){
			hideCapSelector();
			if (currentDropdownOwner) {
				currentDropdownOwner.focus();
			}

		//Select an item & hide the list when the user presses Enter or Tab
		} else if ( (event.which === 13) || (event.which === 9) ){
			hideCapSelector();

			if (currentDropdownOwner) {
				if ( capSelectorDropdown.val() ){
					currentDropdownOwner.val(capSelectorDropdown.val()).change();
				}
				currentDropdownOwner.focus();
			}

			event.preventDefault();
		}
	});

	//Eat Tab keys to prevent focus theft. Required to make the "select item on Tab" thing work.
	dropdownNodes.on('keyup', function(event){
		if ( event.which === 9 ){
			event.preventDefault();
		}
	});


	//Update the input & hide the list when an option is clicked
	dropdownNodes.on('click', function(){
		if (capSelectorDropdown.val()){
			hideCapSelector();
			if (currentDropdownOwner) {
				currentDropdownOwner.val(capSelectorDropdown.val()).change().focus();
			}
		}
	});

	//Highlight an option when the user mouses over it (doesn't work in IE)
	dropdownNodes.on('mousemove', function(event){
		if ( !event.target ){
			return;
		}

		var option = event.target;
		if ( (typeof option.selected !== 'undefined') && !option.selected && option.value ){
			option.selected = true;

			//Preview which roles have this capability and the required cap.
			capSuggestionFeature.previewAccessForItem(currentDropdownOwnerMenu, option.value);
		}
	});

	/************************************************************************
	 *                     Capability suggestions
	 *************************************************************************/

	var capSuggestionFeature = (function() {
		//This feature is not used in the Pro version because it has a different permission UI.
		if (wsEditorData.wsMenuEditorPro) {
			return {
				previewAccessForItem: function () {},
				show: function () {},
				hide: function () {}
			}
		}

		var capabilitySuggestions = $('#ws_capability_suggestions'),
			suggestionBody = capabilitySuggestions.find('table tbody').first().empty(),
			suggestedCapabilities = AmeActors.getSuggestedCapabilities();

		for (var i = 0; i < suggestedCapabilities.length; i++) {
			var role = suggestedCapabilities[i].role, capability = suggestedCapabilities[i].capability;
			$('<tr>')
				.data('role', role)
				.data('capability', capability)
				.append(
					$('<th>', {text: role.displayName, scope: 'row'}).addClass('ws_ame_role_name')
				)
				.append(
					$('<td>', {text: capability}).addClass('ws_ame_suggested_capability')
				)
				.appendTo(suggestionBody);
		}

		var currentPreviewedCaps = null;

		/**
		 * Update the access preview.
		 * @param {string|string[]|null} capabilities
		 */
		function previewAccess(capabilities) {
			if (typeof capabilities === 'string') {
				capabilities = [capabilities];
			}

			if (_.isEqual(capabilities, currentPreviewedCaps)) {
				return;
			}
			currentPreviewedCaps = capabilities;
			capabilitySuggestions.find('#ws_previewed_caps').text(currentPreviewedCaps.join(' + '));

			//Short-circuit the no-caps case.
			if (capabilities === null || capabilities.length === 0) {
				suggestionBody.find('tr').removeClass('ws_preview_has_access');
				return;
			}

			suggestionBody.find('tr').each(function() {
				var $row = $(this),
					role = $row.data('role');

				var hasCaps = true;
				for (var i = 0; i < capabilities.length; i++) {
					hasCaps = hasCaps && AmeActors.hasCap(role.id, capabilities[i]);
				}
				$row.toggleClass('ws_preview_has_access', hasCaps);
			});
		}

		function previewAccessForItem(menuItem, selectedExtraCap) {
			var requiredCap = '', extraCap = '';

			if (menuItem) {
				requiredCap = getFieldValue(menuItem, 'access_level', '');
				extraCap = getFieldValue(menuItem, 'extra_capability', '');
			}
			if (typeof selectedExtraCap !== 'undefined') {
				extraCap = selectedExtraCap;
			}

			var caps = [];
			if (menuItem && (menuItem.template_id !== '') || (extraCap === '')) {
				caps.push(requiredCap);
			}
			if (extraCap !== '') {
				caps.push(extraCap);
			}

			previewAccess(caps);
		}

		suggestionBody.on('mouseenter', 'td.ws_ame_suggested_capability', function() {
			var row = $(this).closest('tr');
			previewAccessForItem(currentDropdownOwnerMenu, row.data('capability'));
		});

		capSelectorDropdown.on('keydown keyup', function() {
			previewAccessForItem(currentDropdownOwnerMenu, capSelectorDropdown.val());
		});

		suggestionBody.on('mousedown', 'td.ws_ame_suggested_capability', function() {
			//Don't immediately hide the list when the user tries to click a suggestion.
			//It would prevent the click from registering.
			isSuggestionClick = true;
		});

		suggestionBody.on('click', 'td.ws_ame_suggested_capability', function() {
			var capability = $(this).closest('tr').data('capability');

			//Change the input to the selected capability.
			if (currentDropdownOwner) {
				currentDropdownOwner.val(capability).change();
			}

			hideCapSelector();
		});

		//Workaround for pressing LMB on a suggestion, then moving the mouse outside the suggestion box and releasing the button.
		$(document).on('click', function(event) {
			if (
				isSuggestionClick
				&& capabilitySuggestions.is(':visible')
				&& ( $(event.target).closest(capabilitySuggestions).length < 1 )
			) {
				hideCapSelector();
			}
		});

		return {
			previewAccessForItem: previewAccessForItem,
			show: function() {
				//Position the capability suggestion table next to the selector and match heights.
				capabilitySuggestions
					.css({
						position: 'absolute',
						zIndex: 1009
					})
					.show()
					.position({
						my: 'left top',
						at: 'right top',
						of: capSelectorDropdown,
						collision: 'none'
					});

				var selectorHeight = capSelectorDropdown.height(),
					suggestionsHeight = capabilitySuggestions.height(),
					desiredHeight = Math.max(selectorHeight, suggestionsHeight);
				if (selectorHeight < desiredHeight) {
					capSelectorDropdown.height(desiredHeight);
				}
				if (suggestionsHeight < desiredHeight) {
					capabilitySuggestions.height(desiredHeight);
				}

				if (currentDropdownOwnerMenu) {
					previewAccessForItem(currentDropdownOwnerMenu);
				}
			},
			hide: function() {
				capabilitySuggestions.hide();
			}
		};
	})();


	/*************************************************************************
	                           Icon selector
	 *************************************************************************/
	var iconSelector = $('#ws_icon_selector');
	var currentIconButton = null; //Keep track of the last clicked icon button.

	var iconSelectorTabs = iconSelector.find('#ws_icon_source_tabs');
	iconSelectorTabs.tabs();

	//When the user clicks one of the available icons, update the menu item.
	iconSelector.on('click', '.ws_icon_option', function() {
		var selectedIcon = $(this).addClass('ws_selected_icon');
		iconSelector.hide();

		//Assign the selected icon to the menu.
		if (currentIconButton) {
			var container = currentIconButton.closest('.ws_container');
			var item = container.data('menu_item');

			//Remove the existing icon class, if any.
			var cssClass = getFieldValue(item, 'css_class', '');
			cssClass = jsTrim( cssClass.replace(/\b(ame-)?menu-icon-[^\s]+\b/, '') );

			if (selectedIcon.data('icon-class')) {
				//Add the new class.
				cssClass = selectedIcon.data('icon-class') + ' ' + cssClass;
				//Can't have both a class and an image or we'll get two overlapping icons.
				item.icon_url = '';
			} else if (selectedIcon.data('icon-url')) {
				item.icon_url = selectedIcon.data('icon-url');
			}
			item.css_class = cssClass;

			updateItemEditor(container);
		}

		currentIconButton = null;
	});

	//Show/hide the icon selector when the user clicks the icon button.
	menuEditorNode.on('click', '.ws_select_icon', function() {
		var button = $(this);
		//Clicking the same button a second time hides the icon list.
		if ( currentIconButton && button.is(currentIconButton) ) {
			iconSelector.hide();
			//noinspection JSUnusedAssignment
			currentIconButton = null;
			return;
		}

		currentIconButton = button;

		var containerNode = currentIconButton.closest('.ws_container');
		var menuItem = containerNode.data('menu_item');
		var cssClass = getFieldValue(menuItem, 'css_class', '');
		var iconUrl = getFieldValue(menuItem, 'icon_url', '', containerNode);

		//Clear the search box and restore icons that were hidden by a previous search.
		const $searchBoxes = iconSelector.find('.ws_icon_search_box');
		$searchBoxes.each(function() {
			const $this = $(this);
			if ($this.val() !== '') {
				$this.val('');
				//Let's call the search handler directly instead of using $.trigger('keyup').
				//The event handler is throttled and might not run until later.
				searchMenuIcons('', $this.closest('.ws_tool_tab'));
			}
		});

		var customImageOption = iconSelector.find('.ws_custom_image_icon').hide();
		iconSelector.data('ame-item-has-custom-image', false);

		//Highlight the currently selected icon.
		iconSelector.find('.ws_selected_icon').removeClass('ws_selected_icon');

		var selectedIcon = null;
		var classMatches = cssClass.match(/\b(ame-)?menu-icon-([^\s]+)\b/);
		//Dashicons and FontAwesome icons are set via the icon URL field, but they are actually CSS-based.
		var iconFontMatches = iconUrl && iconUrl.match('^\s*((?:dashicons|ame-fa)-[a-z0-9\-]+)\s*$');

		if ( iconUrl && iconUrl !== 'none' && iconUrl !== 'div' && !iconFontMatches ) {
			var currentIcon = iconSelector.find('.ws_icon_option img[src="' + iconUrl + '"]').first().closest('.ws_icon_option');
			if ( currentIcon.length > 0 ) {
				selectedIcon = currentIcon.addClass('ws_selected_icon').show();
			} else {
				//Display and highlight the custom image.
				customImageOption.find('img').prop('src', iconUrl);
				customImageOption.addClass('ws_selected_icon').show().data('icon-url', iconUrl);
				iconSelector.data('ame-item-has-custom-image', true);
				selectedIcon = customImageOption;
			}
		} else if ( classMatches || iconFontMatches ) {
			//Highlight the icon that corresponds to the current CSS class or Dashicon/FontAwesome icon.
			var iconClass = iconFontMatches ? iconFontMatches[1] : ((classMatches[1] ? classMatches[1] : '') + 'icon-' + classMatches[2]);
			selectedIcon = iconSelector.find('.' + iconClass).closest('.ws_icon_option').addClass('ws_selected_icon');
		}

		//Activate the tab that contains the icon.
		var activeTabId = ((selectedIcon !== null)
				? selectedIcon.closest('.ws_tool_tab').prop('id')
				: 'ws_core_icons_tab'),
			activeTabItem = iconSelectorTabs.find('a[href="#' + activeTabId + '"]').closest('li');
		if (activeTabItem.length > 0) {
			iconSelectorTabs.tabs('option', 'active', activeTabItem.index());
		}

		//Before showing the selector, clear the fixed height that was set when it was last visible.
		iconSelector.css('height', '');

		iconSelector.show();

		//Set a fixed height while the selector is visible. This prevents the selector's
		//height from changing when the user filters the icon list.
		const initialHeight = iconSelector.height();
		iconSelector.css('height', initialHeight);

		iconSelector.position({ //Requires jQuery UI.
			my: 'left top',
			at: 'left bottom',
			of: button
		});
	});

	//Alternatively, use the WordPress media uploader to select a custom icon.
	//This code is based on the header selection script in /wp-admin/js/custom-header.js.
	var mediaFrame = null;
	$('#ws_choose_icon_from_media').on('click', function(event) {
		event.preventDefault();

		//This option is not usable on the demo site since the filesystem is usually read-only.
		if (wsEditorData.isDemoMode) {
			alert('Sorry, image upload is disabled in demo mode!');
			return;
		}

        //If the media frame already exists, reopen it.
        if ( mediaFrame !== null ) {
            mediaFrame.open();
            return;
        }

        //Create a custom media frame.
        mediaFrame = wp.media.frames.customAdminMenuIcon = wp.media({
            //Set the title of the modal.
            title: 'Choose a Custom Icon (20x20)',

            //Tell it to show only images.
            library: {
                type: 'image'
            },

            //Customize the submit button.
            button: {
                text: 'Set as icon', //Button text.
                close: true //Clicking the button closes the frame.
            }
        });

        //When an image is selected, set it as the menu icon.
        mediaFrame.on( 'select', function() {
            //Grab the selected attachment.
            var attachment = mediaFrame.state().get('selection').first();
            //TODO: Warn the user if the image exceeds 20x20 pixels.

	        //Set the menu icon to the attachment URL.
            if (currentIconButton) {
                var container = currentIconButton.closest('.ws_container');
                var item = container.data('menu_item');

                //Remove the existing icon class, if any.
                var cssClass = getFieldValue(item, 'css_class', '');
	            item.css_class = jsTrim( cssClass.replace(/\b(ame-)?menu-icon-[^\s]+\b/, '') );

	            //Set the new icon URL.
	            item.icon_url = attachment.attributes.url;

                updateItemEditor(container);
            }

            currentIconButton = null;
        });

		//If the user closes the frame by via Esc or the "X" button, clear up state.
		mediaFrame.on('escape', function(){
			currentIconButton = null;
		});

        mediaFrame.open();
		iconSelector.hide();
	});

	//Hide the icon selector if the user clicks outside of it.
	//Exception: Clicks on "Select icon" buttons are handled above.
	$(document).on('mouseup', function(event) {
		if ( !iconSelector.is(':visible') ) {
			return;
		}

		if (
			!iconSelector.is(event.target)
			&& iconSelector.has(event.target).length === 0
			&& $(event.target).closest('.ws_select_icon').length === 0
		) {
			iconSelector.hide();
			currentIconButton = null;
		}
	});

	//Provide search-as-you-type functionality for the icon selector.
	function searchMenuIcons(query, $currentTab) {
		let $searchableItems = $currentTab.find('.ws_icon_option');
		//If the current menu item doesn't have a custom image, exclude the custom image
		//option from the search results.
		if (!iconSelector.data('ame-item-has-custom-image')) {
			$searchableItems = $searchableItems.not('.ws_custom_image_icon');
		}

		let foundAnything = false;

		$searchableItems.each(function() {
			const $icon = $(this);
			const name = $icon.prop('title').toLowerCase();

			if (name.includes(query)) {
				$icon.show();
				foundAnything = true;
			} else {
				$icon.hide();
			}
		});

		$currentTab.find('.ws_no_matching_icons').toggle(!foundAnything);
	}

	iconSelectorTabs.find('.ws_icon_search_box').on('keyup', _.throttle(
		function() {
			const $inputField = $(this);
			const $tab = $inputField.closest('.ws_tool_tab');

			searchMenuIcons($inputField.val().toLowerCase().trim(), $tab);
		},
		250
	));

	/*************************************************************************
	                        Embedded page selector
	 *************************************************************************/

	var pageSelector = $('#ws_embedded_page_selector'),
		pageListBox = pageSelector.find('#ws_current_site_pages'),
		currentPageSelectorButton = null, //The last page dropdown button that was clicked.
		isPageListPopulated = false,
		isPageRequestInProgress = false;

	pageSelector.tabs({
		heightStyle: 'auto',
		hide: false,
		show: false
	});
	//Hack. The selector needs to be hidden by default, but it can't start out as "display: none" because that makes
	//jQuery miscalculate tab heights. So we put it in a hidden container, then hide it on load and move it elsewhere.
	pageSelector.hide().appendTo(menuEditorNode);

	/**
	 * Update the page selector with the current menu item's settings.
	 */
	function updatePageSelector() {
		var menuItem, selectedPageId = 0, selectedBlogId = 1;
		if ( currentPageSelectorButton ) {
			menuItem = currentPageSelectorButton.closest('.ws_container').data('menu_item');
			selectedPageId = parseInt(getFieldValue(menuItem, 'embedded_page_id', 0), 10);
			selectedBlogId = parseInt(getFieldValue(menuItem, 'embedded_page_blog_id', 1), 10);
		}

		if (selectedPageId === 0) {
			pageListBox.val(null);
		} else {
			var optionValue = selectedBlogId + '_' + selectedPageId;
			pageListBox.val(optionValue);
			if ( pageListBox.val() !== optionValue ) {
				pageListBox.val('custom');
			}
		}

		pageSelector.find('#ws_embedded_page_id').val(selectedPageId);
		pageSelector.find('#ws_embedded_page_blog_id').val(selectedBlogId);
	}

	menuEditorNode.on('click', '.ws_embedded_page_selector_trigger', function(event) {
		var thisButton = $(this),
			thisInput = thisButton.closest('.ws_edit_field').find('input.ws_field_value:first');

		//Clicking the same button a second time hides the page selector.
		if (thisButton.is(currentPageSelectorButton) && pageSelector.is(':visible')) {
			pageSelector.hide();
			//noinspection JSUnusedAssignment
			currentPageSelectorButton = null;
			return;
		}

		currentPageSelectorButton = thisButton;
		pageSelector.show();
		pageSelector.position({
			my: 'left top',
			at: 'left bottom',
			of: thisInput
		});

		event.stopPropagation();

		if (!isPageListPopulated && !isPageRequestInProgress) {
			isPageRequestInProgress = true;

			var pageList = pageSelector.find('#ws_current_site_pages');
			pageList.prop('readonly', true);

			$.getJSON(
				wsEditorData.adminAjaxUrl,
				{
					'action' : 'ws_ame_get_pages',
					'_ajax_nonce' : wsEditorData.getPagesNonce
				},
				function(data){
					isPageRequestInProgress = false;
					pageList.prop('readonly', false);

					if (typeof data.error !== 'undefined'){
						alert(data.error);
						return;
					} else if ((typeof data !== 'object') || (typeof data.length === 'undefined')) {
						alert('Error: Could not retrieve a list of pages. Unexpected response from the server.');
						return;
					}

					//An alphabetised list is easier to scan visually.
					var pages = data.sort(function(a, b) {
						return a.post_title.localeCompare(b.post_title);
					});

					//Populate the select box.
					pageList.empty();
					$.each(pages, function(index, page) {
						pageList.append($('<option>', {
							val: page.blog_id + '_' + page.post_id,
							text: page.post_title
						}));
					});

					//Add a "custom" option. Select it when the current setting doesn't match any of the listed pages.
					pageList.prepend($('<option>', {
						val: 'custom',
						text: '< Custom >'
					}));

					updatePageSelector();
					isPageListPopulated = true;
				},
				'json'
			);

		}

		updatePageSelector();

		//Open the "Pages" tab by default, or the "Custom" tab if that's what's selected in the list box.
		//The updatePageSelector call above sets the pageListBox value.
		pageSelector.tabs('option', 'active', (pageListBox.val() === 'custom') ? 1 : 0);
	});

	//Hide the page selector if the user clicks outside of it and outside the current button.
	$(document).on('mouseup', function(event) {
		if ( !pageSelector.is(':visible') ) {
			return;
		}

		var target = $(event.target);
		var isOutsideSelector = target.closest(pageSelector).length === 0;
		var isOutsideButton = currentPageSelectorButton && (target.closest(currentPageSelectorButton).length === 0);

		if (isOutsideSelector && isOutsideButton) {
			pageSelector.hide();
			currentPageSelectorButton = null;
		}
	});

	function setEmbeddedPageForCurrentItem(newPageId, newBlogId, title) {
		if ( currentPageSelectorButton ) {
			var containerNode = currentPageSelectorButton.closest('.ws_container'),
				menuItem = containerNode.data('menu_item');

			menuItem.embedded_page_id = newPageId;
			menuItem.embedded_page_blog_id = newBlogId;

			if (typeof title === 'string') {
				//Store the page title for later. It will be displayed in the text box.
				AmePageTitles.add(newPageId, newBlogId, title);
			}

			updateItemEditor(containerNode);
		}
	}

	//When the user chooses a page from the list, update the menu item and hide the dropdown.
	pageListBox.on('change', function() {
		var selection = pageListBox.val();
		if (selection === 'custom') { // jshint ignore:line
			//Do nothing. Presumably, the user will now switch to the "Custom" tab and enter new settings.
			//If they don't do that and just close the dropdown, we keep the previous settings.
		} else if ( currentPageSelectorButton ) {
			//Set the new page and blog IDs. The expected value format is "blogid_postid".
			var parts = selection.split('_'),
				newBlogId = parseInt(parts[0], 10),
				newPageId = parseInt(parts[1], 10);

			pageSelector.hide();
			setEmbeddedPageForCurrentItem(newPageId, newBlogId, pageListBox.children(':selected').text());
		}
	});

	pageSelector.find('#ws_custom_embedded_page_tab form').on('submit', function(event) {
		event.preventDefault();

		var newPageId = parseInt(pageSelector.find('#ws_embedded_page_id').val(), 10),
			newBlogId = parseInt(pageSelector.find('#ws_embedded_page_blog_id').val(), 10);

		if (isNaN(newPageId) || (newPageId < 0)) {
			alert('Error: Invalid post ID');
		} else if (isNaN(newBlogId) || (newBlogId < 0)) {
			alert('Error: Invalid blog ID');
		} else if ( currentPageSelectorButton ) {
			pageSelector.hide();
			setEmbeddedPageForCurrentItem(newPageId, newBlogId);
		}
	});

	/*************************************************************************
	                  Unsaved changes indicator
	 *************************************************************************/

	/**
	 * @param {JQuery} $rootNode
	 * @constructor
	 */
	function AmeUnsavedChangesIndicator($rootNode) {
		this.rootNode = $rootNode;
		this.reportedUnsavedChanges = 0;

		$(document)
			.on('adminMenuEditor:menuConfigChanged', () => {
				this.reportedUnsavedChanges++;
				this.update();
			})
			.on('menuConfigurationLoaded.adminMenuEditor', () => {
				this.reportedUnsavedChanges = 0;
				this.update();
			});
	}

	AmeUnsavedChangesIndicator.prototype.update = function() {
		const hasUnsavedChanges = this.reportedUnsavedChanges > 0;
		this.rootNode.toggleClass('ws_ame_has_unsaved_changes', hasUnsavedChanges);

		const $saveButton = this.rootNode.find('#ws_save_menu');
		if (hasUnsavedChanges) {
			$saveButton.attr('title', 'Click to save pending changes');
		} else {
			$saveButton.attr('title', '');
		}
	};

	new AmeUnsavedChangesIndicator(menuEditorNode);

//region Toolbar buttons

    /*************************************************************************
	                           Menu toolbar buttons
	 *************************************************************************/
    function getSelectedMenu() {
	    return menuPresenter.getColumnImmediate(1).getSelectedItem();
    }
    AmeEditorApi.getSelectedMenu = getSelectedMenu;

	//Show/Hide menu
	menuEditorNode.on(
		'adminMenuEditor:action-hide',
		/**
		 * @param event
		 * @param {JQuery|null} selectedItem
		 * @param {AmeEditorColumn} column
		 */
		function(event, selectedItem, column) {
			const selection = column.getSelectedItem();
			if (selection.length < 1) {
				return;
			}

			toggleItemHiddenFlag(selection);
		}
	);

	//Hide a menu and deny access.
	menuEditorNode.on(
		'adminMenuEditor:action-deny',
		/**
		 * @param event
		 * @param {JQuery|null} selectedItem
		 * @param {AmeEditorColumn} column
		 */
		function(event, selectedItem, column) {
			const selection = column.getSelectedItem();
			if (selection.length < 1) {
				return;
			}

			function objectFillKeys(keys, value) {
				let result = {};
				_.forEach(keys, function(key) {
					result[key] = value;
				});
				return result;
			}

			if (actorSelectorWidget.selectedActor === null) {
				//Hide from everyone except Super Admin and the current user.
				let menuItem = selection.data('menu_item'),
					validActors = _.keys(wsEditorData.actors),
					alwaysAllowedActors = _.intersection(
						['special:super_admin', 'user:' + wsEditorData.currentUserLogin],
						validActors
					),
					victims = _.difference(validActors, alwaysAllowedActors),
					shouldHide;

				//First, lets check who has access. Maybe this item is already hidden from the victims.
				shouldHide = _.some(victims, _.curry(actorCanAccessMenu, 2)(menuItem));

				let keepEnabled = objectFillKeys(alwaysAllowedActors, true),
					hideAllExceptAllowed = _.assign(objectFillKeys(victims, false), keepEnabled);

				walkMenuTree(selection, function(container, item) {
					let newAccess;
					if (shouldHide) {
						//Yay, hide it now!
						newAccess = hideAllExceptAllowed;
						//Only update had_access_before_hiding if this item isn't hidden yet or the field is missing.
						//We don't want to double-hide an item.
						let actorsWithAccess = _.filter(victims, function(actor) {
							return actorCanAccessMenu(item, actor);
						});
						if ((actorsWithAccess.length) > 0 || _.isEmpty(_.get(item, 'had_access_before_hiding', null))) {
							item.had_access_before_hiding = actorsWithAccess;
						}
					} else {
						//Give back access to the roles and users who previously had access.
						//Careful, don't give access to roles that no longer exist.
						let actorsWhoHadAccess = _.get(item, 'had_access_before_hiding', []) || [];
						actorsWhoHadAccess = _.intersection(actorsWhoHadAccess, validActors);

						newAccess = _.assign(objectFillKeys(actorsWhoHadAccess, true), keepEnabled);
						delete item.had_access_before_hiding;
					}

					setActorAccess(container, newAccess);
					updateItemEditor(container);
				});

			} else {
				//Just toggle the checkbox.
				selection.find('input.ws_actor_access_checkbox').trigger('click');
			}
		}
	);

	//Delete error dialog. It shows up when the user tries to delete one of the default menus.
	var menuDeletionDialog = $('#ws-ame-menu-deletion-error').dialog({
		autoOpen: false,
		modal: true,
		closeText: ' ',
		title: 'Error',
		draggable: false
	});
	var menuDeletionCallback = function(hide) {
		menuDeletionDialog.dialog('close');
		var selection = menuDeletionDialog.data('selected_menu');

		function applyCallbackRecursively(containerNode, callback) {
			callback(containerNode.data('menu_item'));

			var subMenuId = containerNode.data('submenu_id');
			if (subMenuId && containerNode.hasClass('ws_menu')) {
				$('.ws_item', '#' + subMenuId).each(function() {
					var node = $(this);
					callback(node.data('menu_item'));
					updateItemEditor(node);
				});
			}

			updateItemEditor(containerNode);
		}

		function hideRecursively(containerNode, exceptActor) {
			var otherActors = _(actorSelectorWidget.getVisibleActors())
				.map('id')
				.without(exceptActor)
				.value();

			applyCallbackRecursively(containerNode, function(menuItem) {
				//Remember which actors had access to this item so that it
				//can be un-hidden by the toolbar button.
				var actorsWithAccess = _.filter(otherActors, function(actor) {
					return actorCanAccessMenu(menuItem, actor);
				});
				if ((actorsWithAccess.length) > 0) {
					menuItem.had_access_before_hiding = actorsWithAccess;
				}

				denyAccessForAllExcept(menuItem, exceptActor);
			});
			updateParentAccessUi(containerNode);
		}

		//TODO: Write had_access_before_hiding so that it can be un-hidden using the toolbar button.
		if (hide === 'all') {
			if (wsEditorData.wsMenuEditorPro) {
				hideRecursively(selection, null);
			} else {
				//The free version doesn't have role permissions, so use the global "hidden" flag.
				applyCallbackRecursively(selection, function(menuItem) {
					menuItem.hidden = true;
				});
			}
		} else if (hide === 'except_current_user') {
			hideRecursively(selection, 'user:' + wsEditorData.currentUserLogin);
		} else if (hide === 'except_administrator' && !wsEditorData.wsMenuEditorPro) {
			//Set "required capability" to something only the Administrator role would have.
			var adminOnlyCap = 'manage_options';
			applyCallbackRecursively(selection, function(menuItem) {
				menuItem.extra_capability = adminOnlyCap;
			});
			alert('The "required capability" field was set to "' + adminOnlyCap + '".');
		}
	};

	//Callbacks for each of the dialog buttons.
	$('#ws_cancel_menu_deletion').on('click', function() {
		menuDeletionCallback(false);
	});
	$('#ws_hide_menu_from_everyone').on('click', function() {
		menuDeletionCallback('all');
	});
	const $hideExceptCurrentUser = $('#ws_hide_menu_except_current_user').on('click', function() {
		menuDeletionCallback('except_current_user');
	});
	const $hideExceptAdmin = $('#ws_hide_menu_except_administrator').on('click', function() {
		menuDeletionCallback('except_administrator');
	});

	/**
	 * Attempt to delete a menu item. Will check if the item can actually be deleted and ask the user for confirmation.
	 * UI callback.
	 *
	 * @param {JQuery} selection The selected menu item (DOM node).
	 */
	function tryDeleteItem(selection) {
		var menuItem = selection.data('menu_item');
		var shouldDelete = false;

		if (canDeleteItem(selection)) {
			//Custom and duplicate items can be deleted normally.
			shouldDelete = confirm('Delete this menu?');
		} else {
			//Non-custom items can not be deleted, but they can be hidden. Ask the user if they want to do that.
			menuDeletionDialog.find('#ws-ame-menu-type-desc').text(
				getDefaultValue(menuItem, 'is_plugin_page') ? 'an item added by another plugin' : 'a built-in menu item'
			);
			menuDeletionDialog.data('selected_menu', selection);

			//Different versions get slightly different options because only the Pro version has
			//role-specific permissions.
			$hideExceptCurrentUser.toggleClass('hidden', !wsEditorData.wsMenuEditorPro);
			$hideExceptAdmin.toggleClass('hidden', wsEditorData.wsMenuEditorPro);

			menuDeletionDialog.dialog('open');

			//Select "Cancel" as the default button.
			menuDeletionDialog.find('#ws_cancel_menu_deletion').focus();
		}

		if (shouldDelete) {
			const parentSubmenu = selection.closest('.ws_submenu');

			//Delete the menu.
			menuPresenter.destroyItem(selection);

			if (parentSubmenu && (parentSubmenu.length > 0)) {
				//Refresh permissions UI for this menu's parent (if any).
				updateParentAccessUi(parentSubmenu);
			}
		}
	}

	//Delete menu
	menuEditorNode.on(
		'adminMenuEditor:action-delete',
		/**
		 * @param event
		 * @param {JQuery|null} selectedItem
		 * @param {AmeEditorColumn} column
		 */
		function(event, selectedItem, column) {
			const selection = column.getSelectedItem();
			if (selection.length < 1) {
				return;
			}

			tryDeleteItem(selection);
		}
	);

	//Copy menu
	menuEditorNode.on(
		'adminMenuEditor:action-copy',

		/**
		 * @param event
		 * @param {JQuery|null} selectedItem
		 */
		function (event, selectedItem) {
			//Get the selected menu
			if (!selectedItem || (selectedItem.lengt < 1)) {
				return;
			}

			//Store a copy of the current menu state in clipboard
			menu_in_clipboard = readItemState(selectedItem);
		}
	);

	//Cut menu
	menuEditorNode.on(
		'adminMenuEditor:action-cut',

		/**
		 * @param event
		 * @param {JQuery|null} selectedItem
		 * @param {AmeEditorColumn} column
		 */
		function (event, selectedItem, column) {
			if (selectedItem === null) {
				alert('Please select a menu item first.');
				return;
			}
			const submenu = selectedItem.closest('.ws_submenu');

			//Store a copy of the current menu state in clipboard
			menu_in_clipboard = readItemState(selectedItem);

			//Remove the original menu and submenu
			column.destroyItem(selectedItem);

			//If this submenu had mixed permissions, that might have changed now that the item is gone.
			updateParentAccessUi(submenu);
		}
	);

	menuEditorNode.on(
		'adminMenuEditor:action-paste',
		/**
		 * @param event
		 * @param {JQuery|null} selectedItem
		 * @param {AmeEditorColumn} column
		 */
		function(event, selectedItem, column) {
			//Check if anything has been copied/cut
			if (!menu_in_clipboard) {
				return;
			}

			//You can only add separators to submenus in the Pro version.
			if ( menu_in_clipboard.separator && !wsEditorData.wsMenuEditorPro ) {
				return;
			}

			const copyOfItem = $.extend(true, {}, menu_in_clipboard);

			//Paste the menu after the selection.
			column.pasteItem(copyOfItem, selectedItem);
		}
	);

	//New menu
	menuEditorNode.on(
		'adminMenuEditor:action-new-menu',
		/**
		 * @param event
		 * @param {JQuery|null} selectedItem
		 * @param {AmeEditorColumn} column
		 */
		function(event, selectedItem, column) {
			const visibleList = column.getVisibleItemList();
			if (!visibleList || (visibleList.length < 1)) {
				//Abort if there's no item list in this column. This can happen if nothing is selected
				//in the previous column.
				return;
			}

			ws_paste_count++;

			//The new menu starts out rather bare.
			let item = $.extend(true, {}, wsEditorData.blankMenuItem, {
				custom: true, //Important : flag the new menu as custom, or it won't show up after saving.
				template_id: '',
				menu_title: 'Custom Menu ' + ws_paste_count,
				file: randomMenuId(),
				items: []
			});
			item.defaults = $.extend(true, {}, itemTemplates.getDefaults(''));

			//Make it accessible only to the current actor if one is selected.
			if (actorSelectorWidget.selectedActor !== null) {
				denyAccessForAllExcept(item, actorSelectorWidget.selectedActor);
			}

			//Insert the new menu item.
			let selection = column.getSelectedItem();
			if (!selection || (selection.length < 1)) {
				selection = null;
			}
			let result = column.outputItem(item, selection);

			if (result && result.menu) {
				//The menu's editbox is always open
				result.menu.find('.ws_edit_link').trigger('click');

				updateParentAccessUi(result.menu);
			}
		}
	);

	//New separator
	menuEditorNode.on(
		'adminMenuEditor:action-new-separator',
		/**
		 * @param event
		 * @param {JQuery|null} selectedItem
		 * @param {AmeEditorColumn} column
		 */
		function(event, selectedItem, column) {
			const visibleList = column.getVisibleItemList();
			if (!visibleList || (visibleList.length < 1)) {
				//Abort if there's no item list in this column. This can happen if nothing is selected
				//in the previous column.
				return;
			}

			ws_paste_count++;

			const randomId = randomMenuId('separator_');
			let item = $.extend(true, {}, wsEditorData.blankMenuItem, {
				separator: true, //Flag as a separator
				custom: false,   //Separators don't need to flagged as custom to be retained.
				items: [],
				defaults: {
					separator: true,
					css_class : 'wp-menu-separator',
					access_level : 'read',
					file : randomId,
					hookname : randomId
				}
			});

			const selection = column.getSelectedItem();
			column.outputItem(item, (selection.length > 0) ? selection : null);
		}
	);

	//Toggle all menus for the currently selected actor
	menuEditorNode.on(
		'adminMenuEditor:action-toggle-all',
		function() {
			if ( actorSelectorWidget.selectedActor === null ) {
				alert("This button enables/disables all menus for the selected role. To use it, click a role and then click this button again.");
				return;
			}

			//Look at the first menu's permissions and set everything to the opposite.
			const firstColumn = menuPresenter.getColumnImmediate(1);
			const topMenuNodes = $('.ws_menu', firstColumn.getVisibleItemList());

			const allow = ! actorCanAccessMenu(topMenuNodes.eq(0).data('menu_item'), actorSelectorWidget.selectedActor);

			topMenuNodes.each(function() {
				let containerNode = $(this);
				setActorAccessForTreeAndUpdateUi(containerNode, actorSelectorWidget.selectedActor, allow);
			});
		}
	);

	//Copy all menu permissions from one role to another.
	var copyPermissionsDialog = $('#ws-ame-copy-permissions-dialog').dialog({
		autoOpen: false,
		modal: true,
		closeText: ' ',
		draggable: false
	});

	var sourceActorList = $('#ame-copy-source-actor'), destinationActorList = $('#ame-copy-destination-actor');

	//The "Copy permissions" toolbar button.
	menuEditorNode.on(
		'adminMenuEditor:action-copy-permissions',
		function() {
			const previousSource = sourceActorList.val();

			//Populate source/destination lists.
			sourceActorList.find('option').not('[disabled]').remove();
			destinationActorList.find('option').not('[disabled]').remove();
			$.each(actorSelectorWidget.getVisibleActors(), function(index, actor) {
				let option = $('<option>', {
					val: actor.id,
					text: actorSelectorWidget.getNiceName(actor)
				});
				sourceActorList.append(option);
				destinationActorList.append(option.clone());
			});

			//Pre-select the current actor as the destination.
			if (actorSelectorWidget.selectedActor !== null) {
				destinationActorList.val(actorSelectorWidget.selectedActor);
			}

			//Restore the previous source selection.
			if (previousSource) {
				sourceActorList.val(previousSource);
			}
			if (!sourceActorList.val()) {
				sourceActorList.find('option').first().prop('selected', true); //Fallback.
			}

			copyPermissionsDialog.dialog('open');
		}
	);

	//Actually copy the permissions when the user click the confirmation button.
	var copyConfirmationButton = $('#ws-ame-confirm-copy-permissions');
	copyConfirmationButton.on('click', function() {
		var sourceActor = sourceActorList.val();
		var destinationActor = destinationActorList.val();

		if (sourceActor === null || destinationActor === null) {
			alert('Select a source and a destination first.');
			return;
		}

		//Iterate over all menu items and copy the permissions from one actor to the other.
		AmeEditorApi.forEachMenuItem(function (menuItem, node) {
			//Only change permissions when they don't match. This ensures we won't unnecessarily overwrite default
			//permissions and bloat the configuration with extra grant_access entries.
			const sourceAccess      = actorCanAccessMenu(menuItem, sourceActor);
			const destinationAccess = actorCanAccessMenu(menuItem, destinationActor);
			if (sourceAccess !== destinationAccess) {
				setActorAccess(node, destinationActor, sourceAccess);
				//Note: In theory, we could also look at the default permissions for destinationActor and
				//revert to default instead of overwriting if that would make the two actors' permissions match.
			}
		});

		//todo: copy granted permissions like CPTs.

		//If the user is currently looking at the destination actor, force the UI to refresh
		//so that they can see the new permissions.
		if (actorSelectorWidget.selectedActor === destinationActor) {
			//This is a bit of a hack, but right now there's no better way to refresh all items at once.
			actorSelectorWidget.setSelectedActor(null);
			actorSelectorWidget.setSelectedActor(destinationActor);
		}

		//All done.
		copyPermissionsDialog.dialog('close');
	});

	//Only enable the copy button when the user selects a valid source and destination.
	copyConfirmationButton.prop('disabled', true);
	sourceActorList.add(destinationActorList).on('click', function() {
		var sourceActor = sourceActorList.val();
		var destinationActor = destinationActorList.val();

		var validInputs = (sourceActor !== null) && (destinationActor !== null) && (sourceActor !== destinationActor);
		copyConfirmationButton.prop('disabled', !validInputs);
	});

	//Sort menus in ascending or descending order.
	menuEditorNode.on(
		'adminMenuEditor:action-sort',
		/**
		 * @param event
		 * @param {JQuery|null} selectedItem
		 * @param {AmeEditorColumn} column
		 * @param {JQuery} button
		 */
		function(event, selectedItem, column, button) {
			let direction = button.data('sort-direction') || 'asc',
				menuBox = column.getVisibleItemList();

			if (!menuBox || (menuBox.length < 1)) {
				return;
			}

			function sortRecursively($box, currentColumn) {
				//When indirectly sorting the second menu level (regular submenus), leave the first item unmoved.
				//Moving the first item would change the parent menu URL (WP always links it to the first item),
				//which can be unexpected and confusing. The user can always move the first item manually.
				let leaveFirstItem = ((currentColumn !== column) && (currentColumn.level === 2));
				sortMenuItems($box, direction, leaveFirstItem);

				//Also sort child items in the next columns.
				const nextColumn = menuPresenter.getColumnImmediate(currentColumn.level + 1);
				if (nextColumn) {
					$box.find('.ws_container').each(function () {
						const $submenu = getSubmenuOf($(this), null);
						if ($submenu) {
							sortRecursively($submenu, nextColumn);
						}
					});
				}
			}

			sortRecursively(menuBox, column);
		}
	);

	/**
	 * Sort menu items by title.
	 *
	 * @param $menuBox A DOM node that contains multiple menu items.
	 * @param {string} direction 'asc' or 'desc'
	 * @param {boolean} [leaveFirstItem] Leave the first item in its original position. Defaults to false.
	 */
	function sortMenuItems($menuBox, direction, leaveFirstItem) {
		var multiplier = (direction === 'desc') ? -1 : 1,
			items = $menuBox.find('.ws_container'),
			firstItem = items.first();

		//Separators don't have a title, but we don't want them to end up at the top of the list.
		//Instead, lets keep their position the same relative to the previous item.
		var prevItemTitle = '';
		items.each((function(){
			var item = $(this), sortValue;
			if (item.is('.ws_menu_separator')) {
				sortValue = prevItemTitle;
			} else {
				sortValue = jsTrim(item.find('.ws_item_title').text());
				prevItemTitle = sortValue;
			}
			item.data('ame-sort-value', sortValue);
		}));

		function compareMenus(a, b){
			var aTitle = $(a).data('ame-sort-value'),
				bTitle = $(b).data('ame-sort-value');

			aTitle = aTitle.toLowerCase();
			bTitle = bTitle.toLowerCase();

			if (aTitle > bTitle) {
				return multiplier;
			} else if (aTitle < bTitle) {
				return -multiplier;
			}
			return 0;
		}

		items.sort(compareMenus);

		if (leaveFirstItem) {
			//Move the first item back to the top.
			firstItem.prependTo($menuBox);
		}
	}

	//Toggle the second row of toolbar buttons.
	menuEditorNode.on(
		'adminMenuEditor:action-toggle-toolbar',
		function() {
			let visible = menuEditorNode.find('.ws_second_toolbar_row').toggle().is(':visible');
			if (typeof $['cookie'] !== 'undefined') {
				$.cookie('ame-show-second-toolbar', visible ? '1' : '0', {expires: 90});
			}
		}
	);


	/*************************************************************************
	                          Item toolbar buttons
	 *************************************************************************/
	function getSelectedSubmenuItem() {
		return menuPresenter.getColumnImmediate(2).getSelectedItem();
	}

	//endregion

	//==============================================
	//				Main buttons
	//==============================================

	//Save Changes - encode the current menu as JSON and save
	$('#ws_save_menu').on('click', function () {
		try {
			var tree = readMenuTreeState();
		} catch (error) {
			//Right now the only known error condition is duplicate top level URLs.
			if (error.hasOwnProperty('code') && (error.code === 'duplicate_top_level_url')) {
				var message = 'Error: Duplicate menu URLs. The following top level menus have the same URL:\n\n' ;
				for (var i = 0; i < error.duplicates.length; i++) {
					var containerNode = $(error.duplicates[i]);
					message += (i + 1) + '. ' + containerNode.find('.ws_item_title').first().text() + '\n';
				}
				message += '\nPlease change the URLs to be unique or delete the duplicates.';
				alert(message);
			} else {
				alert(error.message);
			}
			return;
		}

		function findItemByTemplateId(items, templateId) {
			var foundItem = null;

			$.each(items, function(index, item) {
				if (item.template_id === templateId) {
					foundItem = item;
					return false;
				}
				if (item.hasOwnProperty('items') && (item.items.length > 0)) {
					foundItem = findItemByTemplateId(item.items, templateId);
					if (foundItem !== null) {
						return false;
					}
				}
				return true;
			});

			return foundItem;
		}

		//Abort the save if it would make the editor inaccessible.
        if (wsEditorData.wsMenuEditorPro) {
            var myMenuItem = findItemByTemplateId(tree.tree, 'options-general.php>menu_editor');
            if (myMenuItem === null) { // jshint ignore:line
                //This is OK - the missing menu item will be re-inserted automatically.
            } else if (!actorCanAccessMenu(myMenuItem, 'user:' + wsEditorData.currentUserLogin)) {
                alert(
	                "Error: This configuration would make you unable to access the menu editor!\n\n" +
	                "Please click either your role name or \"Current user (" + wsEditorData.currentUserLogin + ")\" "+
	                "and enable the \"Menu Editor Pro\" menu item."
                );
                return;
            }
        }

		var data = encodeMenuAsJSON(tree);
		$('#ws_data').val(data);
		$('#ws_data_length').val(data.length);
		$('#ws_selected_actor').val(actorSelectorWidget.selectedActor === null ? '' : actorSelectorWidget.selectedActor);

		$('#ws_is_deep_nesting_enabled').val(JSON.stringify(menuPresenter.isDeepNestingEnabled));

		var selectedMenu = getSelectedMenu();
		if (selectedMenu.length > 0) {
			$('#ws_selected_menu_url').val(AmeEditorApi.getItemDisplayUrl(selectedMenu.data('menu_item')));
			$('#ws_expand_selected_menu').val(selectedMenu.find('.ws_editbox').is(':visible') ? '1' : '');

			var selectedSubmenu = getSelectedSubmenuItem();
			if (selectedSubmenu.length > 0) {
				$('#ws_selected_submenu_url').val(AmeEditorApi.getItemDisplayUrl(selectedSubmenu.data('menu_item')));
				$('#ws_expand_selected_submenu').val(selectedSubmenu.find('.ws_editbox').is(':visible') ? '1' : '');
			}
		}

		$('#ws_main_form').trigger('submit');
	});

	//Load default menu - load the default WordPress menu
	$('#ws_load_menu').on('click', function () {
		if (confirm('Are you sure you want to load the default WordPress menu?')){
			loadMenuConfiguration(defaultMenu);
		}
	});

	//Reset menu - re-load the custom menu. Discards any changes made by user.
	$('#ws_reset_menu').on('click', function () {
		if (confirm('Undo all changes made in the current editing session?')){
			loadMenuConfiguration(customMenu);
		}
	});

	//Enable the "load default menu" and "undo changes" buttons only when "All" is selected.
	//Otherwise some users incorrectly assume these buttons only affect the currently selected role or user.
	actorSelectorWidget.onChange(function (newSelectedActor) {
		$('#ws_load_menu, #ws_reset_menu').prop('disabled', newSelectedActor !== null);
	});
	$('#ws_load_menu, #ws_reset_menu').prop('disabled', actorSelectorWidget.selectedActor !== null);

	$('#ws_toggle_editor_layout').on('click', function () {
		var isCompactLayoutEnabled = menuEditorNode.toggleClass('ws_compact_layout').hasClass('ws_compact_layout');
		if (typeof $['cookie'] !== 'undefined') {
			$.cookie('ame-compact-layout', isCompactLayoutEnabled ? '1' : '0', {expires: 90});
		}

		var button = $(this);
		if (button.is('input')) {
			var checkMark = '\u2713';
			button.val(button.val().replace(checkMark, ''));
			if (isCompactLayoutEnabled) {
				button.val(checkMark + ' ' + button.val());
			}
		}
	});

	//Export menu - download the current menu as a file
	$('#export_dialog').dialog({
		autoOpen: false,
		closeText: ' ',
		modal: true,
		minHeight: 100
	});

	$('#ws_export_menu').on('click', function(){
		var button = $(this);
		button.prop('disabled', true);
		button.val('Exporting...');

		$('#export_complete_notice, #download_menu_button').hide();
		$('#export_progress_notice').show();
		var exportDialog = $('#export_dialog');
		exportDialog.dialog('open');

		//Encode the menu.
		try {
			var exportData = encodeMenuAsJSON();
		} catch (error) {
			exportDialog.dialog('close');
			alert(error.message);

			button.val('Export');
			button.prop('disabled', false);
			return;
		}

		//Store the menu for download.
		$.post(
			wsEditorData.adminAjaxUrl,
			{
				'data' : exportData,
				'action' : 'export_custom_menu',
				'_ajax_nonce' : wsEditorData.exportMenuNonce
			},
			/**
			 * @param {Object} data
			 */
			function(data){
				button.val('Export');
				button.prop('disabled', false);

				if ( typeof data.error !== 'undefined' ){
					exportDialog.dialog('close');
					alert(data.error);
				}

				if ( _.has(data, 'download_url') ){
					//window.location = data.download_url;
					$('#download_menu_button').attr('href', _.get(data, 'download_url')).data('filesize', _.get(data, 'filesize'));
					$('#export_progress_notice').hide();
					$('#export_complete_notice, #download_menu_button').show();
				}
			},
			'json'
		);
	});

	$('#ws_cancel_export').on('click', function(){
		$('#export_dialog').dialog('close');
	});

	$('#download_menu_button').on('click', function(){
		$('#export_dialog').dialog('close');
	});

	//Import menu - upload an exported menu and show it in the editor
	$('#import_dialog').dialog({
		autoOpen: false,
		closeText: ' ',
		modal: true
	});
	const $importMenuForm = $('#import_menu_form');

	$('#ws_cancel_import').on('click', function(){
		$('#import_dialog').dialog('close');
	});

	$('#ws_import_menu').on('click', function(){
		$('#import_progress_notice, #import_progress_notice2, #import_complete_notice, #ws_import_error').hide();
		$('#ws_import_panel').show();
		$importMenuForm.resetForm();
		//The "Upload" button is disabled until the user selects a file
		$('#ws_start_import').attr('disabled', 'disabled');

		var importDialog = $('#import_dialog');
		importDialog.find('.hide-when-uploading').show();
		importDialog.dialog('open');
	});

	$('#import_file_selector').on('change', function(){
		$('#ws_start_import').prop('disabled', ! $(this).val() );
	});

	//This function displays unhandled server side errors. In theory, our upload handler always returns a well-formed
	//response even if there's an error. In practice, stuff can go wrong in unexpected ways (e.g. plugin conflicts).
	function handleUnexpectedImportError(xhr, errorMessage) {
		//The server-side code didn't catch this error, so it's probably something serious
		//and retrying won't work.
		$importMenuForm.resetForm();
		$('#ws_import_panel').hide();

		//Display error information.
		$('#ws_import_error_message').text(errorMessage);
		$('#ws_import_error_http_code').text(xhr.status);
		$('#ws_import_error_response').text((xhr.responseText !== '') ? xhr.responseText : '[Empty response]');
		$('#ws_import_error').show();
	}

	//AJAXify the upload form
	$importMenuForm.ajaxForm({
		dataType : 'json',
		beforeSubmit: function(formData) {

			//Check if the user has selected a file
			for(var i = 0; i < formData.length; i++){
				if ( formData[i].name === 'menu' ){
					if ( (typeof formData[i].value === 'undefined') || !formData[i].value){
						alert('Select a file first!');
						return false;
					}
				}
			}

			$('#import_dialog').find('.hide-when-uploading').hide();
			$('#import_progress_notice').show();

			$('#ws_start_import').attr('disabled', 'disabled');
			return true;
		},
		success: function(data, status, xhr) {
			$('#import_progress_notice').hide();

			var importDialog = $('#import_dialog');
			if ( !importDialog.dialog('isOpen') ){
				//Whoops, the user closed the dialog while the upload was in progress.
				//Discard the response silently.
				return;
			}

			if ( data === null ) {
				handleUnexpectedImportError(xhr, 'Invalid response from server. Please check your PHP error log.');
				return;
			}

			if ( typeof data.error !== 'undefined' ){
				alert(data.error);
				//Let the user try again
				$importMenuForm.resetForm();
				importDialog.find('.hide-when-uploading').show();
			}

			if ( (typeof data.tree !== 'undefined') && data.tree ){
				//Whee, we got back a (seemingly) valid menu. A veritable miracle!
				//Lets load it into the editor.
				var progressNotice = $('#import_progress_notice2').show();
				loadMenuConfiguration(data);
				progressNotice.hide();
				//Display a success notice, then automatically close the window after a few moments
				$('#import_complete_notice').show();
				setTimeout((function(){
					//Close the import dialog
					$('#import_dialog').dialog('close');
				}), 500);
			}

		},
		error: function(xhr, status, errorMessage) {
			handleUnexpectedImportError(xhr, errorMessage);
		}
	});

	/*************************************************************************
	                 Drag & drop items between menu levels
	 *************************************************************************/

	//Allow the user to drag sub-menu items to the top level.
	$('#ws_top_menu_dropzone').droppable({
		'hoverClass' : 'ws_dropzone_hover',
		'activeClass' : 'ws_dropzone_active',

		'accept' : (function(thing){
			return thing.hasClass('ws_item');
		}),

		'drop' : (function(event, ui){
			const firstColumn = menuPresenter.getColumnImmediate(1);
			if (!firstColumn) {
				return;
			}
			const nextColumn = menuPresenter.getColumnImmediate(firstColumn.level + 1);

			const droppedItemData = readItemState(ui.draggable);
			const newItemNodes = firstColumn.pasteItem(droppedItemData, null);

			//If the item was originally a top level menu, also move its original submenu items.
			if ((getFieldValue(droppedItemData, 'parent') === null) && (newItemNodes.submenu)) {
				const droppedItemFile = getFieldValue(droppedItemData, 'file');
				const nearbyItems = $(ui.draggable).siblings('.ws_item');
				nearbyItems.each(function() {
					const containerNode = $(this),
						submenuItem = containerNode.data('menu_item');

					//Was this item originally a child of the dragged menu?
					if (getFieldValue(submenuItem, 'parent') === droppedItemFile) {
						nextColumn.pasteItem(submenuItem, null, newItemNodes.submenu);
						if ( !event.ctrlKey ) {
							menuPresenter.destroyItem(containerNode);
						}
					}
				});
			}

			if ( !event.ctrlKey ) {
				menuPresenter.destroyItem(ui.draggable);
			}
		})
	});

	/******************************************************************
	                 Component visibility settings
	 ******************************************************************/

	var $generalVisBox = $('#ws_ame_general_vis_box'),
		$showAdminMenu = $('#ws_ame_show_admin_menu'),
		$showWpToolbar = $('#ws_ame_show_toolbar');

	AmeEditorApi.actorCanSeeComponent = function(component, actorId) {
		if (actorId === null) {
			return _.some(actorSelectorWidget.getVisibleActors(), function(actor) {
				return AmeEditorApi.actorCanSeeComponent(component, actor.id);
			});
		}

		var actorSpecificSetting = _.get(generalComponentVisibility, [component, actorId], null);
		if (actorSpecificSetting !== null) {
			return actorSpecificSetting;
		}

		//Super Admin can see everything by default.
		if (actorId === AmeSuperAdmin.permanentActorId) {
			return _.get(generalComponentVisibility, [component, AmeSuperAdmin.permanentActorId], true);
		}

		var actor = AmeActors.getActor(actorId);
		if (actor instanceof AmeUser) {
			var grants = _.get(generalComponentVisibility, component, {});

			//Super Admin has priority.
			if (actor.isSuperAdmin) {
				return AmeEditorApi.actorCanSeeComponent(component, AmeSuperAdmin.permanentActorId);
			}

			//The user can see the admin menu/Toolbar if at least one of their roles can see it.
			var result = null;
			_.forEach(actor.roles, function(roleName) {
				var allow = _.get(grants, 'role:' + roleName, true);
				if (result === null) {
					result = allow;
				} else {
					result = result || allow;
				}
			});

			if (result !== null) {
				return result;
			}
		}

		//Everyone can see the admin menu and the Toolbar by default.
		return true;
	};

	AmeEditorApi.refreshComponentVisibility = function() {
		if ($generalVisBox.length < 1) {
			return;
		}

		var actorId = actorSelectorWidget.selectedActor;
		$showAdminMenu.prop('checked', AmeEditorApi.actorCanSeeComponent('adminMenu', actorId));
		$showWpToolbar.prop('checked', AmeEditorApi.actorCanSeeComponent('toolbar', actorId));
	};

	AmeEditorApi.setComponentVisibility = function(section, actorId, enabled) {
		if (actorId === null) {
			_.forEach(actorSelectorWidget.getVisibleActors(), function(actor) {
				_.set(generalComponentVisibility, [section, actor.id], enabled);
			});
		} else {
			_.set(generalComponentVisibility, [section, actorId], enabled);
		}
	};

	if ($generalVisBox.length > 0) {
		$showAdminMenu.on('click', function() {
			AmeEditorApi.setComponentVisibility(
				'adminMenu',
				actorSelectorWidget.selectedActor,
				$(this).is(':checked')
			);
		});
		$showWpToolbar.on('click', function () {
			AmeEditorApi.setComponentVisibility(
				'toolbar',
				actorSelectorWidget.selectedActor,
				$(this).is(':checked')
			);
		});

		$generalVisBox.find('.handlediv').on('click', function() {
			$generalVisBox.toggleClass('closed');
			if (typeof $['cookie'] !== 'undefined') {
				$.cookie(
					'ame_vis_box_open',
					($generalVisBox.hasClass('closed') ? '0' : '1'),
					{ expires: 90 }
				);
			}
		});

		actorSelectorWidget.onChange(function() {
			AmeEditorApi.refreshComponentVisibility();
		});
	}

	//region Aux menu data adapter
	/******************************************************************
	                     Auxiliary menu data adapter
	 ******************************************************************/

	/**
	 * Provides read/write access to additional arbitrary data that can be stored
	 * in the admin menu configuration (i.e. everything that's not the menu tree).
	 *
	 * @constructor
	 */
	class AuxiliaryConfigDataAdapter {
		currentConfig = {};
		registeredKeys = {};
		settingIdMap = {};
		prefixMap = {};

		constructor(adapterConfig = {}) {
			adapterConfig = adapterConfig || {};
			const initialPrefixes = _.get(adapterConfig, 'prefixMap', {});
			//Convert dot-separated paths like "a.b.c" to arrays.
			this.prefixMap = _.mapValues(initialPrefixes, (value) => {
				return _.isString(value) ? value.split('.') : value;
			});

			const initialKeys = _.get(adapterConfig, 'keys', {});
			for (const key in initialKeys) {
				this.registerKey(key, initialKeys[key]);
			}
			const initialSettingIdMap = _.get(adapterConfig, 'settingIdMap', {});
			for (const key in initialSettingIdMap) {
				this.registerSettingId(key, initialSettingIdMap[key]);
			}

			$(document)
				.on('menuConfigurationLoaded.adminMenuEditor', (event, menuConfiguration) => {
					//To avoid accidentally modifying the original config, make
					//a copy of each key except "tree" and "format".
					let configCopy = {};
					for (const key in menuConfiguration) {
						if (!menuConfiguration.hasOwnProperty(key) || (key === 'tree') || (key === 'format')) {
							continue;
						}

						if ((typeof menuConfiguration[key] === 'object') && (menuConfiguration[key] !== null)) {
							configCopy[key] = $.extend(true, {}, menuConfiguration[key]);
						} else {
							configCopy[key] = menuConfiguration[key];
						}

					}
					this.currentConfig = configCopy;
				})
				.on('getMenuConfiguration.adminMenuEditor', (event, menuConfiguration) => {
					//Copy registered settings to the menu configuration.
					for (let key in this.registeredKeys) {
						if (
							!this.registeredKeys.hasOwnProperty(key)
							|| (key === 'tree')
							|| (key === 'format')
						) {
							continue;
						}

						//Don't overwrite keys added by other scripts/event callbacks.
						if (typeof menuConfiguration[key] !== 'undefined') {
							continue;
						}

						if ((typeof this.currentConfig[key] !== 'undefined') && (this.currentConfig[key] !== null)) {
							const newValue = this.currentConfig[key];
							if (typeof newValue === 'object') {
								menuConfiguration[key] = $.extend(true, {}, newValue);
							} else {
								menuConfiguration[key] = newValue;
							}
						} else {
							delete menuConfiguration[key];
						}
					}
				});
		}

		/**
		 * Register a key on the menu configuration object. It will be preserved
		 * when the menu configuration is saved.
		 *
		 * @param {string} key
		 * @param {string|null} settingIdPrefix
		 */
		registerKey(key, settingIdPrefix = null) {
			this.registeredKeys[key] = true;
			this.prefixMap[settingIdPrefix] = [key];
		}

		/**
		 * Register a setting ID that corresponds to a specific path in the menu configuration object.
		 * You will be able to use the setting ID to read and write the corresponding value.
		 *
		 * @param {string} settingId
		 * @param {string|string[]} path
		 */
		registerSettingId(settingId, path) {
			this.settingIdMap[settingId] = path;
		}

		/**
		 * Get a list of all setting prefixes that this adapter may be able to handle.
		 *
		 * @returns {string[]}
		 */
		getKnownPrefixes() {
			return Object.keys(this.prefixMap);
		}

		getSettingValue(settingId, defaultValue = null) {
			const path = this.mapSettingIdToPath(settingId);
			if (path === null) {
				return defaultValue;
			}
			return _.get(this.currentConfig, path, defaultValue);
		}

		/**
		 * @param {string} settingId
		 * @returns {null|string|string[]}
		 */
		mapSettingIdToPath(settingId) {
			const knownPath = this.settingIdMap[settingId];
			if (typeof knownPath !== 'undefined') {
				return knownPath;
			}

			//Does this ID start with a known prefix?
			for (const prefix in this.prefixMap) {
				if (!this.prefixMap.hasOwnProperty(prefix)) {
					continue;
				}

				if (settingId.indexOf(prefix) === 0) {
					const suffix = settingId.substring(prefix.length);
					//Strip leading dots and convert to an array.
					const suffixPath = suffix.replace(/^\.+/, '').split('.');
					//Combine the prefix path with the suffix path.
					return this.prefixMap[prefix].concat(suffixPath);
				}
			}

			return null;
		}

		/**
		 * Set multiple settings at once.
		 *
		 * @param {object} settingsById Object where keys are setting IDs and values are the new setting values.
		 */
		updateSettingsById(settingsById) {
			for (const settingId in settingsById) {
				if (settingsById.hasOwnProperty(settingId)) {
					const path = this.mapSettingIdToPath(settingId);
					if (path !== null) {
						_.set(this.currentConfig, path, settingsById[settingId]);
					}
				}
			}
		}

		/**
		 * Get a value from the menu configuration object. Uses a simple path, not a setting ID.
		 *
		 * @param {string|string[]} path
		 * @param {*} defaultValue
		 * @returns {*}
		 */
		getPath(path, defaultValue = null) {
			return _.get(this.currentConfig, path, defaultValue);
		}

		/**
		 * Directly set a value in the menu configuration object. Does not translate setting IDs.
		 *
		 * @param {string|string[]} path Plain path, not a setting ID.
		 * @param {*} value
		 */
		setPath(path, value) {
			_.set(this.currentConfig, path, value);
		}
	}

	AmeEditorApi.configDataAdapter = new AuxiliaryConfigDataAdapter(wsEditorData.auxDataConfig);
	//endregion

	/******************************************************************
	                      Tooltips and hints
	 ******************************************************************/


	//Increase tooltip z-index to avoid a conflict with the Essential Grid plugin.
	//That plugin sets the jQuery UI dialog z-index to 100102, making tooltips appear
	//underneath the dialog.
	$.fn.qtip.zindex = 100200;
	//Set up tooltips
	$('.ws_tooltip_trigger').qtip({
		style: {
			classes: 'qtip qtip-rounded ws_tooltip_node'
		},
		hide: {
			fixed: true,
			delay: 300
		}
	});

	//Set up menu field tooltips.
	menuEditorNode.on('mouseenter click', '.ws_edit_field .ws_field_tooltip_trigger', function(event) {
		var $trigger = $(this),
			fieldName = $trigger.closest('.ws_edit_field').data('field_name');

		if (knownMenuFields[fieldName].tooltip === null) {
			return;
		}

		var tooltipText = 'Invalid tooltip';
		if (typeof knownMenuFields[fieldName].tooltip === 'string') {
			tooltipText = knownMenuFields[fieldName].tooltip;
		} else if (typeof knownMenuFields[fieldName].tooltip === 'function') {
			tooltipText = function() {
				var $theTrigger = $(this),
					menuItem = $theTrigger.closest('.ws_container').data('menu_item');
				return knownMenuFields[fieldName].tooltip(menuItem);
			}
		}

		$trigger.qtip({
			overwrite: false,
			content: {
				text: tooltipText
			},
			show: {
				event: event.type,
				ready: true //Show immediately.
			},
			style: {
				classes: 'qtip qtip-rounded ws_tooltip_node'
			},
			hide: {
				fixed: true,
				delay: 300
			},
			position: {
				my: 'bottom center',
				at: 'top center'
			}
		}, event);
	});

	//Set up the "additional permissions are available" tooltips.
	menuEditorNode.on('mouseenter click', '.ws_ext_permissions_indicator', function() {
		var $indicator = $(this);
		$indicator.qtip({
			overwrite: false,
			content: {
				text: function() {
					var indicator = $(this),
						extPermissions = indicator.data('ext_permissions'),
						text = 'Additional permission settings are available. Click "Edit..." to change them.',
						heading = '',
						$content = $('<span></span>');

					if (extPermissions && extPermissions.hasOwnProperty('title')) {
						heading = extPermissions.title;
						if (extPermissions.hasOwnProperty('type')) {
							heading = _.capitalize(_.startCase(extPermissions.type).toLowerCase()) + ': ' + heading;
						}
						$content.append($('<strong></strong>').text(heading)).append('<br>');
					}

					$content.append($(document.createTextNode(text)));
					return $content;
				}
			},
			show: {
				ready: true //Show immediately.
			},
			style: {
				classes: 'qtip qtip-rounded ws_tooltip_node'
			},
			hide: {
				fixed: true,
				delay: 300
			},
			position: {
				my: 'bottom center',
				at: 'top center'
			}
		});
	});

	//Flag closed hints as hidden by sending the appropriate AJAX request to the backend.
	$('.ws_hint_close').on('click', function() {
		var hint = $(this).parents('.ws_hint').first();
		hint.hide();
		wsEditorData.showHints[hint.attr('id')] = false;
		$.post(
			wsEditorData.adminAjaxUrl,
			{
				'action': 'ws_ame_hide_hint',
				'_ajax_nonce': wsEditorData.hideHintNonce,
				'hint': hint.attr('id')
			}
		);
	});

	//Expand/collapse the "How To" box.
	var $howToBox = $("#ws_ame_how_to_box");
	$howToBox.find(".handlediv").on('click', function() {
		$howToBox.toggleClass('closed');
		if (typeof $['cookie'] !== 'undefined') {
			$.cookie(
				'ame_how_to_box_open',
				($howToBox.hasClass('closed') ? '0' : '1'),
				{ expires: 180 }
			);
		}
	});


	/******************************************************************
	                           Actor views
	 ******************************************************************/

	if (wsEditorData.wsMenuEditorPro) {
		actorSelectorWidget.onChange(function() {
			//There are some UI elements that can be visible or hidden depending on whether an actor is selected.
			var editorNode = $('#ws_menu_editor');
			editorNode.toggleClass('ws_is_actor_view', (actorSelectorWidget.selectedActor !== null));

			//Update the menu item states to indicate whether they're accessible.
			editorNode.find('.ws_container').each(function() {
				updateActorAccessUi($(this));
			});
		});

		if (wsEditorData.hasOwnProperty('selectedActor') && wsEditorData.selectedActor) {
			actorSelectorWidget.setSelectedActor(wsEditorData.selectedActor);
		} else {
			actorSelectorWidget.setSelectedActor(null);
		}
	}

	/******************************************************************
	                        "Test Access" feature
	 ******************************************************************/
	var testAccessDialog = $('#ws_ame_test_access_screen').dialog({
			autoOpen: false,
			modal: true,
			closeText: ' ',
			title: 'Test access',
			width: 900
			//draggable: false
		}),
		testMenuItemList = $('#ws_ame_test_menu_item'),
		testActorList = $('#ws_ame_test_relevant_actor'),
		testAccessButton = $('#ws_ame_start_access_test'),
		testAccessFrame = $('#ws_ame_test_access_frame'),
		testConfig = null,

		testProgress = $('#ws_ame_test_progress'),
		testProgressText = $('#ws_ame_test_progress_text');

	$('#ws_test_access').on('click', function () {
		testConfig = readMenuTreeState();

		var selectedMenuContainer = getSelectedMenu(),
			selectedItemContainer = getSelectedSubmenuItem(),
			selectedMenu = null,
			selectedItem = null,
			selectedUrl = null;
		if (selectedMenuContainer.length > 0) {
			selectedMenu = selectedMenuContainer.data('menu_item');
			selectedUrl = getFieldValue(selectedMenu, 'url');
		}
		if (selectedItemContainer.length > 0) {
			selectedItem = selectedItemContainer.data('menu_item');
			selectedUrl = getFieldValue(selectedItem, 'url');
		}

		function addMenuItems(collection, parentTitle, parentFile) {
			_.each(collection, function (menuItem) {
				if (menuItem.separator) {
					return;
				}

				var title = formatMenuTitle(getFieldValue(menuItem, 'menu_title', '[Untitled menu]'));
				if (parentTitle) {
					title = parentTitle + ' -> ' + title;
				}
				var url = getFieldValue(menuItem, 'url', '[no-url]');

				var option = $(
					'<option>', {
						val: url,
						text: title
					}
				);
				option.data('menu_item', menuItem);
				option.data('parent_file', parentFile || '');
				option.prop('selected', (url === selectedUrl));

				testMenuItemList.append(option);

				if (menuItem.items) {
					addMenuItems(menuItem.items, title, getFieldValue(menuItem, 'file', ''));
				}
			});
		}

		//Populate the list of menu items.
		testMenuItemList.empty();
		addMenuItems(testConfig.tree);

		//Populate the actor list.
		testActorList.empty();
		testActorList.append($('<option>', {text: 'Not selected', val: ''}));
		_.each(actorSelectorWidget.getVisibleActors(), function (actor) {
			//TODO: Skip anything that isn't a role
			var option = $('<option>', {
				val: actor.id,
				text: actorSelectorWidget.getNiceName(actor)
			});
			testActorList.append(option);
		});

		//Pre-select the current actor.
		if (actorSelectorWidget.selectedActor !== null) {
			testActorList.val(actorSelectorWidget.selectedActor);
		}

		testAccessDialog.dialog('open');
	});

	testAccessButton.on('click', function () {
		testAccessButton.prop('disabled', true);
		testProgress.show();
		testProgressText.text('Sending menu settings...');

		var selectedOption = testMenuItemList.find('option:selected').first(),
			selectedMenu = selectedOption.data('menu_item');

		$.ajax(
			wsEditorData.adminAjaxUrl,
			{
				data: {
					'action': 'ws_ame_set_test_configuration',
					'data': encodeMenuAsJSON(testConfig),
					'_ajax_nonce': wsEditorData.setTestConfigurationNonce
				},
				method: 'post',
				dataType: 'json',
				success: function(response) {
					if (!response) {
						alert('Error: Could not parse the server response.');
						testAccessButton.prop('disabled', false);
						return;
					}
					if (response.error) {
						alert(response.error);
						testAccessButton.prop('disabled', false);
						return;
					}
					if (!response.success) {
						alert('Error: The request failed, but there is no error information available.');
						testAccessButton.prop('disabled', false);
						return;
					}

					throw new Error('Not fully implemented yet!');
					//Caution: Won't work in IE. Needs compat checks.
					//var testPageUrl = new URL(menuUrl, window.location.href);
					var testPageUrl = 'fixme';
					testPageUrl.searchParams.append('ame-test-menu-access-as', $('#ws_ame_test_access_username').val());
					testPageUrl.searchParams.append('_wpnonce', wsEditorData.testAccessNonce);
					testPageUrl.searchParams.append('ame-test-relevant-role', testActorList.val());

					testPageUrl.searchParams.append('ame-test-target-item', getFieldValue(selectedMenu, 'file', ''));
					testPageUrl.searchParams.append('ame-test-target-parent', selectedOption.data('parent_file'));

					testProgressText.text('Loading the test page....');
					$('#ws_ame_test_frame_placeholder').hide();

					$(window).on('message', receiveTestAccessResults);
					testAccessFrame
						.show()
						.on('load', onAccessTestLoaded)
						.prop('src', testPageUrl.href);
				},
				error: function(jqXHR, textStatus) {
					alert('HTTP Error: ' + textStatus);
					testAccessButton.prop('disabled', false);
				}
			}
		);
	});

	function onAccessTestLoaded() {
		testAccessFrame.off('load', onAccessTestLoaded);
		testProgress.hide();

		testAccessButton.prop('disabled', false);
	}

	function receiveTestAccessResults(event) {
		if (event.originalEvent.source !== testAccessFrame.get(0).contentWindow) {
			if (console && console.warn) {
				console.warn('AME: Received a message from an unexpected source. Message ignored.');
			}
			return;
		}
		var message = event.originalEvent.data || event.originalEvent.message;
		console.log('message received', message);

		$(window).off('message', receiveTestAccessResults);
	}


	//Finally, show the menu
	loadMenuConfiguration(customMenu);

	//Select the previous selected menu, if any.
	if (wsEditorData.selectedMenu) {
		AmeEditorApi.selectMenuItemByUrl(
			'#ws_menu_box',
			wsEditorData.selectedMenu,
			_.get(wsEditorData, 'expandSelectedMenu') === '1'
		);

		if (wsEditorData.selectedSubmenu) {
			AmeEditorApi.selectMenuItemByUrl(
				'#ws_submenu_box',
				wsEditorData.selectedSubmenu,
				_.get(wsEditorData, 'expandSelectedSubmenu') === '1'
			);
		}
	}

	//... and make the UI visible now that it's fully rendered.
	menuEditorNode.css('visibility', 'visible');

	//Add an extra class to the editor toolbars when their "position: sticky" triggers.
	//This is useful for adding a bottom border and other styles.
	if (IntersectionObserver) {
		/*
		This assumes that the toolbars stick below the admin bar. If that changes,
		this code will need to be updated.

		How do we detect that?
		- We could use IntersectionObserver to detect when the toolbar leaves the viewport,
		  but since it's sticky, it usually won't.
		- We can get around that by using negative root margins. Negative margins effectively shrink
		  the bounding box of the viewport. If we set the top margin to "-1px", the effective top of
		  the viewport will be 1px lower, so the observer will fire just *before* the toolbar would
		  leave the viewport.
		- The admin bar is always at the top of the viewport.
		- So we can detect when the toolbar is right below the admin bar by using a negative top
		  margin that is equal to the height of the admin bar + 1px.
		*/
		let observerRootMargin = '-33px'; //Default admin bar height is 32px.
		const adminBarHeight = $('#wpadminbar').outerHeight();
		if (adminBarHeight > 0) {
			observerRootMargin = (-1 * adminBarHeight - 1) + 'px';
		}

		const observer = new IntersectionObserver(
			(entries) => {
				for (const e of entries) {
					e.target.classList.toggle('ws_is_sticky_toolbar', e.intersectionRatio < 1);
				}
			},
			{
				threshold: [1],
				rootMargin: observerRootMargin + ' 0px 0px 0px'
			}
		);

		const editorToolbars = document.querySelectorAll('.ws_main_container .ws_toolbar');
		for (const toolbar of editorToolbars) {
			//Skip the toolbar that's inside the template column.
			if (toolbar.closest && (toolbar.closest('#ame-submenu-column-template') !== null)) {
				continue;
			}
			observer.observe(toolbar);
		}
	}
}

$(document).ready(ameOnDomReady);

//Compatibility workaround: If another plugin or theme throws an exception in its jQuery.ready() handler,
//our callback might never get run. As a backup, set a timer and manually check if the DOM is ready.
var domCheckAttempts = 0,
	maxDomCheckAttempts = 30;
var domCheckIntervalId = window.setInterval(function () {
	if (isDomReadyDone || (domCheckAttempts >= maxDomCheckAttempts)) {
		window.clearInterval(domCheckIntervalId);
		return;
	}
	domCheckAttempts++;

	if ($ && $.isReady) {
		window.clearInterval(domCheckIntervalId);
		ameOnDomReady();
	}
}, 1000);

})(jQuery, wsAmeLodash);

//==============================================
//				Screen options
//==============================================

jQuery(function($){
	'use strict';

	var screenOptions = $('#ws-ame-screen-meta-contents');
	var hideSettingsCheckbox = screenOptions.find('#ws-hide-advanced-settings');
	hideSettingsCheckbox.prop('checked', wsEditorData.hideAdvancedSettings);

	//Update editor state when settings change
	$('#ws-hide-advanced-settings').on('click', function(){
		wsEditorData.hideAdvancedSettings = hideSettingsCheckbox.prop('checked');

		//Show/hide advanced settings dynamically as the user changes the setting.
		if ($(this).is(hideSettingsCheckbox)) {
			var menuEditorNode = $('#ws_menu_editor');
			if ( wsEditorData.hideAdvancedSettings ){
				menuEditorNode.find('div.ws_advanced').hide();
				menuEditorNode.find('a.ws_toggle_advanced_fields').text(wsEditorData.captionShowAdvanced).show();
			} else {
				menuEditorNode.find('div.ws_advanced').show();
				menuEditorNode.find('a.ws_toggle_advanced_fields').text(wsEditorData.captionHideAdvanced).hide();
			}
		}

		$.post(
			wsEditorData.adminAjaxUrl,
			{
				'action' : 'ws_ame_save_screen_options',
				'hide_advanced_settings' : wsEditorData.hideAdvancedSettings ? 1 : 0,
				'show_extra_icons' : wsEditorData.showExtraIcons ? 1 : 0,
				'_ajax_nonce' : wsEditorData.hideAdvancedSettingsNonce
			}
		);
	});

	//Move our options into the screen meta panel
	var advSettings = $('#adv-settings');
	if (advSettings.length > 0) {
		advSettings.empty().append(screenOptions.show());
	}
});

Filemanager

Name Type Size Permission Actions
actor-manager.js File 32.62 KB 0644
actor-manager.js.map File 24.64 KB 0644
actor-manager.ts File 32.81 KB 0644
admin-helpers.js File 2.78 KB 0644
common.d.ts File 2.72 KB 0644
jquery-json.d.ts File 109 B 0644
jquery.biscuit.d.ts File 455 B 0644
jquery.biscuit.js File 5.37 KB 0644
jquery.color.d.ts File 5.06 KB 0644
jquery.d.ts File 146.39 KB 0644
jquery.form.d.ts File 804 B 0644
jquery.form.js File 40.93 KB 0644
jquery.json.js File 5.13 KB 0644
jquery.qtip.js File 100.34 KB 0644
jquery.qtip.min.js File 43.23 KB 0644
jquery.sort.js File 1.92 KB 0644
jqueryui.d.ts File 77.89 KB 0644
knockout-sortable.js File 22.49 KB 0644
knockout.d.ts File 42.65 KB 0644
knockout.js File 66.65 KB 0644
lazyload.d.ts File 10.2 KB 0644
lazyload.min.js File 8.1 KB 0644
lodash4.min.js File 71.54 KB 0644
menu-editor.d.ts File 662 B 0644
menu-editor.js File 185.29 KB 0644
menu-highlight-fix.js File 11.12 KB 0644
mini-func.js File 4.75 KB 0644
mini-func.js.map File 4.1 KB 0644
mini-func.ts File 5.02 KB 0644
tab-utils.js File 2.25 KB 0644