├── .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 | [![DOI](https://zenodo.org/badge/16010906.svg)](https://zenodo.org/badge/latestdoi/16010906) 5 | 6 | ![Preview](https://raw.githubusercontent.com/cytoscape/cytoscape.js-cxtmenu/master/preview.png) 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 | cytoscape-cxtmenu.js demo with adaptative spotlight 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 44 | 45 | 190 | 191 | 192 | 193 |

cytoscape-cxtmenu demo with adaptative spotlight

194 | 195 |
196 | 197 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /demo-cancel-outside.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | cytoscape-cxtmenu.js cancel outside demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 44 | 45 | 142 | 143 | 144 | 145 |

cytoscape-cxtmenu demo with cancel outside menu<

146 | 147 |
148 | 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | cytoscape-cxtmenu.js demo 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 44 | 45 | 142 | 143 | 144 | 145 |

cytoscape-cxtmenu demo

146 | 147 |
148 | 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cytoscape-cxtmenu", 3 | "version": "3.5.0", 4 | "description": "A circular, swipeable context menu extension for Cytoscape.js", 5 | "main": "cytoscape-cxtmenu.js", 6 | "author": { 7 | "name": "Max Franz", 8 | "email": "maxkfranz@gmail.com" 9 | }, 10 | "scripts": { 11 | "postpublish": "run-s gh-pages", 12 | "gh-pages": "gh-pages -d pages", 13 | "copyright": "update license", 14 | "lint": "eslint src", 15 | "build": "cross-env NODE_ENV=production webpack", 16 | "build:min": "cross-env NODE_ENV=production MIN=true webpack", 17 | "build:release": "run-s build copyright", 18 | "watch": "webpack --progress --watch", 19 | "dev": "webpack-dev-server --open", 20 | "test": "mocha" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/cytoscape/cytoscape.js-cxtmenu.git" 25 | }, 26 | "keywords": [ 27 | "cytoscape", 28 | "cytoscape-extension" 29 | ], 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/cytoscape/cytoscape.js-cxtmenu/issues" 33 | }, 34 | "homepage": "https://github.com/cytoscape/cytoscape.js-cxtmenu", 35 | "devDependencies": { 36 | "babel-core": "^6.24.1", 37 | "babel-loader": "^7.0.0", 38 | "babel-preset-env": "^1.5.1", 39 | "camelcase": "^4.1.0", 40 | "chai": "4.0.2", 41 | "cpy-cli": "^1.0.1", 42 | "cross-env": "^5.0.0", 43 | "eslint": "^3.9.1", 44 | "gh-pages": "^1.0.0", 45 | "mocha": "3.4.2", 46 | "npm-run-all": "^4.1.2", 47 | "rimraf": "^2.6.2", 48 | "update": "^0.7.4", 49 | "updater-license": "^1.0.0", 50 | "webpack": "^2.6.1", 51 | "webpack-dev-server": "^2.4.5" 52 | }, 53 | "peerDependencies": { 54 | "cytoscape": "^3.2.0" 55 | }, 56 | "dependencies": {} 57 | } 58 | -------------------------------------------------------------------------------- /pages/cytoscape-cxtmenu.js: -------------------------------------------------------------------------------- 1 | ../cytoscape-cxtmenu.js -------------------------------------------------------------------------------- /pages/demo-adaptative.html: -------------------------------------------------------------------------------- 1 | ../demo-adaptative.html -------------------------------------------------------------------------------- /pages/demo-cancel-outside.html: -------------------------------------------------------------------------------- 1 | ../demo-cancel-outside.html -------------------------------------------------------------------------------- /pages/demo.html: -------------------------------------------------------------------------------- 1 | ../demo.html -------------------------------------------------------------------------------- /pages/index.html: -------------------------------------------------------------------------------- 1 | ../demo.html -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cytoscape/cytoscape.js-cxtmenu/e5f39132c954b2b195c91c605f4946de7261fe88/preview.png -------------------------------------------------------------------------------- /src/assign.js: -------------------------------------------------------------------------------- 1 | // Simple, internal Object.assign() polyfill for options objects etc. 2 | 3 | module.exports = Object.assign != null ? Object.assign.bind( Object ) : function( tgt, ...srcs ){ 4 | srcs.filter(src => src != null).forEach( src => { 5 | Object.keys( src ).forEach( k => tgt[k] = src[k] ); 6 | } ); 7 | 8 | return tgt; 9 | }; 10 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | module.exports = function(){ 2 | let cy = this; 3 | 4 | // your extension impl... 5 | 6 | return this; // chainability 7 | }; 8 | -------------------------------------------------------------------------------- /src/cxtmenu.js: -------------------------------------------------------------------------------- 1 | const defaults = require('./defaults'); 2 | const assign = require('./assign'); 3 | const { removeEles, setStyles, createElement, getPixelRatio, getOffset } = require('./dom-util'); 4 | 5 | let cxtmenu = function(params){ 6 | let options = assign({}, defaults, params); 7 | let cy = this; 8 | let container = cy.container(); 9 | let target; 10 | 11 | let data = { 12 | options: options, 13 | handlers: [], 14 | container: createElement({class: 'cxtmenu'}) 15 | }; 16 | 17 | let wrapper = data.container; 18 | let parent = createElement(); 19 | let canvas = createElement({tag: 'canvas'}); 20 | let commands = []; 21 | let c2d = canvas.getContext('2d'); 22 | 23 | let r = 100; // defailt radius; 24 | let containerSize = (r + options.activePadding)*2; 25 | let activeCommandI; 26 | let offset; 27 | 28 | container.insertBefore(wrapper, container.firstChild); 29 | wrapper.appendChild(parent); 30 | parent.appendChild(canvas); 31 | 32 | setStyles(wrapper, { 33 | position: 'absolute', 34 | zIndex: options.zIndex, 35 | userSelect: 'none', 36 | pointerEvents: 'none' // prevent events on menu in modern browsers 37 | }); 38 | 39 | // prevent events on menu in legacy browsers 40 | ['mousedown', 'mousemove', 'mouseup', 'contextmenu'].forEach(evt => { 41 | wrapper.addEventListener(evt, e => { 42 | e.preventDefault(); 43 | 44 | return false; 45 | }); 46 | }); 47 | 48 | setStyles(parent, { 49 | display: 'none', 50 | width: containerSize + 'px', 51 | height: containerSize + 'px', 52 | position: 'absolute', 53 | zIndex: 1, 54 | marginLeft: - options.activePadding + 'px', 55 | marginTop: - options.activePadding + 'px', 56 | userSelect: 'none' 57 | }); 58 | 59 | canvas.width = containerSize; 60 | canvas.height = containerSize; 61 | 62 | function createMenuItems(r, rs) { 63 | removeEles('.cxtmenu-item', parent); 64 | let dtheta = 2 * Math.PI / (commands.length); 65 | let theta1 = Math.PI / 2; 66 | let theta2 = theta1 + dtheta; 67 | 68 | for (let i = 0; i < commands.length; i++) { 69 | let command = commands[i]; 70 | 71 | let midtheta = (theta1 + theta2) / 2; 72 | let rx1 = ((r + rs)/2) * Math.cos(midtheta); 73 | let ry1 = ((r + rs)/2) * Math.sin(midtheta); 74 | 75 | // Arbitrary multiplier to increase the sizing of the space 76 | // available for the item. 77 | let width = 1 * Math.abs((r - rs) * Math.cos(midtheta)); 78 | let height = 1 * Math.abs((r - rs) * Math.sin(midtheta)); 79 | width = Math.max(width, height) 80 | 81 | let item = createElement({class: 'cxtmenu-item'}); 82 | setStyles(item, { 83 | color: options.itemColor, 84 | cursor: 'default', 85 | display: 'table', 86 | 'text-align': 'center', 87 | //background: 'red', 88 | position: 'absolute', 89 | 'text-shadow': '-1px -1px 2px ' + options.itemTextShadowColor + ', 1px -1px 2px ' + options.itemTextShadowColor + ', -1px 1px 2px ' + options.itemTextShadowColor + ', 1px 1px 1px ' + options.itemTextShadowColor, 90 | left: '50%', 91 | top: '50%', 92 | 'min-height': width + 'px', 93 | width: width + 'px', 94 | height: width + 'px', 95 | marginLeft: (rx1 - width/2) + 'px', 96 | marginTop: (-ry1 - width/2) + 'px' 97 | }); 98 | 99 | let content = createElement({class: 'cxtmenu-content'}); 100 | 101 | if( command.content instanceof HTMLElement ){ 102 | content.appendChild( command.content ); 103 | } else { 104 | content.innerHTML = command.content; 105 | } 106 | 107 | setStyles(content, { 108 | 'width': width + 'px', 109 | 'height': width + 'px', 110 | 'vertical-align': 'middle', 111 | 'display': 'table-cell', 112 | }); 113 | 114 | setStyles(content, command.contentStyle || {}); 115 | 116 | if (command.disabled === true || command.enabled === false) { 117 | content.setAttribute('class', 'cxtmenu-content cxtmenu-disabled'); 118 | } 119 | 120 | parent.appendChild(item); 121 | item.appendChild(content); 122 | 123 | theta1 += dtheta; 124 | theta2 += dtheta; 125 | } 126 | } 127 | 128 | function queueDrawBg( radius, rspotlight ){ 129 | redrawQueue.drawBg = [ radius, rspotlight ]; 130 | } 131 | 132 | function drawBg( radius, rspotlight ){ 133 | c2d.globalCompositeOperation = 'source-over'; 134 | 135 | c2d.clearRect(0, 0, containerSize, containerSize); 136 | 137 | // draw background items 138 | c2d.fillStyle = options.fillColor; 139 | let dtheta = 2*Math.PI/(commands.length); 140 | let theta1 = Math.PI/2; 141 | let theta2 = theta1 + dtheta; 142 | 143 | for( let index = 0; index < commands.length; index++ ){ 144 | let command = commands[index]; 145 | 146 | if( command.fillColor ){ 147 | c2d.fillStyle = command.fillColor; 148 | } 149 | c2d.beginPath(); 150 | c2d.moveTo(radius + options.activePadding, radius + options.activePadding); 151 | c2d.arc(radius + options.activePadding, radius + options.activePadding, radius, 2*Math.PI - theta1, 2*Math.PI - theta2, true); 152 | c2d.closePath(); 153 | c2d.fill(); 154 | 155 | theta1 += dtheta; 156 | theta2 += dtheta; 157 | 158 | c2d.fillStyle = options.fillColor; 159 | } 160 | 161 | // draw separators between items 162 | c2d.globalCompositeOperation = 'destination-out'; 163 | c2d.strokeStyle = 'white'; 164 | c2d.lineWidth = options.separatorWidth; 165 | theta1 = Math.PI/2; 166 | theta2 = theta1 + dtheta; 167 | 168 | for( let i = 0; i < commands.length; i++ ){ 169 | let rx1 = radius * Math.cos(theta1); 170 | let ry1 = radius * Math.sin(theta1); 171 | c2d.beginPath(); 172 | c2d.moveTo(radius + options.activePadding, radius + options.activePadding); 173 | c2d.lineTo(radius + options.activePadding + rx1, radius + options.activePadding - ry1); 174 | c2d.closePath(); 175 | c2d.stroke(); 176 | 177 | theta1 += dtheta; 178 | theta2 += dtheta; 179 | } 180 | 181 | 182 | c2d.fillStyle = 'white'; 183 | c2d.globalCompositeOperation = 'destination-out'; 184 | c2d.beginPath(); 185 | c2d.arc(radius + options.activePadding, radius + options.activePadding, rspotlight + options.spotlightPadding, 0, Math.PI*2, true); 186 | c2d.closePath(); 187 | c2d.fill(); 188 | 189 | c2d.globalCompositeOperation = 'source-over'; 190 | } 191 | 192 | function queueDrawCommands( rx, ry, radius, theta, rspotlight ){ 193 | redrawQueue.drawCommands = [ rx, ry, radius, theta, rspotlight ]; 194 | } 195 | 196 | function drawCommands( rx, ry, radius, theta, rs ){ 197 | let dtheta = 2*Math.PI/(commands.length); 198 | let theta1 = Math.PI/2; 199 | let theta2 = theta1 + dtheta; 200 | 201 | theta1 += dtheta * activeCommandI; 202 | theta2 += dtheta * activeCommandI; 203 | 204 | c2d.fillStyle = options.activeFillColor; 205 | c2d.strokeStyle = 'black'; 206 | c2d.lineWidth = 1; 207 | c2d.beginPath(); 208 | c2d.moveTo(radius + options.activePadding, radius + options.activePadding); 209 | c2d.arc(radius + options.activePadding, radius + options.activePadding, radius + options.activePadding, 2*Math.PI - theta1, 2*Math.PI - theta2, true); 210 | c2d.closePath(); 211 | c2d.fill(); 212 | 213 | c2d.fillStyle = 'white'; 214 | c2d.globalCompositeOperation = 'destination-out'; 215 | 216 | let tx = radius + options.activePadding + rx/radius*(rs + options.spotlightPadding - options.indicatorSize/4); 217 | let ty = radius + options.activePadding + ry/radius*(rs + options.spotlightPadding - options.indicatorSize/4); 218 | let rot = Math.PI/4 - theta; 219 | 220 | c2d.translate( tx, ty ); 221 | c2d.rotate( rot ); 222 | 223 | // clear the indicator 224 | // The indicator size (arrow) depends on the node size as well. If the indicator size is bigger and the rendered node size + padding, 225 | // use the rendered node size + padding as the indicator size. 226 | let indicatorSize = options.indicatorSize > rs + options.spotlightPadding ? rs + options.spotlightPadding : options.indicatorSize 227 | c2d.beginPath(); 228 | c2d.fillRect(-indicatorSize/2, -indicatorSize/2, indicatorSize, indicatorSize); 229 | c2d.closePath(); 230 | c2d.fill(); 231 | 232 | c2d.rotate( -rot ); 233 | c2d.translate( -tx, -ty ); 234 | 235 | // c2d.setTransform( 1, 0, 0, 1, 0, 0 ); 236 | 237 | // clear the spotlight 238 | c2d.beginPath(); 239 | c2d.arc(radius + options.activePadding, radius + options.activePadding, rs + options.spotlightPadding, 0, Math.PI*2, true); 240 | c2d.closePath(); 241 | c2d.fill(); 242 | 243 | c2d.globalCompositeOperation = 'source-over'; 244 | } 245 | 246 | function updatePixelRatio(){ 247 | let pxr = getPixelRatio(); 248 | let w = containerSize; 249 | let h = containerSize; 250 | 251 | canvas.width = w * pxr; 252 | canvas.height = h * pxr; 253 | 254 | canvas.style.width = w + 'px'; 255 | canvas.style.height = h + 'px'; 256 | 257 | c2d.setTransform( 1, 0, 0, 1, 0, 0 ); 258 | c2d.scale( pxr, pxr ); 259 | } 260 | 261 | let redrawing = true; 262 | let redrawQueue = {}; 263 | 264 | let raf = ( 265 | window.requestAnimationFrame 266 | || window.webkitRequestAnimationFrame 267 | || window.mozRequestAnimationFrame 268 | || window.msRequestAnimationFrame 269 | || (fn => setTimeout(fn, 16)) 270 | ); 271 | 272 | let redraw = function(){ 273 | if( redrawQueue.drawBg ){ 274 | drawBg.apply( null, redrawQueue.drawBg ); 275 | } 276 | 277 | if( redrawQueue.drawCommands ){ 278 | drawCommands.apply( null, redrawQueue.drawCommands ); 279 | } 280 | 281 | redrawQueue = {}; 282 | 283 | if( redrawing ){ 284 | raf( redraw ); 285 | } 286 | }; 287 | 288 | // kick off 289 | updatePixelRatio(); 290 | redraw(); 291 | 292 | let ctrx, ctry, rs; 293 | 294 | let bindings = { 295 | on: function(events, selector, fn){ 296 | 297 | let _fn = fn; 298 | if( selector === 'core'){ 299 | _fn = function( e ){ 300 | if( e.cyTarget === cy || e.target === cy ){ // only if event target is directly core 301 | return fn.apply( this, [ e ] ); 302 | } 303 | }; 304 | } 305 | 306 | data.handlers.push({ 307 | events: events, 308 | selector: selector, 309 | fn: _fn 310 | }); 311 | 312 | if( selector === 'core' ){ 313 | cy.on(events, _fn); 314 | } else { 315 | cy.on(events, selector, _fn); 316 | } 317 | 318 | return this; 319 | } 320 | }; 321 | 322 | function addEventListeners(){ 323 | let grabbable; 324 | let inGesture = false; 325 | let dragHandler; 326 | let zoomEnabled; 327 | let panEnabled; 328 | let boxEnabled; 329 | let gestureStartEvent; 330 | let hoverOn; 331 | 332 | let restoreZoom = function(){ 333 | if( zoomEnabled ){ 334 | cy.userZoomingEnabled( true ); 335 | } 336 | }; 337 | 338 | let restoreGrab = function(){ 339 | if( grabbable ){ 340 | target.grabify(); 341 | } 342 | }; 343 | 344 | let restorePan = function(){ 345 | if( panEnabled ){ 346 | cy.userPanningEnabled( true ); 347 | } 348 | }; 349 | 350 | let restoreBoxSeln = function(){ 351 | if( boxEnabled ){ 352 | cy.boxSelectionEnabled( true ); 353 | } 354 | }; 355 | 356 | let restoreGestures = function(){ 357 | restoreGrab(); 358 | restoreZoom(); 359 | restorePan(); 360 | restoreBoxSeln(); 361 | }; 362 | 363 | window.addEventListener('resize', updatePixelRatio); 364 | 365 | bindings 366 | .on('resize', function(){ 367 | updatePixelRatio(); 368 | }) 369 | 370 | .on(options.openMenuEvents, options.selector, function(e){ 371 | target = this; // Remember which node the context menu is for 372 | let ele = this; 373 | let isCy = this === cy; 374 | 375 | if (inGesture) { 376 | parent.style.display = 'none'; 377 | 378 | inGesture = false; 379 | 380 | restoreGestures(); 381 | } 382 | 383 | if( typeof options.commands === 'function' ){ 384 | const res = options.commands(target); 385 | if( res.then ){ 386 | res.then(_commands => { 387 | commands = _commands; 388 | openMenu(); 389 | }) 390 | } else { 391 | commands = res; 392 | openMenu(); 393 | } 394 | } else { 395 | commands = options.commands; 396 | openMenu(); 397 | } 398 | 399 | function openMenu(){ 400 | if( !commands || commands.length === 0 ){ return; } 401 | 402 | zoomEnabled = cy.userZoomingEnabled(); 403 | cy.userZoomingEnabled( false ); 404 | 405 | panEnabled = cy.userPanningEnabled(); 406 | cy.userPanningEnabled( false ); 407 | 408 | boxEnabled = cy.boxSelectionEnabled(); 409 | cy.boxSelectionEnabled( false ); 410 | 411 | grabbable = target.grabbable && target.grabbable(); 412 | if( grabbable ){ 413 | target.ungrabify(); 414 | } 415 | 416 | let rp, rw, rh, rs; 417 | if( !isCy && ele && ele.isNode instanceof Function && ele.isNode() && !ele.isParent() && !options.atMouse ){ 418 | // If it's a node, the default spotlight radius for a node is the node width 419 | rp = ele.renderedPosition(); 420 | rw = ele.renderedOuterWidth(); 421 | rh = ele.renderedOuterHeight(); 422 | rs = rw/2; 423 | // If adaptativeNodespotlightRadius is not enabled and min|maxSpotlighrRadius is defined, use those instead 424 | rs = !options.adaptativeNodeSpotlightRadius && options.minSpotlightRadius ? Math.max(rs, options.minSpotlightRadius): rs; 425 | rs = !options.adaptativeNodeSpotlightRadius && options.maxSpotlightRadius ? Math.min(rs, options.maxSpotlightRadius): rs; 426 | } else { 427 | // If it's the background or an edge, the spotlight radius is the min|maxSpotlightRadius 428 | rp = e.renderedPosition || e.cyRenderedPosition; 429 | rw = 1; 430 | rh = 1; 431 | rs = rw/2; 432 | rs = options.minSpotlightRadius ? Math.max(rs, options.minSpotlightRadius): rs; 433 | rs = options.maxSpotlightRadius ? Math.min(rs, options.maxSpotlightRadius): rs; 434 | } 435 | 436 | offset = getOffset(container); 437 | 438 | ctrx = rp.x; 439 | ctry = rp.y; 440 | r = rw/2 + (options.menuRadius instanceof Function ? options.menuRadius(target) : Number(options.menuRadius)); 441 | containerSize = (r + options.activePadding)*2; 442 | updatePixelRatio(); 443 | 444 | setStyles(parent, { 445 | width: containerSize + 'px', 446 | height: containerSize + 'px', 447 | display: 'block', 448 | left: (rp.x - r) + 'px', 449 | top: (rp.y - r) + 'px' 450 | }); 451 | createMenuItems(r, rs); 452 | queueDrawBg(r, rs); 453 | 454 | activeCommandI = undefined; 455 | 456 | inGesture = true; 457 | gestureStartEvent = e; 458 | } 459 | }) 460 | 461 | .on('cxtdrag tapdrag', options.selector, dragHandler = function(e){ 462 | 463 | if( !inGesture ){ return; } 464 | e.preventDefault(); // Otherwise, on mobile, the pull-down refresh gesture gets activated 465 | 466 | let origE = e.originalEvent; 467 | let isTouch = origE.touches && origE.touches.length > 0; 468 | 469 | let pageX = (isTouch ? origE.touches[0].pageX : origE.pageX) - window.pageXOffset; 470 | let pageY = (isTouch ? origE.touches[0].pageY : origE.pageY) - window.pageYOffset; 471 | 472 | activeCommandI = undefined; 473 | 474 | let dx = pageX - offset.left - ctrx; 475 | let dy = pageY - offset.top - ctry; 476 | 477 | if( dx === 0 ){ dx = 0.01; } 478 | 479 | let d = Math.sqrt( dx*dx + dy*dy ); 480 | let cosTheta = (dy*dy - d*d - dx*dx)/(-2 * d * dx); 481 | let theta = Math.acos( cosTheta ); 482 | 483 | 484 | let rw; 485 | if(target && target.isNode instanceof Function && target.isNode() && !target.isParent() && !options.atMouse ){ 486 | // If it's a node, the default spotlight radius for a node is the node width 487 | rw = target.renderedOuterWidth(); 488 | rs = rw/2; 489 | // If adaptativeNodespotlightRadius is not enabled and min|maxSpotlighrRadius is defined, use those instead 490 | rs = !options.adaptativeNodeSpotlightRadius && options.minSpotlightRadius ? Math.max(rs, options.minSpotlightRadius): rs; 491 | rs = !options.adaptativeNodeSpotlightRadius && options.maxSpotlightRadius ? Math.min(rs, options.maxSpotlightRadius): rs; 492 | } else { 493 | // If it's the background or an edge, the spotlight radius is the min|maxSpotlightRadius 494 | rw = 1; 495 | rs = rw/2; 496 | rs = options.minSpotlightRadius ? Math.max(rs, options.minSpotlightRadius): rs; 497 | rs = options.maxSpotlightRadius ? Math.min(rs, options.maxSpotlightRadius): rs; 498 | } 499 | 500 | r = rw/2 + (options.menuRadius instanceof Function ? options.menuRadius(target) : Number(options.menuRadius)); 501 | if( d < rs + options.spotlightPadding 502 | || (typeof options.outsideMenuCancel === "number" && d > r + options.activePadding + options.outsideMenuCancel)){ // 503 | 504 | queueDrawBg(r, rs); 505 | return; 506 | } 507 | queueDrawBg(r, rs); 508 | 509 | let rx = dx*r / d; 510 | let ry = dy*r / d; 511 | 512 | if( dy > 0 ){ 513 | theta = Math.PI + Math.abs(theta - Math.PI); 514 | } 515 | 516 | let dtheta = 2*Math.PI/(commands.length); 517 | let theta1 = Math.PI/2; 518 | let theta2 = theta1 + dtheta; 519 | 520 | for( let i = 0; i < commands.length; i++ ){ 521 | let command = commands[i]; 522 | 523 | let inThisCommand = theta1 <= theta && theta <= theta2 524 | || theta1 <= theta + 2*Math.PI && theta + 2*Math.PI <= theta2; 525 | 526 | if( command.disabled === true || command.enabled === false ){ 527 | inThisCommand = false; 528 | } 529 | 530 | if( inThisCommand ){ 531 | activeCommandI = i; 532 | break; 533 | } 534 | 535 | theta1 += dtheta; 536 | theta2 += dtheta; 537 | } 538 | queueDrawCommands( rx, ry, r, theta, rs ); 539 | }) 540 | 541 | .on('tapdrag', dragHandler) 542 | 543 | .on('mousemove', function () { 544 | if (activeCommandI !== undefined) { 545 | let hovered = commands[activeCommandI].hover; 546 | if (hovered) { 547 | if (hoverOn !== activeCommandI) { 548 | hovered.apply(target, [target, gestureStartEvent]); 549 | } 550 | hoverOn = activeCommandI; 551 | } 552 | } 553 | }) 554 | 555 | .on('cxttapend tapend', function(){ 556 | parent.style.display = 'none'; 557 | if( activeCommandI !== undefined ){ 558 | let select = commands[ activeCommandI ].select; 559 | 560 | if( select ){ 561 | select.apply( target, [target, gestureStartEvent] ); 562 | activeCommandI = undefined; 563 | } 564 | } 565 | 566 | hoverOn = undefined; 567 | 568 | inGesture = false; 569 | 570 | restoreGestures(); 571 | }) 572 | ; 573 | } 574 | 575 | function removeEventListeners(){ 576 | let handlers = data.handlers; 577 | 578 | for( let i = 0; i < handlers.length; i++ ){ 579 | let h = handlers[i]; 580 | 581 | if( h.selector === 'core' ){ 582 | cy.off(h.events, h.fn); 583 | } else { 584 | cy.off(h.events, h.selector, h.fn); 585 | } 586 | } 587 | 588 | window.removeEventListener('resize', updatePixelRatio); 589 | } 590 | 591 | function destroyInstance(){ 592 | redrawing = false; 593 | 594 | removeEventListeners(); 595 | 596 | wrapper.remove(); 597 | } 598 | 599 | addEventListeners(); 600 | 601 | return { 602 | destroy: function(){ 603 | destroyInstance(); 604 | } 605 | }; 606 | 607 | }; 608 | 609 | module.exports = cxtmenu; 610 | -------------------------------------------------------------------------------- /src/defaults.js: -------------------------------------------------------------------------------- 1 | let defaults = { 2 | menuRadius: function(ele){ return 100 }, // the radius of the circular menu in pixels 3 | selector: 'node', // elements matching this Cytoscape.js selector will trigger cxtmenus 4 | commands: [ // an array of commands to list in the menu or a function that returns the array 5 | /* 6 | { // example command 7 | fillColor: 'rgba(200, 200, 200, 0.75)', // optional: custom background color for item 8 | content: 'a command name' // html/text content to be displayed in the menu 9 | contentStyle: {}, // css key:value pairs to set the command's css in js if you want 10 | hover: function(ele){ // a function to execute when the command is hovered 11 | console.log( ele.id() ) // `ele` holds the reference to the active element 12 | }, 13 | select: function(ele){ // a function to execute when the command is selected 14 | console.log( ele.id() ) // `ele` holds the reference to the active element 15 | }, 16 | enabled: true // whether the command is selectable 17 | } 18 | */ 19 | ], // function( ele ){ return [ /*...*/ ] }, // example function for commands 20 | fillColor: 'rgba(0, 0, 0, 0.75)', // the background colour of the menu 21 | activeFillColor: 'rgba(1, 105, 217, 0.75)', // the colour used to indicate the selected command 22 | activePadding: 20, // additional size in pixels for the active command 23 | 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, 24 | separatorWidth: 3, // the empty spacing in pixels between successive commands 25 | spotlightPadding: 4, // extra spacing in pixels between the element and the spotlight 26 | adaptativeNodeSpotlightRadius: false, // specify whether the spotlight radius should adapt to the node size 27 | 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) 28 | 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) 29 | openMenuEvents: 'cxttapstart taphold', // space-separated cytoscape events that will open the menu; only `cxttapstart` and/or `taphold` work here 30 | itemColor: 'white', // the colour of text in the command's content 31 | itemTextShadowColor: 'transparent', // the text shadow colour of the command's content 32 | zIndex: 9999, // the z-index of the ui div 33 | atMouse: false, // draw menu at mouse position 34 | 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 35 | }; 36 | 37 | module.exports = defaults; 38 | -------------------------------------------------------------------------------- /src/dom-util.js: -------------------------------------------------------------------------------- 1 | const removeEles = function(query, ancestor = document) { 2 | let els = ancestor.querySelectorAll(query); 3 | 4 | for( let i = 0; i < els.length; i++ ){ 5 | let el = els[i]; 6 | 7 | el.parentNode.removeChild(el); 8 | } 9 | }; 10 | 11 | const setStyles = function(el, style) { 12 | let props = Object.keys(style); 13 | 14 | for (let i = 0, l = props.length; i < l; i++) { 15 | el.style[props[i]] = style[props[i]]; 16 | } 17 | }; 18 | 19 | const createElement = function(options){ 20 | options = options || {}; 21 | 22 | let el = document.createElement(options.tag || 'div'); 23 | 24 | el.className = options.class || ''; 25 | 26 | if (options.style) { 27 | setStyles(el, options.style); 28 | } 29 | 30 | return el; 31 | }; 32 | 33 | const getPixelRatio = function(){ 34 | return window.devicePixelRatio || 1; 35 | }; 36 | 37 | const getOffset = function(el){ 38 | let offset = el.getBoundingClientRect(); 39 | 40 | return { 41 | left: offset.left + document.body.scrollLeft + 42 | parseFloat(getComputedStyle(document.body)['padding-left']) + 43 | parseFloat(getComputedStyle(document.body)['border-left-width']), 44 | top: offset.top + document.body.scrollTop + 45 | parseFloat(getComputedStyle(document.body)['padding-top']) + 46 | parseFloat(getComputedStyle(document.body)['border-top-width']) 47 | }; 48 | }; 49 | 50 | module.exports = { removeEles, setStyles, createElement, getPixelRatio, getOffset }; 51 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const cxtmenu = require('./cxtmenu'); 2 | 3 | // registers the extension on a cytoscape lib ref 4 | let register = function( cytoscape ){ 5 | if( !cytoscape ){ return; } // can't register if cytoscape unspecified 6 | 7 | cytoscape( 'core', 'cxtmenu', cxtmenu ); // register with cytoscape.js 8 | }; 9 | 10 | if( typeof cytoscape !== 'undefined' ){ // expose to global cytoscape (i.e. window.cytoscape) 11 | register( cytoscape ); 12 | } 13 | 14 | module.exports = register; 15 | -------------------------------------------------------------------------------- /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 webpack = require('webpack'); 6 | const env = process.env; 7 | const NODE_ENV = env.NODE_ENV; 8 | const MIN = env.MIN; 9 | const PROD = NODE_ENV === 'production'; 10 | 11 | let config = { 12 | devtool: PROD ? false : 'inline-source-map', 13 | entry: './src/index.js', 14 | output: { 15 | path: path.join( __dirname ), 16 | filename: pkg.name + '.js', 17 | library: camelcase( pkg.name ), 18 | libraryTarget: 'umd' 19 | }, 20 | module: { 21 | rules: [ 22 | { test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' } 23 | ] 24 | }, 25 | externals: PROD ? Object.keys( pkg.dependencies || {} ) : [], 26 | plugins: MIN ? [ 27 | new webpack.optimize.UglifyJsPlugin({ 28 | compress: { 29 | warnings: false, 30 | drop_console: false, 31 | } 32 | }) 33 | ] : [] 34 | }; 35 | 36 | module.exports = config; 37 | --------------------------------------------------------------------------------