├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github └── stale.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── bower.json ├── cytoscape-cxtmenu.js ├── demo-adaptative.html ├── demo-cancel-outside.html ├── demo.html ├── package-lock.json ├── package.json ├── pages ├── cytoscape-cxtmenu.js ├── demo-adaptative.html ├── demo-cancel-outside.html ├── demo.html └── index.html ├── preview.png ├── src ├── assign.js ├── core │ └── index.js ├── cxtmenu.js ├── defaults.js ├── dom-util.js └── index.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "node": true, 6 | "amd": true, 7 | "es6": true 8 | }, 9 | "extends": "eslint:recommended", 10 | "rules": { 11 | "semi": "error" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 14 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | # Label to use when marking an issue as stale 9 | staleLabel: stale 10 | # Comment to post when marking an issue as stale. Set to `false` to disable 11 | markComment: > 12 | This issue has been automatically marked as stale, because it has not had 13 | activity within the past 14 days. It will be closed if no further activity 14 | occurs within the next 7 days. If a feature request is important to you, 15 | please consider making a pull request. Thank you for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | Copyright (c) 2016-2021, 2023, The Cytoscape Consortium. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the “Software”), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | cytoscape-cxtmenu 2 | ================================================================================ 3 | 4 | [](https://zenodo.org/badge/latestdoi/16010906) 5 | 6 |  7 | 8 | ## Description 9 | 10 | A circular, swipeable context menu extension for Cytoscape.js 11 | - Demo with default options: [demo](https://cytoscape.github.io/cytoscape.js-cxtmenu) 12 | - Demo with adaptative spotlight radius features: [demo](https://cytoscape.github.io/cytoscape.js-cxtmenu/demo-adaptative.html) 13 | - Demo with `outsideMenuCancel`: [demo](https://cytoscape.github.io/cytoscape.js-cxtmenu/demo-cancel-outside.html) 14 | 15 | This extension creates a widget that lets the user operate circular context menus on nodes in Cytoscape.js. The user swipes along the circular menu to select a menu item and perform a command on either a node, a edge, or the graph background. 16 | 17 | ## Dependencies 18 | 19 | * Cytoscape.js ^3.2.0 20 | 21 | 22 | ## Usage instructions 23 | 24 | Download the library: 25 | * via npm: `npm install cytoscape-cxtmenu`, 26 | * via bower: `bower install cytoscape-cxtmenu`, or 27 | * via direct download in the repository (probably from a tag). 28 | 29 | Import the library as appropriate for your project: 30 | 31 | ES import: 32 | 33 | ```js 34 | import cytoscape from 'cytoscape'; 35 | import cxtmenu from 'cytoscape-cxtmenu'; 36 | 37 | cytoscape.use( cxtmenu ); 38 | ``` 39 | 40 | CommonJS require: 41 | 42 | ```js 43 | let cytoscape = require('cytoscape'); 44 | let cxtmenu = require('cytoscape-cxtmenu'); 45 | 46 | cytoscape.use( cxtmenu ); // register extension 47 | ``` 48 | 49 | AMD: 50 | 51 | ```js 52 | require(['cytoscape', 'cytoscape-cxtmenu'], function( cytoscape, cxtmenu ){ 53 | cxtmenu( cytoscape ); // register extension 54 | }); 55 | ``` 56 | 57 | Plain HTML/JS has the extension registered for you automatically, because no `require()` is needed. 58 | 59 | 60 | ## CSS 61 | 62 | You can style the font of the command text with the `cxtmenu-content` class, and you can style disabled entries with the `cxtmenu-disabled` class. 63 | 64 | 65 | ## API 66 | 67 | You initialise the plugin on the same HTML DOM element container used for Cytoscape.js: 68 | 69 | ```js 70 | 71 | let cy = cytoscape({ 72 | container: document.getElementById('cy'), 73 | /* ... */ 74 | }); 75 | 76 | // the default values of each option are outlined below: 77 | let defaults = { 78 | menuRadius: function(ele){ return 100; }, // the outer radius (node center to the end of the menu) in pixels. It is added to the rendered size of the node. Can either be a number or function as in the example. 79 | selector: 'node', // elements matching this Cytoscape.js selector will trigger cxtmenus 80 | commands: [ // an array of commands to list in the menu or a function that returns the array 81 | /* 82 | { // example command 83 | fillColor: 'rgba(200, 200, 200, 0.75)', // optional: custom background color for item 84 | content: 'a command name', // html/text content to be displayed in the menu 85 | contentStyle: {}, // css key:value pairs to set the command's css in js if you want 86 | select: function(ele){ // a function to execute when the command is selected 87 | console.log( ele.id() ) // `ele` holds the reference to the active element 88 | }, 89 | hover: function(ele){ // a function to execute when the command is hovered 90 | console.log( ele.id() ) // `ele` holds the reference to the active element 91 | }, 92 | enabled: true // whether the command is selectable 93 | } 94 | */ 95 | ], // function( ele ){ return [ /*...*/ ] }, // a function that returns commands or a promise of commands 96 | fillColor: 'rgba(0, 0, 0, 0.75)', // the background colour of the menu 97 | activeFillColor: 'rgba(1, 105, 217, 0.75)', // the colour used to indicate the selected command 98 | activePadding: 20, // additional size in pixels for the active command 99 | indicatorSize: 24, // the size in pixels of the pointer to the active command, will default to the node size if the node size is smaller than the indicator size, 100 | separatorWidth: 3, // the empty spacing in pixels between successive commands 101 | spotlightPadding: 4, // extra spacing in pixels between the element and the spotlight 102 | adaptativeNodeSpotlightRadius: false, // specify whether the spotlight radius should adapt to the node size 103 | minSpotlightRadius: 24, // the minimum radius in pixels of the spotlight (ignored for the node if adaptativeNodeSpotlightRadius is enabled but still used for the edge & background) 104 | maxSpotlightRadius: 38, // the maximum radius in pixels of the spotlight (ignored for the node if adaptativeNodeSpotlightRadius is enabled but still used for the edge & background) 105 | openMenuEvents: 'cxttapstart taphold', // space-separated cytoscape events that will open the menu; only `cxttapstart` and/or `taphold` work here 106 | itemColor: 'white', // the colour of text in the command's content 107 | itemTextShadowColor: 'transparent', // the text shadow colour of the command's content 108 | zIndex: 9999, // the z-index of the ui div 109 | atMouse: false, // draw menu at mouse position 110 | outsideMenuCancel: false // if set to a number, this will cancel the command if the pointer is released outside of the spotlight, padded by the number given 111 | }; 112 | 113 | let menu = cy.cxtmenu( defaults ); 114 | ``` 115 | 116 | You get access to the cxtmenu API as the returned value of calling the extension. You can use this to clean up and destroy the menu instance: 117 | 118 | ```js 119 | let menu = cy.cxtmenu( someOptions ); 120 | 121 | menu.destroy(); 122 | ``` 123 | 124 | 125 | ## Build targets 126 | 127 | * `npm run test` : Run Mocha tests in `./test` 128 | * `npm run build` : Build `./src/**` into `cytoscape-cxtmenu.js` 129 | * `npm run watch` : Automatically build on changes with live reloading (N.b. you must already have an HTTP server running) 130 | * `npm run dev` : Automatically build on changes with live reloading with webpack dev server 131 | * `npm run lint` : Run eslint on the source 132 | 133 | N.b. all builds use babel, so modern ES features can be used in the `src`. 134 | 135 | 136 | ## Publishing instructions 137 | 138 | This project is set up to automatically be published to npm and bower. To publish: 139 | 140 | 1. Build the extension : `npm run build:release` 141 | 1. Commit the build : `git commit -am "Build for release"` 142 | 1. Bump the version number and tag: `npm version major|minor|patch` 143 | 1. Push to origin: `git push && git push --tags` 144 | 1. Publish to npm: `npm publish .` 145 | 1. If publishing to bower for the first time, you'll need to run `bower register cytoscape-cxtmenu https://github.com/cytoscape/cytoscape.js-cxtmenu.git` 146 | 1. [Make a new release](https://github.com/cytoscape/cytoscape.js-cxtmenu/releases/new) for Zenodo. 147 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cytoscape-cxtmenu", 3 | "description": "A circular, swipeable context menu extension for Cytoscape.js", 4 | "main": "cytoscape-cxtmenu.js", 5 | "dependencies": { 6 | "cytoscape": "^3.2.0" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/cytoscape/cytoscape.js-cxtmenu.git" 11 | }, 12 | "ignore": [ 13 | "**/.*", 14 | "node_modules", 15 | "bower_components", 16 | "test", 17 | "tests" 18 | ], 19 | "keywords": [ 20 | "cytoscape", 21 | "cytoscape-extension" 22 | ], 23 | "license": "MIT" 24 | } 25 | -------------------------------------------------------------------------------- /cytoscape-cxtmenu.js: -------------------------------------------------------------------------------- 1 | (function webpackUniversalModuleDefinition(root, factory) { 2 | if(typeof exports === 'object' && typeof module === 'object') 3 | module.exports = factory(); 4 | else if(typeof define === 'function' && define.amd) 5 | define([], factory); 6 | else if(typeof exports === 'object') 7 | exports["cytoscapeCxtmenu"] = factory(); 8 | else 9 | root["cytoscapeCxtmenu"] = factory(); 10 | })(this, function() { 11 | return /******/ (function(modules) { // webpackBootstrap 12 | /******/ // The module cache 13 | /******/ var installedModules = {}; 14 | /******/ 15 | /******/ // The require function 16 | /******/ function __webpack_require__(moduleId) { 17 | /******/ 18 | /******/ // Check if module is in cache 19 | /******/ if(installedModules[moduleId]) { 20 | /******/ return installedModules[moduleId].exports; 21 | /******/ } 22 | /******/ // Create a new module (and put it into the cache) 23 | /******/ var module = installedModules[moduleId] = { 24 | /******/ i: moduleId, 25 | /******/ l: false, 26 | /******/ exports: {} 27 | /******/ }; 28 | /******/ 29 | /******/ // Execute the module function 30 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 31 | /******/ 32 | /******/ // Flag the module as loaded 33 | /******/ module.l = true; 34 | /******/ 35 | /******/ // Return the exports of the module 36 | /******/ return module.exports; 37 | /******/ } 38 | /******/ 39 | /******/ 40 | /******/ // expose the modules object (__webpack_modules__) 41 | /******/ __webpack_require__.m = modules; 42 | /******/ 43 | /******/ // expose the module cache 44 | /******/ __webpack_require__.c = installedModules; 45 | /******/ 46 | /******/ // identity function for calling harmony imports with the correct context 47 | /******/ __webpack_require__.i = function(value) { return value; }; 48 | /******/ 49 | /******/ // define getter function for harmony exports 50 | /******/ __webpack_require__.d = function(exports, name, getter) { 51 | /******/ if(!__webpack_require__.o(exports, name)) { 52 | /******/ Object.defineProperty(exports, name, { 53 | /******/ configurable: false, 54 | /******/ enumerable: true, 55 | /******/ get: getter 56 | /******/ }); 57 | /******/ } 58 | /******/ }; 59 | /******/ 60 | /******/ // getDefaultExport function for compatibility with non-harmony modules 61 | /******/ __webpack_require__.n = function(module) { 62 | /******/ var getter = module && module.__esModule ? 63 | /******/ function getDefault() { return module['default']; } : 64 | /******/ function getModuleExports() { return module; }; 65 | /******/ __webpack_require__.d(getter, 'a', getter); 66 | /******/ return getter; 67 | /******/ }; 68 | /******/ 69 | /******/ // Object.prototype.hasOwnProperty.call 70 | /******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; 71 | /******/ 72 | /******/ // __webpack_public_path__ 73 | /******/ __webpack_require__.p = ""; 74 | /******/ 75 | /******/ // Load entry module and return exports 76 | /******/ return __webpack_require__(__webpack_require__.s = 4); 77 | /******/ }) 78 | /************************************************************************/ 79 | /******/ ([ 80 | /* 0 */ 81 | /***/ (function(module, exports, __webpack_require__) { 82 | 83 | "use strict"; 84 | 85 | 86 | var defaults = __webpack_require__(2); 87 | var assign = __webpack_require__(1); 88 | 89 | var _require = __webpack_require__(3), 90 | removeEles = _require.removeEles, 91 | setStyles = _require.setStyles, 92 | createElement = _require.createElement, 93 | getPixelRatio = _require.getPixelRatio, 94 | getOffset = _require.getOffset; 95 | 96 | var cxtmenu = function cxtmenu(params) { 97 | var options = assign({}, defaults, params); 98 | var cy = this; 99 | var container = cy.container(); 100 | var target = void 0; 101 | 102 | var data = { 103 | options: options, 104 | handlers: [], 105 | container: createElement({ class: 'cxtmenu' }) 106 | }; 107 | 108 | var wrapper = data.container; 109 | var parent = createElement(); 110 | var canvas = createElement({ tag: 'canvas' }); 111 | var commands = []; 112 | var c2d = canvas.getContext('2d'); 113 | 114 | var r = 100; // defailt radius; 115 | var containerSize = (r + options.activePadding) * 2; 116 | var activeCommandI = void 0; 117 | var offset = void 0; 118 | 119 | container.insertBefore(wrapper, container.firstChild); 120 | wrapper.appendChild(parent); 121 | parent.appendChild(canvas); 122 | 123 | setStyles(wrapper, { 124 | position: 'absolute', 125 | zIndex: options.zIndex, 126 | userSelect: 'none', 127 | pointerEvents: 'none' // prevent events on menu in modern browsers 128 | }); 129 | 130 | // prevent events on menu in legacy browsers 131 | ['mousedown', 'mousemove', 'mouseup', 'contextmenu'].forEach(function (evt) { 132 | wrapper.addEventListener(evt, function (e) { 133 | e.preventDefault(); 134 | 135 | return false; 136 | }); 137 | }); 138 | 139 | setStyles(parent, { 140 | display: 'none', 141 | width: containerSize + 'px', 142 | height: containerSize + 'px', 143 | position: 'absolute', 144 | zIndex: 1, 145 | marginLeft: -options.activePadding + 'px', 146 | marginTop: -options.activePadding + 'px', 147 | userSelect: 'none' 148 | }); 149 | 150 | canvas.width = containerSize; 151 | canvas.height = containerSize; 152 | 153 | function createMenuItems(r, rs) { 154 | removeEles('.cxtmenu-item', parent); 155 | var dtheta = 2 * Math.PI / commands.length; 156 | var theta1 = Math.PI / 2; 157 | var theta2 = theta1 + dtheta; 158 | 159 | for (var i = 0; i < commands.length; i++) { 160 | var command = commands[i]; 161 | 162 | var midtheta = (theta1 + theta2) / 2; 163 | var rx1 = (r + rs) / 2 * Math.cos(midtheta); 164 | var ry1 = (r + rs) / 2 * Math.sin(midtheta); 165 | 166 | // Arbitrary multiplier to increase the sizing of the space 167 | // available for the item. 168 | var width = 1 * Math.abs((r - rs) * Math.cos(midtheta)); 169 | var height = 1 * Math.abs((r - rs) * Math.sin(midtheta)); 170 | width = Math.max(width, height); 171 | 172 | var item = createElement({ class: 'cxtmenu-item' }); 173 | setStyles(item, { 174 | color: options.itemColor, 175 | cursor: 'default', 176 | display: 'table', 177 | 'text-align': 'center', 178 | //background: 'red', 179 | position: 'absolute', 180 | 'text-shadow': '-1px -1px 2px ' + options.itemTextShadowColor + ', 1px -1px 2px ' + options.itemTextShadowColor + ', -1px 1px 2px ' + options.itemTextShadowColor + ', 1px 1px 1px ' + options.itemTextShadowColor, 181 | left: '50%', 182 | top: '50%', 183 | 'min-height': width + 'px', 184 | width: width + 'px', 185 | height: width + 'px', 186 | marginLeft: rx1 - width / 2 + 'px', 187 | marginTop: -ry1 - width / 2 + 'px' 188 | }); 189 | 190 | var content = createElement({ class: 'cxtmenu-content' }); 191 | 192 | if (command.content instanceof HTMLElement) { 193 | content.appendChild(command.content); 194 | } else { 195 | content.innerHTML = command.content; 196 | } 197 | 198 | setStyles(content, { 199 | 'width': width + 'px', 200 | 'height': width + 'px', 201 | 'vertical-align': 'middle', 202 | 'display': 'table-cell' 203 | }); 204 | 205 | setStyles(content, command.contentStyle || {}); 206 | 207 | if (command.disabled === true || command.enabled === false) { 208 | content.setAttribute('class', 'cxtmenu-content cxtmenu-disabled'); 209 | } 210 | 211 | parent.appendChild(item); 212 | item.appendChild(content); 213 | 214 | theta1 += dtheta; 215 | theta2 += dtheta; 216 | } 217 | } 218 | 219 | function queueDrawBg(radius, rspotlight) { 220 | redrawQueue.drawBg = [radius, rspotlight]; 221 | } 222 | 223 | function drawBg(radius, rspotlight) { 224 | c2d.globalCompositeOperation = 'source-over'; 225 | 226 | c2d.clearRect(0, 0, containerSize, containerSize); 227 | 228 | // draw background items 229 | c2d.fillStyle = options.fillColor; 230 | var dtheta = 2 * Math.PI / commands.length; 231 | var theta1 = Math.PI / 2; 232 | var theta2 = theta1 + dtheta; 233 | 234 | for (var index = 0; index < commands.length; index++) { 235 | var command = commands[index]; 236 | 237 | if (command.fillColor) { 238 | c2d.fillStyle = command.fillColor; 239 | } 240 | c2d.beginPath(); 241 | c2d.moveTo(radius + options.activePadding, radius + options.activePadding); 242 | c2d.arc(radius + options.activePadding, radius + options.activePadding, radius, 2 * Math.PI - theta1, 2 * Math.PI - theta2, true); 243 | c2d.closePath(); 244 | c2d.fill(); 245 | 246 | theta1 += dtheta; 247 | theta2 += dtheta; 248 | 249 | c2d.fillStyle = options.fillColor; 250 | } 251 | 252 | // draw separators between items 253 | c2d.globalCompositeOperation = 'destination-out'; 254 | c2d.strokeStyle = 'white'; 255 | c2d.lineWidth = options.separatorWidth; 256 | theta1 = Math.PI / 2; 257 | theta2 = theta1 + dtheta; 258 | 259 | for (var i = 0; i < commands.length; i++) { 260 | var rx1 = radius * Math.cos(theta1); 261 | var ry1 = radius * Math.sin(theta1); 262 | c2d.beginPath(); 263 | c2d.moveTo(radius + options.activePadding, radius + options.activePadding); 264 | c2d.lineTo(radius + options.activePadding + rx1, radius + options.activePadding - ry1); 265 | c2d.closePath(); 266 | c2d.stroke(); 267 | 268 | theta1 += dtheta; 269 | theta2 += dtheta; 270 | } 271 | 272 | c2d.fillStyle = 'white'; 273 | c2d.globalCompositeOperation = 'destination-out'; 274 | c2d.beginPath(); 275 | c2d.arc(radius + options.activePadding, radius + options.activePadding, rspotlight + options.spotlightPadding, 0, Math.PI * 2, true); 276 | c2d.closePath(); 277 | c2d.fill(); 278 | 279 | c2d.globalCompositeOperation = 'source-over'; 280 | } 281 | 282 | function queueDrawCommands(rx, ry, radius, theta, rspotlight) { 283 | redrawQueue.drawCommands = [rx, ry, radius, theta, rspotlight]; 284 | } 285 | 286 | function drawCommands(rx, ry, radius, theta, rs) { 287 | var dtheta = 2 * Math.PI / commands.length; 288 | var theta1 = Math.PI / 2; 289 | var theta2 = theta1 + dtheta; 290 | 291 | theta1 += dtheta * activeCommandI; 292 | theta2 += dtheta * activeCommandI; 293 | 294 | c2d.fillStyle = options.activeFillColor; 295 | c2d.strokeStyle = 'black'; 296 | c2d.lineWidth = 1; 297 | c2d.beginPath(); 298 | c2d.moveTo(radius + options.activePadding, radius + options.activePadding); 299 | c2d.arc(radius + options.activePadding, radius + options.activePadding, radius + options.activePadding, 2 * Math.PI - theta1, 2 * Math.PI - theta2, true); 300 | c2d.closePath(); 301 | c2d.fill(); 302 | 303 | c2d.fillStyle = 'white'; 304 | c2d.globalCompositeOperation = 'destination-out'; 305 | 306 | var tx = radius + options.activePadding + rx / radius * (rs + options.spotlightPadding - options.indicatorSize / 4); 307 | var ty = radius + options.activePadding + ry / radius * (rs + options.spotlightPadding - options.indicatorSize / 4); 308 | var rot = Math.PI / 4 - theta; 309 | 310 | c2d.translate(tx, ty); 311 | c2d.rotate(rot); 312 | 313 | // clear the indicator 314 | // The indicator size (arrow) depends on the node size as well. If the indicator size is bigger and the rendered node size + padding, 315 | // use the rendered node size + padding as the indicator size. 316 | var indicatorSize = options.indicatorSize > rs + options.spotlightPadding ? rs + options.spotlightPadding : options.indicatorSize; 317 | c2d.beginPath(); 318 | c2d.fillRect(-indicatorSize / 2, -indicatorSize / 2, indicatorSize, indicatorSize); 319 | c2d.closePath(); 320 | c2d.fill(); 321 | 322 | c2d.rotate(-rot); 323 | c2d.translate(-tx, -ty); 324 | 325 | // c2d.setTransform( 1, 0, 0, 1, 0, 0 ); 326 | 327 | // clear the spotlight 328 | c2d.beginPath(); 329 | c2d.arc(radius + options.activePadding, radius + options.activePadding, rs + options.spotlightPadding, 0, Math.PI * 2, true); 330 | c2d.closePath(); 331 | c2d.fill(); 332 | 333 | c2d.globalCompositeOperation = 'source-over'; 334 | } 335 | 336 | function updatePixelRatio() { 337 | var pxr = getPixelRatio(); 338 | var w = containerSize; 339 | var h = containerSize; 340 | 341 | canvas.width = w * pxr; 342 | canvas.height = h * pxr; 343 | 344 | canvas.style.width = w + 'px'; 345 | canvas.style.height = h + 'px'; 346 | 347 | c2d.setTransform(1, 0, 0, 1, 0, 0); 348 | c2d.scale(pxr, pxr); 349 | } 350 | 351 | var redrawing = true; 352 | var redrawQueue = {}; 353 | 354 | var raf = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame || function (fn) { 355 | return setTimeout(fn, 16); 356 | }; 357 | 358 | var redraw = function redraw() { 359 | if (redrawQueue.drawBg) { 360 | drawBg.apply(null, redrawQueue.drawBg); 361 | } 362 | 363 | if (redrawQueue.drawCommands) { 364 | drawCommands.apply(null, redrawQueue.drawCommands); 365 | } 366 | 367 | redrawQueue = {}; 368 | 369 | if (redrawing) { 370 | raf(redraw); 371 | } 372 | }; 373 | 374 | // kick off 375 | updatePixelRatio(); 376 | redraw(); 377 | 378 | var ctrx = void 0, 379 | ctry = void 0, 380 | rs = void 0; 381 | 382 | var bindings = { 383 | on: function on(events, selector, fn) { 384 | 385 | var _fn = fn; 386 | if (selector === 'core') { 387 | _fn = function _fn(e) { 388 | if (e.cyTarget === cy || e.target === cy) { 389 | // only if event target is directly core 390 | return fn.apply(this, [e]); 391 | } 392 | }; 393 | } 394 | 395 | data.handlers.push({ 396 | events: events, 397 | selector: selector, 398 | fn: _fn 399 | }); 400 | 401 | if (selector === 'core') { 402 | cy.on(events, _fn); 403 | } else { 404 | cy.on(events, selector, _fn); 405 | } 406 | 407 | return this; 408 | } 409 | }; 410 | 411 | function addEventListeners() { 412 | var grabbable = void 0; 413 | var inGesture = false; 414 | var dragHandler = void 0; 415 | var zoomEnabled = void 0; 416 | var panEnabled = void 0; 417 | var boxEnabled = void 0; 418 | var gestureStartEvent = void 0; 419 | var hoverOn = void 0; 420 | 421 | var restoreZoom = function restoreZoom() { 422 | if (zoomEnabled) { 423 | cy.userZoomingEnabled(true); 424 | } 425 | }; 426 | 427 | var restoreGrab = function restoreGrab() { 428 | if (grabbable) { 429 | target.grabify(); 430 | } 431 | }; 432 | 433 | var restorePan = function restorePan() { 434 | if (panEnabled) { 435 | cy.userPanningEnabled(true); 436 | } 437 | }; 438 | 439 | var restoreBoxSeln = function restoreBoxSeln() { 440 | if (boxEnabled) { 441 | cy.boxSelectionEnabled(true); 442 | } 443 | }; 444 | 445 | var restoreGestures = function restoreGestures() { 446 | restoreGrab(); 447 | restoreZoom(); 448 | restorePan(); 449 | restoreBoxSeln(); 450 | }; 451 | 452 | window.addEventListener('resize', updatePixelRatio); 453 | 454 | bindings.on('resize', function () { 455 | updatePixelRatio(); 456 | }).on(options.openMenuEvents, options.selector, function (e) { 457 | target = this; // Remember which node the context menu is for 458 | var ele = this; 459 | var isCy = this === cy; 460 | 461 | if (inGesture) { 462 | parent.style.display = 'none'; 463 | 464 | inGesture = false; 465 | 466 | restoreGestures(); 467 | } 468 | 469 | if (typeof options.commands === 'function') { 470 | var res = options.commands(target); 471 | if (res.then) { 472 | res.then(function (_commands) { 473 | commands = _commands; 474 | openMenu(); 475 | }); 476 | } else { 477 | commands = res; 478 | openMenu(); 479 | } 480 | } else { 481 | commands = options.commands; 482 | openMenu(); 483 | } 484 | 485 | function openMenu() { 486 | if (!commands || commands.length === 0) { 487 | return; 488 | } 489 | 490 | zoomEnabled = cy.userZoomingEnabled(); 491 | cy.userZoomingEnabled(false); 492 | 493 | panEnabled = cy.userPanningEnabled(); 494 | cy.userPanningEnabled(false); 495 | 496 | boxEnabled = cy.boxSelectionEnabled(); 497 | cy.boxSelectionEnabled(false); 498 | 499 | grabbable = target.grabbable && target.grabbable(); 500 | if (grabbable) { 501 | target.ungrabify(); 502 | } 503 | 504 | var rp = void 0, 505 | rw = void 0, 506 | rh = void 0, 507 | rs = void 0; 508 | if (!isCy && ele && ele.isNode instanceof Function && ele.isNode() && !ele.isParent() && !options.atMouse) { 509 | // If it's a node, the default spotlight radius for a node is the node width 510 | rp = ele.renderedPosition(); 511 | rw = ele.renderedOuterWidth(); 512 | rh = ele.renderedOuterHeight(); 513 | rs = rw / 2; 514 | // If adaptativeNodespotlightRadius is not enabled and min|maxSpotlighrRadius is defined, use those instead 515 | rs = !options.adaptativeNodeSpotlightRadius && options.minSpotlightRadius ? Math.max(rs, options.minSpotlightRadius) : rs; 516 | rs = !options.adaptativeNodeSpotlightRadius && options.maxSpotlightRadius ? Math.min(rs, options.maxSpotlightRadius) : rs; 517 | } else { 518 | // If it's the background or an edge, the spotlight radius is the min|maxSpotlightRadius 519 | rp = e.renderedPosition || e.cyRenderedPosition; 520 | rw = 1; 521 | rh = 1; 522 | rs = rw / 2; 523 | rs = options.minSpotlightRadius ? Math.max(rs, options.minSpotlightRadius) : rs; 524 | rs = options.maxSpotlightRadius ? Math.min(rs, options.maxSpotlightRadius) : rs; 525 | } 526 | 527 | offset = getOffset(container); 528 | 529 | ctrx = rp.x; 530 | ctry = rp.y; 531 | r = rw / 2 + (options.menuRadius instanceof Function ? options.menuRadius(target) : Number(options.menuRadius)); 532 | containerSize = (r + options.activePadding) * 2; 533 | updatePixelRatio(); 534 | 535 | setStyles(parent, { 536 | width: containerSize + 'px', 537 | height: containerSize + 'px', 538 | display: 'block', 539 | left: rp.x - r + 'px', 540 | top: rp.y - r + 'px' 541 | }); 542 | createMenuItems(r, rs); 543 | queueDrawBg(r, rs); 544 | 545 | activeCommandI = undefined; 546 | 547 | inGesture = true; 548 | gestureStartEvent = e; 549 | } 550 | }).on('cxtdrag tapdrag', options.selector, dragHandler = function dragHandler(e) { 551 | 552 | if (!inGesture) { 553 | return; 554 | } 555 | e.preventDefault(); // Otherwise, on mobile, the pull-down refresh gesture gets activated 556 | 557 | var origE = e.originalEvent; 558 | var isTouch = origE.touches && origE.touches.length > 0; 559 | 560 | var pageX = (isTouch ? origE.touches[0].pageX : origE.pageX) - window.pageXOffset; 561 | var pageY = (isTouch ? origE.touches[0].pageY : origE.pageY) - window.pageYOffset; 562 | 563 | activeCommandI = undefined; 564 | 565 | var dx = pageX - offset.left - ctrx; 566 | var dy = pageY - offset.top - ctry; 567 | 568 | if (dx === 0) { 569 | dx = 0.01; 570 | } 571 | 572 | var d = Math.sqrt(dx * dx + dy * dy); 573 | var cosTheta = (dy * dy - d * d - dx * dx) / (-2 * d * dx); 574 | var theta = Math.acos(cosTheta); 575 | 576 | var rw = void 0; 577 | if (target && target.isNode instanceof Function && target.isNode() && !target.isParent() && !options.atMouse) { 578 | // If it's a node, the default spotlight radius for a node is the node width 579 | rw = target.renderedOuterWidth(); 580 | rs = rw / 2; 581 | // If adaptativeNodespotlightRadius is not enabled and min|maxSpotlighrRadius is defined, use those instead 582 | rs = !options.adaptativeNodeSpotlightRadius && options.minSpotlightRadius ? Math.max(rs, options.minSpotlightRadius) : rs; 583 | rs = !options.adaptativeNodeSpotlightRadius && options.maxSpotlightRadius ? Math.min(rs, options.maxSpotlightRadius) : rs; 584 | } else { 585 | // If it's the background or an edge, the spotlight radius is the min|maxSpotlightRadius 586 | rw = 1; 587 | rs = rw / 2; 588 | rs = options.minSpotlightRadius ? Math.max(rs, options.minSpotlightRadius) : rs; 589 | rs = options.maxSpotlightRadius ? Math.min(rs, options.maxSpotlightRadius) : rs; 590 | } 591 | 592 | r = rw / 2 + (options.menuRadius instanceof Function ? options.menuRadius(target) : Number(options.menuRadius)); 593 | if (d < rs + options.spotlightPadding || typeof options.outsideMenuCancel === "number" && d > r + options.activePadding + options.outsideMenuCancel) { 594 | // 595 | 596 | queueDrawBg(r, rs); 597 | return; 598 | } 599 | queueDrawBg(r, rs); 600 | 601 | var rx = dx * r / d; 602 | var ry = dy * r / d; 603 | 604 | if (dy > 0) { 605 | theta = Math.PI + Math.abs(theta - Math.PI); 606 | } 607 | 608 | var dtheta = 2 * Math.PI / commands.length; 609 | var theta1 = Math.PI / 2; 610 | var theta2 = theta1 + dtheta; 611 | 612 | for (var i = 0; i < commands.length; i++) { 613 | var command = commands[i]; 614 | 615 | var inThisCommand = theta1 <= theta && theta <= theta2 || theta1 <= theta + 2 * Math.PI && theta + 2 * Math.PI <= theta2; 616 | 617 | if (command.disabled === true || command.enabled === false) { 618 | inThisCommand = false; 619 | } 620 | 621 | if (inThisCommand) { 622 | activeCommandI = i; 623 | break; 624 | } 625 | 626 | theta1 += dtheta; 627 | theta2 += dtheta; 628 | } 629 | queueDrawCommands(rx, ry, r, theta, rs); 630 | }).on('tapdrag', dragHandler).on('mousemove', function () { 631 | if (activeCommandI !== undefined) { 632 | var hovered = commands[activeCommandI].hover; 633 | if (hovered) { 634 | if (hoverOn !== activeCommandI) { 635 | hovered.apply(target, [target, gestureStartEvent]); 636 | } 637 | hoverOn = activeCommandI; 638 | } 639 | } 640 | }).on('cxttapend tapend', function () { 641 | parent.style.display = 'none'; 642 | if (activeCommandI !== undefined) { 643 | var select = commands[activeCommandI].select; 644 | 645 | if (select) { 646 | select.apply(target, [target, gestureStartEvent]); 647 | activeCommandI = undefined; 648 | } 649 | } 650 | 651 | hoverOn = undefined; 652 | 653 | inGesture = false; 654 | 655 | restoreGestures(); 656 | }); 657 | } 658 | 659 | function removeEventListeners() { 660 | var handlers = data.handlers; 661 | 662 | for (var i = 0; i < handlers.length; i++) { 663 | var h = handlers[i]; 664 | 665 | if (h.selector === 'core') { 666 | cy.off(h.events, h.fn); 667 | } else { 668 | cy.off(h.events, h.selector, h.fn); 669 | } 670 | } 671 | 672 | window.removeEventListener('resize', updatePixelRatio); 673 | } 674 | 675 | function destroyInstance() { 676 | redrawing = false; 677 | 678 | removeEventListeners(); 679 | 680 | wrapper.remove(); 681 | } 682 | 683 | addEventListeners(); 684 | 685 | return { 686 | destroy: function destroy() { 687 | destroyInstance(); 688 | } 689 | }; 690 | }; 691 | 692 | module.exports = cxtmenu; 693 | 694 | /***/ }), 695 | /* 1 */ 696 | /***/ (function(module, exports, __webpack_require__) { 697 | 698 | "use strict"; 699 | 700 | 701 | // Simple, internal Object.assign() polyfill for options objects etc. 702 | 703 | module.exports = Object.assign != null ? Object.assign.bind(Object) : function (tgt) { 704 | for (var _len = arguments.length, srcs = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { 705 | srcs[_key - 1] = arguments[_key]; 706 | } 707 | 708 | srcs.filter(function (src) { 709 | return src != null; 710 | }).forEach(function (src) { 711 | Object.keys(src).forEach(function (k) { 712 | return tgt[k] = src[k]; 713 | }); 714 | }); 715 | 716 | return tgt; 717 | }; 718 | 719 | /***/ }), 720 | /* 2 */ 721 | /***/ (function(module, exports, __webpack_require__) { 722 | 723 | "use strict"; 724 | 725 | 726 | var defaults = { 727 | menuRadius: function menuRadius(ele) { 728 | return 100; 729 | }, // the radius of the circular menu in pixels 730 | selector: 'node', // elements matching this Cytoscape.js selector will trigger cxtmenus 731 | commands: [// an array of commands to list in the menu or a function that returns the array 732 | /* 733 | { // example command 734 | fillColor: 'rgba(200, 200, 200, 0.75)', // optional: custom background color for item 735 | content: 'a command name' // html/text content to be displayed in the menu 736 | contentStyle: {}, // css key:value pairs to set the command's css in js if you want 737 | hover: function(ele){ // a function to execute when the command is hovered 738 | console.log( ele.id() ) // `ele` holds the reference to the active element 739 | }, 740 | select: function(ele){ // a function to execute when the command is selected 741 | console.log( ele.id() ) // `ele` holds the reference to the active element 742 | }, 743 | enabled: true // whether the command is selectable 744 | } 745 | */ 746 | ], // function( ele ){ return [ /*...*/ ] }, // example function for commands 747 | fillColor: 'rgba(0, 0, 0, 0.75)', // the background colour of the menu 748 | activeFillColor: 'rgba(1, 105, 217, 0.75)', // the colour used to indicate the selected command 749 | activePadding: 20, // additional size in pixels for the active command 750 | indicatorSize: 24, // the size in pixels of the pointer to the active command, will default to the node size if the node size is smaller than the indicator size, 751 | separatorWidth: 3, // the empty spacing in pixels between successive commands 752 | spotlightPadding: 4, // extra spacing in pixels between the element and the spotlight 753 | adaptativeNodeSpotlightRadius: false, // specify whether the spotlight radius should adapt to the node size 754 | minSpotlightRadius: 24, // the minimum radius in pixels of the spotlight (ignored for the node if adaptativeNodeSpotlightRadius is enabled but still used for the edge & background) 755 | maxSpotlightRadius: 38, // the maximum radius in pixels of the spotlight (ignored for the node if adaptativeNodeSpotlightRadius is enabled but still used for the edge & background) 756 | openMenuEvents: 'cxttapstart taphold', // space-separated cytoscape events that will open the menu; only `cxttapstart` and/or `taphold` work here 757 | itemColor: 'white', // the colour of text in the command's content 758 | itemTextShadowColor: 'transparent', // the text shadow colour of the command's content 759 | zIndex: 9999, // the z-index of the ui div 760 | atMouse: false, // draw menu at mouse position 761 | outsideMenuCancel: false // if set to a number, this will cancel the command if the pointer is released outside of the spotlight, padded by the number given 762 | }; 763 | 764 | module.exports = defaults; 765 | 766 | /***/ }), 767 | /* 3 */ 768 | /***/ (function(module, exports, __webpack_require__) { 769 | 770 | "use strict"; 771 | 772 | 773 | var removeEles = function removeEles(query) { 774 | var ancestor = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : document; 775 | 776 | var els = ancestor.querySelectorAll(query); 777 | 778 | for (var i = 0; i < els.length; i++) { 779 | var el = els[i]; 780 | 781 | el.parentNode.removeChild(el); 782 | } 783 | }; 784 | 785 | var setStyles = function setStyles(el, style) { 786 | var props = Object.keys(style); 787 | 788 | for (var i = 0, l = props.length; i < l; i++) { 789 | el.style[props[i]] = style[props[i]]; 790 | } 791 | }; 792 | 793 | var createElement = function createElement(options) { 794 | options = options || {}; 795 | 796 | var el = document.createElement(options.tag || 'div'); 797 | 798 | el.className = options.class || ''; 799 | 800 | if (options.style) { 801 | setStyles(el, options.style); 802 | } 803 | 804 | return el; 805 | }; 806 | 807 | var getPixelRatio = function getPixelRatio() { 808 | return window.devicePixelRatio || 1; 809 | }; 810 | 811 | var getOffset = function getOffset(el) { 812 | var offset = el.getBoundingClientRect(); 813 | 814 | return { 815 | left: offset.left + document.body.scrollLeft + parseFloat(getComputedStyle(document.body)['padding-left']) + parseFloat(getComputedStyle(document.body)['border-left-width']), 816 | top: offset.top + document.body.scrollTop + parseFloat(getComputedStyle(document.body)['padding-top']) + parseFloat(getComputedStyle(document.body)['border-top-width']) 817 | }; 818 | }; 819 | 820 | module.exports = { removeEles: removeEles, setStyles: setStyles, createElement: createElement, getPixelRatio: getPixelRatio, getOffset: getOffset }; 821 | 822 | /***/ }), 823 | /* 4 */ 824 | /***/ (function(module, exports, __webpack_require__) { 825 | 826 | "use strict"; 827 | 828 | 829 | var cxtmenu = __webpack_require__(0); 830 | 831 | // registers the extension on a cytoscape lib ref 832 | var register = function register(cytoscape) { 833 | if (!cytoscape) { 834 | return; 835 | } // can't register if cytoscape unspecified 836 | 837 | cytoscape('core', 'cxtmenu', cxtmenu); // register with cytoscape.js 838 | }; 839 | 840 | if (typeof cytoscape !== 'undefined') { 841 | // expose to global cytoscape (i.e. window.cytoscape) 842 | register(cytoscape); 843 | } 844 | 845 | module.exports = register; 846 | 847 | /***/ }) 848 | /******/ ]); 849 | }); -------------------------------------------------------------------------------- /demo-adaptative.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 |