diff --git a/README.md b/README.md index f3aeb3b..3218d09 100644 --- a/README.md +++ b/README.md @@ -7,59 +7,46 @@ This extension adds an additional item to the MediaMonkey 5 main menu bar that a Download the latest release from the releases section and double click extensionsMenu.mmip. An MediaMonkey dialog will automatically pop up, prompting you to confirm the installation. ## Registering actions -To add an action to the Extensions menu, follow the steps below. -Create an action for the function of the calling Extensions -```javascript -actions.testExtensions = { - create: { - title: function() { - return _('&TestExtension Create Action') - }, - hotkeyAble: false, - category: actionCategories.extensions, - icon: 'createIcon', - execute: function() { - ... - } - }, +The extension menu automatically imports all valid actions from the global actions object if they are in the Extenions category. The following properties are required for an action to be considered valid: - delete: { - title: function() { - return _('&TestExtension Delete Action') - }, - hotkeyAble: false, - category: actionCategories.extensions, - icon: 'deleteIcon', - execute: function() { - ... - } - }, -} -``` -Since it's not possible to enforce a load order for extensions or otherwise ensure that the Extensions Menu extension is loaded before an extension wants to register an action, new menu items are added indirectly by pushing them into an import queue, which is picked up and processed once the Extensions Menu extension is loaded. +| Name | Type | Description | +| :------------ |:---------- | :---------- | +| title | function | Display name of the action | +| extension | function | Display name of the extension | +| execute | function | Function to execute when calling the action | + +## Example +To create new actions, simply add new actions to the global actions object. ```javascript -// Add global section to register extension actions if it doesn't exist yet -if (typeof extensions == "undefined") - var extensions = {} +// Make sure to keep this header as it's needed to properly discover and +// import extension actions +if(!actionCategories.hasOwnProperty('extensions')){ + actionCategories.extensions = () => _('Extensions'); +} -// If the Extensions Menu was not loaded yet, not import queue exists. -if (!extensions.hasOwnProperty('extensionsMenuImportQueue')) - extensions.extensionsMenuImportQueue = [] +// Create the needed actions +actions.MyAddonAction = { + title: () => _('&My Addon Action'), + hotkeyAble: true, + category: actionCategories.extensions, + icon: 'myAddonIcon', + extension: () => _("My &Addon"), + execute: () => alert('My Addon Action') +} -// Push actions that should be added to the menu to the import queue. -// Do not replace the import queue with a new array as this might remove already queued actions from other extensions -extensions.extensionsMenuImportQueue.push({action:actions.testExtension.create, order: 10, category:'TestExtension'}) -extensions.extensionsMenuImportQueue.push({action:actions.testExtension.delete, order: 20, category:'TestExtension'}) +actions.MyOtherAddonAction = { + title: () => _('My &Other Addon Action'), + hotkeyAble: true, + category: actionCategories.extensions, + icon: 'myOtherAddonIcon', + extension: () => _("My &Addon"), + execute: () => alert('My Other Addon Action') +} -// Refresh the menu to import actions that were added to the queue. +// Refresh the extension menu to import new actions. // No need to worry if the menu can't be refreshed at this point because it's not loaded yet. -// It will automatically import all pending entries in the import queue as soon as its loaded. -if(extensions.extensionsMenu != null) +// It will automatically import all actions as soon it gets loaded by MediaMonkey. +if(typeof extensions != "undefined" && extensions.extensionsMenu != null) extensions.extensionsMenu.refresh(); -``` - -Each pushed to the import queue needs to have three properties: -* action: Contains the action to be executed. -* category: Category/Grouping name for all entries. This should usually be the name of the extension. -* order: Sort order for the respective entry within its category. \ No newline at end of file +``` \ No newline at end of file diff --git a/actions_add.js b/actions_add.js index 78bb70f..2de69f5 100644 --- a/actions_add.js +++ b/actions_add.js @@ -1,111 +1 @@ -"use strict"; - -window.actionCategories.extensions = function(){ - return _('Extensions'); -} - -// Add global section to register extension actions if it doesn't exist yet -if (typeof extensions == "undefined") - var extensions = {} - -extensions.extensionsMenu = { - menuOrder: 55, - menuGrouporder: 10, - menu: [], - - refresh: async function(){ - let _this = this; - if (!extensions.hasOwnProperty('extensionsMenuImportQueue') || !extensions.extensionsMenuImportQueue instanceof Array) { - extensions.extensionsMenuImportQueue = []; - return; - } - - if(extensions.extensionsMenuImportQueue.length == 0) - return; - - let importItems = extensions.extensionsMenuImportQueue; - extensions.extensionsMenuImportQueue = []; - - // filter out items with missing properties - importItems = importItems.filter(menuItm => { - return (menuItm.hasOwnProperty('action') - && menuItm.action.hasOwnProperty('title') - && menuItm.hasOwnProperty('order') - && menuItm.hasOwnProperty('category')); - }); - - // filter out items with missing title function or blank title - importItems = importItems.filter(menuItm => { - return (menuItm.action.title instanceof Function && menuItm.action.title() != ''); - }); - - // Add imported entries to menu, but skip duplicates - importItems.forEach(extensionItm => { - if (typeof (_this.menu.find(item => item.action.title() == extensionItm.action.title() && item.category == extensionItm.category)) != "undefined") { - return Promise.reject('Item already exists'); - } - - _this.menu.push({action: extensionItm.action, order: extensionItm.order, category: extensionItm.category, grouporder: 100}); - }) - - _this.sortMenu(); - await _this.pushToUi(); - }, - - pushToUi: async function(){ - // pushes the internal menu to the ui - let _this = this; - - if(_this.menu.length == 0) - return; - - // Check if the menu was already pushed to UI, and only update the menu items if it was - for (let i = 0; i < window.mainMenuItems.length; i++) { - const itm = window.mainMenuItems[i].action; - - if(itm.hasOwnProperty('title') && itm.title instanceof Function && itm.title() == '&Extensions'){ - itm.submenu = _this.menu; - return; - } - } - - let newMenu = { - action: { - title: function () { - return _('&Extensions'); - }, - visible: !webApp, - submenu: _this.menu - }, - order: _this.menuOrder, - grouporder: _this.menuGrouporder, - } - - window.mainMenuItems.push(newMenu); - uitools.switchMainMenu(false); - uitools.switchMainMenu(true); - }, - - getCategories: function(){ - // returns an array with all categories in the menu - return [...new Set(this.menu.map(x => x.category))]; - }, - - sortMenu: function(){ - // sorts the menu by category - - // get list of all categories and add a sort value - let cat = this.getCategories().sort(); - let catOrder = {}; - for (let index = 0; index < cat.length; index++) { - catOrder[cat[index]] = (index + 1) * 100; - } - - // update each menu item with the sort index - this.menu.forEach(el => { - el.grouporder = catOrder[el.category] - }); - } -} - -extensions.extensionsMenu.refresh() +actionCategories.extensions = () => _('Extensions'); \ No newline at end of file diff --git a/config.html b/config.html new file mode 100644 index 0000000..da3bcc7 --- /dev/null +++ b/config.html @@ -0,0 +1,9 @@ +
+
+
+
New
+
Delete
+
Rename
+
Reset
+
+
diff --git a/config.js b/config.js new file mode 100644 index 0000000..90a5c66 --- /dev/null +++ b/config.js @@ -0,0 +1,64 @@ +"use strict"; + +requirejs('viewHandlers.js'); +requirejs("Scripts/ExtensionsMenu/extensionsMenu") +requirejs("controls/extensionTree") + +window.configInfo = { + load: function(panel, addon){ + let _this = this; + panel.innerHTML = window.loadFile(addon.configFile.replace('config.js','config.html')); + let pnl = panel.firstElementChild; + initializeControls(pnl); + + let UI = getAllUIElements(qid('pnlCollectionsRoot')); + let TV = UI.lvTreeView; + let ds = TV.controlClass.dataSource; + + extensions.extensionsMenu.discardChanges(); + ds.root.handlerID = 'extensionsMenuTreeRoot'; + ds.root.dataSource = extensions.extensionsMenu.getEditRootNode(); + + TV.controlClass.expandAll() + + app.listen(UI.btnNewGroup, 'click', function () { + let newGroupNode = extensions.extensionsMenu.newGroup("New Group"); + nodeUtils.refreshNodeChildren(TV.controlClass.root); + let newGroup = TV.controlClass.root.findChild(`extensionsGroupNode:${newGroupNode.id}`); + + // focus node and enter edit node + TV.controlClass.focusNode(newGroup); + TV.controlClass.editStart() + }); + + app.listen(UI.btnDeleteGroup, 'click', function () { + TV.controlClass.deleteSelected(); + }); + + app.listen(UI.btnRenameGroup, 'click', function () { + TV.controlClass.editStart() + }); + + app.listen(UI.btnResetTree, 'click', () => { + extensions.extensionsMenu.resetActionTree(); + let tree = app.createTree(); + tree.root.handlerID = 'extensionsMenuTreeRoot'; + tree.root.dataSource = extensions.extensionsMenu.getEditRootNode(); + TV.controlClass.dataSource = tree; + TV.controlClass.expandAll() + }); + }, + + save: function(panel, addon){ + + extensions.extensionsMenu.applyChanges() + extensions.extensionsMenu.saveSettings(); + extensions.extensionsMenu.reloadActionTree(); + + // the config menu runs in a separate context from the main window + let mainAppWindow = app.dialogs.getMainWindow()._window; + mainAppWindow.extensions.extensionsMenu.refresh(); + }, +} + + diff --git a/controls/extensionTree.js b/controls/extensionTree.js new file mode 100644 index 0000000..a56a50e --- /dev/null +++ b/controls/extensionTree.js @@ -0,0 +1,118 @@ +/** +@module UI snippets +*/ + +requirejs('controls/checkboxTree'); + +/** +@class ExtensionTree +@constructor +@extends CheckboxTree +*/ + +inheritClass('ExtensionTree', CheckboxTree, { + _onExpanded: function (e) { + var node = e.detail.currentNode; + if (this.dataSource.keepChildrenWhenCollapsed && node.expandCount > 1) + return; + + let childCheckedCount = 0; + if ((node.checked) && ((resolveToValue(nodeHandlers[node.handlerID].checkboxRule) != 'parent_independent') || node.modified)) + node.children.setAllChecked(true); + else{ + + node.children.forEach(child =>{ + child.checked = child.dataSource.show; + childCheckedCount++; + }) + + if(childCheckedCount == 0){ + node.checked = false; + } + else{ + if(childCheckedCount == node.children.count){ + node.checked = true + } + } + } + }, + + canDrop: function (e) { + TreeView.prototype.runFuncOnHittest.call(this, e); + return true; + }, + + handleNodeCheck: function (node) { + TreeView.prototype.handleNodeCheck.call(this, node); + node.dataSource.show = node.checked; + }, + + drop: function (e) { + + if (this._lastDropNodeResult /* this property is from window.dnd.getFocusedItemHandler */ ) { + let handler = nodeHandlers[this._dropNode.handlerID]; + if (handler && handler.drop) { + e._dropNode = this._dropNode; + handler.drop(this._dropNode.dataSource, e); + } + } + + if(e.path[0].classList[0] == "lvViewport"){ + // object was dropped inside the treeview element but not on a node + // move the element to the top level + let handler = nodeHandlers['extensionsMenuTreeRoot']; + if (handler && handler.drop) { + e._dropNode = this.dataSource.root; + handler.drop(this.dataSource.root.dataSource, e); + } + } + + let srcObjectNode = dnd.getDragObject(e); + let datatype = dnd.getDropDataType(e); + + if(srcObjectNode.type == 'action'){ + // Nodes tend to forget their checked status when they are moved between + // parents, set their status again + let ctrl = e.dataTransfer.getSourceControl(); + + let parentType = srcObjectNode.group.split(".")[0] + let targetParent = this.dataSource.root; + + if(parentType == "groups"){ + targetParent = ctrl.controlClass.dataSource.root.findChild(`extensionsGroupNode:${srcObjectNode.group}`); + } + + let srcObject = targetParent.findChild(`${datatype}:${srcObjectNode.id}`); + srcObject.checked = srcObjectNode.show; + } + + this.cancelDrop(); + }, + + deleteSelected: function (permanent) { + var node = this.focusedNode; + + if(!node) + return; + + let handler = nodeHandlers[node.handlerID]; + + if (handler && handler.deleteItems) { + if (!nodeUtils.isDeleteDisabled(node)) { + // get the IDs of all children of the deleted node + // to restore their checked state later + let childNodeIDs = []; + node.children.forEach(node => childNodeIDs.push(node.persistentID)) + handler.deleteItems(node); + + childNodeIDs.forEach(nodeId => { + let movedNode = this.dataSource.root.findChild(nodeId); + movedNode.checked = movedNode.dataSource.show; + }); + + nodeUtils.refreshNodeChildren(this.dataSource.root); + } + } + }, +}, { +}); diff --git a/extensionsMenu.js b/extensionsMenu.js new file mode 100644 index 0000000..5f7cb06 --- /dev/null +++ b/extensionsMenu.js @@ -0,0 +1,553 @@ +"use strict"; + +// Add global section to register extension actions if it doesn't exist yet +extensions = extensions || {}; + +extensions.extensionsMenu = { + + rootNode: { + type: "root", + id: "root", + actions: [] + }, + + editNode: { + type: "root", + id: "root", + actions: [] + }, + + miscCategoryName: "Misc", + addonName: () => "ExtensionsMenu", + + refresh: async function(){ + let _this = this; + _this.reloadActionTree(); + let menu = _this.buildMainMenuArray() + await _this.pushToUi(menu); + }, + + pushToUi: async function(Menu){ + // pushes the internal menu to the ui + let _this = this; + + if(Menu.length == 0) + return; + + // Check if the menu was already pushed to UI, and only update the menu items if it was + for (let i = 0; i < window.mainMenuItems.length; i++) { + const itm = window.mainMenuItems[i].action; + + if(itm.hasOwnProperty('title') && itm.title instanceof Function && itm.title() == '&Extensions'){ + itm.submenu = Menu; + return; + } + } + + // The extensions menu item has not yet been added to the main menu + + // The help menu is, by convention, the last item on a menu bar, so the extension menu should be positioned before it. + + let helpMenu = window.mainMenuItems.filter(itm => itm.action.title instanceof Function && itm.action.title() == "&Help")[0] + // While just taking the first index before the helpmenu could cause a collision with other items, + // it doesn't matter beacause what ultimately decides the used order is the position within the mainMenuItems array, + // not the order property + let extMenuIndex = helpMenu.order -1; + + let newMenu = { + action: { + title: function () { + return _('&Extensions'); + }, + visible: !webApp, + submenu: Menu + }, + order: extMenuIndex, + grouporder: 10, + } + + window.mainMenuItems.push(newMenu); + window.mainMenuItems.sort((a,b) => !a.hasOwnProperty('order') || a.order > b.order); + uitools.switchMainMenu(false); + uitools.switchMainMenu(true); + }, + + getExtensionActions: function(){ + // returns a list of all actions with an extension property + + return Object.keys(actions) + .filter((key) => actions[key].hasOwnProperty("extension") + && actions[key].extension instanceof Function + && (actions[key].extension())) + .map(function(key){return {key:key,action:actions[key]}}) + }, + + getValidActions: function(){ + // returns a list of all extension actions with valid properties + + let allActions = this.getExtensionActions(); + + let validActions = allActions.filter(ext => { + return (ext.action.hasOwnProperty('execute') + && ext.action.hasOwnProperty('title') + && ext.action.title instanceof Function + && (ext.action.title()) + ); + }) + + return validActions; + }, + + getExtensionList: function(actionObjects){ + // Returns the grouped extension list of all loaded actions + + return [...new Set(actionObjects.map(act => act.action.extension()))] + }, + + groupActions: function(actionObjects){ + // returns an array of all valid actions grouped by their extension; + + let groupedActions = []; + let extensionList = this.getExtensionList(actionObjects); + + // expand valid actions to full object to be able to group them + // let actionFunctions = Object.keys(actions) + // .filter(key => validActions.includes(key)) + // .map(function(key){return {key:key,action:actions[key]}}) + + // get the actions for each extension and group them + extensionList.forEach(ext =>{ + let filteredActions = actionObjects + .filter(act => act.action.extension() == ext) + .map(act => act.key) + + groupedActions.push({ + extension: ext, + actions: filteredActions + }); + }); + + return groupedActions; + }, + + buildNodeTree: function(actionObjects){ + // builds a node tree from a list of actions + + let groupedActions = this.groupActions(actionObjects); + groupedActions.sort(); + + let nodeTree = []; + + let extSortOrder = 0; + groupedActions.forEach(ext =>{ + + // map each action with its order within the extension and an unique ID + let actionSortOrder = 0; + let newGroup = this.newGroup(ext.extension, [], (extSortOrder += 10)) + + let extActions = ext.actions.map(act => { + return { + action: act, + id: `actions.${act}`, + type: "action", + group: newGroup.id, + order: (actionSortOrder += 10), + show: true + } + }); + + newGroup.actions = extActions; + nodeTree.push(newGroup); + }) + return nodeTree; + }, + + buildActionTree: function(){ + // Creates an extension tree from the currently loaded actions + + let validActions = this.getValidActions(); + return this.buildNodeTree(validActions); + + }, + + buildUserActionTree: function(){ + // Creates an extension tree from the currently loaded actions + // and applies user settings to it + + let validActions = this.getValidActions(); + let userSettings = this.loadSettings(); + userSettings.sort(this.sortGroup); + + // only include extension actions if they have not been saved before + let validActionsKeys = validActions.map(itm => itm.key); + let userSettingsKeys = userSettings + .filter(itm => itm.type == 'action') + .map(itm => itm.action); + + validActionsKeys = validActionsKeys.filter(itm => !userSettingsKeys.includes(itm)) + let extActions = validActions.filter(itm => validActionsKeys.includes(itm.key)) + + // create node tree from the filtered actions + let actionNodes = this.buildNodeTree(extActions); + + // build node tree from loaded user settings + let userActions = userSettings.filter(itm => itm.type == 'action'); + let userGroups = userSettings.filter(itm => itm.type == 'group'); + + let actionTree = [] + userGroups.forEach(grp => { + let grpActions = userActions.filter(act => act.group == grp.id); + // grp.order = (groupOrder += 10); + grp.actions = grpActions; + actionTree.push(grp); + }); + + let rootActions = userActions.filter(act => act.group == 'root'); + rootActions.forEach(act =>{ + actionTree.push(act); + }) + + actionTree.sort(this.sortGroup); + + // add all new actions to the bottom of the list + let groupOrder = actionTree[actionTree.length -1].order; + + actionNodes.forEach(node => { + node.order = (groupOrder += 10); + actionTree.push(node); + }) + + return actionTree; + }, + + getRootNode: function(){ + // returns the main extension menu root node + if(this.rootNode.actions.length == 0) + this.rootNode.actions = this.buildUserActionTree(); + + return this.rootNode; + }, + + getEditRootNode: function(){ + // returns a temporary extension menu root node + if(this.editNode.actions.length == 0) + this.editNode.actions = this.buildUserActionTree(); + + return this.editNode; + }, + + applyChanges: function(){ + // applies all changes from the edit root node + // to the main root node + + this.rootNode.actions = this.editNode.actions; + this.discardChanges(); + }, + + buildMainMenuArray: function(){ + // Creates menu that can be pushed to the main menu + + let menu = [] + let extTree = this.getRootNode().actions; + let groups = extTree.filter(itm => itm.type == "group"); + + groups.forEach(ext =>{ + // Unwrap each tree item and reorganize it to a structure + // that can be understood by the main menu + + let actionList = ext.actions + .filter(act => act.show == true) + .map(act => { + return { + grouporder:10, + order: act.order, + action: actions[act.action] + } + }) + + let extensionAction = { + title: () => ext.title, + submenu: actionList + }; + + menu.push({ + grouporder: 10, + order: ext.order, + action: extensionAction + }); + }); + + let rootActions = extTree + .filter(itm => itm.type == "action" && itm.group == "root"); + + rootActions.forEach(ext =>{ + menu.push({ + grouporder: 10, + order: ext.order, + action: actions[ext.action] + }); + }); + + return menu; + }, + + resetActionTree: function(){ + // discards all user settings and rebuilds the action tree + this.rootNode.actions = this.buildActionTree(); + this.editNode.actions = this.buildActionTree(); + }, + + reloadActionTree: function(){ + // discards the current action tree and rebuilds it + this.discardChanges(); + this.rootNode.actions = this.buildUserActionTree(); + }, + + discardChanges: function(){ + // discards all changes in the edit root node + this.editNode.actions = this.buildUserActionTree(); + }, + + moveGroup: function(group, target){ + // moves the provided group to a new parent + + + this.editNode.actions.sort(this.sortGroup); + + if(target.type == "action"){ + if(target.group == "root"){ + // move group behind target in root + this.moveToIndex(group, target.order) + return; + } + + if(group.id != target.group){ + // group was dropped on an action in a different group + // action is in a group, move the current group behind the parent of the target + let targetIndex = this.getActionParent(target).order; + this.moveToIndex(group, targetIndex) + } + + // else group was dropped on action in same group, nothing to do here + } else { + if(target.type == "group"){ + this.moveToIndex(group, target.order); + } else { + // group was dropped on root, move the group to the last index + this.moveToIndex(group, this.editNode.actions[this.editNode.actions.length -1].order); + } + } + }, + + moveAction: function(action, target){ + // moves the provided action to a new parent + + if(target.type == "action"){ + if(action.group == target.group){ + // action was dropped on action in same group, move action behind target + this.moveToIndex(action, target.order); + } else { + // action was dropped on action in different group + // move action behind target in the new group + let targetParent = this.getActionParent(target) + this.moveActionToGroup(action, targetParent) + this.moveToIndex(action, target.order); + } + } else{ + if(action.group != target.id || target.id == "root"){ + // action was dropped on different group, move to last index of the group + this.moveActionToGroup(action, target) + } + // else action was dropped on same group, nothing to do here + } + this.cleanupGroups(); + }, + + moveActionToGroup: function(item, target){ + // moves action to the last position of a group + + let oldParent = this.getActionParent(item) + let actionIndex = oldParent.actions.findIndex(x => x.id == item.id); + let newParent = (target.type == 'root' ? this.editNode : this.editNode.actions.filter(x => x.id == target.id)[0]); + + if(!oldParent || !newParent || actionIndex == null) + return; + + // move action to new group, and assign the order + // of the current highest element +10 + + newParent.actions.sort(this.sortGroup); + let highestOrder = 0; + if(newParent.actions.length > 0) + highestOrder = newParent.actions[newParent.actions.length - 1].order + + newParent.actions.push(item); + item.group = newParent.id; + item.order = highestOrder + 10 + + // Reorder the old parent by shifting down the order of + // all elements beginning from the order of the moved item + + oldParent = oldParent.actions; + oldParent.splice(actionIndex,1); + if(oldParent.length == 0){ + // the last node was moved away, remove the parent + return; + } + + oldParent.sort(this.sortGroup); + let newItemOrder = (actionIndex == 0 ? 10 : (oldParent[actionIndex-1].order) + 10); + + for (let index = actionIndex; index < oldParent.length; index++) { + oldParent[index].order = (newItemOrder += 10); + } + }, + + moveToIndex: function(item, order){ + // moves the item to the specified index of a group + // shifting up the order of all elements after it + + let itemParent = this.getActionParent(item).actions; + itemParent.sort(this.sortGroup); + let currentItemIndex = itemParent.findIndex(x =>x.id == item.id); + + let targetIndex = itemParent.length + if(itemParent[targetIndex-1].order > order){ + targetIndex = itemParent.findIndex(x => x.order >= order); + } + + itemParent.splice(targetIndex, 0, itemParent.splice(currentItemIndex, 1)[0]); + + // Assign a new order value to each item to + // keep the order in sync with the array position + + let itemOrder = 0; + for (let index = 0; index < itemParent.length; index++) { + itemParent[index].order = (itemOrder += 10); + } + }, + + getActionParent: function(action){ + return (action.group == "root" ? this.editNode : this.editNode.actions.filter(x => x.type == "group" && x.id == action.group)[0]); + }, + + sortGroup: function(a,b) { + return a.order-b.order; + }, + + removeGroup: function(group){ + // removes a group from the action tree if the group + // contains actions, they will be moved to the root node + + if(group.type == "action") + return; + + let groupIndex = this.editNode.actions.findIndex(x => x.type == "group" && x.id == group.id); + + if(groupIndex < 0) + return; + + if(group.actions.length > 0){ + for (let index = (group.actions.length - 1); index >= 0; index--) { + const action = group.actions[index]; + this.moveActionToGroup(action, this.editNode); + } + } + + this.editNode.actions.splice(groupIndex, 1); + + let newGroupOrder = 0; + for (let index = 0; index < this.editNode.actions.length; index++) { + this.editNode.actions[index].order = (newGroupOrder += 10); + } + }, + + cleanupGroups: function(){ + // removes all groups without action + + for (let index = this.editNode.actions.length-1; index >= 0 ; index--) { + const treeElement = this.editNode.actions[index]; + if(treeElement.type != "action" && treeElement.actions.length == 0){ + this.removeGroup(this.editNode.actions[index]); + } + } + }, + + newGroup: function(title, actionList, sortOrder){ + // adds a new group edit root node and returns a reference to the group + + if(!sortOrder){ + sortOrder = 10; + + if(this.editNode.actions.length > 0) + sortOrder = this.editNode.actions[this.editNode.actions.length -1].order; + } + + if(!title) + title = ""; + + if(!actionList) + actionList = []; + + let randomString = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + + let newGroup = { + id: `groups.${randomString}`, + order: sortOrder, + title: title, + group: "root", + type: "group", + actions: actionList + } + + this.editNode.actions.push(newGroup) + return newGroup; + }, + + setTitle: function(item, newTitle){ + // changes the display title of an element + let actionTree = this.getEditRootNode().actions; + actionTree[0].title = "eeee"; + }, + + saveSettings: function(){ + // persists user settings + + // flatten the array to save all actions and groups as + // list for easier comparison when loading them later + + let saveArr = this.rootNode.actions + .filter(itm => itm.type == 'group') + .map(itm => itm = itm.actions) + .flat(); + + let rootActions = this.rootNode.actions + .filter(itm => itm.type == 'action'); + + // also append the empty groups to preserve their names and order + + let rootGroups = this.rootNode.actions + .filter(itm => itm.type == 'group') + .map(itm => { + return { + id: itm.id, + group: itm.group, + order: itm.order, + title: itm.title, + type: itm.type + } + }) + + saveArr.push(...rootActions); + saveArr.push(...rootGroups); + + app.setValue(this.addonName(), saveArr); + }, + + loadSettings: function(){ + // loads saved user settings + + return app.getValue(this.addonName(), []); + } + +} \ No newline at end of file diff --git a/info.json b/info.json index 26f5573..3f72309 100644 --- a/info.json +++ b/info.json @@ -4,5 +4,6 @@ "description": "Adds an Extensions Section to the Main Menu", "version": "1.0.0", "type": "general", - "author": "Michael Kellner" + "author": "Michael Kellner", + "config": "config.js" } \ No newline at end of file diff --git a/init.js b/init.js new file mode 100644 index 0000000..f668321 --- /dev/null +++ b/init.js @@ -0,0 +1,2 @@ +localRequirejs('extensionsMenu') +extensions.extensionsMenu.refresh() diff --git a/viewHandlers_add.js b/viewHandlers_add.js new file mode 100644 index 0000000..33560df --- /dev/null +++ b/viewHandlers_add.js @@ -0,0 +1,225 @@ +requirejs('Scripts/ExtensionsMenu/extensionsMenu') + +nodeHandlers.extensionsMenuTreeRoot = inheritNodeHandler('extensionsMenuTreeRoot', 'Base', { + getChildren: function (node) { + return new Promise(function (resolve, reject) { + if(!node.datasource){ + resolve(); + } + + node.dataSource.actions.forEach(itm => { + if(itm.type == "group"){ + node.addChild(itm,'extensionsGroupNode') + } else{ + node.addChild(itm,'extensionsMenuNode') + } + }) + resolve(); + }); + }, + + drop: function (dataSource, e, index) { + let srcObjectNode = dnd.getDragObject(e); + + // datatype of the element that was dropped + let datatype = dnd.getDropDataType(e); + + if (srcObjectNode && (datatype == 'extensionsGroupNode' || datatype == 'extensionsMenuNode')) { + + // the details of the datasource will change after it has been + // moved, save the current details for later + let ctrl = e.dataTransfer.getSourceControl(); + let srcObjectParent; + if(srcObjectNode.group == "root"){ + srcObjectParent = ctrl.controlClass.dataSource.root; + } else { + srcObjectParent = ctrl.controlClass.dataSource.root.findChild(`extensionsGroupNode:${srcObjectNode.group}`); + } + + let targetParent = ctrl.controlClass.dataSource.root; + + if(datatype == 'extensionsMenuNode'){ + extensions.extensionsMenu.moveAction(srcObjectNode,dataSource); + ctrl.controlClass.dataSource.notifyChanged(); + if(targetParent.persistentID != srcObjectParent.persistentID){ + // parent has changed, also update source node + nodeUtils.refreshNodeChildren(srcObjectParent); + } + } else { + extensions.extensionsMenu.moveGroup(srcObjectNode,dataSource); + } + + ctrl.controlClass.dataSource.notifyChanged(); + nodeUtils.refreshNodeChildren(ctrl.controlClass.root); + } + }, +}); + +nodeHandlers.extensionsGroupNode = inheritNodeHandler('extensionsGroupNode', 'Base', { + hideCheckbox: function (node) { + return true; + }, + + title: function (node) { + return node.dataSource.title; + }, + + hasChildren: function(node){ + return (node.dataSource.hasOwnProperty('actions') && node.dataSource.actions.length > 0); + }, + + getChildren: function (node) { + return new Promise(function (resolve, reject) { + if(nodeHandlers[node.handlerID].hasChildren(node)){ + node.dataSource.actions.forEach(itm => { + node.addChild(itm,'extensionsMenuNode') + }); + } + resolve(); + }); + }, + + canDrop: node => true, + canEdit: node => true, + canDelete: node => true, + + setTitle: function (node, newTitle) { + node.dataSource.title = newTitle; + nodeUtils.refreshNodeChildren(node.parent); + }, + + drop: function (dataSource, e, index) { + let srcObjectNode = dnd.getDragObject(e); + + // datatype of the element that was dropped + let datatype = dnd.getDropDataType(e); + + if (srcObjectNode && (datatype == 'extensionsGroupNode' || datatype == 'extensionsMenuNode')) { + if (srcObjectNode.id == dataSource.id){ + // we cannot drop to itself + return + } + + // the details of the datasource will change after it has been + // moved, save the current details for later + let ctrl = e.dataTransfer.getSourceControl(); + let srcObjectParent; + if(srcObjectNode.group == "root"){ + srcObjectParent = ctrl.controlClass.dataSource.root; + } else { + srcObjectParent = ctrl.controlClass.dataSource.root.findChild(`extensionsGroupNode:${srcObjectNode.group}`); + } + + let targetParent = e._dropNode.parent; + if(datatype == "extensionsMenuNode"){ + // if the dropped element was an action it will be moved + // to the item it has been dropped on + targetParent = e._dropNode; + } + + if(datatype == 'extensionsMenuNode'){ + extensions.extensionsMenu.moveAction(srcObjectNode,dataSource); + } else { + extensions.extensionsMenu.moveGroup(srcObjectNode,dataSource); + } + + ctrl.controlClass.dataSource.notifyChanged(); + nodeUtils.refreshNodeChildren(ctrl.controlClass.root); + + if(datatype == 'extensionsMenuNode'){ + nodeUtils.refreshNodeChildren(targetParent); + + if(targetParent.persistentID != srcObjectParent.persistentID){ + // parent has changed, also update source node + nodeUtils.refreshNodeChildren(srcObjectParent); + } + } + } + }, + + deleteItems: function (node) { + extensions.extensionsMenu.removeGroup(node.dataSource); + nodeUtils.refreshNodeChildren(node.parent); + }, + +}); + +nodeHandlers.extensionsMenuNode = inheritNodeHandler('extensionsMenuNode', 'Base', { + hideCheckbox: function (node) { + return false; + }, + + title: function (node) { + // var nodeTitle = node.dataSource.hasOwnProperty('title') ? node.dataSource.title : actions[node.dataSource.action].title(); + return window.uitools.getPureTitle(actions[node.dataSource.action].title()); + }, + + hasChildren: function(node){ + return (node.dataSource.hasOwnProperty('actions') && node.dataSource.actions.length > 0); + }, + + getChildren: function (node) { + return new Promise(function (resolve, reject) { + if(nodeHandlers[node.handlerID].hasChildren(node)){ + node.dataSource.actions.forEach(itm => { + node.addChild(itm,'extensionsMenuNode') + }); + } + resolve(); + }); + }, + + canDrop: node => true, + canDelete: node => false, + + drop: function (dataSource, e, index) { + let srcObjectNode = dnd.getDragObject(e); + + // datatype of the element that was dropped + let datatype = dnd.getDropDataType(e); + + if (srcObjectNode && (datatype == 'extensionsGroupNode' || datatype == 'extensionsMenuNode')) { + if (srcObjectNode.id == dataSource.id){ + // we cannot drop to itself + return + } + + // the details of the datasource will change after it has been + // moved, save the current details for later + let ctrl = e.dataTransfer.getSourceControl(); + let srcObjectParent; + if(srcObjectNode.group == "root"){ + srcObjectParent = ctrl.controlClass.dataSource.root; + } else { + srcObjectParent = ctrl.controlClass.dataSource.root.findChild(`extensionsGroupNode:${srcObjectNode.group}`); + } + + let targetParent; + if(dataSource.type == "action" ){ + targetParent = e._dropNode.parent; + } else { + alert('how can this even happen?') + // element was dropped on a group or root, use the target node as parent + targetParent = e._dropNode; + } + + if(datatype == 'extensionsMenuNode'){ + extensions.extensionsMenu.moveAction(srcObjectNode,dataSource); + } else { + extensions.extensionsMenu.moveGroup(srcObjectNode,dataSource); + } + + ctrl.controlClass.dataSource.notifyChanged(); + nodeUtils.refreshNodeChildren(ctrl.controlClass.root); + + if(datatype == 'extensionsMenuNode'){ + nodeUtils.refreshNodeChildren(targetParent); + + if(targetParent.persistentID != srcObjectParent.persistentID){ + // parent has changed, also update source node + nodeUtils.refreshNodeChildren(srcObjectParent); + } + } + } + }, +}); \ No newline at end of file