Information System : //(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(
'
',
itemData.separator ? '' : '
',
'',
'',
formatMenuTitle(menuTitle),
' ',
'
',
''
);
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."+
"
Use the \"Extra capability\" field to restrict access to this menu.",
visible: function(menuItem) {
//Show only in the free version, on non-custom menus.
return !wsEditorData.wsMenuEditorPro && (menuItem.template_id !== '');
},
display: function(menuItem, displayValue, input) {
input.prop('readonly', true);
return getFieldValue(menuItem, 'access_level', '');
},
write: function(menuItem, value) {
//The required capability is read-only. Ignore writes.
}
}),
'extra_capability' : $.extend({}, baseField, {
caption: 'Extra capability',
defaultValue: 'read',
type: 'text',
addDropdown: 'ws_cap_selector',
tooltip: function(menuItem) {
if (menuItem.template_id === '') {
return 'Only users who have this capability can see the menu.';
}
return 'An additional capability check that is applied on top of the required capability.';
},
display: function(menuItem) {
var requiredCap = getFieldValue(menuItem, 'access_level', '');
var extraCap = getFieldValue(menuItem, 'extra_capability', '');
//On custom menus, show the default required cap when no extra cap is selected.
//Otherwise there would be no visible capability requirements at all.
var displayValue = extraCap;
if ((menuItem.template_id === '') && (extraCap === '')) {
displayValue = requiredCap;
}
return displayValue;
},
write: function(menuItem, value) {
value = jsTrim(value);
//Reset to default if the user clears the input.
if (value === '') {
menuItem.extra_capability = null;
return;
}
menuItem.extra_capability = value;
}
}),
'appearance_heading' : $.extend({}, baseField, {
caption: 'Appearance',
advanced : true,
onlyForTopMenus: false,
type: 'heading',
standardCaption: false,
visible: false //Only visible in the Pro version.
}),
'icon_url' : $.extend({}, baseField, {
caption: 'Icon URL',
type : 'icon_selector',
advanced : true,
defaultValue: 'div',
onlyForTopMenus: true,
display: function(menuItem, displayValue, input, containerNode) {
//Display the current icon in the selector.
var cssClass = getFieldValue(menuItem, 'css_class', '');
var iconUrl = getFieldValue(menuItem, 'icon_url', '', containerNode);
displayValue = iconUrl;
//When submenu icon visibility is set to "only if manually selected",
//don't show the default submenu icons.
var isDefault = (typeof menuItem.icon_url === 'undefined') || (menuItem.icon_url === null);
if (isDefault && (wsEditorData.submenuIconsEnabled === 'if_custom') && containerNode.hasClass('ws_item')) {
iconUrl = 'none';
cssClass = '';
}
var selectButton = input.closest('.ws_edit_field').find('.ws_select_icon');
var cssIcon = selectButton.find('.ws_icon_image');
var imageIcon = selectButton.find('img');
var matches = cssClass.match(/\b(ame-)?menu-icon-([^\s]+)\b/);
var iconFontMatches = iconUrl && iconUrl.match(/^\s*((dashicons|ame-fa)-[a-z0-9\-]+)/);
//Icon URL takes precedence over icon class.
if ( iconUrl && iconUrl !== 'none' && iconUrl !== 'div' && !iconFontMatches ) {
//Regular image icon.
cssIcon.hide();
imageIcon.prop('src', iconUrl).show();
} else if ( iconFontMatches ) {
cssIcon.removeClass().addClass('ws_icon_image');
if ( iconFontMatches[2] === 'dashicons' ) {
//Dashicon.
cssIcon.addClass('dashicons ' + iconFontMatches[1]);
} else if ( iconFontMatches[2] === 'ame-fa' ) {
//FontAwesome icon.
cssIcon.addClass('ame-fa ' + iconFontMatches[1]);
}
imageIcon.hide();
cssIcon.show();
} else if ( matches ) {
//Other CSS-based icon.
imageIcon.hide();
var iconClass = (matches[1] ? matches[1] : '') + 'icon-' + matches[2];
cssIcon.removeClass().addClass('ws_icon_image ' + iconClass).show();
} else {
//This menu has no icon at all. This is actually a valid state
//and WordPress will display a menu like that correctly.
imageIcon.hide();
cssIcon.removeClass().addClass('ws_icon_image').show();
}
return displayValue;
}
}),
'colors' : $.extend({}, baseField, {
caption: 'Color scheme',
defaultValue: 'Default',
type: 'color_scheme_editor',
onlyForTopMenus: true,
visible: false,
advanced : true,
display: function(menuItem, displayValue, input, containerNode) {
var colors = getFieldValue(menuItem, 'colors', {}) || {};
var colorList = containerNode.find('.ws_color_scheme_display');
colorList.empty();
var count = 0, maxColorsToShow = 7;
$.each(colors, function(name, value) {
if ( !value || (count >= maxColorsToShow) ) {
return;
}
colorList.append(
$('').addClass('ws_color_display_item').css('background-color', value)
);
count++;
});
if (count === 0) {
colorList.append('Default');
}
return 'Placeholder. You should never see this.';
},
write: function(menuItem) {
//Menu colors can't be directly edited.
}
}),
'html_heading' : $.extend({}, baseField, {
caption: 'HTML',
advanced : true,
onlyForTopMenus: true,
type: 'heading',
standardCaption: false
}),
'open_in' : $.extend({}, baseField, {
caption: 'Open in',
advanced : true,
type : 'select',
options : [
['Same window or tab', 'same_window'],
['New window', 'new_window'],
['Frame', 'iframe']
],
defaultValue: 'same_window',
visible: false
}),
'iframe_height' : $.extend({}, baseField, {
caption: 'Frame height (pixels)',
advanced : true,
visible: function(menuItem) {
return wsEditorData.wsMenuEditorPro && (getFieldValue(menuItem, 'open_in') === 'iframe');
},
display: function(menuItem, displayValue, input) {
input.prop('placeholder', 'Auto');
if (displayValue === 0 || displayValue === '0') {
displayValue = '';
}
return displayValue;
},
write: function(menuItem, value) {
value = parseInt(value, 10);
if (isNaN(value) || (value < 0)) {
value = 0;
}
value = Math.round(value);
if (value > 10000) {
value = 10000;
}
if (value === 0) {
menuItem.iframe_height = null;
} else {
menuItem.iframe_height = value;
}
}
}),
'css_class' : $.extend({}, baseField, {
caption: 'CSS classes',
advanced : true,
onlyForTopMenus: true
}),
'hookname' : $.extend({}, baseField, {
caption: 'ID attribute',
advanced : true,
onlyForTopMenus: true
}),
'page_properties_heading' : $.extend({}, baseField, {
caption: 'Page',
advanced : true,
onlyForTopMenus: true,
type: 'heading',
standardCaption: false
}),
'page_heading' : $.extend({}, baseField, {
caption: 'Page heading',
advanced : true,
onlyForTopMenus: false,
visible: false
}),
'page_title' : $.extend({}, baseField, {
caption: "Window title",
standardCaption : true,
advanced : true
}),
'is_always_open' : $.extend({}, baseField, {
caption: 'Keep this menu expanded',
advanced : true,
onlyForTopMenus: true,
type: 'checkbox',
standardCaption: false
})
};
var visibleMenuFieldsByType = {};
AmeEditorApi.getItemDisplayUrl = function(menuItem) {
var url = getFieldValue(menuItem, 'file', '');
if (menuItem.template_id !== '') {
//Use the template URL. It's a preset that can't be overridden.
var defaultUrl = itemTemplates.getDefaultValue(menuItem.template_id, 'url');
if (defaultUrl) {
url = defaultUrl;
}
}
return url;
};
/*
* Create editors for the visible fields of a menu entry and append them to the specified node.
*/
function buildEditboxFields(fieldContainer, entry, isTopLevel){
isTopLevel = (typeof isTopLevel === 'undefined') ? false : isTopLevel;
var basicFields = $('').appendTo(fieldContainer);
var advancedFields = $('').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(
$('
').addClass('ws_toggle_container').append(
$('', {href: '#'})
.addClass('ws_toggle_advanced_fields')
.text(
wsEditorData.hideAdvancedSettings
? wsEditorData.captionShowAdvanced
: wsEditorData.captionHideAdvanced
)
.toggle(!!wsEditorData.hideAdvancedSettings) //Conver to boolean because it could be a string ("1" or "0").
)
);
}
/*
* Create an editor for a specified field.
*/
//noinspection JSUnusedLocalSymbols
function buildEditboxField(entry, field_name, field_settings){
//Build a form field of the appropriate type
var inputBox;
var basicTextField = '';
//noinspection FallthroughInSwitchStatementJS
switch(field_settings.type){
case 'select':
inputBox = $('