//(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.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 {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);
	}
};

/**
 * 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;
}

function outputWpMenu(menu){
	var menuCopy = $.extend(true, {}, menu);
	var menuBox = $('#ws_menu_box');

	//Remove the current menu data
	menuBox.empty();
	$('#ws_submenu_box').empty();

	//Display the new menu
	var i = 0;
	for (var filename in menuCopy){
		if (!menuCopy.hasOwnProperty(filename)){
			continue;
		}
		outputTopMenu(menuCopy[filename]);
		i++;
	}

	//Automatically select the first top-level menu
	menuBox.find('.ws_menu:first').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);
}

/*
 * 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){
	//Create the menu widget
	var menu_obj = buildMenuItem(menu, true);

	if ( (typeof afterNode !== 'undefined') && (afterNode !== null) ){
		$(afterNode).after(menu_obj);
	} else {
		menu_obj.appendTo('#ws_menu_box');
	}

	//Create a container for menu items, even if there are none
	var submenu = buildSubmenu(menu.items, menu_obj.attr('id'));
	submenu.appendTo('#ws_submenu_box');
	menu_obj.data('submenu_id', submenu.attr('id'));

	//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(menu_obj);

	return {
		'menu' : menu_obj,
		'submenu' : submenu
	};
}

/*
 * Create and populate a submenu container.
 */
function buildSubmenu(items, parentMenuId){
	//Create a container for menu items, even if there are none
	var submenu = $('<div class="ws_submenu" style="display:none;"></div>');
	submenu.attr('id', 'ws-submenu-'+(wsIdCounter++));

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

	//Only show menus that have items.
	//Skip arrays (with a length) because filled menus are encoded as custom objects.
	var entry = null;
	if (items) {
		$.each(items, function(index, item) {
			entry = buildMenuItem(item, false);
			if ( entry ){
				submenu.append(entry);
				updateItemEditor(entry);
			}
		});
	}

	//Make the submenu sortable
	makeBoxSortable(submenu);

	return submenu;
}

/**
 * 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;

	//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 ( isTopLevel && !itemData.separator ){
		//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){
				var droppedItemData = readItemState(ui.draggable);
				var new_item = buildMenuItem(droppedItemData, false);

				var sourceSubmenu = ui.draggable.parent();
				var submenu = $('#' + item.data('submenu_id'));
				submenu.append(new_item);

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

				updateItemEditor(new_item);

				//Moving an item can change aggregate menu permissions. Update the UI accordingly.
				updateParentAccessUi(submenu);
				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;
}

//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').html(formatMenuTitle(displayValue) + '&nbsp;');
			return displayValue;
		},
		write: function(menuItem, value, input, containerNode) {
			menuItem.menu_title = value;
			containerNode.find('.ws_item_title').html(stripAllTags(input.val()) + '&nbsp;');
		}
	}),

	'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',
		visible: false, //Displayed on-demand.
		addDropdown: 'ws_embedded_page_selector',

		display: function(menuItem, displayValue, input) {
			//Only show this field if the "Embed WP page" template is selected.
			input.closest('.ws_edit_field').toggle(menuItem.template_id === wsEditorData.embeddedPageTemplateId);

			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.
		}
	}),

	'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.attr('readonly', 'readonly');
				displayValue = itemTemplates.getDefaultValue(menuItem.template_id, 'url');
			} else {
				input.removeAttr('readonly');
			}
			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;
		}
	}),

	'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('.icon16');
			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('icon16');
				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('icon16 ' + 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('icon16').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
	})
};

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 class="ws_toggle_container"><a href="#" class="ws_toggle_advanced_fields"'+
		(wsEditorData.hideAdvancedSettings ? '' : ' style="display:none;" ' )+'>'+
		(wsEditorData.hideAdvancedSettings ? wsEditorData.captionShowAdvanced : wsEditorData.captionHideAdvanced)
		+'</a></div>'
	);
}

/*
 * 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 = null;
	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><input type="checkbox" class="ws_field_value"> <span class="ws_field_label_text">'+
                field_settings.caption + '</span></label>'
            );
            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="icon16 icon-settings"></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>' + field_settings.caption + '</span>');
			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 = '';
	if (field_settings.standardCaption) {
		var tooltip = '';
		if (field_settings.tooltip !== null) {
			tooltip = ' <a class="ws_field_tooltip_trigger"><div class="dashicons dashicons-info"></div></a>';
		}
		caption = '<span class="ws_field_label_text">' + field_settings.caption + tooltip + '</span><br>';
	}
	var editField = $('<div>' + caption + '</div>')
		.attr('class', className)
		.append(inputBox);

	if (field_settings.addDropdown) {
		//Add a dropdown button
		var dropdownId = field_settings.addDropdown;
		editField.append(
			$('<input type="button" value="&#9660;">')
				.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 = true;
	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 $([]);
	}
}

/**
 * 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');

	//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));
	}

	//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);
		}

		if (typeof (knownMenuFields[fieldName].visible) === 'function') {
			var isFieldVisible = knownMenuFields[fieldName].visible(menuItem, fieldName);
			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,

		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);
			}
		}
	});
}

/**
 * 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 {string} boxSelector
 * @param {string} url
 * @param {boolean|null} [expandProperties]
 * @returns {JQuery}
 */
AmeEditorApi.selectMenuItemByUrl = function(boxSelector, url, expandProperties) {
	if (typeof expandProperties === 'undefined') {
		expandProperties = null;
	}

	var box = $(boxSelector);
	if (box.is('#ws_submenu_box')) {
		box = box.find('.ws_submenu:visible').first();
	}

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

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

		if (expandProperties !== null) {
			var expandLink = containerNode.find('.ws_edit_link').first();
			if (expandLink.hasClass('ws_edit_link_expanded') !== expandProperties) {
				expandLink.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 $.toJSON(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;
	});

	AmeCapabilityManager.pruneGrantedUserCapabilities();

	var result = {
		tree: tree,
		color_presets: $.extend(true, {}, colorPresets),
		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 = false;
	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() {
	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.
	$(document).trigger('filterMenuFields.adminMenuEditor', [knownMenuFields, baseField]);

	//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
	 ***************************************************************************/
	var menuEditorNode = $('#ws_menu_editor'),
		submenuBox = $('#ws_submenu_box'),
		submenuDropZone = submenuBox.closest('.ws_main_container').find('.ws_dropzone');

	var currentVisibleSubmenu = null;

	/**
	 * Select a menu item and show its submenu.
	 *
	 * @param {JQuery|HTMLElement} container Menu container node.
	 */
	function selectItem(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');
		if (container.hasClass('ws_menu')) {
			//Show/hide the appropriate submenu
			if ( currentVisibleSubmenu ){
				currentVisibleSubmenu.hide();
			}
			currentVisibleSubmenu = $('#' + container.data('submenu_id')).show();

			updateSubmenuBoxHeight(container);

			currentVisibleSubmenu.closest('.ws_main_container')
				.find('.ws_toolbar .ws_delete_menu_button')
				.toggleClass('ws_button_disabled', !canDeleteItem(getSelectedSubmenuItem()));
		}

		//Make the "delete" button appear disabled if you can't delete this item.
		container.closest('.ws_main_container')
			.find('.ws_toolbar .ws_delete_menu_button')
			.toggleClass('ws_button_disabled', !canDeleteItem(container));
	}
	AmeEditorApi.selectItem = selectItem;

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

	function updateSubmenuBoxHeight(selectedMenu) {
		//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() + 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');
        }

        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) {
		    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]
	 */
	function setActorAccessForTreeAndUpdateUi(containerNode, actor, allowAccess) {
		setActorAccess(containerNode, actor, allowAccess);

		//Apply the same permissions to sub-menus.
		var subMenuId = containerNode.data('submenu_id');
		if (subMenuId && containerNode.hasClass('ws_menu')) {
			$('.ws_item', '#' + subMenuId).each(function() {
				var node = $(this);
				setActorAccess(node, actor, allowAccess);
				updateItemEditor(node);
			});
		}

		updateItemEditor(containerNode);
		updateParentAccessUi(containerNode);
	}

	/**
	 * 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').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;

	//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 = null;
		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 = $('#ws_extra_capability');
			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).
	$('#ws_extra_capability').bind('keyup', function(event){
		if ( event.which === 40 ){
			$('#ws_trigger_capability_dropdown').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.blur(function(){
		if (!isSuggestionClick) {
			hideCapSelector();
		}
	});

	dropdownNodes.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.keyup(function(event){
		if ( event.which === 9 ){
			event.preventDefault();
		}
	});


	//Update the input & hide the list when an option is clicked
	dropdownNodes.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.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);

		var customImageOption = iconSelector.find('.ws_custom_image_icon').hide();

		//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);
				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());
		}

		iconSelector.show();
		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').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;
		}
	});


	/*************************************************************************
	                        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);
		}
	});


	/*************************************************************************
	                             Color picker
	 *************************************************************************/

	var menuColorDialog = $('#ws-ame-menu-color-settings');
	if (menuColorDialog.length > 0) {
		menuColorDialog.dialog({
			autoOpen: false,
			closeText: ' ',
			draggable: false,
			modal: true,
			minHeight: 400,
			minWidth: 520
		});
	}

	var colorDialogState = {
		menuItem: null,
		editingGlobalColors: false
	};

	var menuColorVariables = [
		'base-color',
		'text-color',
		'highlight-color',
		'icon-color',

		'menu-highlight-text',
		'menu-highlight-icon',
		'menu-highlight-background',

		'menu-current-text',
		'menu-current-icon',
		'menu-current-background',

		'menu-submenu-text',
		'menu-submenu-background',
		'menu-submenu-focus-text',
		'menu-submenu-current-text',

		'menu-bubble-text',
		'menu-bubble-background',
		'menu-bubble-current-text',
		'menu-bubble-current-background'
	];

	var colorPresetDropdown = $('#ame-menu-color-presets'),
		colorPresetDeleteButton = $("#ws-ame-delete-color-preset"),
		areColorChangesIgnored = false;

	//Show only the primary color settings by default.
	var showAdvancedColors = false;
	$('#ws-ame-show-advanced-colors').click(function() {
		showAdvancedColors = !showAdvancedColors;
		$('#ws-ame-menu-color-settings').find('.ame-advanced-menu-color').toggle(showAdvancedColors);
		$(this).text(showAdvancedColors ? 'Hide advanced options' : 'Show advanced options');
	});

	var colorPickersInitialized = false;
	function setUpColorDialog(dialogTitle) {
		//Initializing the color pickers takes a while, so we only do it when needed instead of on document ready.
		if ( !colorPickersInitialized ) {
			menuColorDialog.find('.ame-color-picker').wpColorPicker({
				//Deselect the current preset when the user changes any of the color options.
				change: deselectPresetOnColorChange,
				clear: deselectPresetOnColorChange
			});
			colorPickersInitialized = true;
		}

		//Populate presets and deselect the previously selected option.
		colorPresetDropdown.val('');
		if (!wasPresetDropdownPopulated) {
			populatePresetDropdown();
			wasPresetDropdownPopulated = true;
		}

		//Update the dialog title.
		menuColorDialog.dialog('option', 'title', dialogTitle);
	}

	//"Edit.." color schemes.
	menuEditorNode.on('click', '.ws_open_color_editor, .ws_color_scheme_display', function() {
		var containerNode = $(this).parents('.ws_container').first();
		var menuItem = containerNode.data('menu_item');

		colorDialogState.containerNode = containerNode;
		colorDialogState.menuItem = menuItem;
		colorDialogState.editingGlobalColors = false;

		//Add menu title to the dialog caption.
		var title = getFieldValue(menuItem, 'menu_title', null);
		setUpColorDialog(title ? ('Colors: ' + formatMenuTitle(title)) : 'Colors');

		//Show the [global] preset only if the user has set it up.
		var globalPresetExists = colorPresets.hasOwnProperty('[global]');
		menuColorDialog.find('#ame-global-colors-preset').toggle(globalPresetExists);

		var colors = getFieldValue(menuItem, 'colors', {}),
			colorsToDisplay = colors || {};
		if (_.isEmpty(colors)) {
			//Normalization. No custom colors = use default colors, and null is used to indicate default settings.
			menuItem.colors = null;
			//If no custom colors, select and display the global preset.
			if (globalPresetExists) {
				colorsToDisplay = colorPresets['[global]'];
				colorPresetDropdown.val('[global]');
				colorPresetDeleteButton.hide();
			}
		}
		displayColorSettingsInDialog(colorsToDisplay);

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

	//The "Colors" button in the main sidebar.
	$('#ws_edit_global_colors').click(function() {
		colorDialogState.editingGlobalColors = true;
		colorDialogState.menuItem = null;
		colorDialogState.containerNode = null;

		setUpColorDialog('Default menu colors');
		displayColorSettingsInDialog(_.get(colorPresets, '[global]', {}));

		//Hide the [global] preset. We'll be editing it.
		menuColorDialog.find('#ame-global-colors-preset').hide();

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

	function getColorSettingsFromDialog() {
		var colors = {}, colorCount = 0;

		for (var i = 0; i < menuColorVariables.length; i++) {
			var name = menuColorVariables[i];
			var value = $('#ame-color-' + name).val();
			if (value) {
				colors[name] = value;
				colorCount++;
			}
		}

		if (colorCount > 0) {
			return colors;
		} else {
			return null;
		}
	}

	function displayColorSettingsInDialog(colors) {
		//noinspection JSUnusedAssignment
		areColorChangesIgnored = true;
		var customColorCount = 0;

		for (var i = 0; i < menuColorVariables.length; i++) {
			var name = menuColorVariables[i];
			var value = colors.hasOwnProperty(name) ? colors[name] : false;

			if ( value ) {
				$('#ame-color-' + name).wpColorPicker('color', value);
				customColorCount++;
			} else {
				$('#ame-color-' + name).closest('.wp-picker-container').find('.wp-picker-clear').click();
			}
		}

		areColorChangesIgnored = false;
		return customColorCount;
	}

	//The "Save Changes" button in the color dialog.
	$('#ws-ame-save-menu-colors').click(function() {
		menuColorDialog.dialog('close');
		var colors = getColorSettingsFromDialog();

		if ( colorDialogState.editingGlobalColors ) {
			if (colors === null) {
				delete colorPresets['[global]'];
			} else {
				colorPresets['[global]'] = colors;
			}
		} else if ( colorDialogState.menuItem ) {
			var menuItem = colorDialogState.menuItem;
			//If colors match the global settings, reset them to null. Using the [global] preset is the default.
			if (_.has(colorPresets, '[global]') && _.isEqual(colors, colorPresets['[global]'])) {
				menuItem.colors = null;
			} else {
				menuItem.colors = colors;
			}
			updateItemEditor(colorDialogState.containerNode);
		}

		colorDialogState.containerNode = null;
		colorDialogState.menuItem = null;
		colorDialogState.editingGlobalColors = false;
	});

	//The "Apply to All" button in the same dialog.
	$('#ws-ame-apply-colors-to-all').click(function() {
		if (!confirm('Apply these color settings to ALL top level menus?')) {
			return;
		}

		//Set this as the global preset and remove custom settings from all items.
		var newColors = getColorSettingsFromDialog();
		if (newColors === null) {
			delete colorPresets['[global]'];
		} else {
			colorPresets['[global]'] = newColors;
		}
		$('#ws_menu_box').find('.ws_menu').each(function() {
			var containerNode = $(this),
				menuItem = containerNode.data('menu_item');
			if (!menuItem.separator) {
				menuItem.colors = null;
				updateItemEditor(containerNode);
			}
		});

		menuColorDialog.dialog('close');
		colorDialogState.containerNode = null;
		colorDialogState.menuItem = null;
	});

	function addColorPreset(name, colors) {
		colorPresets[name] = colors;
		populatePresetDropdown();
		colorPresetDropdown.val(name);
		colorPresetDeleteButton.removeClass('hidden');
	}

	function deleteColorPreset(name) {
		delete colorPresets[name];
		populatePresetDropdown();
		colorPresetDropdown.val('');
		colorPresetDeleteButton.addClass('hidden');
	}

	function populatePresetDropdown() {
		var separator = colorPresetDropdown.find('#ame-color-preset-separator');

		//Delete the old options, but keep the "save preset" option and so on.
		colorPresetDropdown.find('option').not('.ame-meta-option').remove();

		//Sort presets alphabetically.
		var presetNames = $.map(colorPresets, function(unused, name) {
			return name;
		}).sort(function(a, b) {
			return a.localeCompare(b);
		});

		//Add them all to the dropdown.
		var newOptions = jQuery([]);
		$.each(presetNames, function(unused, name) {
			if (name === '[global]') {
				return;
			}

			newOptions = newOptions.add($('<option>', {
				val: name,
				text: name
			}));
		});
		newOptions.insertBefore(separator);
	}

	function deselectPresetOnColorChange() {
		//Most jQuery widgets don't trigger change events when you update them via JavaScript,
		//but apparently wpColorPicker does. We want to ignore those superfluous events.
		if (!areColorChangesIgnored && (colorPresetDropdown.val() !== '')) {
			colorPresetDropdown.val('');
		}
	}

	colorPresetDropdown.change(function() {
		var dropdown = $(this),
			presetName = dropdown.val();

		colorPresetDeleteButton.toggleClass('hidden', _.includes(['[save_preset]', '[global]', '', null], presetName));

		if ((presetName === '[save_preset]') && menuColorDialog.dialog('isOpen')) {
			//Create a new preset.
			var colors = getColorSettingsFromDialog();
			if (colors === null) {
				dropdown.val('');
				alert('Error: No colors selected');
				return;
			}

			var newPresetName = window.prompt('New preset name:', '');
			if ((newPresetName === null) || (jsTrim(newPresetName) === '')) {
				dropdown.val('');
				return;
			}

			addColorPreset(newPresetName, colors);
		} else if (presetName !== '') {
			//Apply the selected preset.
			var preset = colorPresets[presetName];
			displayColorSettingsInDialog(preset);
		}
	});

	colorPresetDeleteButton.click(function() {
		var presetName = $('#ame-menu-color-presets').val();
		if ( _.includes(['[save_preset]', '[global]', '', null], presetName) ) {
			return false;
		}
		if (!confirm('Are you sure you want to delete the preset "' + presetName + '"?')) {
			return false;
		}

		deleteColorPreset(presetName);
		return false;
	});

    /*************************************************************************
	                           Menu toolbar buttons
	 *************************************************************************/
    function getSelectedMenu() {
	    return $('#ws_menu_box').find('.ws_active');
    }

	//Show/Hide menu
	$('#ws_hide_menu').click(function (event) {
		event.preventDefault();

		//Get the selected menu
		var selection = getSelectedMenu();
		if (!selection.length) {
			return;
		}

		toggleItemHiddenFlag(selection);
	});

	//Hide a menu and deny access.
	menuEditorNode.find('.ws_toolbar').on('click', '.ws_hide_and_deny_button', function() {
		var $box = $(this).closest('.ws_main_container').find('.ws_box'),
			selection = $box.is('#ws_menu_box') ? getSelectedMenu() : getSelectedSubmenuItem();
		if (selection.length < 1) {
			return;
		}

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

		if (actorSelectorWidget.selectedActor === null) {
			//Hide from everyone except Super Admin and the current user.
			var 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));

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

			walkMenuTree(selection, function(container, item) {
				var 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.
					var 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.
					var 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').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())
				.pluck('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').click(function() {
		menuDeletionCallback(false);
	});
	$('#ws_hide_menu_from_everyone').click(function() {
		menuDeletionCallback('all');
	});
	$('#ws_hide_menu_except_current_user').click(function() {
		menuDeletionCallback('except_current_user');
	});
	$('#ws_hide_menu_except_administrator').click(function() {
		menuDeletionCallback('except_administrator');
	});

	/**
	 * 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);
	}

	/**
	 * 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.
			$('#ws_hide_menu_except_current_user').toggleClass('hidden', !wsEditorData.wsMenuEditorPro);
			$('#ws_hide_menu_except_administrator').toggleClass('hidden', wsEditorData.wsMenuEditorPro);

			menuDeletionDialog.dialog('open');

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

		if (shouldDelete) {
			//Delete this menu's submenu first, if any.
			var submenuId = selection.data('submenu_id');
			if (submenuId) {
				$('#' + submenuId).remove();
			}
			var parentSubmenu = selection.closest('.ws_submenu');

			//Delete the menu.
			selection.remove();

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

	//Delete menu
	$('#ws_delete_menu').click(function (event) {
		event.preventDefault();

		//Get the selected menu
		var selection = getSelectedMenu();
		if (!selection.length) {
			return;
		}

		tryDeleteItem(selection);
	});

	//Copy menu
	$('#ws_copy_menu').click(function (event) {
		event.preventDefault();

		//Get the selected menu
		var selection = $('#ws_menu_box').find('.ws_active');
		if (!selection.length) {
			return;
		}

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

	//Cut menu
	$('#ws_cut_menu').click(function (event) {
		event.preventDefault();

		//Get the selected menu
		var selection = $('#ws_menu_box').find('.ws_active');
		if (!selection.length) {
			return;
		}

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

		//Remove the original menu and submenu
		$('#'+selection.data('submenu_id')).remove();
		selection.remove();
	});

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

		//If we're pasting from a sub-menu, we may need to fix some properties
		//that are blank for sub-menu items but required for top-level menus.
		if (getFieldValue(menu, 'css_class', '') == '') {
			menu.css_class = 'menu-top';
		}
		if (getFieldValue(menu, 'icon_url', '') == '') {
			menu.icon_url = 'dashicons-admin-generic';
		}
		if (getFieldValue(menu, 'hookname', '') == '') {
			menu.hookname = randomMenuId();
		}

		//Paste the menu after the specified one, or at the end of the list.
		if (afterMenu) {
			return outputTopMenu(menu, afterMenu);
		} else {
			return outputTopMenu(menu);
		}
	}

	$('#ws_paste_menu').click(function (event) {
		event.preventDefault();

		//Check if anything has been copied/cut
		if (!menu_in_clipboard) {
			return;
		}

		var menu = $.extend(true, {}, menu_in_clipboard);

		//Get the selected menu
		var selection = $('#ws_menu_box').find('.ws_active');
		//Paste the menu after the selection.
		pasteMenu(menu, (selection.length > 0) ? selection : null);
	});

	//New menu
	$('#ws_new_menu').click(function (event) {
		event.preventDefault();

		ws_paste_count++;

		//The new menu starts out rather bare
		var randomId = randomMenuId();
		var menu = $.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 : randomId,
			items: []
		});
		menu.defaults = $.extend(true, {}, itemTemplates.getDefaults(''));

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

		//Insert the new menu
		var selection = $('#ws_menu_box').find('.ws_active');
		var result = outputTopMenu(menu, (selection.length > 0) ? selection : null);

		//The menus's editbox is always open
		result.menu.find('.ws_edit_link').click();
	});

	//New separator
	$('#ws_new_separator, #ws_new_submenu_separator').click(function (event) {
		event.preventDefault();

		ws_paste_count++;

		//The new menu starts out rather bare
		var randomId = randomMenuId('separator_');
		var menu = $.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
			}
		});

		if ( $(this).attr('id').indexOf('submenu') === -1 ) {
			//Insert in the top-level menu.
			var selection = $('#ws_menu_box').find('.ws_active');
			outputTopMenu(menu, (selection.length > 0) ? selection : null);
		} else {
			//Insert in the currently visible submenu.
			pasteItem(menu);
		}
	});

	//Toggle all menus for the currently selected actor
	$('#ws_toggle_all_menus').click(function(event) {
		event.preventDefault();

		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;
		}

		var topMenuNodes = $('.ws_menu', '#ws_menu_box');
		//Look at the first menu's permissions and set everything to the opposite.
		var allow = ! actorCanAccessMenu(topMenuNodes.eq(0).data('menu_item'), actorSelectorWidget.selectedActor);

		topMenuNodes.each(function() {
			var 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.
	$('#ws_copy_role_permissions').click(function(event) {
		event.preventDefault();

		var 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) {
			var 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.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.
		var allMenuNodes = $('.ws_menu', '#ws_menu_box').add('.ws_item', submenuBox);
		allMenuNodes.each(function() {
			var node = $(this);
			var menuItem = node.data('menu_item');

			//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.
			var sourceAccess      = actorCanAccessMenu(menuItem, sourceActor);
			var 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).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.find('.ws_toolbar').on('click', '.ws_sort_menus_button', function(event) {
		event.preventDefault();

		var button = $(this),
			direction = button.data('sort-direction') || 'asc',
			menuBox = $(this).closest('.ws_main_container').find('.ws_box').first();

		if (menuBox.is('#ws_submenu_box')) {
			menuBox = menuBox.find('.ws_submenu:visible').first();
		}

		if (menuBox.length > 0) {
			sortMenuItems(menuBox, direction);
		}

		//When sorting the top level menu also sort submenus, but 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.
		if (menuBox.is('#ws_menu_box')) {
			$('#ws_submenu_box').find('.ws_submenu').each(function() {
				sortMenuItems($(this), direction, true);
			});
		}
	});

	/**
	 * 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.
	$('#ws_toggle_toolbar').click(function() {
		var 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 $('#ws_submenu_box').find('.ws_submenu:visible .ws_active');
	}

	//Show/Hide item
	$('#ws_hide_item').click(function (event) {
		event.preventDefault();

		//Get the selected item
		var selection = getSelectedSubmenuItem();
		if (!selection.length) {
			return;
		}

		//Mark the item as hidden/visible
		toggleItemHiddenFlag(selection);
	});

	//Delete item
	$('#ws_delete_item').click(function (event) {
		event.preventDefault();

		var selection = getSelectedSubmenuItem();
		if (!selection.length) {
			return;
		}

		tryDeleteItem(selection);
	});

	//Copy item
	$('#ws_copy_item').click(function (event) {
		event.preventDefault();

		//Get the selected item
		var selection = getSelectedSubmenuItem();
		if (!selection.length) {
			return;
		}

		//Store a copy of item state in the clipboard
		menu_in_clipboard = readItemState(selection);
	});

	//Cut item
	$('#ws_cut_item').click(function (event) {
		event.preventDefault();

		//Get the selected item
		var selection = getSelectedSubmenuItem();
		if (!selection.length) {
			return;
		}

		//Store a copy of item state in the clipboard
		menu_in_clipboard = readItemState(selection);

		var submenu = selection.parent();
		//Remove the original item
		selection.remove();
		updateParentAccessUi(submenu);
	});

	//Paste item
	function pasteItem(item, targetSubmenu) {
		//We're pasting this item into a sub-menu, so it can't have a sub-menu of its own.
		//Instead, any sub-menu items belonging to this item will be pasted after the item.
		var newItems = [];
		for (var file in item.items) {
			if (item.items.hasOwnProperty(file)) {
				newItems.push(buildMenuItem(item.items[file], false));
			}
		}
		item.items = [];

		newItems.unshift(buildMenuItem(item, false));

		//Paste into the currently visible submenu by default.
		targetSubmenu = targetSubmenu || $('#ws_submenu_box').find('.ws_submenu:visible');
		//Get the selected menu
		var selection = targetSubmenu.find('.ws_active');
		for(var i = 0; i < newItems.length; i++) {
			if (selection.length > 0) {
				//If an item is selected add the pasted items after it
				selection.after(newItems[i]);
			} else {
				//Otherwise add the pasted items at the end
				targetSubmenu.append(newItems[i]);
			}

			updateItemEditor(newItems[i]);
			newItems[i].show();
		}

		updateParentAccessUi(targetSubmenu);
	}

	$('#ws_paste_item').click(function (event) {
		event.preventDefault();

		//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;
		}

		//Paste it.
		var item = $.extend(true, {}, menu_in_clipboard);
		pasteItem(item);
	});

	//New item
	$('#ws_new_item').click(function (event) {
		event.preventDefault();

		if ($('.ws_submenu:visible').length < 1) {
			return; //Abort if no submenu visible
		}

		ws_paste_count++;

		var entry = $.extend(true, {}, wsEditorData.blankMenuItem, {
			custom: true,
			template_id : '',
			menu_title : 'Custom Item ' + ws_paste_count,
			file : randomMenuId(),
			items: []
		});
		entry.defaults = $.extend(true, {}, itemTemplates.getDefaults(''));

		//Make it accessible to only the currently selected actor.
		if (actorSelectorWidget.selectedActor !== null) {
			denyAccessForAllExcept(entry, actorSelectorWidget.selectedActor);
		}

		var menu = buildMenuItem(entry);

		//Insert the item into the currently open submenu.
		var visibleSubmenu = $('#ws_submenu_box').find('.ws_submenu:visible');
		var selection = visibleSubmenu.find('.ws_active');
		if (selection.length > 0) {
			selection.after(menu);
		} else {
			visibleSubmenu.append(menu);
		}
		updateItemEditor(menu);

		//The items's editbox is always open
		menu.find('.ws_edit_link').click();

		updateParentAccessUi(menu);
	});

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

	//Save Changes - encode the current menu as JSON and save
	$('#ws_save_menu').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);

		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').submit();
	});

	//Load default menu - load the default WordPress menu
	$('#ws_load_menu').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').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').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').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').click(function(){
		$('#export_dialog').dialog('close');
	});

	$('#download_menu_button').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
	});

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

	$('#ws_import_menu').click(function(){
		$('#import_progress_notice, #import_progress_notice2, #import_complete_notice, #ws_import_error').hide();
		$('#ws_import_panel').show();
		$('#import_menu_form').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').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.
		$('#import_menu_form').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
	$('#import_menu_form').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
				$('#import_menu_form').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
	 *************************************************************************/

	if (wsEditorData.wsMenuEditorPro) {
		//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){
				var droppedItemData = readItemState(ui.draggable);
				var newItemNodes = pasteMenu(droppedItemData);

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

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

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

		//...and to drag top level menus to a sub-menu.
		submenuBox.closest('.ws_main_container').droppable({
			'hoverClass' : 'ws_top_to_submenu_drop_hover',

			'accept' : (function(thing){
				var visibleSubmenu = $('#ws_submenu_box').find('.ws_submenu:visible');
				return (
					//Accept top-level menus
					thing.hasClass('ws_menu') &&

					//Prevent users from dropping a menu on its own sub-menu.
					(visibleSubmenu.attr('id') !== thing.data('submenu_id'))
				);
			}),

			'drop' : (function(event, ui){
				var droppedItemData = readItemState(ui.draggable);
				pasteItem(droppedItemData);
				if ( !event.ctrlKey ) {
					ui.draggable.remove();
				}
			})
		});
	}

	/******************************************************************
	                 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.click(function() {
			AmeEditorApi.setComponentVisibility(
				'adminMenu',
				actorSelectorWidget.selectedActor,
				$(this).is(':checked')
			);
		});
		$showWpToolbar.click(function () {
			AmeEditorApi.setComponentVisibility(
				'toolbar',
				actorSelectorWidget.selectedActor,
				$(this).is(':checked')
			);
		});

		$generalVisBox.find('.handlediv').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();
		});
	}

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


	//Set up tooltips
	$('.ws_tooltip_trigger').qtip({
		style: {
			classes: 'qtip qtip-rounded ws_tooltip_node'
		},
		hide: {
			fixed: true,
			delay: 300
		}
	});

	//Set up menu field toltips.
	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 = '';

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

					return text;
				}
			},
			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').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',
				'hint' : hint.attr('id')
			}
		);
	});

	//Expand/collapse the "How To" box.
	var $howToBox = $("#ws_ame_how_to_box");
	$howToBox.find(".handlediv").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').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.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'),
			menuUrl = selectedOption.val();

		$.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;
					}

					//Caution: Won't work in IE. Needs compat checks.
					var testPageUrl = new URL(menuUrl, window.location.href);
					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');
}

$(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) {
		isDomReadyDone = true;
		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').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());
	}
});