├── .babelrc ├── .gitignore ├── .npmignore ├── CITATION.cff ├── LICENSE.md ├── README.md ├── assets ├── add.svg ├── example.png ├── remove.svg └── submenu-indicator-default.svg ├── bower.json ├── cytoscape-context-menus.css ├── cytoscape-context-menus.js ├── demo-customized.html ├── demo.html ├── jsconfig.json ├── package-lock.json ├── package.json ├── src ├── constants.js ├── context-menu.js ├── cytoscape-context-menus.js ├── index.js └── utils.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /nbproject/ 2 | /nbproject/* 3 | /node_modules/ 4 | /bower_components/ 5 | .vscode/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite it as below." 3 | authors: 4 | - family-names: "Dogrusoz" 5 | given-names: "Ugur" 6 | orcid: "https://orcid.org/0000-0002-7153-0784" 7 | - family-names: "Karacelik" 8 | given-names: "Alper" 9 | orcid: "https://orcid.org/0000-0000-0000-0000" 10 | - family-names: "Safarli" 11 | given-names: "Ilkin" 12 | - family-names: "Balci" 13 | given-names: "Hasan" 14 | orcid: "https://orcid.org/0000-0001-8319-7758" 15 | - family-names: "Dervishi" 16 | given-names: "Leonard" 17 | - family-names: "Siper" 18 | given-names: "Metin Can" 19 | title: "cytoscape-context-menus" 20 | version: 4.1.0 21 | date-released: 2021-06-16 22 | url: "https://github.com/iVis-at-Bilkent/cytoscape.js-context-menus" 23 | preferred-citation: 24 | type: article 25 | authors: 26 | - family-names: "Dogrusoz" 27 | given-names: "Ugur" 28 | orcid: "https://orcid.org/0000-0002-7153-0784" 29 | - family-names: "Karacelik" 30 | given-names: "Alper" 31 | orcid: "https://orcid.org/0000-0000-0000-0000" 32 | - family-names: "Safarli" 33 | given-names: "Ilkin" 34 | - family-names: "Balci" 35 | given-names: "Hasan" 36 | orcid: "https://orcid.org/0000-0001-8319-7758" 37 | - family-names: "Dervishi" 38 | given-names: "Leonard" 39 | - family-names: "Siper" 40 | given-names: "Metin Can" 41 | doi: "10.1371/journal.pone.0197238" 42 | journal: "PLOS ONE" 43 | month: 5 44 | start: 1 # First page number 45 | end: 18 # Last page number 46 | title: "Efficient methods and readily customizable libraries for managing complexity of large networks" 47 | issue: 5 48 | volume: 13 49 | year: 2018 50 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 iVis-at-Bilkent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cytoscape-context-menus 2 | ================================================================================ 3 | 4 | 5 | ## Description 6 | 7 | A Cytoscape.js extension to provide context menu around elements and core instance distributed under [The MIT License](https://opensource.org/licenses/MIT). 8 | 9 | ![Image of extension](assets/example.png) 10 | 11 | Please cite the following paper when using this extension: 12 | 13 | U. Dogrusoz , A. Karacelik, I. Safarli, H. Balci, L. Dervishi, and M.C. Siper, "[Efficient methods and readily customizable libraries for managing complexity of large networks](https://doi.org/10.1371/journal.pone.0197238)", PLoS ONE, 13(5): e0197238, 2018. 14 | 15 | ## Demo 16 | 17 | Here are demos: **simple** and **customized**, respectively: 18 |

19 |   20 | 21 |

22 | 23 | ## Dependencies 24 | 25 | * Cytoscape.js ^2.7.0 || ^3.0.0 26 | 27 | 28 | ## Usage instructions 29 | 30 | Download the library: 31 | * via npm: `npm install cytoscape-context-menus`, 32 | * via bower: `bower install cytoscape-context-menus`, or 33 | * via direct download in the repository (probably from a tag). 34 | 35 | Import the library as appropriate for your project: 36 | 37 | ES import: 38 | 39 | Note: es import doesn't work for plain javascript applications because webpack doesn't support es module output at the moment. 40 | 41 | ```js 42 | import cytoscape from 'cytoscape'; 43 | import contextMenus from 'cytoscape-context-menus'; 44 | 45 | // register extension 46 | cytoscape.use(contextMenus); 47 | 48 | // import CSS as well 49 | import 'cytoscape-context-menus/cytoscape-context-menus.css'; 50 | ``` 51 | 52 | CommonJS: 53 | ```js 54 | var cytoscape = require('cytoscape'); 55 | var contextMenus = require('cytoscape-context-menus'); 56 | 57 | contextMenus(cytoscape); // register extension 58 | ``` 59 | 60 | AMD: 61 | ```js 62 | require(['cytoscape', 'cytoscape-context-menus'], function(cytoscape, contextMenus) { 63 | contextMenus(cytoscape); // register extension 64 | }); 65 | ``` 66 | 67 | Plain HTML/JS has the extension registered for you automatically, because no `require()` is needed. 68 | 69 | ## Default Options 70 | ```js 71 | var options = { 72 | // Customize event to bring up the context menu 73 | // Possible options https://js.cytoscape.org/#events/user-input-device-events 74 | evtType: 'cxttap', 75 | // List of initial menu items 76 | // A menu item must have either onClickFunction or submenu or both 77 | menuItems: [/* 78 | { 79 | id: 'remove', // ID of menu item 80 | content: 'remove', // Display content of menu item 81 | tooltipText: 'remove', // Tooltip text for menu item 82 | image: {src : "remove.svg", width : 12, height : 12, x : 6, y : 4}, // menu icon 83 | // Filters the elements to have this menu item on cxttap 84 | // If the selector is not truthy no elements will have this menu item on cxttap 85 | selector: 'node, edge', 86 | onClickFunction: function () { // The function to be executed on click 87 | console.log('remove element'); 88 | }, 89 | disabled: false, // Whether the item will be created as disabled 90 | show: false, // Whether the item will be shown or not 91 | hasTrailingDivider: true, // Whether the item will have a trailing divider 92 | coreAsWell: false // Whether core instance have this item on cxttap 93 | submenu: [] // Shows the listed menuItems as a submenu for this item. An item must have either submenu or onClickFunction or both. 94 | }, 95 | { 96 | id: 'hide', 97 | content: 'hide', 98 | tooltipText: 'hide', 99 | selector: 'node, edge', 100 | onClickFunction: function () { 101 | console.log('hide element'); 102 | }, 103 | disabled: true 104 | }, 105 | { 106 | id: 'add-node', 107 | content: 'add node', 108 | tooltipText: 'add node', 109 | image: {src : "add.svg", width : 12, height : 12, x : 6, y : 4}, 110 | selector: 'node', 111 | coreAsWell: true, 112 | onClickFunction: function () { 113 | console.log('add node'); 114 | } 115 | }*/ 116 | ], 117 | // css classes that menu items will have 118 | menuItemClasses: [ 119 | // add class names to this list 120 | ], 121 | // css classes that context menu will have 122 | contextMenuClasses: [ 123 | // add class names to this list 124 | ], 125 | // Indicates that the menu item has a submenu. If not provided default one will be used 126 | submenuIndicator: { src: 'assets/submenu-indicator-default.svg', width: 12, height: 12 } 127 | }; 128 | ``` 129 | 130 | **Note:** `selector` and `coreAsWell` options are ignored for the items that are inside a submenu. Their visiblity depends on their root parent's visibility. 131 | 132 | ## API 133 | 134 | ### Instance API 135 | 136 | ```js 137 | var instance = cy.contextMenus(options); 138 | ``` 139 | 140 | #### `instance.isActive()` 141 | * Returns whether the extension is active. 142 | 143 | #### `instance.appendMenuItem(item, parentID = undefined)` 144 | * Appends given menu item to the menu items list. 145 | * If parentID is specified, the item is inserted to the submenu of the item with parentID. 146 | * If the parent has no submenu then it will automatically be created. 147 | * If not specified item will be inserted to the root of the contextmenu 148 | 149 | #### `instance.appendMenuItems(items, parentID = undefined)` 150 | * Same with above but takes an array of items 151 | 152 | #### `instance.removeMenuItem(itemID)` 153 | * Removes the menu item with given ID and its submenu along with 154 | 155 | #### `instance.setTrailingDivider(itemID, status)` 156 | * Sets whether the menuItem with given ID will have a following divider 157 | 158 | #### `instance.insertBeforeMenuItem(item, existingItemID)` 159 | * Inserts given item before the existingitem 160 | 161 | #### `instance.moveToSubmenu(itemID, options = null)` 162 | * Moves the item with given ID to the submenu of the parent with the given ID or to root with the specified options 163 | * If `options` is a `string`, then it is the id of the parent 164 | * If `options` is a `{ selector?: string, coreAsWell?: boolean }`, then old properties are overwritten by them and the menu item is moved to the root. If it doesn't have either properties item is **not moved**. 165 | * If `options` is null or not provided, then it is just moved to the root 166 | 167 | #### `instance.moveBeforeOtherMenuItem(itemID, existingItemID)` 168 | * Inserts the `item` before the `existingItem` and moves it to the submenu that contains the `existingItem` 169 | 170 | #### `instance.disableMenuItem(itemID)` 171 | * Disables the menu item with given ID. 172 | 173 | #### `instance.enableMenuItem(itemID)` 174 | * Enables the menu item with given ID. 175 | 176 | #### `instance.showMenuItem(itemID)` 177 | * Shows the menu item with given ID. 178 | 179 | #### `instance.hideMenuItem(itemID)` 180 | * Hides the menu item with given ID. 181 | 182 | #### `instance.destroy()` 183 | * Destroys the extension instance 184 | 185 | #### `instance.swapItems(x, y)` 186 | * Changes the order of items. `x` and `y` are id of context menu items to be swapped 187 | 188 | #### `instance.getOptions()` 189 | * Returns the used options 190 | 191 | ### Other API 192 | 193 | #### `cy.contextMenus('get')` 194 | * Returns the existing instance to the extension 195 | 196 | ## Build targets 197 | 198 | * `npm run build` : Build `./src/**` into `cytoscape-edge-editing.js` in production environment and minimize the file. 199 | * `npm run build:dev` : Build `./src/**` into `cytoscape-edge-editing.js` in development environment without minimizing the file. 200 | 201 | ## Publishing instructions 202 | 203 | This project is set up to automatically be published to npm and bower. To publish: 204 | 205 | 1. Build the extension : `npm run build` 206 | 1. Commit the build : `git commit -am "Build for release"` 207 | 1. Bump the version number and tag: `npm version major|minor|patch` 208 | 1. Push to origin: `git push && git push --tags` 209 | 1. Publish to npm: `npm publish .` 210 | 1. If publishing to bower for the first time, you'll need to run `bower register cytoscape-context-menus https://github.com/iVis-at-Bilkent/cytoscape.js-context-menus.git` 211 | 212 | ## Team 213 | 214 | * [Hasan Balcı](https://github.com/hasanbalci), [Ugur Dogrusoz](https://github.com/ugurdogrusoz) of [i-Vis at Bilkent University](http://www.cs.bilkent.edu.tr/~ivis) 215 | 216 | ### Alumni 217 | * [Metin Can Siper](https://github.com/metincansiper) and [Onur Sahin](https://github.com/onsah) 218 | -------------------------------------------------------------------------------- /assets/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /assets/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iVis-at-Bilkent/cytoscape.js-context-menus/7e1de0b5690ecfce9518bf292e0c07f38c31321d/assets/example.png -------------------------------------------------------------------------------- /assets/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml -------------------------------------------------------------------------------- /assets/submenu-indicator-default.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cytoscape-context-menus", 3 | "description": "A Cytoscape.js extension to provide context menu around elements and core instance.", 4 | "main": "cytoscape-context-menus.js", 5 | "dependencies": { 6 | "cytoscape": "^2.7.0 || ^3.0.0" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/iVis-at-Bilkent/cytoscape.js-context-menus.git" 11 | }, 12 | "ignore": [ 13 | "**/.*", 14 | "node_modules", 15 | "bower_components", 16 | "test", 17 | "tests" 18 | ], 19 | "keywords": [ 20 | "cytoscape", 21 | "cyext" 22 | ], 23 | "license": "MIT" 24 | } 25 | -------------------------------------------------------------------------------- /cytoscape-context-menus.css: -------------------------------------------------------------------------------- 1 | .cy-context-menus-cxt-menu { 2 | display:none; 3 | z-index: 1000; 4 | position:absolute; 5 | border:1px solid #A0A0A0; 6 | padding: 0; 7 | margin: 0; 8 | width:auto; 9 | } 10 | 11 | .cy-context-menus-cxt-menuitem { 12 | display:block; 13 | width: 100%; 14 | padding: 3px 20px; 15 | position:relative; 16 | margin:0; 17 | background-color:#f8f8f8; 18 | font-weight:normal; 19 | font-size: 12px; 20 | white-space:nowrap; 21 | border: 0; 22 | text-align: left; 23 | } 24 | 25 | .cy-context-menus-cxt-menuitem:enabled { 26 | color: #000000; 27 | } 28 | 29 | .cy-context-menus-ctx-operation:focus { 30 | outline: none; 31 | } 32 | 33 | .cy-context-menus-cxt-menuitem:hover { 34 | color: #ffffff; 35 | text-decoration: none; 36 | background-color: #0B9BCD; 37 | background-image: none; 38 | cursor: pointer; 39 | } 40 | 41 | .cy-context-menus-cxt-menuitem[content]:before { 42 | content:attr(content); 43 | } 44 | 45 | .cy-context-menus-divider { 46 | border-bottom:1px solid #A0A0A0; 47 | } 48 | 49 | .cy-context-menus-submenu-indicator { 50 | position: absolute; 51 | right: 2px; 52 | top: 50%; 53 | transform: translateY(-50%); 54 | } -------------------------------------------------------------------------------- /cytoscape-context-menus.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.cytoscapeContextMenus=t():e.cytoscapeContextMenus=t()}(this,(function(){return(()=>{var e={621:(e,t,n)=>{"use strict";function i(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,i=new Array(t);n_});var a="cy-context-menus-divider",c={evtType:"cxttap",menuItems:[],menuItemClasses:["cy-context-menus-cxt-menuitem"],contextMenuClasses:["cy-context-menus-cxt-menu"],submenuIndicator:{src:"assets/submenu-indicator-default.svg",width:12,height:12}};function l(e){return(l="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function f(e,t){var n;if("undefined"==typeof Symbol||null==e[Symbol.iterator]){if(Array.isArray(e)||(n=function(e,t){if(e){if("string"==typeof e)return d(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?d(e,t):void 0}}(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var i=0,o=function(){};return{s:o,n:function(){return i>=e.length?{done:!0}:{done:!1,value:e[i++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var r,u=!0,s=!1;return{s:function(){n=e[Symbol.iterator]()},n:function(){var e=n.next();return u=e.done,e},e:function(e){s=!0,r=e},f:function(){try{u||null==n.return||n.return()}finally{if(s)throw r}}}}function d(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,i=new Array(t);n1&&void 0!==arguments[1]?arguments[1]:void 0;this.hasSubmenu()||this._createSubmenu(),this.submenu.appendMenuItem(e,t)}},{key:"isClickable",value:function(){return void 0!==this.onClickFunction}},{key:"display",value:function(){this.show=!0,this.style.display="block"}},{key:"isVisible",value:function(){return!0===this.show&&"none"!==this.style.display}},{key:"removeSubmenu",value:function(){this.hasSubmenu()&&(this.submenu.removeAllMenuItems(),this.detachSubmenu())}},{key:"detachSubmenu",value:function(){this.hasSubmenu()&&(this.removeChild(this.submenu),this.removeChild(this.indicator),this.removeEventListener("mouseenter",this.mouseEnterHandler),this.removeEventListener("mouseleave",this.mouseLeaveHandler),this.submenu=void 0,this.indicator=void 0)}},{key:"_onMouseEnter",value:function(e){var t=this.getBoundingClientRect(),i=function(e){e.style.opacity="0",e.style.display="block";var t=e.getBoundingClientRect();return e.style.opacity="1",e.style.display="none",t}(this.submenu),o=t.right+i.width>window.innerWidth,r=t.top+i.height>window.innerHeight;o||r?o&&!r?(this.submenu.style.right=this.clientWidth+"px",this.submenu.style.top="0px",this.submenu.style.left="auto",this.submenu.style.bottom="auto"):o&&r?(this.submenu.style.right=this.clientWidth+"px",this.submenu.style.bottom="0px",this.submenu.style.top="auto",this.submenu.style.left="auto"):(this.submenu.style.left=this.clientWidth+"px",this.submenu.style.bottom="0px",this.submenu.style.right="auto",this.submenu.style.top="auto"):(this.submenu.style.left=this.clientWidth+"px",this.submenu.style.top="0px",this.submenu.style.right="auto",this.submenu.style.bottom="auto"),this.submenu.display();var u=Array.from(this.submenu.children).filter((function(e){if(e instanceof n)return e.isVisible()})),s=u.length;u.forEach((function(e,t){e instanceof n&&(t=(r=n.getBoundingClientRect()).left&&i<=r.right&&o>=r.top&&o<=r.bottom||this.submenu.hide()}},{key:"_createSubmenu",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.indicator=this.scratchpad.submenuIndicatorGen(),this.submenu=new T(this.onMenuItemClick,this.scratchpad),this.appendChild(this.indicator),this.appendChild(this.submenu);var t,i=f(e);try{for(i.s();!(t=i.n()).done;){var o=t.value,r=new n(o,this.onMenuItemClick,this.scratchpad);this.submenu.appendMenuItem(r)}}catch(e){i.e(e)}finally{i.f()}this.mouseEnterHandler=this._onMouseEnter.bind(this),this.mouseLeaveHandler=this._onMouseLeave.bind(this),this.addEventListener("mouseenter",this.mouseEnterHandler),this.addEventListener("mouseleave",this.mouseLeaveHandler)}},{key:"_getMenuItemClassStr",value:function(e,t){return t?e+" "+a:e}}],[{key:"define",value:function(){s("ctx-menu-item",n,"button")}}]),n}(x(HTMLButtonElement)),T=function(e){y(n,e);var t=p(n);function n(e,i){var o,r;return h(this,n),w((o=g(r=t.call(this)),k(n.prototype)),"setAttribute",o).call(o,"class",i.cxtMenuClasses),r.style.position="absolute",r.onMenuItemClick=e,r.scratchpad=i,r}return v(n,[{key:"hide",value:function(){this.isVisible()&&(this.hideSubmenus(),this.style.display="none")}},{key:"display",value:function(){this.style.display="block"}},{key:"isVisible",value:function(){return"none"!==this.style.display}},{key:"hideMenuItems",value:function(){var e,t=f(this.children);try{for(t.s();!(e=t.n()).done;){var n=e.value;n instanceof HTMLElement?n.style.display="none":console.warn("".concat(n," is not a HTMLElement"))}}catch(e){t.e(e)}finally{t.f()}}},{key:"hideSubmenus",value:function(){var e,t=f(this.children);try{for(t.s();!(e=t.n()).done;){var n=e.value;n instanceof S&&n.submenu&&n.submenu.hide()}}catch(e){t.e(e)}finally{t.f()}}},{key:"appendMenuItem",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:void 0;if(void 0!==t){if(t.parentNode!==this)throw new Error("The item with id='".concat(t.id,"' is not a child of the context menu"));this.insertBefore(e,t)}else this.appendChild(e);e.isClickable()&&this._performBindings(e)}},{key:"moveBefore",value:function(e,t){if(e.parentNode!==this)throw new Error("The item with id='".concat(e.id,"' is not a child of context menu"));if(t.parentNode!==this)throw new Error("The item with id='".concat(t.id,"' is not a child of context menu"));this.removeChild(e),this.insertBefore(e,t)}},{key:"removeAllMenuItems",value:function(){for(;this.firstChild;){var e=this.lastChild;e instanceof S?this._removeImmediateMenuItem(e):(console.warn("Found non menu item in the context menu: ",e),this.removeChild(e))}}},{key:"_removeImmediateMenuItem",value:function(e){if(!this._detachImmediateMenuItem(e))throw new Error("menu item(id=".concat(e.id,") is not in the context menu"));e.detachSubmenu(),e.unbindOnClickFunctions()}},{key:"_detachImmediateMenuItem",value:function(e){if(e.parentNode===this){if(this.removeChild(e),this.children.length<=0){var t=this.parentNode;t instanceof S&&t.detachSubmenu()}return!0}return!1}},{key:"_performBindings",value:function(e){var t=this._bindOnClick(e.onClickFunction);e.bindOnClickFunction(t),e.bindOnClickFunction(this.onMenuItemClick)}},{key:"_bindOnClick",value:function(e){var t=this;return function(){var n=t.scratchpad.currentCyEvent;e(n)}}}],[{key:"define",value:function(){s("menu-item-list",n,"div")}}]),n}(x(HTMLDivElement)),A=function(e){y(n,e);var t=p(n);function n(e,i){var o;return h(this,n),(o=t.call(this,e,i)).onMenuItemClick=function(t){E(t),o.hide(),e()},o}return v(n,[{key:"removeMenuItem",value:function(e){var t=e.parentElement;t instanceof T&&this.contains(t)&&t._removeImmediateMenuItem(e)}},{key:"appendMenuItem",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:void 0;this.ensureDoesntContain(e.id),w(k(n.prototype),"appendMenuItem",this).call(this,e,t)}},{key:"insertMenuItem",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.before,i=t.parent;if(this.ensureDoesntContain(e.id),void 0!==n){if(!this.contains(n))throw new Error("before(id=".concat(n.id,") is not in the context menu"));var o=n.parentNode;if(!(o instanceof T))throw new Error("Parent of before(id=".concat(n.id,") is not a submenu"));o.appendMenuItem(e,n)}else if(void 0!==i){if(!this.contains(i))throw new Error("parent(id=".concat(i.id,") is not a descendant of the context menu"));i.appendSubmenuItem(e)}else this.appendMenuItem(e)}},{key:"moveBefore",value:function(e,t){var n=e.parentElement;if(!this.contains(n))throw new Error("parent(id=".concat(n.id,") is not in the contex menu"));if(!this.contains(t))throw new Error("before(id=".concat(t.id,") is not in the context menu"));n.removeChild(e),this.insertMenuItem(e,{before:t})}},{key:"moveToSubmenu",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,i=e.parentElement;if(!(i instanceof T))throw new Error("current parent(id=".concat(i.id,") is not a submenu"));if(!this.contains(i))throw new Error("parent of the menu item(id=".concat(i.id,") is not in the context menu"));if(null!==t){if(!this.contains(t))throw new Error("parent(id=".concat(t.id,") is not in the context menu"));i._detachImmediateMenuItem(e),t.appendSubmenuItem(e)}else null!==n&&(e.selector=n.selector,e.coreAsWell=n.coreAsWell),i._detachImmediateMenuItem(e),this.appendMenuItem(e)}},{key:"ensureDoesntContain",value:function(e){var t=document.getElementById(e);if(void 0!==t&&this.contains(t))throw new Error("There is already an element with id=".concat(e," in the context menu"))}},{key:"ensureContains",value:function(e){var t=document.getElementById(e);if(null==t||null==t||!this.contains(t))throw new Error("An element with id '".concat(e,"' does not exist!"))}}],[{key:"define",value:function(){s("ctx-menu",n,"div")}}]),n}(T);function O(e,t){var n;if("undefined"==typeof Symbol||null==e[Symbol.iterator]){if(Array.isArray(e)||(n=function(e,t){if(e){if("string"==typeof e)return L(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?L(e,t):void 0}}(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var i=0,o=function(){};return{s:o,n:function(){return i>=e.length?{done:!0}:{done:!1,value:e[i++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var r,u=!0,s=!1;return{s:function(){n=e[Symbol.iterator]()},n:function(){var e=n.next();return u=e.done,e},e:function(e){s=!0,r=e},f:function(){try{u||null==n.return||n.return()}finally{if(s)throw r}}}}function L(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,i=new Array(t);n1&&void 0!==arguments[1]?arguments[1]:void 0,n=v(e);if(void 0!==t){var i=p(t);d.insertMenuItem(n,{parent:i})}else d.insertMenuItem(n)},m=function(e){for(var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:void 0,n=0;n0&&(u.top+=f,u.left+=f);var h=i.clientHeight,m=i.clientWidth,v=h/2,y=m/2;a.y>v&&a.x<=y?(d.style.left=a.x+"px",d.style.bottom=h-a.y+"px",d.style.right="auto",d.style.top="auto"):a.y>v&&a.x>y?(d.style.right=m-a.x+"px",d.style.bottom=h-a.y+"px",d.style.left="auto",d.style.top="auto"):a.y<=v&&a.x<=y?(d.style.left=a.x+"px",d.style.top=a.y+"px",d.style.right="auto",d.style.bottom="auto"):(d.style.right=m-a.x+"px",d.style.top=a.y+"px",d.style.left="auto",d.style.bottom="auto")}}(e);var n,i=e.target||e.cyTarget,o=O(d.children);try{for(o.s();!(n=o.n()).done;){var r=n.value;r instanceof S&&(i===t?r.coreAsWell:i.is(r.selector))&&r.show&&(d.display(),l("anyVisibleChild",!0),r.display())}}catch(e){o.e(e)}finally{o.f()}var u=Array.from(d.children).filter((function(e){if(e instanceof S)return e.isVisible()})),c=u.length;u.forEach((function(e,t){e instanceof S&&(t=e.length?{done:!0}:{done:!1,value:e[o++]}},e:function(e){throw e},f:r}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var u,s=!0,a=!1;return{s:function(){n=e[Symbol.iterator]()},n:function(){var e=n.next();return s=e.done,e},e:function(e){a=!0,u=e},f:function(){try{s||null==n.return||n.return()}finally{if(a)throw u}}}}(document.getElementsByClassName("cy-context-menus-cxt-menu"));try{for(t.s();!(e=t.n()).done;)e.value.addEventListener("contextmenu",(function(e){return e.preventDefault()}))}catch(e){t.e(e)}finally{t.f()}}()}return function(e){return{isActive:function(){return s("active")},appendMenuItem:function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:void 0;return h(t,n),e},appendMenuItems:function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:void 0;return m(t,n),e},removeMenuItem:function(t){var n=p(t);return d.removeMenuItem(n),e},setTrailingDivider:function(t,n){var i=p(t);return i.setHasTrailingDivider(n),n?i.classList.add(a):i.classList.remove(a),e},insertBeforeMenuItem:function(t,n){var i=v(t),o=p(n);return d.insertMenuItem(i,{before:o}),e},moveToSubmenu:function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,i=p(t);if(null===n)d.moveToSubmenu(i);else if("string"==typeof n){var o=p(n.toString());d.moveToSubmenu(i,o)}else void 0!==n.coreAsWell||void 0!==n.selector?d.moveToSubmenu(i,null,n):console.warn("options neither has coreAsWell nor selector property but it is an object. Are you sure that this is what you want to do?");return e},moveBeforeOtherMenuItem:function(t,n){var i=p(t),o=p(n);return d.moveBefore(i,o),e},disableMenuItem:function(t){return p(t).disable(),e},enableMenuItem:function(t){return p(t).enable(),e},hideMenuItem:function(t){return p(t).hide(),b(),e},showMenuItem:function(t){return p(t).display(),b(),e},destroy:function(){return y(),e},getOptions:function(){return o(c,f)},swapItems:function(e,t){d.ensureContains(e),d.ensureContains(t);var n=document.getElementById(e),i=document.getElementById(t),o=n.nextSibling,r=i.nextSibling,u=n.parentNode,s=i.parentNode;if(!u.isSameNode(s))throw new Error("To swap, the items should have the same parent!");o&&o.isSameNode(i)?u.insertBefore(i,n):r&&r.isSameNode(n)?u.insertBefore(n,i):(u.insertBefore(i,n),u.insertBefore(n,r))}}}(this)}},579:(e,t,n)=>{var i=n(621).contextMenus,o=function(e){e&&e("core","contextMenus",i)};"undefined"!=typeof cytoscape&&o(cytoscape),e.exports=o}},t={};function n(i){var o=t[i];if(void 0!==o)return o.exports;var r=t[i]={exports:{}};return e[i](r,r.exports,n),r.exports}return n.d=(e,t)=>{for(var i in t)n.o(t,i)&&!n.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:t[i]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n(579)})()})); -------------------------------------------------------------------------------- /demo-customized.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | cytoscape-context-menus.js demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 57 | 58 | 319 | 320 | 321 | 322 |

cytoscape-context-menus customized demo

323 | 324 |
325 | 326 | 327 | 328 | 329 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | cytoscape-context-menus.js demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 40 | 41 | 300 | 301 | 302 | 303 |

cytoscape-context-menus demo

304 | 305 |
306 | 307 | 308 | 309 | 310 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | /** 2 | * Provides some helpful warnings 3 | * https://code.visualstudio.com/docs/nodejs/working-with-javascript 4 | */ 5 | { 6 | "compilerOptions": { 7 | "checkJs": true, 8 | "target": "es6" 9 | }, 10 | "exclude": ["node_modules", "**/node_modules/*", "cytoscape-context-menus.js", "./webpack.config.js"], 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cytoscape-context-menus", 3 | "version": "4.2.1", 4 | "description": "A Cytoscape.js extension to provide context menu around elements and core instance.", 5 | "main": "cytoscape-context-menus.js", 6 | "style": "cytoscape-context-menus.css", 7 | "spm": { 8 | "main": "cytoscape-context-menus.js" 9 | }, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "build": "cross-env NODE_ENV=production webpack", 13 | "build:dev": "cross-env NODE_ENV=development webpack" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/iVis-at-Bilkent/cytoscape.js-context-menus.git" 18 | }, 19 | "keywords": [ 20 | "cytoscape", 21 | "cyext" 22 | ], 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/iVis-at-Bilkent/cytoscape.js-context-menus/issues" 26 | }, 27 | "homepage": "https://github.com/iVis-at-Bilkent/cytoscape.js-context-menus", 28 | "devDependencies": { 29 | "@babel/core": "^7.10.3", 30 | "@babel/preset-env": "^7.10.3", 31 | "babel-loader": "^8.1.0", 32 | "camelcase": "^6.2.0", 33 | "cross-env": "^7.0.2", 34 | "webpack": "^5.36.1", 35 | "webpack-cli": "^4.6.0" 36 | }, 37 | "peerDependencies": { 38 | "cytoscape": "^2.7.0 || ^3.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const CXT_MENU_CSS_CLASS = 'cy-context-menus-cxt-menu'; 2 | export const MENUITEM_CSS_CLASS = 'cy-context-menus-cxt-menuitem'; 3 | export const DIVIDER_CSS_CLASS = 'cy-context-menus-divider'; 4 | export const INDICATOR_CSS_CLASS = 'cy-context-menus-submenu-indicator'; 5 | 6 | export const DEFAULT_OPTS = { 7 | // Customize event to bring up the context menu 8 | // Possible options https://js.cytoscape.org/#events/user-input-device-events 9 | evtType: 'cxttap', 10 | // List of initial menu items 11 | menuItems: [ 12 | /* 13 | { 14 | id: 'remove', 15 | content: 'remove', 16 | tooltipText: 'remove', 17 | selector: 'node, edge', 18 | onClickFunction: function () { 19 | console.log('remove element'); 20 | }, 21 | hasTrailingDivider: true 22 | }, 23 | { 24 | id: 'hide', 25 | content: 'hide', 26 | tooltipText: 'remove', 27 | selector: 'node, edge', 28 | onClickFunction: function () { 29 | console.log('hide element'); 30 | }, 31 | disabled: true 32 | }*/ 33 | ], 34 | // css classes that menu items will have 35 | menuItemClasses: [ 36 | MENUITEM_CSS_CLASS, 37 | ], 38 | // css classes that context menu will have 39 | contextMenuClasses: [ 40 | CXT_MENU_CSS_CLASS, 41 | ], 42 | submenuIndicator: { src: 'assets/submenu-indicator-default.svg', width: 12, height: 12 } 43 | }; 44 | -------------------------------------------------------------------------------- /src/context-menu.js: -------------------------------------------------------------------------------- 1 | import { setBooleanAttribute, getClassStr, isIn, getDimensionsHidden, defineCustomElement } from './utils'; 2 | import { DIVIDER_CSS_CLASS } from './constants'; 3 | 4 | // TODO: add submenu property 5 | 6 | function stopEvent(event) { 7 | event.preventDefault(); 8 | event.stopPropagation(); 9 | } 10 | 11 | export class MenuItem extends HTMLButtonElement { 12 | /** 13 | * @param {{ 14 | * id: string; 15 | * tooltipText?: string; 16 | * disabled?: boolean; 17 | * image?: { 18 | * src: string; 19 | * width: number; 20 | * height: number; 21 | * y: string; 22 | * x: string; 23 | * }; 24 | * content: string; 25 | * selector: string; 26 | * show?: boolean; 27 | * submenu?: Array; 28 | * coreAsWell?: boolean; 29 | * onClickFunction?: any; 30 | * hasTrailingDivider?: boolean; 31 | * }} params 32 | * @param { * } onMenuItemClick 33 | * passed so that submenu items can have this 34 | * called when the menu item is clicked 35 | */ 36 | constructor( 37 | params, 38 | onMenuItemClick, 39 | scratchpad 40 | ) { 41 | super(); 42 | 43 | super.setAttribute('id', params.id); 44 | 45 | let className = this._getMenuItemClassStr(scratchpad['cxtMenuItemClasses'], params.hasTrailingDivider); 46 | 47 | super.setAttribute('class', className); 48 | 49 | super.setAttribute('title', params.tooltipText ?? ""); 50 | 51 | if (params.disabled) { 52 | setBooleanAttribute(this, 'disabled', true); 53 | } 54 | 55 | if (params.image) { 56 | let img = document.createElement('img'); 57 | img.src = params.image.src; 58 | img.width = params.image.width; 59 | img.height = params.image.height; 60 | img.style.position = 'absolute'; 61 | img.style.top = params.image.y + 'px'; 62 | img.style.left = params.image.x + 'px'; 63 | 64 | super.appendChild(img); 65 | } 66 | 67 | this.innerHTML += params.content; 68 | 69 | this.onMenuItemClick = onMenuItemClick; 70 | 71 | this.data = {}; 72 | this.clickFns = []; 73 | this.selector = params.selector; 74 | this.hasTrailingDivider = params.hasTrailingDivider; 75 | this.show = (typeof params.show === 'undefined') || params.show; 76 | this.coreAsWell = params.coreAsWell || false; 77 | this.scratchpad = scratchpad; 78 | 79 | if (typeof params.onClickFunction === 'undefined' && 80 | typeof params.submenu === 'undefined') { 81 | 82 | throw new Error("A menu item must either have click function or a submenu or both"); 83 | } 84 | 85 | this.onClickFunction = params.onClickFunction; 86 | 87 | // Create the submenu if neccessary 88 | if (params.submenu instanceof Array) { 89 | this._createSubmenu(params.submenu); 90 | } 91 | 92 | super.addEventListener('mousedown', stopEvent); 93 | super.addEventListener('mouseup', stopEvent); 94 | super.addEventListener('touchstart', stopEvent); 95 | super.addEventListener('touchend', stopEvent); 96 | } 97 | 98 | bindOnClickFunction(onClickFn) { 99 | this.clickFns.push(onClickFn); 100 | 101 | super.addEventListener('click', onClickFn); 102 | } 103 | 104 | unbindOnClickFunctions() { 105 | for (let onClickFn of this.clickFns) { 106 | super.removeEventListener('click', onClickFn); 107 | } 108 | this.clickFns = []; 109 | } 110 | 111 | enable() { 112 | setBooleanAttribute(this, 'disabled', false); 113 | 114 | if (this.hasSubmenu()) { 115 | this.addEventListener('mouseenter', this.mouseEnterHandler); 116 | } 117 | } 118 | 119 | disable() { 120 | setBooleanAttribute(this, 'disabled', true); 121 | 122 | if (this.hasSubmenu()) { 123 | this.removeEventListener('mouseenter', this.mouseEnterHandler); 124 | } 125 | } 126 | 127 | hide() { 128 | this.show = false; 129 | this.style.display = 'none'; 130 | } 131 | 132 | getHasTrailingDivider() { 133 | // may be undefined so use this way 134 | return this.hasTrailingDivider ? true : false; 135 | } 136 | /** 137 | * @param {boolean} status 138 | */ 139 | setHasTrailingDivider(status) { 140 | this.hasTrailingDivider = status; 141 | } 142 | 143 | hasSubmenu() { 144 | return this.submenu instanceof MenuItemList; 145 | } 146 | 147 | appendSubmenuItem(menuItem, before = undefined) { 148 | if (!this.hasSubmenu()) { 149 | this._createSubmenu(); 150 | } 151 | this.submenu.appendMenuItem(menuItem, before) 152 | } 153 | 154 | isClickable() { 155 | return this.onClickFunction !== undefined; 156 | } 157 | 158 | display() { 159 | this.show = true; 160 | this.style.display = 'block'; 161 | } 162 | 163 | /** 164 | * Returns true if this menu item is currently visible 165 | */ 166 | isVisible() { 167 | return this.show === true && this.style.display !== 'none'; 168 | } 169 | 170 | /** 171 | * Removes the submenu if exists 172 | */ 173 | removeSubmenu() { 174 | if (this.hasSubmenu()) { 175 | this.submenu.removeAllMenuItems(); 176 | this.detachSubmenu(); 177 | } 178 | } 179 | 180 | detachSubmenu() { 181 | if (this.hasSubmenu()) { 182 | this.removeChild(this.submenu); 183 | this.removeChild(this.indicator); 184 | this.removeEventListener('mouseenter', this.mouseEnterHandler); 185 | this.removeEventListener('mouseleave', this.mouseLeaveHandler); 186 | this.submenu = undefined; 187 | this.indicator = undefined; 188 | } 189 | } 190 | 191 | _onMouseEnter(_event) { 192 | let rect = this.getBoundingClientRect(); 193 | let submenuRect = getDimensionsHidden(this.submenu); 194 | 195 | let exceedsRight = (rect.right + submenuRect.width) > window.innerWidth; 196 | let exceedsBottom = (rect.top + submenuRect.height) > window.innerHeight; 197 | 198 | // Adjusts the position of the submenu 199 | if (!exceedsRight && !exceedsBottom) { 200 | this.submenu.style.left = this.clientWidth + "px"; 201 | this.submenu.style.top = "0px"; 202 | this.submenu.style.right = "auto"; 203 | this.submenu.style.bottom = "auto"; 204 | } else if (exceedsRight && !exceedsBottom) { 205 | this.submenu.style.right = this.clientWidth + "px"; 206 | this.submenu.style.top = "0px"; 207 | this.submenu.style.left = "auto"; 208 | this.submenu.style.bottom = "auto"; 209 | } else if (exceedsRight && exceedsBottom) { 210 | this.submenu.style.right = this.clientWidth + "px"; 211 | this.submenu.style.bottom = "0px"; 212 | this.submenu.style.top = "auto"; 213 | this.submenu.style.left = "auto"; 214 | } else { 215 | this.submenu.style.left = this.clientWidth + "px"; 216 | this.submenu.style.bottom = "0px"; 217 | this.submenu.style.right = "auto"; 218 | this.submenu.style.top = "auto"; 219 | } 220 | 221 | this.submenu.display(); 222 | 223 | // Remove trailing divider from last visible menu item if it has it. 224 | // For other visible items, add divider if the associated menu item 225 | // should have divider, i.e, it was last item at some point and 226 | // the divider was removed but it should be there when the item 227 | // is not last 228 | 229 | const visibleItems = Array.from(this.submenu.children).filter(item => { 230 | if (item instanceof MenuItem) 231 | return item.isVisible(); 232 | }); 233 | const length = visibleItems.length; 234 | visibleItems.forEach((item, index) => { 235 | if (!(item instanceof MenuItem)) 236 | return; 237 | 238 | if (index < length - 1 && item.getHasTrailingDivider()) { 239 | item.classList.add(DIVIDER_CSS_CLASS); 240 | } 241 | else if (item.getHasTrailingDivider()) { 242 | item.classList.remove(DIVIDER_CSS_CLASS); 243 | }; 244 | }); 245 | } 246 | 247 | _onMouseLeave(event) { 248 | let pos = { x: event.clientX, y: event.clientY }; 249 | 250 | // Hide if mouse is not passed to the submenu 251 | if (!isIn(pos, this.submenu)) { 252 | this.submenu.hide(); 253 | } 254 | } 255 | 256 | _createSubmenu(items = []) { 257 | // We generate another indicator for each 258 | this.indicator = this.scratchpad['submenuIndicatorGen'](); 259 | this.submenu = new MenuItemList(this.onMenuItemClick, this.scratchpad); 260 | 261 | this.appendChild(this.indicator); 262 | this.appendChild(this.submenu); 263 | 264 | for (let item of items) { 265 | let menuItem = new MenuItem(item, this.onMenuItemClick, this.scratchpad); 266 | this.submenu.appendMenuItem(menuItem); 267 | } 268 | 269 | this.mouseEnterHandler = this._onMouseEnter.bind(this); 270 | this.mouseLeaveHandler = this._onMouseLeave.bind(this); 271 | 272 | // submenu should be visible when mouse is over 273 | this.addEventListener('mouseenter', this.mouseEnterHandler); 274 | 275 | this.addEventListener('mouseleave', this.mouseLeaveHandler); 276 | } 277 | 278 | // TODO: can be static 279 | _getMenuItemClassStr(classStr, hasTrailingDivider) { 280 | return hasTrailingDivider ? 281 | classStr + ' ' + DIVIDER_CSS_CLASS : 282 | classStr; 283 | }; 284 | 285 | static define() { 286 | defineCustomElement('ctx-menu-item', MenuItem, 'button'); 287 | } 288 | } 289 | 290 | export class MenuItemList extends HTMLDivElement { 291 | constructor(onMenuItemClick, scratchpad) { 292 | super(); 293 | 294 | super.setAttribute('class', scratchpad['cxtMenuClasses']); 295 | 296 | this.style.position = 'absolute'; 297 | 298 | this.onMenuItemClick = onMenuItemClick; 299 | this.scratchpad = scratchpad; 300 | } 301 | 302 | hide() { 303 | if (this.isVisible()) { 304 | this.hideSubmenus(); 305 | this.style.display = 'none'; 306 | } 307 | } 308 | 309 | display() { 310 | this.style.display = 'block'; 311 | } 312 | 313 | isVisible() { 314 | return this.style.display !== 'none'; 315 | } 316 | 317 | /** 318 | * Hides all menu items 319 | */ 320 | hideMenuItems(){ 321 | for (let item of this.children) { 322 | if (item instanceof HTMLElement) { 323 | item.style.display = 'none'; 324 | } else { 325 | console.warn(`${item} is not a HTMLElement`); 326 | } 327 | } 328 | } 329 | 330 | hideSubmenus() { 331 | for (let menuItem of this.children) { 332 | if (menuItem instanceof MenuItem) { 333 | if (menuItem.submenu) { 334 | menuItem.submenu.hide(); 335 | } 336 | } 337 | } 338 | } 339 | 340 | /** 341 | * @param { MenuItem } menuItem 342 | * @param { Element? } before 343 | * If before is specified menuItem is inserted before this element instead of at the end \ 344 | * By default appends at the end of the this 345 | */ 346 | appendMenuItem(menuItem, before = undefined) { 347 | if (typeof before !== 'undefined') { 348 | if (before.parentNode === this) { 349 | this.insertBefore(menuItem, before); 350 | } else { 351 | throw new Error(`The item with id='${before.id}' is not a child of the context menu`); 352 | } 353 | } else { 354 | this.appendChild(menuItem); 355 | } 356 | 357 | if (menuItem.isClickable()) { 358 | this._performBindings(menuItem); 359 | } 360 | } 361 | 362 | /** 363 | * Removes any menuItem that is any children of the context menu 364 | * Returns true if child is found and removed, false otherwise 365 | * @param { MenuItem } menuItem 366 | */ 367 | /* removeMenuItem(menuItem) { 368 | if (this._removeImmediateMenuItem(menuItem)) { 369 | return true; 370 | } else { 371 | for (let child of this.children) { 372 | if (child instanceof MenuItem && child.hasSubmenu()) { 373 | if (child.submenu.removeMenuItem(menuItem)) { 374 | return true; 375 | } 376 | } 377 | } 378 | // throw new Error(`The item with id='${menuItem.id}' is not a child of the context menu`); 379 | return false; 380 | } 381 | } */ 382 | 383 | /** 384 | * Moves a menuItem before another 385 | * @param { MenuItem } menuItem 386 | * @param { MenuItem } before 387 | */ 388 | moveBefore(menuItem, before) { 389 | if (menuItem.parentNode !== this) { 390 | throw new Error(`The item with id='${menuItem.id}' is not a child of context menu`); 391 | } 392 | if (before.parentNode !== this) { 393 | throw new Error(`The item with id='${before.id}' is not a child of context menu`); 394 | } 395 | 396 | this.removeChild(menuItem); 397 | this.insertBefore(menuItem, before); 398 | } 399 | 400 | removeAllMenuItems() { 401 | // https://stackoverflow.com/a/3955238/12045421 402 | while (this.firstChild) { 403 | let child = this.lastChild; 404 | if (child instanceof MenuItem) { 405 | this._removeImmediateMenuItem(child); 406 | } else { 407 | console.warn("Found non menu item in the context menu: ", child); 408 | // Remove it as well 409 | this.removeChild(child); 410 | } 411 | } 412 | } 413 | 414 | /** 415 | * Removes if the `menuItem` is direct child of the parent 416 | * @param { MenuItem } menuItem 417 | */ 418 | _removeImmediateMenuItem(menuItem) { 419 | if (this._detachImmediateMenuItem(menuItem)) { 420 | menuItem.detachSubmenu(); 421 | menuItem.unbindOnClickFunctions(); 422 | } else { 423 | throw new Error(`menu item(id=${menuItem.id}) is not in the context menu`); 424 | } 425 | } 426 | 427 | /** 428 | * Detaches `menuItem` from `this` doesn't destroy it 429 | * @param { MenuItem } menuItem 430 | * @returns { boolean } 431 | */ 432 | _detachImmediateMenuItem(menuItem) { 433 | if (menuItem.parentNode === this) { 434 | this.removeChild(menuItem); 435 | 436 | if (this.children.length <= 0) { 437 | let parent = this.parentNode; 438 | if (parent instanceof MenuItem) { 439 | parent.detachSubmenu(); 440 | } 441 | } 442 | 443 | return true; 444 | } else { 445 | return false; 446 | } 447 | } 448 | 449 | /** 450 | * @param { MenuItem } menuItem 451 | */ 452 | _performBindings(menuItem) { 453 | let callback = this._bindOnClick(menuItem.onClickFunction); 454 | menuItem.bindOnClickFunction(callback); 455 | menuItem.bindOnClickFunction(this.onMenuItemClick); 456 | } 457 | 458 | _bindOnClick(onClickFn) { 459 | return () => { 460 | let event = this.scratchpad['currentCyEvent']; 461 | onClickFn(event); 462 | }; 463 | } 464 | 465 | static define() { 466 | defineCustomElement('menu-item-list', MenuItemList, 'div'); 467 | } 468 | } 469 | 470 | export class ContextMenu extends MenuItemList { 471 | 472 | constructor(onMenuItemClick, scratchpad) { 473 | super(onMenuItemClick, scratchpad); 474 | 475 | // Called when a menu item is clicked 476 | this.onMenuItemClick = (event) => { 477 | // So that parent menuItems won't be clicked 478 | stopEvent(event); 479 | this.hide(); 480 | onMenuItemClick(); 481 | }; 482 | 483 | /* this.addEventListener('mouseleave', (_event) => { 484 | this.hideMenuItemSubmenus(); 485 | }); */ 486 | } 487 | 488 | /** 489 | * @param { MenuItem } menuItem 490 | */ 491 | removeMenuItem(menuItem) { 492 | let parent = menuItem.parentElement; 493 | 494 | if (parent instanceof MenuItemList && this.contains(parent)) { 495 | parent._removeImmediateMenuItem(menuItem); 496 | } 497 | } 498 | 499 | /** 500 | * @param { MenuItem } menuItem 501 | * @param { Element? } before 502 | */ 503 | appendMenuItem(menuItem, before = undefined) { 504 | this.ensureDoesntContain(menuItem.id); 505 | 506 | super.appendMenuItem(menuItem, before); 507 | } 508 | 509 | /** 510 | * Inserts the menu item to the context menu \ 511 | * If before is specified, item is inserted before the 'before' inside the same submenu \ 512 | * The parent argument is ignored if before is specified because parent can be inferred from the before argument \ 513 | * If parent is specified, item is inserted into the submenu of specified parent 514 | * @param { MenuItem } menuItem 515 | * @param {{ before?: MenuItem, parent?: MenuItem }} param1 516 | */ 517 | insertMenuItem(menuItem, { before, parent } = {}) { 518 | this.ensureDoesntContain(menuItem.id); 519 | 520 | if (typeof before !== 'undefined') { 521 | if (this.contains(before)) { 522 | let parent = before.parentNode; 523 | if (parent instanceof MenuItemList) { 524 | parent.appendMenuItem(menuItem, before); 525 | } else { 526 | throw new Error(`Parent of before(id=${before.id}) is not a submenu`); 527 | } 528 | } else { 529 | throw new Error(`before(id=${before.id}) is not in the context menu`); 530 | } 531 | } else if (typeof parent !== 'undefined') { 532 | if (this.contains(parent)) { 533 | parent.appendSubmenuItem(menuItem); 534 | } else { 535 | throw new Error(`parent(id=${parent.id}) is not a descendant of the context menu`); 536 | } 537 | } else { 538 | this.appendMenuItem(menuItem); 539 | } 540 | } 541 | 542 | /** 543 | * @param { MenuItem } menuItem 544 | * @param { MenuItem } before 545 | */ 546 | moveBefore(menuItem, before) { 547 | let parent = menuItem.parentElement; 548 | if (this.contains(parent)) { 549 | if (this.contains(before)) { 550 | parent.removeChild(menuItem); 551 | this.insertMenuItem(menuItem, { before }); 552 | } else { 553 | throw new Error(`before(id=${before.id}) is not in the context menu`); 554 | } 555 | } else { 556 | throw new Error(`parent(id=${parent.id}) is not in the contex menu`); 557 | } 558 | } 559 | 560 | /** 561 | * @param { MenuItem } menuItem 562 | * @param { MenuItem } parent 563 | * @param { { selector?: string, coreAsWell: boolean } } options 564 | */ 565 | moveToSubmenu(menuItem, parent = null, options = null) { 566 | let oldParent = menuItem.parentElement; 567 | if (oldParent instanceof MenuItemList) { 568 | if (this.contains(oldParent)) { 569 | // Assuming parameters are always correct since this is an internal function 570 | if (parent !== null) { 571 | if (this.contains(parent)) { 572 | oldParent._detachImmediateMenuItem(menuItem); 573 | parent.appendSubmenuItem(menuItem); 574 | } else { 575 | throw new Error(`parent(id=${parent.id}) is not in the context menu`); 576 | } 577 | } else { 578 | if (options !== null) { 579 | menuItem.selector = options.selector; 580 | menuItem.coreAsWell = options.coreAsWell; 581 | } 582 | 583 | oldParent._detachImmediateMenuItem(menuItem); 584 | this.appendMenuItem(menuItem); 585 | } 586 | } else { 587 | throw new Error(`parent of the menu item(id=${oldParent.id}) is not in the context menu`); 588 | } 589 | } else { 590 | throw new Error(`current parent(id=${oldParent.id}) is not a submenu`); 591 | } 592 | 593 | } 594 | 595 | ensureDoesntContain(id) { 596 | let elem = document.getElementById(id); 597 | if (typeof elem !== 'undefined' && this.contains(elem)) { 598 | throw new Error(`There is already an element with id=${id} in the context menu`); 599 | } 600 | } 601 | 602 | ensureContains(id) { 603 | const e = document.getElementById(id); 604 | if (e == undefined || e == null || !this.contains(e)) { 605 | throw new Error(`An element with id '${id}' does not exist!`); 606 | } 607 | } 608 | 609 | static define() { 610 | defineCustomElement('ctx-menu', ContextMenu, 'div'); 611 | } 612 | } 613 | -------------------------------------------------------------------------------- /src/cytoscape-context-menus.js: -------------------------------------------------------------------------------- 1 | import * as utils from './utils.js'; 2 | import { DEFAULT_OPTS, DIVIDER_CSS_CLASS, INDICATOR_CSS_CLASS } from './constants.js'; 3 | import { MenuItem, ContextMenu, MenuItemList } from './context-menu.js'; 4 | 5 | export function contextMenus(opts) { 6 | let cy = this; 7 | 8 | // Initilize scratch pad 9 | if (!cy.scratch('cycontextmenus')) { 10 | cy.scratch('cycontextmenus', {}); 11 | } 12 | 13 | let getScratchProp = (propname) => 14 | cy.scratch('cycontextmenus')[propname]; 15 | 16 | let setScratchProp = (propname, value) => 17 | cy.scratch('cycontextmenus')[propname] = value; 18 | 19 | let hasScratchProp = (propname) => 20 | typeof cy.scratch('cycontextmenus')[propname] !== 'undefined'; 21 | 22 | let options = getScratchProp('options'); 23 | /** @type { ContextMenu } */ 24 | let cxtMenu = getScratchProp('cxtMenu'); 25 | 26 | /** 27 | * Right click event 28 | */ 29 | let bindOnCxttap = () => { 30 | 31 | // TODO: move this to ContextMenu, just do the binding here 32 | let onCxttap = (event) => { 33 | setScratchProp('currentCyEvent', event); 34 | adjustCxtMenu(event); // adjust the position of context menu 35 | 36 | let target = event.target || event.cyTarget; 37 | 38 | // Check for each menuItem, if show is true, show the menuItem 39 | for (let menuItem of cxtMenu.children) { 40 | if (menuItem instanceof MenuItem) { 41 | let shouldDisplay = (target === cy) ? 42 | // If user clicked in cy area then show core items 43 | menuItem.coreAsWell : 44 | // If selector of the item matches then show 45 | target.is(menuItem.selector); 46 | // User clicked on empty area and menuItem is core 47 | if (shouldDisplay && menuItem.show) { 48 | cxtMenu.display(); 49 | 50 | // anyVisibleChild indicates if there is any visible child of context menu if not do not show the context menu 51 | setScratchProp('anyVisibleChild', true); // there is visible child 52 | menuItem.display(); 53 | } 54 | } 55 | } 56 | 57 | // Remove trailing divider from last visible menu item if it has it. 58 | // For other visible items, add divider if the associated menu item 59 | // should have divider, i.e, it was last item at some point and 60 | // the divider was removed but it should be there when the item 61 | // is not last 62 | 63 | const visibleItems = Array.from(cxtMenu.children).filter(item => { 64 | if (item instanceof MenuItem) 65 | return item.isVisible(); 66 | }); 67 | const length = visibleItems.length; 68 | visibleItems.forEach((item, index) => { 69 | if (!(item instanceof MenuItem)) 70 | return; 71 | 72 | if (index < length - 1 && item.getHasTrailingDivider()) { 73 | item.classList.add(DIVIDER_CSS_CLASS); 74 | } 75 | else if (item.getHasTrailingDivider()) { 76 | item.classList.remove(DIVIDER_CSS_CLASS); 77 | }; 78 | }); 79 | 80 | if (!getScratchProp('anyVisibleChild') && utils.isElementVisible(cxtMenu)) { 81 | cxtMenu.hide(); 82 | } 83 | }; 84 | 85 | cy.on(options.evtType, onCxttap); 86 | setScratchProp('onCxttap', onCxttap); 87 | }; 88 | 89 | let bindCyEvents = () => { 90 | 91 | let eventCyTapStart = event => { 92 | if (cxtMenu.contains(event.originalEvent.target)) { 93 | return false; 94 | } 95 | cxtMenu.hide(); 96 | setScratchProp('cxtMenuPosition', undefined); 97 | setScratchProp('currentCyEvent', undefined); 98 | }; 99 | 100 | cy.on('tapstart', eventCyTapStart); 101 | setScratchProp('eventCyTapStart', eventCyTapStart); 102 | 103 | let eventCyViewport = () => { 104 | cxtMenu.hide(); 105 | }; 106 | 107 | cy.on('viewport', eventCyViewport); 108 | setScratchProp('onViewport', eventCyViewport); 109 | }; 110 | 111 | // Hide callbacks outside the cytoscape canvas 112 | let bindHideCallbacks = () => { 113 | let onClick = (event) => { 114 | let cyContainer = cy.container(); 115 | // Hide only if click is outside of the Cytoscape area and the context menu 116 | if (!cyContainer.contains(event.target) && !cxtMenu.contains(event.target)) { 117 | cxtMenu.hide(); 118 | setScratchProp('cxtMenuPosition', undefined); 119 | } 120 | }; 121 | 122 | document.addEventListener('mouseup', onClick); 123 | setScratchProp('hideOnNonCyClick', onClick); 124 | }; 125 | 126 | // Adjusts context menu if necessary 127 | let adjustCxtMenu = (event) => { 128 | const container = cy.container(); 129 | let currentCxtMenuPosition = getScratchProp('cxtMenuPosition'); 130 | let cyPos = event.position || event.cyPosition; 131 | 132 | if (currentCxtMenuPosition != cyPos) { 133 | cxtMenu.hideMenuItems(); 134 | setScratchProp('anyVisibleChild', false);// we hide all children there is no visible child remaining 135 | setScratchProp('cxtMenuPosition', cyPos); 136 | 137 | let containerPos = utils.getOffset(container); 138 | let renderedPos = event.renderedPosition || event.cyRenderedPosition; 139 | 140 | let borderWidth = getComputedStyle(container)['border-width']; 141 | let borderThickness = parseInt(borderWidth.replace("px","")) || 0; 142 | if (borderThickness > 0) { 143 | containerPos.top += borderThickness; 144 | containerPos.left += borderThickness; 145 | } 146 | let containerHeight = container.clientHeight; 147 | let containerWidth = container.clientWidth; 148 | 149 | let horizontalSplit = containerHeight / 2; 150 | let verticalSplit = containerWidth / 2; 151 | 152 | //When user clicks on bottom-left part of window 153 | if (renderedPos.y > horizontalSplit && renderedPos.x <= verticalSplit) { 154 | cxtMenu.style.left = renderedPos.x + 'px'; 155 | cxtMenu.style.bottom = (containerHeight - renderedPos.y) + 'px'; 156 | cxtMenu.style.right = "auto"; 157 | cxtMenu.style.top = "auto"; 158 | } else if (renderedPos.y > horizontalSplit && renderedPos.x > verticalSplit) { 159 | cxtMenu.style.right = (containerWidth - renderedPos.x) + 'px'; 160 | cxtMenu.style.bottom = (containerHeight - renderedPos.y) + 'px'; 161 | cxtMenu.style.left = "auto"; 162 | cxtMenu.style.top = "auto"; 163 | } else if (renderedPos.y <= horizontalSplit && renderedPos.x <= verticalSplit) { 164 | cxtMenu.style.left = renderedPos.x + 'px'; 165 | cxtMenu.style.top = renderedPos.y + 'px'; 166 | cxtMenu.style.right = "auto"; 167 | cxtMenu.style.bottom = "auto"; 168 | } else { 169 | cxtMenu.style.right = (containerWidth - renderedPos.x) + 'px'; 170 | cxtMenu.style.top = renderedPos.y + 'px'; 171 | cxtMenu.style.left = "auto"; 172 | cxtMenu.style.bottom = "auto"; 173 | } 174 | } 175 | }; 176 | 177 | let createAndAppendMenuItemComponent = (opts, parentID = undefined) => { 178 | // Create and append menu item 179 | let menuItemComponent = createMenuItemComponent(opts); 180 | 181 | if (typeof parentID !== 'undefined') { 182 | let parent = asMenuItem(parentID); 183 | 184 | cxtMenu.insertMenuItem(menuItemComponent, { parent }); 185 | } else { 186 | cxtMenu.insertMenuItem(menuItemComponent); 187 | } 188 | };//insertComponentBeforeExistingItem(component, existingItemID) 189 | 190 | let createAndAppendMenuItemComponents = (optionsArr, parentID = undefined) => { 191 | for (let i = 0; i < optionsArr.length; i++) { 192 | createAndAppendMenuItemComponent(optionsArr[i], parentID); 193 | } 194 | }; 195 | 196 | // Creates a menu item as an html component 197 | let createMenuItemComponent = (opts) => { 198 | let scratchpad = cy.scratch('cycontextmenus'); 199 | return new MenuItem(opts, cxtMenu.onMenuItemClick, scratchpad); 200 | }; 201 | 202 | let destroyCxtMenu = () => { 203 | if(!getScratchProp('active')) { 204 | return; 205 | } 206 | 207 | cxtMenu.removeAllMenuItems(); 208 | 209 | cy.off('tapstart', getScratchProp('eventCyTapStart')); 210 | cy.off(options.evtType, getScratchProp('onCxttap')); 211 | cy.off('viewport', getScratchProp('onViewport')); 212 | document.removeEventListener('mouseup', getScratchProp('hideOnNonCyClick')); 213 | 214 | cxtMenu.parentNode.removeChild(cxtMenu); 215 | cxtMenu = undefined; 216 | 217 | setScratchProp('cxtMenu', undefined); 218 | setScratchProp('active', false); 219 | setScratchProp('anyVisibleChild', false); 220 | setScratchProp('onCxttap', undefined); 221 | setScratchProp('onViewport', undefined); 222 | setScratchProp('hideOnNonCyClick', undefined); 223 | }; 224 | 225 | let makeSubmenuIndicator = (props) => { 226 | let elem = document.createElement('img'); 227 | elem.src = props.src; 228 | elem.width = props.width; 229 | elem.height = props.height; 230 | elem.classList.add(INDICATOR_CSS_CLASS); 231 | 232 | return elem; 233 | }; 234 | 235 | /** 236 | * @param { string } menuItemID 237 | */ 238 | let asMenuItem = (menuItemID) => { 239 | let menuItem = document.getElementById(menuItemID); 240 | if (menuItem instanceof MenuItem) { 241 | return menuItem; 242 | } else { 243 | throw new Error(`The item with id=${menuItemID} is not a menu item`); 244 | } 245 | }; 246 | 247 | let setParentVisibilityFromItems = () => { 248 | let b = false; 249 | for (let menuItem of cxtMenu.children) { 250 | if (menuItem instanceof MenuItem && menuItem.show && menuItem.style.display != 'none') { 251 | b = true; 252 | break; 253 | } 254 | } 255 | if (b) { 256 | cxtMenu.display(); 257 | } else { 258 | cxtMenu.hide(); 259 | } 260 | }; 261 | 262 | // Get an extension instance to enable users to access extension methods 263 | let getInstance = (cy) => { 264 | let instance = { 265 | // Returns whether the extension is active 266 | isActive: function() { 267 | return getScratchProp('active'); 268 | }, 269 | // Appends given menu item to the menu items list. 270 | appendMenuItem: function(item, parentID = undefined) { 271 | createAndAppendMenuItemComponent(item, parentID); 272 | return cy; 273 | }, 274 | // Appends menu items in the given list to the menu items list. 275 | appendMenuItems: function(items, parentID = undefined) { 276 | createAndAppendMenuItemComponents(items, parentID); 277 | return cy; 278 | }, 279 | // Removes the menu item with given ID. 280 | removeMenuItem: function(itemID) { 281 | let item = asMenuItem(itemID); 282 | 283 | cxtMenu.removeMenuItem(item); 284 | return cy; 285 | }, 286 | // Sets whether the menuItem with given ID will have a following divider. 287 | setTrailingDivider: function(itemID, status) { 288 | let menuItem = asMenuItem(itemID); 289 | menuItem.setHasTrailingDivider(status); 290 | 291 | if (status) { 292 | menuItem.classList.add(DIVIDER_CSS_CLASS); 293 | } else { 294 | menuItem.classList.remove(DIVIDER_CSS_CLASS); 295 | } 296 | return cy; 297 | }, 298 | // Inserts given item before the existingitem. 299 | insertBeforeMenuItem: function(item, existingItemID) { 300 | let menuItemComponent = createMenuItemComponent(item); 301 | let existingItem = asMenuItem(existingItemID); 302 | 303 | cxtMenu.insertMenuItem(menuItemComponent, { before: existingItem }); 304 | return cy; 305 | }, 306 | // Moves the item to the submenu of the parent with the given ID 307 | moveToSubmenu: function(itemID, options = null) { 308 | let item = asMenuItem(itemID); 309 | 310 | if (options === null) { 311 | cxtMenu.moveToSubmenu(item); 312 | } else if (typeof options === 'string') { 313 | // options is parentID 314 | let parent = asMenuItem(options.toString()); 315 | cxtMenu.moveToSubmenu(item, parent); 316 | } else if (typeof options.coreAsWell !== 'undefined' || typeof options.selector !== 'undefined') { 317 | cxtMenu.moveToSubmenu(item, null, options); 318 | } else { 319 | console.warn('options neither has coreAsWell nor selector property but it is an object. Are you sure that this is what you want to do?'); 320 | } 321 | 322 | return cy; 323 | }, 324 | // Moves the item with given ID before the existingitem. 325 | moveBeforeOtherMenuItem: function(itemID, existingItemID) { 326 | let item = asMenuItem(itemID); 327 | let before = asMenuItem(existingItemID); 328 | 329 | cxtMenu.moveBefore(item, before); 330 | return cy; 331 | }, 332 | // Disables the menu item with given ID. 333 | disableMenuItem: function(itemID) { 334 | let menuItem = asMenuItem(itemID); 335 | 336 | menuItem.disable(); 337 | return cy; 338 | }, 339 | // Enables the menu item with given ID. 340 | enableMenuItem: function(itemID) { 341 | let menuItem = asMenuItem(itemID); 342 | 343 | menuItem.enable(); 344 | return cy; 345 | }, 346 | // Disables the menu item with given ID. 347 | hideMenuItem: function(itemID) { 348 | let menuItem = asMenuItem(itemID); 349 | menuItem.hide(); 350 | setParentVisibilityFromItems(); 351 | return cy; 352 | }, 353 | // Enables the menu item with given ID. 354 | showMenuItem: function(itemID) { 355 | let menuItem = asMenuItem(itemID); 356 | menuItem.display(); 357 | setParentVisibilityFromItems(); 358 | return cy; 359 | }, 360 | // Destroys the extension instance 361 | destroy: function() { 362 | destroyCxtMenu(); 363 | return cy; 364 | }, 365 | // Returns the used options 366 | getOptions: function() { 367 | // use `extend` to create a deep copy of options 368 | return utils.extend(DEFAULT_OPTS, options); 369 | }, 370 | // `x` and `y` are id of context menu items to be swapped 371 | swapItems: function (x, y) { 372 | cxtMenu.ensureContains(x); 373 | cxtMenu.ensureContains(y); 374 | const i1 = document.getElementById(x); 375 | const i2 = document.getElementById(y); 376 | const n1 = i1.nextSibling; 377 | const n2 = i2.nextSibling; 378 | const parent1 = i1.parentNode; 379 | const parent2 = i2.parentNode; 380 | if (!parent1.isSameNode(parent2)) { 381 | throw new Error(`To swap, the items should have the same parent!`); 382 | } 383 | if (n1 && n1.isSameNode(i2)) { 384 | parent1.insertBefore(i2, i1); 385 | return; 386 | } 387 | if (n2 && n2.isSameNode(i1)) { 388 | parent1.insertBefore(i1, i2); 389 | return; 390 | } 391 | 392 | parent1.insertBefore(i2, i1); 393 | parent1.insertBefore(i1, n2); 394 | 395 | } 396 | }; 397 | 398 | return instance; 399 | }; 400 | 401 | if ( opts !== 'get' ) { 402 | // https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements 403 | MenuItem.define(); 404 | MenuItemList.define(); 405 | ContextMenu.define(); 406 | 407 | // merge the options with default ones 408 | options = utils.extend(DEFAULT_OPTS, opts); 409 | setScratchProp('options', options); 410 | 411 | // Clear old context menu if needed 412 | if (getScratchProp('active')) { 413 | destroyCxtMenu(); 414 | } 415 | 416 | setScratchProp('active', true); 417 | 418 | setScratchProp('submenuIndicatorGen', makeSubmenuIndicator.bind(undefined, options.submenuIndicator)); 419 | 420 | // Create cxtMenu and append it to body 421 | let cxtMenuClasses = utils.getClassStr(options.contextMenuClasses); 422 | setScratchProp('cxtMenuClasses', cxtMenuClasses); 423 | 424 | let onMenuItemClick = 425 | () => setScratchProp('cxtMenuPosition', undefined); 426 | 427 | let scratchpad = cy.scratch('cycontextmenus'); 428 | cxtMenu = new ContextMenu(onMenuItemClick, scratchpad); 429 | 430 | setScratchProp('cxtMenu', cxtMenu); 431 | //document.body.appendChild(cxtMenu); 432 | cy.container().appendChild(cxtMenu); 433 | 434 | setScratchProp('cxtMenuItemClasses', utils.getClassStr(options.menuItemClasses)); 435 | let menuItems = options.menuItems; 436 | createAndAppendMenuItemComponents(menuItems); 437 | 438 | bindOnCxttap(); 439 | bindCyEvents(); 440 | bindHideCallbacks(); 441 | 442 | utils.preventDefaultContextTap(); 443 | } 444 | 445 | return getInstance(this); 446 | } 447 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // We have to use CommonJS here https://stackoverflow.com/questions/33505992/babel-6-changes-how-it-exports-default 2 | let { contextMenus } = require('./cytoscape-context-menus.js'); 3 | 4 | let register = function(cytoscape) { 5 | if (!cytoscape) { 6 | return; 7 | } // can't register if cytoscape unspecified 8 | 9 | cytoscape('core', 'contextMenus', contextMenus); 10 | }; 11 | 12 | // @ts-ignore 13 | if (typeof cytoscape !== 'undefined') { 14 | // Register for plain javascript 15 | // @ts-ignore 16 | register(cytoscape); 17 | } 18 | 19 | module.exports = register; -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // Utility functions that are not directly related with the extension 2 | export function getOffset(el) { 3 | let rect = el.getBoundingClientRect(); 4 | 5 | return { 6 | top: rect.top, 7 | left: rect.left, 8 | }; 9 | } 10 | 11 | export function matches(el, selector) { 12 | return (el.matches || 13 | el.matchesSelector || 14 | el.msMatchesSelector || 15 | el.mozMatchesSelector || 16 | el.webkitMatchesSelector || 17 | el.oMatchesSelector).call(el, selector); 18 | } 19 | 20 | export function isElementHidden(elem) { 21 | return elem.offsetWidth <= 0 && elem.offsetHeight <= 0 || 22 | ((elem.style && elem.style.display) || getComputedStyle(elem)['display']); 23 | } 24 | 25 | export function isElementVisible(elem) { 26 | return !isElementHidden(elem); 27 | } 28 | // Merge default options with the ones coming from parameter 29 | export function extend(defaults, options) { 30 | let obj = {}; 31 | 32 | for (let i in defaults) { 33 | obj[i] = defaults[i]; 34 | } 35 | 36 | for (let i in options) { 37 | // Arrays should be merged 38 | if (obj[i] instanceof Array) { 39 | obj[i] = obj[i].concat(options[i]); 40 | } else { 41 | obj[i] = options[i]; 42 | } 43 | } 44 | 45 | return obj; 46 | } 47 | 48 | // Get string representation of css classes 49 | export function getClassStr(classes) { 50 | let str = ''; 51 | 52 | for (let i = 0; i < classes.length; i++) { 53 | let className = classes[i]; 54 | str += className; 55 | if (i !== classes.length - 1) { 56 | str += ' '; 57 | } 58 | } 59 | 60 | return str; 61 | } 62 | 63 | export function preventDefaultContextTap() { 64 | let contextMenuAreas = document.getElementsByClassName('cy-context-menus-cxt-menu'); 65 | 66 | for (const cxtMenuArea of contextMenuAreas) { 67 | cxtMenuArea.addEventListener('contextmenu', e => e.preventDefault()); 68 | } 69 | } 70 | 71 | /** 72 | * https://stackoverflow.com/a/38057647/12045421 73 | * 74 | * @param { Element } element 75 | * @param { string } attribute 76 | * @param { boolean } boolValue 77 | */ 78 | export function setBooleanAttribute(element, attribute, boolValue) { 79 | if (boolValue) { 80 | element.setAttribute(attribute, ''); 81 | } else { 82 | element.removeAttribute(attribute); 83 | } 84 | } 85 | 86 | /** 87 | * Returns true if the first parameter is inside the element 88 | * @param {*} param0 89 | * @param { HTMLElement } element 90 | */ 91 | export function isIn({ x, y }, element) { 92 | let rect = element.getBoundingClientRect(); 93 | 94 | return x >= rect.left && 95 | x <= rect.right && 96 | y >= rect.top && 97 | y <= rect.bottom; 98 | } 99 | 100 | /** 101 | * Get the dimensions from a hidden element 102 | * @param { HTMLElement } element 103 | */ 104 | export function getDimensionsHidden(element) { 105 | // Temporarily show the element 106 | element.style.opacity = "0"; 107 | element.style.display = "block"; 108 | 109 | let rect = element.getBoundingClientRect(); 110 | 111 | // Hide back after getting the dimensions 112 | element.style.opacity = "1"; 113 | element.style.display = "none"; 114 | 115 | return rect; 116 | } 117 | 118 | /** 119 | * Defines a new custom html element 120 | * https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements 121 | * @param { string } name 122 | * @param { * } klass 123 | * @param { string } extendsType 124 | */ 125 | export function defineCustomElement(name, klass, extendsType) { 126 | // We have to check otherwise it throws an exception if already added 127 | if (typeof customElements.get(name) === 'undefined') { 128 | customElements.define(name, klass, { extends: extendsType }) 129 | } 130 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pkg = require('./package.json'); 3 | const camelcase = require('camelcase'); 4 | const process = require('process'); 5 | const env = process.env; 6 | 7 | const NODE_ENV = env.NODE_ENV; 8 | const PROD = NODE_ENV === 'production'; 9 | const SRC_DIR = "./src"; 10 | 11 | module.exports = { 12 | entry: path.join(__dirname, SRC_DIR, 'index.js'), 13 | output: { 14 | path: path.join(__dirname), 15 | filename: pkg.name + '.js', 16 | library: camelcase(pkg.name), 17 | libraryTarget: 'umd', 18 | globalObject: 'this' 19 | }, 20 | mode: 'production', 21 | // devtool: PROD ? false : 'inline-source-map', 22 | optimization: { 23 | minimize: PROD ? true: false, 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.js$/, 29 | exclude: /node_modules/, 30 | use: 'babel-loader' 31 | } 32 | ] 33 | } 34 | }; --------------------------------------------------------------------------------