//(c) W-Shadow /*global wsEditorData, defaultMenu, customMenu, _:false */ /** * @property wsEditorData * @property {boolean} wsEditorData.wsMenuEditorPro * * @property {object} wsEditorData.blankMenuItem * @property {object} wsEditorData.itemTemplates * @property {object} wsEditorData.customItemTemplate * * @property {string} wsEditorData.adminAjaxUrl * @property {string} wsEditorData.imagesUrl * * @property {string} wsEditorData.menuFormatName * @property {string} wsEditorData.menuFormatVersion * * @property {boolean} wsEditorData.hideAdvancedSettings * @property {boolean} wsEditorData.showExtraIcons * @property {boolean} wsEditorData.dashiconsAvailable * @property {string} wsEditorData.submenuIconsEnabled * * @property {Object} wsEditorData.showHints * @property {string} wsEditorData.hideHintNonce * * @property {string} wsEditorData.hideAdvancedSettingsNonce * @property {string} wsEditorData.getPagesNonce * @property {string} wsEditorData.getPageDetailsNonce * @property {string} wsEditorData.disableDashboardConfirmationNonce * * @property {string} wsEditorData.captionShowAdvanced * @property {string} wsEditorData.captionHideAdvanced * * @property {string} wsEditorData.unclickableTemplateId * @property {string} wsEditorData.unclickableTemplateClass * @property {string} wsEditorData.embeddedPageTemplateId * * @property {string} wsEditorData.currentUserLogin * @property {string|null} wsEditorData.selectedActor * * @property {object} wsEditorData.actors * @property {string[]} wsEditorData.visibleUsers * * @property {object} wsEditorData.postTypes * @property {object} wsEditorData.taxonomies * * @property {string|null} wsEditorData.selectedMenu * @property {string|null} wsEditorData.selectedSubmenu * * @property {string} wsEditorData.setTestConfigurationNonce * @property {string} wsEditorData.testAccessNonce * * @property {string|null} wsEditorData.deepNestingEnabled * * @property {object} wsEditorData.auxDataConfig * * @property {boolean} wsEditorData.isDemoMode * @property {boolean} wsEditorData.isMasterMode */ wsEditorData.wsMenuEditorPro = !!wsEditorData.wsMenuEditorPro; //Cast to boolean. var wsIdCounter = 0; //A bit of black magic/hack to convince my IDE that wsAmeLodash is an alias for lodash. window.wsAmeLodash = (function() { 'use strict'; if (typeof wsAmeLodash !== 'undefined') { return wsAmeLodash; } return _.noConflict(); })(); //These two properties must be objects, not arrays. jQuery.each(['grant_access', 'hidden_from_actor'], function(unused, key) { 'use strict'; if (wsEditorData.blankMenuItem.hasOwnProperty(key) && !jQuery.isPlainObject(wsEditorData.blankMenuItem[key])) { wsEditorData.blankMenuItem[key] = {}; } }); AmeCapabilityManager = AmeActors; /** * A utility for retrieving post and page titles. */ var AmePageTitles = (function($) { 'use strict'; var me = {}, cache = {}; function getCacheKey(pageId, blogId) { return blogId + '_' + pageId; } /** * Add a page title to the cache. * * @param {Number} pageId Post or page ID. * @param {Number} blogId Blog ID. * @param {String} title The title of the post or page. */ me.add = function(pageId, blogId, title) { cache[getCacheKey(pageId, blogId)] = title; }; /** * Get page title. * * Note: This method does not return the title. Instead, it calls the provided callback with the title * as the first argument. The callback will be executed asynchronously if the title hasn't been cached yet. * * @param {Number} pageId * @param {Number} blogId * @param {Function} callback */ me.get = function(pageId, blogId, callback) { var key = getCacheKey(pageId, blogId); if (typeof cache[key] !== 'undefined') { callback(cache[key], pageId, blogId); return; } $.getJSON( wsEditorData.adminAjaxUrl, { 'action' : 'ws_ame_get_page_details', '_ajax_nonce' : wsEditorData.getPageDetailsNonce, 'post_id' : pageId, 'blog_id' : blogId }, function(details) { var title; if (typeof details.error !== 'undefined'){ title = details.error; } else if ((typeof details !== 'object') || (typeof details.post_title === 'undefined')) { title = '< Server error >'; } else { title = details.post_title; } cache[key] = title; callback(cache[key], pageId, blogId); } ); }; return me; })(jQuery); var AmeEditorApi = {}; window.AmeEditorApi = AmeEditorApi; (function ($, _){ 'use strict'; var actorSelectorWidget = new AmeActorSelector(AmeActors, wsEditorData.wsMenuEditorPro); AmeEditorApi.actorSelectorWidget = actorSelectorWidget; var itemTemplates = { templates: wsEditorData.itemTemplates, getTemplateById: function(templateId) { if (wsEditorData.itemTemplates.hasOwnProperty(templateId)) { return wsEditorData.itemTemplates[templateId]; } else if ((templateId === '') || (templateId === 'custom')) { return wsEditorData.customItemTemplate; } return null; }, getDefaults: function (templateId) { var template = this.getTemplateById(templateId); if (template) { return template.defaults; } else { return null; } }, getDefaultValue: function (templateId, fieldName) { if (fieldName === 'template_id') { return null; } var defaults = this.getDefaults(templateId); if (defaults && (typeof defaults[fieldName] !== 'undefined')) { return defaults[fieldName]; } return null; }, hasDefaultValue: function(templateId, fieldName) { return (this.getDefaultValue(templateId, fieldName) !== null); } }; /** * @type {AmeMenuPresenter} */ let menuPresenter; /** * Set an input field to a value. The only difference from jQuery.val() is that * setting a checkbox to true/false will check/clear it. * * @param input * @param value */ function setInputValue(input, value) { if (input.attr('type') === 'checkbox'){ input.prop('checked', value); } else { input.val(value); } } /** * Get the value of an input field. The only difference from jQuery.val() is that * checked/unchecked checkboxes will return true/false. * * @param input * @return {*} */ function getInputValue(input) { if (input.attr('type') === 'checkbox'){ return input.is(':checked'); } return input.val(); } /* * Utility function for generating pseudo-random alphanumeric menu IDs. * Rationale: Simpler than atomically auto-incrementing or globally unique IDs. */ function randomMenuId(prefix, size){ prefix = (typeof prefix === 'undefined') ? 'custom_item_' : prefix; size = (typeof size === 'undefined') ? 5 : size; var suffix = ""; var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; for( var i=0; i < size; i++ ) { suffix += possible.charAt(Math.floor(Math.random() * possible.length)); } return prefix + suffix; } AmeEditorApi.randomMenuId = randomMenuId; function outputWpMenu(menu){ const menuCopy = $.extend(true, {}, menu); //Remove the current menu data menuPresenter.clear(); //Display the new menu const firstColumn = menuPresenter.getColumnImmediate(1); const itemList = firstColumn.getVisibleItemList(); for (let filename in menuCopy){ if (!menuCopy.hasOwnProperty(filename)){ continue; } firstColumn.outputItem(menuCopy[filename], null, itemList); } //Automatically select the first top-level menu if (itemList) { itemList.find('.ws_menu:first').trigger('click'); } } /** * Load a menu configuration in the editor. * Note: All previous settings will be discarded without warning. Unsaved changes will be lost. * * @param {Object} adminMenu The menu structure to load. */ function loadMenuConfiguration(adminMenu) { //There are some menu properties that need to be objects, but PHP JSON-encodes empty associative //arrays as numeric arrays. We want them to be empty objects instead. if (adminMenu.hasOwnProperty('color_presets') && !$.isPlainObject(adminMenu.color_presets)) { adminMenu.color_presets = {}; } var objectProperties = ['grant_access', 'hidden_from_actor']; //noinspection JSUnusedLocalSymbols function fixEmptyObjects(unused, menuItem) { for (var i = 0; i < objectProperties.length; i++) { var key = objectProperties[i]; if (menuItem.hasOwnProperty(key) && !$.isPlainObject(menuItem[key])) { menuItem[key] = {}; } } if (menuItem.hasOwnProperty('items')) { $.each(menuItem.items, fixEmptyObjects); } } $.each(adminMenu.tree, fixEmptyObjects); //Load color presets from the new configuration. if (typeof adminMenu.color_presets === 'object') { colorPresets = $.extend(true, {}, adminMenu.color_presets); } else { colorPresets = {}; } wasPresetDropdownPopulated = false; //Load capabilities. AmeCapabilityManager.setGrantedCapabilities(_.get(adminMenu, 'granted_capabilities', {})); //Load general menu visibility. generalComponentVisibility = _.get(adminMenu, 'component_visibility', {}); AmeEditorApi.refreshComponentVisibility(); //Display the new admin menu. outputWpMenu(adminMenu.tree); $(document).trigger('menuConfigurationLoaded.adminMenuEditor', adminMenu); } /** * Check if it's possible to delete a menu item. * * @param {JQuery} containerNode * @returns {boolean} */ function canDeleteItem(containerNode) { if (!containerNode || (containerNode.length < 1)) { return false; } var menuItem = containerNode.data('menu_item'); var isDefaultItem = ( menuItem.template_id !== '') && ( menuItem.template_id !== wsEditorData.unclickableTemplateId) && ( menuItem.template_id !== wsEditorData.embeddedPageTemplateId) && (!menuItem.separator); var otherCopiesExist = false; if (isDefaultItem) { //Check if there are any other menus with the same template ID. $('#ws_menu_editor').find('.ws_container').each(function() { var otherItem = $(this).data('menu_item'); if ((menuItem !== otherItem) && (menuItem.template_id === otherItem.template_id)) { otherCopiesExist = true; return false; } return true; }); } return (!isDefaultItem || otherCopiesExist); } /** * Get or create the submenu container of a menu item. * * @param {JQuery|null} container * @param {AmeEditorColumn} [nextColumn] * @return {JQuery|null} */ function getSubmenuOf(container, nextColumn) { if (!container || (container.length < 1)) { return null; } const submenuId = container.data('submenu_id'); if (submenuId) { let $submenu = $('#' + submenuId).first(); if ($submenu.length > 0) { return $submenu; } } //If a submenu doesn't exist yet, create it in the next column. if (nextColumn) { return createSubmenuFor(container, nextColumn); } else { return null; } } /** * Create a submenu container for a menu item. * @param {JQuery} container * @param {AmeEditorColumn} nextColumn * @return {JQuery} */ function createSubmenuFor(container, nextColumn) { const $submenu = nextColumn.buildSubmenuContainer(container.attr('id')); nextColumn.appendSubmenuContainer($submenu); container.data('submenu_id', $submenu.attr('id')) return $submenu; } /** * @param {Number} level * @param {JQuery|null} predecessor * @param {JQuery|null} [container] * @param {Function} [getNextColumn] * @constructor */ function AmeEditorColumn(level, predecessor, container, getNextColumn) { const self = this; this.level = level; this.usesSubmenuContainers = (this.level > 1); if ((typeof container === 'undefined') || (container === null)) { container = $('#ame-submenu-column-template').first().clone(); container.attr('id', ''); container.find('.ws_box').first().attr('id', ''); container.show().insertAfter(predecessor); } container.data('ame-menu-level', level); container.addClass('ame-editor-column-' + level); this.container = container; this.menuBox = container.find('.ws_box').first(); this.dropZone = container.children('.ws_dropzone').first(); this.visibleItemList = null; if (!this.usesSubmenuContainers) { this.menuBox.addClass('ame-visible-item-list'); } if (typeof getNextColumn !== 'undefined') { this.getNextColumn = getNextColumn; } else { this.getNextColumn = function(callback) { callback(null); }; } this.container.children('.ws_toolbar').on('click', '.ws_button', function() { const $button = $(this); let buttonAction = $button.data('ame-button-action') || 'unknown'; let selectedItem = self.getSelectedItem(); self.container.trigger( 'adminMenuEditor:action-' + buttonAction, [(selectedItem.length > 0) ? selectedItem : null, self, $button] ); return false; }); } /** * Create editor widgets for a menu item and its submenus. * * @param {Object} itemData An object containing menu data. * @param {JQuery|null|number} [insertPosition] Insert the widget after this node. If it's NULL, the widget * will be added to the end fo the list. If it's -1, the widget will be added to the beginning. * @param {JQuery} [itemList] The container where to insert the widget. Defaults to the currently * visible item list. For columns that don't use submenu containers, it's always the menuBox. * @return {Object} Object with two fields - 'menu' and 'submenu' - containing the jQuery objects * of the created widgets. */ AmeEditorColumn.prototype.outputItem = function(itemData, insertPosition, itemList) { if (!itemList) { itemList = this.getVisibleItemList(); } const self = this; //Create the menu widget const isTopLevel = this.level <= 1; const $item = buildMenuItem(itemData, isTopLevel); if (typeof insertPosition === 'undefined') { insertPosition = null; } if (insertPosition === null) { $item.appendTo(itemList); } else if (insertPosition === -1) { $item.prependTo(itemList); } else { //phpcs:ignore WordPressVIPMinimum.JS.HTMLExecutingFunctions -- buildMenuItem() should be safe. $(insertPosition).after($item); } const children = (typeof itemData.items !== 'undefined') ? itemData.items : []; const hasChildren = !_.isEmpty(children); let $submenu = null; this.getNextColumn( /** * @param {AmeEditorColumn|null} nextColumn */ function (nextColumn) { if (nextColumn) { //Create a submenu container even if this item doesn't have children. //The user could add submenu items later. $submenu = createSubmenuFor($item, nextColumn); //Output children. if (hasChildren) { $.each(children, function (index, item) { nextColumn.outputItem(item, null, $submenu); }); } } else { //TODO: This branch could be optimized by letting the recursive outputItem call know that there is no next column. //There is no next column, so any submenu items that belong to this item will be //displayed in the same column, below the item. if (hasChildren) { let $previousItem = $item; $.each(children, function (index, child) { const result = self.outputItem(child, $previousItem, itemList); if (result && result.menu) { $previousItem = result.menu; } }); } } //Note: Update the menu only after its children are ready. It needs the submenu items to decide //whether to display the access checkbox as checked or indeterminate. updateItemEditor($item); }, hasChildren ); //Note that $submenu could still be NULL at this point if the "get next column" callback //is called asynchronously. return { 'menu': $item, 'submenu': $submenu }; }; /** * Paste a menu item in this column. * * @param {Object} item * @param {JQuery|null|number} [insertPosition] Defaults to inserting the item below the current selection. * Set to NULL to paste at the end of the list, or -1 to paste at the beginning. * @param {JQuery} [itemList] */ AmeEditorColumn.prototype.pasteItem = function (item, insertPosition, itemList) { if (typeof insertPosition === 'undefined') { insertPosition = this.getSelectedItem(); if (insertPosition.length < 1) { insertPosition = null; } } if (!itemList) { itemList = this.getVisibleItemList(); } //The user shouldn't need to worry about giving separators a unique filename. if (item.separator) { item.defaults.file = randomMenuId('separator_'); } //If we're pasting from a sub-menu into the top level, we may need to fix some properties //that are blank for sub-menu items but required for top level menus. const isTopLevel = this.level <= 1; if (isTopLevel) { function isNonEmptyString(value) { return (typeof value === 'string') && (value !== ''); } if (!isNonEmptyString(getFieldValue(item, 'css_class', ''))) { item.css_class = 'menu-top'; } if (!isNonEmptyString(getFieldValue(item, 'icon_url', ''))) { item.icon_url = 'dashicons-admin-generic'; } if (!isNonEmptyString(getFieldValue(item, 'hookname', ''))) { item.hookname = randomMenuId(); } } const result = this.outputItem(item, insertPosition, itemList); if (this.level > 1) { updateParentAccessUi(itemList); } return result; }; /** * @return {JQuery|null} */ AmeEditorColumn.prototype.getVisibleItemList = function() { if (this.usesSubmenuContainers) { if (this.visibleItemList) { return this.visibleItemList; } const $list = this.menuBox.children('.ws_submenu:visible').first().addClass('ame-visible-item-list'); if ($list && ($list.length > 0)) { this.visibleItemList = $list; } return $list; } else { return this.menuBox; } }; /** * @param {JQuery|null} $submenu */ AmeEditorColumn.prototype.setVisibleItemList = function($submenu) { //Do nothing if the new list is the same as the old one. if (($submenu === this.visibleItemList) || ($submenu && ($submenu.is(this.visibleItemList)))) { return; } if (this.visibleItemList) { this.visibleItemList.hide().removeClass('ame-visible-item-list'); } this.visibleItemList = $submenu; if (this.visibleItemList) { this.visibleItemList.show().addClass('ame-visible-item-list'); } //Each item list/submenu has its own own selected item, so switching to a different item list //also effectively changes the selected item. this.selectionHasChanged(); }; /** * @return {JQuery} */ AmeEditorColumn.prototype.getAllItemLists = function() { if (this.usesSubmenuContainers) { return this.menuBox.children('.ws_submenu'); } return this.menuBox; }; /** * @return {JQuery} */ AmeEditorColumn.prototype.getSelectedItem = function() { const list = this.getVisibleItemList(); if (list && (list.length > 0)) { return list.children('.ws_active').first(); } return $([]); }; /** * @param {JQuery} container */ AmeEditorColumn.prototype.selectItem = function(container) { if (container.hasClass('ws_active')) { //The menu item is already selected. return; } //Highlight the active item and un-highlight the previous one container.addClass('ws_active'); container.siblings('.ws_active').removeClass('ws_active'); this.selectionHasChanged(container); }; /** * @param {JQuery|null} [$item] */ AmeEditorColumn.prototype.selectionHasChanged = function($item) { if (typeof $item === 'undefined') { $item = this.getSelectedItem(); } if (!$item || ($item.length < 1)) { $item = null; } //Make the "delete" button appear disabled if you can't delete this item. this.container.find('.ws_toolbar .ws_delete_menu_button') .toggleClass('ws_button_disabled', !canDeleteItem($item)) const self = this; this.getNextColumn(function(nextColumn) { if (nextColumn) { nextColumn.setVisibleItemList(getSubmenuOf($item, nextColumn)); if ($item) { self.updateSubmenuBoxHeight($item, nextColumn); } } }, false); }; /** * @param {JQuery} selectedMenu * @param {AmeEditorColumn} nextColumn */ AmeEditorColumn.prototype.updateSubmenuBoxHeight = function updateSubmenuBoxHeight(selectedMenu, nextColumn) { if (!nextColumn || (nextColumn === this)) { return; } let mainMenuBox = this.menuBox, submenuBox = nextColumn.menuBox, submenuDropZone = nextColumn.dropZone; //Make the submenu box tall enough to reach the selected item. //This prevents the menu tip (if any) from floating in empty space. if (selectedMenu.hasClass('ws_menu_separator')) { submenuBox.css('min-height', ''); } else { var menuTipHeight = 30, empiricalExtraHeight = 4, verticalBoxOffset = (submenuBox.offset().top - mainMenuBox.offset().top), minSubmenuHeight = (selectedMenu.offset().top - mainMenuBox.offset().top) - verticalBoxOffset + menuTipHeight - (submenuDropZone.outerHeight() || 0) + empiricalExtraHeight; minSubmenuHeight = Math.max(minSubmenuHeight, 0); submenuBox.css('min-height', minSubmenuHeight); } } AmeEditorColumn.prototype.buildSubmenuContainer = function(parentMenuId) { //Create a container for menu items. const submenu = $('
'); submenu.attr('id', 'ws-submenu-'+(wsIdCounter++)); if (parentMenuId) { submenu.data('parent_menu_id', parentMenuId); } //Make the submenu sortable makeBoxSortable(submenu); return submenu; }; AmeEditorColumn.prototype.appendSubmenuContainer = function($submenu) { this.usesSubmenuContainers = true; $submenu.appendTo(this.menuBox); }; /** * Delete a menu item and all of its children. * * @param {JQuery} container */ AmeEditorColumn.prototype.destroyItem = function(container) { const wasSelected = container.is('.ws_active'); //Recursively destroy any submenu items. const submenuId = container.data('submenu_id'); if (submenuId) { const self = this; const $submenu = $('#' + submenuId); $submenu.children('.ws_container').each(function() { self.destroyItem($(this)); }); $submenu.remove(); } //Destroy the item itself. container.remove(); if (wasSelected) { this.selectionHasChanged(); } }; /** * Check if this column can accept a menu item that's being dragged/moved to it. * * @param {JQuery} $itemNode * @returns {boolean} */ AmeEditorColumn.prototype.canAcceptItem = function($itemNode) { const visibleSubmenu = this.getVisibleItemList(); if (!visibleSubmenu || (visibleSubmenu.length < 1)) { return false; //Can't move anything to a non-existent submenu. } return ( //It must actually be a menu item. $itemNode.hasClass('ws_container') //Prevent users from dropping a parent menu on one of its own sub-menus. && !isParentMenuNodeOf($itemNode, visibleSubmenu) ); } /** * Remove all items and item lists from this column. * * Note: Does not remove item submenus that are in other columns. */ AmeEditorColumn.prototype.reset = function() { this.menuBox.empty(); this.visibleItemList = null; this.selectionHasChanged(null); }; /** * * @param {JQuery} editorNode * @param {Boolean|null|string} [deepNestingEnabled] * @param {Number} [maxLevels] * @param {Number} [initialLevels] * @constructor */ function AmeMenuPresenter(editorNode, deepNestingEnabled, maxLevels, initialLevels ) { const self = this; this.editorNode = editorNode; if (typeof deepNestingEnabled === 'string') { deepNestingEnabled = (deepNestingEnabled === '1'); } this.isDeepNestingEnabled = (typeof deepNestingEnabled !== 'undefined') ? deepNestingEnabled : null; this.nestingQueryPromise = null; if (typeof maxLevels === 'undefined') { maxLevels = 3; } if (typeof initialLevels === 'undefined') { if (this.isDeepNestingEnabled) { //If additional levels are enabled, show the maximum number of levels. initialLevels = maxLevels; } else { //WordPress only supports up to two levels by default. initialLevels = Math.min(maxLevels, 2); } } if (initialLevels > this.maxLevels) { initialLevels = this.maxLevels; } this.maxLevels = maxLevels; const $topLevelContainer = this.editorNode.find('#ws_menu_box').first().closest('.ws_main_container'); this.columns = [ //Empty zeroth column. new AmeEditorColumn(0, null, $()), //The first column contains top level menus. new AmeEditorColumn(1, null, $topLevelContainer, makeNextColumnGetter(1)) ]; this.currentLevels = this.columns.length - 1; function makeNextColumnGetter(ownLevel) { if (ownLevel >= self.maxLevels) { //This column will never have a next column, so we can just use NULL. return function(callback) { callback(null); }; } return function(callback, createIfNotExists) { self.getColumn(ownLevel + 1, callback, createIfNotExists); }; } /** * @param {Number} level * @return {AmeEditorColumn} */ function createColumn(level) { if (level > self.maxLevels) { throw new Error('Cannot exceed maximum nesting level: ' + self.maxLevels); } if (typeof self.columns[level] !== 'undefined') { throw new Error('Cannot overwrite an existing column ' + level); } let predecessor; if (typeof self.columns[level - 1] !== 'undefined') { predecessor = self.columns[level - 1].container; } else { predecessor = self.columns[self.currentLevels].container; } let newColumn = new AmeEditorColumn(level, predecessor, null, makeNextColumnGetter(level)); self.columns.push(newColumn); if (level > self.currentLevels) { self.currentLevels = level; } return newColumn; } /** * Can we create another column? * * @param {Number} level * @param {Function} callback */ function queryCanCreateColumn(level, callback) { if ( (level > self.maxLevels) //Do not exceed the maximum depth. || (typeof self.columns[level] !== 'undefined') //Do not overwrite existing columns. ) { callback(false); return; } //WordPress core only supports two admin menu levels. We call anything beyond that "deep". const isDeep = (level > 2); if (!isDeep) { callback(true); return; } //Do we already know if we can create deeply nested menus? if (self.isDeepNestingEnabled !== null) { callback(self.isDeepNestingEnabled); return; } //If we're already waiting for a decision, just add another callback to the queue. if (self.nestingQueryPromise !== null) { self.nestingQueryPromise.always(function() { callback(self.isDeepNestingEnabled); }); return; } //Let's allow other code/plugins to decide this. Scripts can add deferred objects or promises //to an array. All deferred objects must resolve successfully to enable deep nesting. let deferreds = []; self.editorNode.trigger('adminMenuEditor:queryDeepNesting', [deferreds]); if (deferreds.length > 0) { self.nestingQueryPromise = $.when.apply($, deferreds) .done(function() { self.isDeepNestingEnabled = true; }) .fail(function() { self.isDeepNestingEnabled = false; }) .always(function() { callback(self.isDeepNestingEnabled); }); } else { //Deep nesting is disabled by default. self.isDeepNestingEnabled = false; callback(self.isDeepNestingEnabled); } } /** * Get or create a column. The callback will be called with one argument: either the column object, * or NULL if the column does not exist and could not be created. * * @param {Number} level * @param {Function} callback * @param {Boolean} [createIfNotExists] Defaults to true. */ this.getColumn = function(level, callback, createIfNotExists) { if (typeof this.columns[level] !== 'undefined') { callback(this.columns[level]); return; } if (typeof createIfNotExists === 'undefined') { createIfNotExists = true; } if (createIfNotExists) { queryCanCreateColumn(level, function (isAllowed) { //It could be that another callback has already created the next column, //so we need to check again if it exists. if (typeof self.columns[level] !== 'undefined') { callback(self.columns[level]); } else if (isAllowed) { callback(createColumn(level)); } else { callback(null); } }); } else { callback(null); } }; /** * Get or create a column. Like getColumn(), but it will default to not creating deeply nested * menu levels unless that feature is already enabled. * * @param {Number} level * @return {AmeEditorColumn|null} */ this.getColumnImmediate = function(level) { if (typeof this.columns[level] !== 'undefined') { return this.columns[level]; } if (level > this.maxLevels) { return null; } if ((level <= 2) || (this.isDeepNestingEnabled === true)) { return createColumn(level); } return null; }; /** * Get the column that contains a specific menu item or element. * * @param {JQuery} container Menu item container, or another element that's inside a column. * @return {AmeEditorColumn|null} */ this.getItemColumn = function(container) { if (!container) { return null; } const level = container.closest('.ws_main_container').data('ame-menu-level'); if (typeof level === 'undefined') { return null; } return this.getColumnImmediate(level); }; /** * Create editor widgets for a menu item and its submenus and append them all to the DOM. * * @param {Number} level * @param {Object} itemData * @param {JQuery} [afterNode] Insert the widget after this node. */ this.outputMenuItem = function(level, itemData, afterNode) { const column = this.getColumnImmediate(level); return column.outputItem(itemData, afterNode); } /** * Select a menu item and show its submenu. * * @param {JQuery} container */ this.selectItem = function(container) { const thisColumn = this.getColumnImmediate(container.closest('.ws_main_container').data('ame-menu-level')); if (thisColumn) { thisColumn.selectItem(container); } }; /** * Delete a menu item and all of its children. * * @param {JQuery} container */ this.destroyItem = function(container) { const column = this.getItemColumn(container); if (column) { column.destroyItem(container); } }; /** * Delete all items and reset all columns. */ this.clear = function() { for (let level = 0; level < this.columns.length; level++) { if (typeof this.columns[level] !== 'undefined') { this.columns[level].reset(); } } }; //Initialisation. for (let level = this.currentLevels + 1; level <= initialLevels; level++) { createColumn(level); } } /* * Create edit widgets for a top-level menu and its submenus and append them all to the DOM. * * Inputs : * menu - an object containing menu data * afterNode - if specified, the new menu widget will be inserted after this node. Otherwise, * it will be added to the end of the list. * Outputs : * Object with two fields - 'menu' and 'submenu' - containing the DOM nodes of the created widgets. */ function outputTopMenu(menu, afterNode){ if (!menuPresenter) { throw new Error('outputTopMenu cannot be called before the menu presenter has been initialised.'); } return menuPresenter.outputMenuItem(1, menu, afterNode); } /** * Create an edit widget for a menu item. * * @param {Object} itemData * @param {Boolean} [isTopLevel] Specify if this is a top-level menu or a sub-menu item. Defaults to false (= sub-item). * @return {*} The created widget as a jQuery object. */ function buildMenuItem(itemData, isTopLevel) { isTopLevel = (typeof isTopLevel === 'undefined') ? false : isTopLevel; const canHaveSubmenuItems = isTopLevel && !itemData.separator; //Create the menu HTML var item = $('') .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 = ' '; } contents.push( '', '' ); item.append(contents.join('')); //Apply flags based on the item's state var flags = ['hidden', 'unused', 'custom']; for (var i = 0; i < flags.length; i++) { setMenuFlag(item, flags[i], getFieldValue(itemData, flags[i], false)); } if ( canHaveSubmenuItems ){ //Allow the user to drag menu items to top-level menus item.droppable({ 'hoverClass' : 'ws_menu_drop_hover', 'accept' : (function(thing){ return thing.hasClass('ws_item'); }), 'drop' : (function(event, ui){ const column = menuPresenter.getItemColumn(item); if (!column) { return; } const nextColumn = menuPresenter.getColumnImmediate(column.level + 1); const submenu = getSubmenuOf(item, nextColumn); if (!submenu || !nextColumn) { return; } const droppedItemData = readItemState(ui.draggable); const sourceSubmenu = ui.draggable.parent(); let result = nextColumn.outputItem(droppedItemData, null, submenu); if ( !event.ctrlKey ) { menuPresenter.destroyItem(ui.draggable); } updateItemEditor(result.menu); //Moving an item can change aggregate menu permissions. Update the UI accordingly. updateParentAccessUi(submenu); if (sourceSubmenu) { updateParentAccessUi(sourceSubmenu); } }) }); } return item; } function jsTrim(str){ return str.replace(/^\s+|\s+$/g, ""); } //Expose this handy tool to our other scripts. AmeEditorApi.jsTrim = jsTrim; function stripAllTags(input) { //Based on: http://phpjs.org/functions/strip_tags/ var tags = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, commentsAndPhpTags = /|<\?(?:php)?[\s\S]*?\?>/gi; return input.replace(commentsAndPhpTags, '').replace(tags, ''); } function truncateString(input, maxLength, padding) { if (typeof padding === 'undefined') { padding = ''; } if (input.length > maxLength) { input = input.substring(0, maxLength - 1) + padding; } return input; } /** * Format menu title for display in HTML. * Strips tags and truncates long titles. * * @param {String} title * @returns {String} */ function formatMenuTitle(title) { title = stripAllTags(title); //Compact whitespace. title = title.replace(/[\s\t\r\n]+/g, ' '); title = jsTrim(title); //The max. length was chosen empirically. title = truncateString(title, 34, '\u2026'); return title; } AmeEditorApi.formatMenuTitle = formatMenuTitle; //Editor field spec template. var baseField = { caption : '[No caption]', standardCaption : true, advanced : false, type : 'text', defaultValue: '', onlyForTopMenus: false, addDropdown : false, visible: true, write: null, display: null, tooltip: null }; /* * List of all menu fields that have an associated editor */ var knownMenuFields = { 'menu_title' : $.extend({}, baseField, { caption : 'Menu title', display: function(menuItem, displayValue, input, containerNode) { //Update the header as well. containerNode.find('.ws_item_title').text(formatMenuTitle(displayValue) + '\xa0'); return displayValue; }, write: function(menuItem, value, input, containerNode) { menuItem.menu_title = value; containerNode.find('.ws_item_title').text(stripAllTags(input.val()) + '\xa0'); } }), 'template_id' : $.extend({}, baseField, { caption : 'Target page', type : 'select', options : (function(){ //Generate name => id mappings for all item templates + the special "Custom" template. var itemTemplateIds = []; itemTemplateIds.push([wsEditorData.customItemTemplate.name, '']); for (var template_id in wsEditorData.itemTemplates) { if (wsEditorData.itemTemplates.hasOwnProperty(template_id)) { itemTemplateIds.push([wsEditorData.itemTemplates[template_id].name, template_id]); } } itemTemplateIds.sort(function(a, b) { if (a[1] === b[1]) { return 0; } //The "Custom" item is always first. if (a[1] === '') { return -1; } else if (b[1] === '') { return 1; } //Top-level items go before submenus. var aIsTop = (a[1].charAt(0) === '>') ? 1 : 0; var bIsTop = (b[1].charAt(0) === '>') ? 1 : 0; if (aIsTop !== bIsTop) { return bIsTop - aIsTop; } //Everything else is sorted by name, in alphabetical order. if (a[0] > b[0]) { return 1; } else if (a[0] < b[0]) { return -1; } return 0; }); return itemTemplateIds; })(), write: function(menuItem, value, input, containerNode) { var oldTemplateId = menuItem.template_id; menuItem.template_id = value; menuItem.defaults = itemTemplates.getDefaults(menuItem.template_id); menuItem.custom = (menuItem.template_id === ''); // The file/URL of non-custom items is read-only and equal to the default // value. Rationale: simplifies menu generation, prevents some user mistakes. if (menuItem.template_id !== '') { menuItem.file = null; } // The new template might not have default values for some of the fields // currently set to null (= "default"). In those cases, we need to make // the current values explicit. containerNode.find('.ws_edit_field').each(function(index, field){ field = $(field); var fieldName = field.data('field_name'); var isSetToDefault = (menuItem[fieldName] === null); var hasDefaultValue = itemTemplates.hasDefaultValue(menuItem.template_id, fieldName); if (isSetToDefault && !hasDefaultValue) { var oldDefaultValue = itemTemplates.getDefaultValue(oldTemplateId, fieldName); if (oldDefaultValue !== null) { menuItem[fieldName] = oldDefaultValue; } } }); } }), 'embedded_page_id' : $.extend({}, baseField, { caption: 'Embedded page ID', defaultValue: 'Select page to display', type: 'text', addDropdown: 'ws_embedded_page_selector', display: function(menuItem, displayValue, input) { input.prop('readonly', true); var pageId = parseInt(getFieldValue(menuItem, 'embedded_page_id', 0), 10), blogId = parseInt(getFieldValue(menuItem, 'embedded_page_blog_id', 1), 10), formattedId = 'ID: ' + pageId; if (pageId <= 0) { return 'Select page =>'; } if (blogId !== 1) { formattedId = formattedId + ', blog ID: ' + blogId; } displayValue = formattedId; AmePageTitles.get(pageId, blogId, function(title) { //If we retrieved the title via AJAX, the user might have selected a different page in the meantime. //Make sure it's still the same page before displaying the title. var currentPageId = parseInt(getFieldValue(menuItem, 'embedded_page_id', 0), 10), currentBlogId = parseInt(getFieldValue(menuItem, 'embedded_page_blog_id', 1), 10); if ((currentPageId !== pageId) || (currentBlogId !== blogId)) { return; } displayValue = title + ' (' + formattedId + ')'; input.val(displayValue); }); return displayValue; }, write: function() { //The user cannot directly edit this field. We deliberately ignore writes. }, visible: function(menuItem) { //Only show this field if the "Embed WP page" template is selected. return (menuItem.template_id === wsEditorData.embeddedPageTemplateId); } }), 'file' : $.extend({}, baseField, { caption: 'URL', display: function(menuItem, displayValue, input) { // The URL/file field is read-only for default menus. Also, since the "file" // field is usually set to a page slug or plugin filename for plugin/hook pages, // we display the dynamically generated "url" field here (i.e. the actual URL) instead. if (menuItem.template_id !== '') { input.prop('readonly', true); displayValue = itemTemplates.getDefaultValue(menuItem.template_id, 'url'); } else { input.prop('readonly', false); } return displayValue; }, write: function(menuItem, value) { // A menu must always have a non-empty URL. If the user deletes the current value, // reset it to the old value. if (value === '') { value = menuItem.file; } // Default menus always point to the default file/URL. if (menuItem.template_id !== '') { value = null; } menuItem.file = value; } }), 'access_level' : $.extend({}, baseField, { caption: 'Permissions', defaultValue: 'read', type: 'access_editor', visible: false, //Will be set to visible only in Pro version. display: function(menuItem) { //Permissions display is a little complicated and could use improvement. var requiredCap = getFieldValue(menuItem, 'access_level', ''); var extraCap = getFieldValue(menuItem, 'extra_capability', ''); var displayValue = (menuItem.template_id === '') ? '< Custom >' : requiredCap; if (extraCap !== '') { if (menuItem.template_id === '') { displayValue = extraCap; } else { displayValue = displayValue + '+' + extraCap; } } return displayValue; }, write: function(menuItem) { //The required capability can't be directly edited and always equals the default. menuItem.access_level = null; } }), //TODO: Never save this field. It just wastes database space. 'required_capability_read_only' : $.extend({}, baseField, { caption: 'Required capability', defaultValue: 'none', type: 'text', tooltip: "Only users who have this capability can see the menu. "+ "The capability can't be changed because it's usually hard-coded in WordPress or the plugin that created the menu."+ "