1/******************************************************************************* 2 * jquery.ui-contextmenu.js plugin. 3 * 4 * jQuery plugin that provides a context menu (based on the jQueryUI menu widget). 5 * 6 * @see https://github.com/mar10/jquery-ui-contextmenu 7 * 8 * Copyright (c) 2013-2018, Martin Wendt (http://wwWendt.de). Licensed MIT. 9 */ 10 11(function( factory ) { 12 "use strict"; 13 if ( typeof define === "function" && define.amd ) { 14 // AMD. Register as an anonymous module. 15 define([ "jquery", "jquery-ui/ui/widgets/menu" ], factory ); 16 } else { 17 // Browser globals 18 factory( jQuery ); 19 } 20}(function( $ ) { 21 22"use strict"; 23 24var supportSelectstart = "onselectstart" in document.createElement("div"), 25 match = $.ui.menu.version.match(/^(\d)\.(\d+)/), 26 uiVersion = { 27 major: parseInt(match[1], 10), 28 minor: parseInt(match[2], 10) 29 }, 30 isLTE110 = ( uiVersion.major < 2 && uiVersion.minor <= 10 ), 31 isLTE111 = ( uiVersion.major < 2 && uiVersion.minor <= 11 ); 32 33$.widget("moogle.contextmenu", { 34 version: "@VERSION", 35 options: { 36 addClass: "ui-contextmenu", // Add this class to the outer <ul> 37 closeOnWindowBlur: true, // Close menu when window loses focus 38 appendTo: "body", // Set keyboard focus to first entry on open 39 autoFocus: false, // Set keyboard focus to first entry on open 40 autoTrigger: true, // open menu on browser's `contextmenu` event 41 delegate: null, // selector 42 hide: { effect: "fadeOut", duration: "fast" }, 43 ignoreParentSelect: true, // Don't trigger 'select' for sub-menu parents 44 menu: null, // selector or jQuery pointing to <UL>, or a definition hash 45 position: null, // popup positon 46 preventContextMenuForPopup: false, // prevent opening the browser's system 47 // context menu on menu entries 48 preventSelect: false, // disable text selection of target 49 show: { effect: "slideDown", duration: "fast" }, 50 taphold: false, // open menu on taphold events (requires external plugins) 51 uiMenuOptions: {}, // Additional options, used when UI Menu is created 52 // Events: 53 beforeOpen: $.noop, // menu about to open; return `false` to prevent opening 54 blur: $.noop, // menu option lost focus 55 close: $.noop, // menu was closed 56 create: $.noop, // menu was initialized 57 createMenu: $.noop, // menu was initialized (original UI Menu) 58 focus: $.noop, // menu option got focus 59 open: $.noop, // menu was opened 60 select: $.noop // menu option was selected; return `false` to prevent closing 61 }, 62 /** Constructor */ 63 _create: function() { 64 var cssText, eventNames, targetId, 65 opts = this.options; 66 67 this.$headStyle = null; 68 this.$menu = null; 69 this.menuIsTemp = false; 70 this.currentTarget = null; 71 this.extraData = {}; 72 this.previousFocus = null; 73 74 if (opts.delegate == null) { 75 $.error("ui-contextmenu: Missing required option `delegate`."); 76 } 77 if (opts.preventSelect) { 78 // Create a global style for all potential menu targets 79 // If the contextmenu was bound to `document`, we apply the 80 // selector relative to the <body> tag instead 81 targetId = ($(this.element).is(document) ? $("body") 82 : this.element).uniqueId().attr("id"); 83 cssText = "#" + targetId + " " + opts.delegate + " { " + 84 "-webkit-user-select: none; " + 85 "-khtml-user-select: none; " + 86 "-moz-user-select: none; " + 87 "-ms-user-select: none; " + 88 "user-select: none; " + 89 "}"; 90 this.$headStyle = $("<style class='moogle-contextmenu-style' />") 91 .prop("type", "text/css") 92 .appendTo("head"); 93 94 try { 95 this.$headStyle.html(cssText); 96 } catch ( e ) { 97 // issue #47: fix for IE 6-8 98 this.$headStyle[0].styleSheet.cssText = cssText; 99 } 100 // TODO: the selectstart is not supported by FF? 101 if (supportSelectstart) { 102 this.element.on("selectstart" + this.eventNamespace, opts.delegate, 103 function(event) { 104 event.preventDefault(); 105 }); 106 } 107 } 108 this._createUiMenu(opts.menu); 109 110 eventNames = "contextmenu" + this.eventNamespace; 111 if (opts.taphold) { 112 eventNames += " taphold" + this.eventNamespace; 113 } 114 this.element.on(eventNames, opts.delegate, $.proxy(this._openMenu, this)); 115 }, 116 /** Destructor, called on $().contextmenu("destroy"). */ 117 _destroy: function() { 118 this.element.off(this.eventNamespace); 119 120 this._createUiMenu(null); 121 122 if (this.$headStyle) { 123 this.$headStyle.remove(); 124 this.$headStyle = null; 125 } 126 }, 127 /** (Re)Create jQuery UI Menu. */ 128 _createUiMenu: function(menuDef) { 129 var ct, ed, 130 opts = this.options; 131 132 // Remove temporary <ul> if any 133 if (this.isOpen()) { 134 // #58: 'replaceMenu' in beforeOpen causing select: to lose ui.target 135 ct = this.currentTarget; 136 ed = this.extraData; 137 // close without animation, to force async mode 138 this._closeMenu(true); 139 this.currentTarget = ct; 140 this.extraData = ed; 141 } 142 if (this.menuIsTemp) { 143 this.$menu.remove(); // this will also destroy ui.menu 144 } else if (this.$menu) { 145 this.$menu 146 .menu("destroy") 147 .removeClass(opts.addClass) 148 .hide(); 149 } 150 this.$menu = null; 151 this.menuIsTemp = false; 152 // If a menu definition array was passed, create a hidden <ul> 153 // and generate the structure now 154 if ( !menuDef ) { 155 return; 156 } else if ($.isArray(menuDef)) { 157 this.$menu = $.moogle.contextmenu.createMenuMarkup(menuDef, null, opts); 158 this.menuIsTemp = true; 159 }else if ( typeof menuDef === "string" ) { 160 this.$menu = $(menuDef); 161 } else { 162 this.$menu = menuDef; 163 } 164 // Create - but hide - the jQuery UI Menu widget 165 this.$menu 166 .hide() 167 .addClass(opts.addClass) 168 // Create a menu instance that delegates events to our widget 169 .menu($.extend(true, {}, opts.uiMenuOptions, { 170 items: "> :not(.ui-widget-header)", 171 blur: $.proxy(opts.blur, this), 172 create: $.proxy(opts.createMenu, this), 173 focus: $.proxy(opts.focus, this), 174 select: $.proxy(function(event, ui) { 175 // User selected a menu entry 176 var retval, 177 isParent = $.moogle.contextmenu.isMenu(ui.item), 178 actionHandler = ui.item.data("actionHandler"); 179 180 ui.cmd = ui.item.attr("data-command"); 181 ui.target = $(this.currentTarget); 182 ui.extraData = this.extraData; 183 // ignore clicks, if they only open a sub-menu 184 if ( !isParent || !opts.ignoreParentSelect) { 185 retval = this._trigger.call(this, "select", event, ui); 186 if ( actionHandler ) { 187 retval = actionHandler.call(this, event, ui); 188 } 189 if ( retval !== false ) { 190 this._closeMenu.call(this); 191 } 192 event.preventDefault(); 193 } 194 }, this) 195 })); 196 }, 197 /** Open popup (called on 'contextmenu' event). */ 198 _openMenu: function(event, recursive) { 199 var res, promise, ui, 200 opts = this.options, 201 posOption = opts.position, 202 self = this, 203 manualTrigger = !!event.isTrigger; 204 205 if ( !opts.autoTrigger && !manualTrigger ) { 206 // ignore browser's `contextmenu` events 207 return; 208 } 209 // Prevent browser from opening the system context menu 210 event.preventDefault(); 211 212 this.currentTarget = event.target; 213 this.extraData = event._extraData || {}; 214 215 ui = { menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData, 216 originalEvent: event, result: null }; 217 218 if ( !recursive ) { 219 res = this._trigger("beforeOpen", event, ui); 220 promise = (ui.result && $.isFunction(ui.result.promise)) ? ui.result : null; 221 ui.result = null; 222 if ( res === false ) { 223 this.currentTarget = null; 224 return false; 225 } else if ( promise ) { 226 // Handler returned a Deferred or Promise. Delay menu open until 227 // the promise is resolved 228 promise.done(function() { 229 self._openMenu(event, true); 230 }); 231 this.currentTarget = null; 232 return false; 233 } 234 ui.menu = this.$menu; // Might have changed in beforeOpen 235 } 236 237 // Register global event handlers that close the dropdown-menu 238 $(document).on("keydown" + this.eventNamespace, function(event) { 239 if ( event.which === $.ui.keyCode.ESCAPE ) { 240 self._closeMenu(); 241 } 242 }).on("mousedown" + this.eventNamespace + " touchstart" + this.eventNamespace, 243 function(event) { 244 // Close menu when clicked outside menu 245 if ( !$(event.target).closest(".ui-menu-item").length ) { 246 self._closeMenu(); 247 } 248 }); 249 $(window).on("blur" + this.eventNamespace, function(event) { 250 if ( opts.closeOnWindowBlur ) { 251 self._closeMenu(); 252 } 253 }); 254 255 // required for custom positioning (issue #18 and #13). 256 if ($.isFunction(posOption)) { 257 posOption = posOption(event, ui); 258 } 259 posOption = $.extend({ 260 my: "left top", 261 at: "left bottom", 262 // if called by 'open' method, event does not have pageX/Y 263 of: (event.pageX === undefined) ? event.target : event, 264 collision: "fit" 265 }, posOption); 266 267 // Update entry statuses from callbacks 268 this._updateEntries(this.$menu); 269 270 // Finally display the popup 271 this.$menu 272 .show() // required to fix positioning error 273 .css({ 274 position: "absolute", 275 left: 0, 276 top: 0 277 }).position(posOption) 278 .hide(); // hide again, so we can apply nice effects 279 280 if ( opts.preventContextMenuForPopup ) { 281 this.$menu.on("contextmenu" + this.eventNamespace, function(event) { 282 event.preventDefault(); 283 }); 284 } 285 this._show(this.$menu, opts.show, function() { 286 var $first; 287 288 // Set focus to first active menu entry 289 if ( opts.autoFocus ) { 290 self.previousFocus = $(event.target); 291 // self.$menu.focus(); 292 $first = self.$menu 293 .children("li.ui-menu-item") 294 .not(".ui-state-disabled") 295 .first(); 296 self.$menu.menu("focus", null, $first).focus(); 297 } 298 self._trigger.call(self, "open", event, ui); 299 }); 300 }, 301 /** Close popup. */ 302 _closeMenu: function(immediately) { 303 var self = this, 304 hideOpts = immediately ? false : this.options.hide, 305 ui = { menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData }; 306 307 // Note: we don't want to unbind the 'contextmenu' event 308 $(document) 309 .off("mousedown" + this.eventNamespace) 310 .off("touchstart" + this.eventNamespace) 311 .off("keydown" + this.eventNamespace); 312 $(window) 313 .off("blur" + this.eventNamespace); 314 315 self.currentTarget = null; // issue #44 after hide animation is too late 316 self.extraData = {}; 317 if ( this.$menu ) { // #88: widget might have been destroyed already 318 this.$menu 319 .off("contextmenu" + this.eventNamespace); 320 this._hide(this.$menu, hideOpts, function() { 321 if ( self.previousFocus ) { 322 self.previousFocus.focus(); 323 self.previousFocus = null; 324 } 325 self._trigger("close", null, ui); 326 }); 327 } else { 328 self._trigger("close", null, ui); 329 } 330 }, 331 /** Handle $().contextmenu("option", key, value) calls. */ 332 _setOption: function(key, value) { 333 switch (key) { 334 case "menu": 335 this.replaceMenu(value); 336 break; 337 } 338 $.Widget.prototype._setOption.apply(this, arguments); 339 }, 340 /** Return ui-menu entry (<LI> tag). */ 341 _getMenuEntry: function(cmd) { 342 return this.$menu.find("li[data-command=" + cmd + "]"); 343 }, 344 /** Close context menu. */ 345 close: function() { 346 if (this.isOpen()) { 347 this._closeMenu(); 348 } 349 }, 350 /* Apply status callbacks when menu is opened. */ 351 _updateEntries: function() { 352 var self = this, 353 ui = { 354 menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData }; 355 356 $.each(this.$menu.find(".ui-menu-item"), function(i, o) { 357 var $entry = $(o), 358 fn = $entry.data("disabledHandler"), 359 res = fn ? fn({ type: "disabled" }, ui) : null; 360 361 ui.item = $entry; 362 ui.cmd = $entry.attr("data-command"); 363 // Evaluate `disabled()` callback 364 if ( res != null ) { 365 self.enableEntry(ui.cmd, !res); 366 self.showEntry(ui.cmd, res !== "hide"); 367 } 368 // Evaluate `title()` callback 369 fn = $entry.data("titleHandler"), 370 res = fn ? fn({ type: "title" }, ui) : null; 371 if ( res != null ) { 372 self.setTitle(ui.cmd, "" + res); 373 } 374 // Evaluate `tooltip()` callback 375 fn = $entry.data("tooltipHandler"), 376 res = fn ? fn({ type: "tooltip" }, ui) : null; 377 if ( res != null ) { 378 $entry.attr("title", "" + res); 379 } 380 }); 381 }, 382 /** Enable or disable the menu command. */ 383 enableEntry: function(cmd, flag) { 384 this._getMenuEntry(cmd).toggleClass("ui-state-disabled", (flag === false)); 385 }, 386 /** Return ui-menu entry (LI tag) as jQuery object. */ 387 getEntry: function(cmd) { 388 return this._getMenuEntry(cmd); 389 }, 390 /** Return ui-menu entry wrapper as jQuery object. 391 UI 1.10: this is the <a> tag inside the LI 392 UI 1.11: this is the LI istself 393 UI 1.12: this is the <div> tag inside the LI 394 */ 395 getEntryWrapper: function(cmd) { 396 return this._getMenuEntry(cmd).find(">[role=menuitem]").addBack("[role=menuitem]"); 397 }, 398 /** Return Menu element (UL). */ 399 getMenu: function() { 400 return this.$menu; 401 }, 402 /** Return true if menu is open. */ 403 isOpen: function() { 404// return this.$menu && this.$menu.is(":visible"); 405 return !!this.$menu && !!this.currentTarget; 406 }, 407 /** Open context menu on a specific target (must match options.delegate) 408 * Optional `extraData` is passed to event handlers as `ui.extraData`. 409 */ 410 open: function(targetOrEvent, extraData) { 411 // Fake a 'contextmenu' event 412 extraData = extraData || {}; 413 414 var isEvent = (targetOrEvent && targetOrEvent.type && targetOrEvent.target), 415 event = isEvent ? targetOrEvent : {}, 416 target = isEvent ? targetOrEvent.target : targetOrEvent, 417 e = jQuery.Event("contextmenu", { 418 target: $(target).get(0), 419 pageX: event.pageX, 420 pageY: event.pageY, 421 originalEvent: isEvent ? targetOrEvent : undefined, 422 _extraData: extraData 423 }); 424 return this.element.trigger(e); 425 }, 426 /** Replace the menu altogether. */ 427 replaceMenu: function(data) { 428 this._createUiMenu(data); 429 }, 430 /** Redefine a whole menu entry. */ 431 setEntry: function(cmd, entry) { 432 var $ul, 433 $entryLi = this._getMenuEntry(cmd); 434 435 if (typeof entry === "string") { 436 window.console && window.console.warn( 437 "setEntry(cmd, t) with a plain string title is deprecated since v1.18." + 438 "Use setTitle(cmd, '" + entry + "') instead."); 439 return this.setTitle(cmd, entry); 440 } 441 $entryLi.empty(); 442 entry.cmd = entry.cmd || cmd; 443 $.moogle.contextmenu.createEntryMarkup(entry, $entryLi); 444 if ($.isArray(entry.children)) { 445 $ul = $("<ul/>").appendTo($entryLi); 446 $.moogle.contextmenu.createMenuMarkup(entry.children, $ul); 447 } 448 // #110: jQuery UI 1.12: refresh only works when this class is not set: 449 $entryLi.removeClass("ui-menu-item"); 450 this.getMenu().menu("refresh"); 451 }, 452 /** Set icon (pass null to remove). */ 453 setIcon: function(cmd, icon) { 454 return this.updateEntry(cmd, { uiIcon: icon }); 455 }, 456 /** Set title. */ 457 setTitle: function(cmd, title) { 458 return this.updateEntry(cmd, { title: title }); 459 }, 460 // /** Set tooltip (pass null to remove). */ 461 // setTooltip: function(cmd, tooltip) { 462 // this._getMenuEntry(cmd).attr("title", tooltip); 463 // }, 464 /** Show or hide the menu command. */ 465 showEntry: function(cmd, flag) { 466 this._getMenuEntry(cmd).toggle(flag !== false); 467 }, 468 /** Redefine selective attributes of a menu entry. */ 469 updateEntry: function(cmd, entry) { 470 var $icon, $wrapper, 471 $entryLi = this._getMenuEntry(cmd); 472 473 if ( entry.title !== undefined ) { 474 $.moogle.contextmenu.updateTitle($entryLi, "" + entry.title); 475 } 476 if ( entry.tooltip !== undefined ) { 477 if ( entry.tooltip === null ) { 478 $entryLi.removeAttr("title"); 479 } else { 480 $entryLi.attr("title", entry.tooltip); 481 } 482 } 483 if ( entry.uiIcon !== undefined ) { 484 $wrapper = this.getEntryWrapper(cmd), 485 $icon = $wrapper.find("span.ui-icon").not(".ui-menu-icon"); 486 $icon.remove(); 487 if ( entry.uiIcon ) { 488 $wrapper.append($("<span class='ui-icon' />").addClass(entry.uiIcon)); 489 } 490 } 491 if ( entry.hide !== undefined ) { 492 $entryLi.toggle(!entry.hide); 493 } else if ( entry.show !== undefined ) { 494 // Note: `show` is an undocumented variant. `hide: false` is preferred 495 $entryLi.toggle(!!entry.show); 496 } 497 // if ( entry.isHeader !== undefined ) { 498 // $entryLi.toggleClass("ui-widget-header", !!entry.isHeader); 499 // } 500 if ( entry.data !== undefined ) { 501 $entryLi.data(entry.data); 502 } 503 504 // Set/clear class names, but handle ui-state-disabled separately 505 if ( entry.disabled === undefined ) { 506 entry.disabled = $entryLi.hasClass("ui-state-disabled"); 507 } 508 if ( entry.setClass ) { 509 if ( $entryLi.hasClass("ui-menu-item") ) { 510 entry.setClass += " ui-menu-item"; 511 } 512 $entryLi.removeClass(); 513 $entryLi.addClass(entry.setClass); 514 } else if ( entry.addClass ) { 515 $entryLi.addClass(entry.addClass); 516 } 517 $entryLi.toggleClass("ui-state-disabled", !!entry.disabled); 518 // // #110: jQuery UI 1.12: refresh only works when this class is not set: 519 // $entryLi.removeClass("ui-menu-item"); 520 // this.getMenu().menu("refresh"); 521 } 522}); 523 524/* 525 * Global functions 526 */ 527$.extend($.moogle.contextmenu, { 528 /** Convert a menu description into a into a <li> content. */ 529 createEntryMarkup: function(entry, $parentLi) { 530 var $wrapper = null; 531 532 $parentLi.attr("data-command", entry.cmd); 533 534 if ( !/[^\-\u2014\u2013\s]/.test( entry.title ) ) { 535 // hyphen, em dash, en dash: separator as defined by UI Menu 1.10 536 $parentLi.text(entry.title); 537 } else { 538 if ( isLTE110 ) { 539 // jQuery UI Menu 1.10 or before required an `<a>` tag 540 $wrapper = $("<a/>", { 541 html: "" + entry.title, 542 href: "#" 543 }).appendTo($parentLi); 544 545 } else if ( isLTE111 ) { 546 // jQuery UI Menu 1.11 preferes to avoid `<a>` tags or <div> wrapper 547 $parentLi.html("" + entry.title); 548 $wrapper = $parentLi; 549 550 } else { 551 // jQuery UI Menu 1.12 introduced `<div>` wrappers 552 $wrapper = $("<div/>", { 553 html: "" + entry.title 554 }).appendTo($parentLi); 555 } 556 if ( entry.uiIcon ) { 557 $wrapper.append($("<span class='ui-icon' />").addClass(entry.uiIcon)); 558 } 559 // Store option callbacks in entry's data 560 $.each( [ "action", "disabled", "title", "tooltip" ], function(i, attr) { 561 if ( $.isFunction(entry[attr]) ) { 562 $parentLi.data(attr + "Handler", entry[attr]); 563 } 564 }); 565 if ( entry.disabled === true ) { 566 $parentLi.addClass("ui-state-disabled"); 567 } 568 if ( entry.isHeader ) { 569 $parentLi.addClass("ui-widget-header"); 570 } 571 if ( entry.addClass ) { 572 $parentLi.addClass(entry.addClass); 573 } 574 if ( $.isPlainObject(entry.data) ) { 575 $parentLi.data(entry.data); 576 } 577 if ( typeof entry.tooltip === "string" ) { 578 $parentLi.attr("title", entry.tooltip); 579 } 580 } 581 }, 582 /** Convert a nested array of command objects into a <ul> structure. */ 583 createMenuMarkup: function(options, $parentUl, opts) { 584 var i, menu, $ul, $li, 585 appendTo = (opts && opts.appendTo) ? opts.appendTo : "body"; 586 587 if ( $parentUl == null ) { 588 $parentUl = $("<ul class='ui-helper-hidden' />").appendTo(appendTo); 589 } 590 for (i = 0; i < options.length; i++) { 591 menu = options[i]; 592 $li = $("<li/>").appendTo($parentUl); 593 594 $.moogle.contextmenu.createEntryMarkup(menu, $li); 595 596 if ( $.isArray(menu.children) ) { 597 $ul = $("<ul/>").appendTo($li); 598 $.moogle.contextmenu.createMenuMarkup(menu.children, $ul); 599 } 600 } 601 return $parentUl; 602 }, 603 /** Returns true if the menu item has child menu items */ 604 isMenu: function(item) { 605 if ( isLTE110 ) { 606 return item.has(">a[aria-haspopup='true']").length > 0; 607 } else if ( isLTE111 ) { // jQuery UI 1.11 used no tag wrappers 608 return item.is("[aria-haspopup='true']"); 609 } else { 610 return item.has(">div[aria-haspopup='true']").length > 0; 611 } 612 }, 613 /** Replace the title of elem', but retain icons andchild entries. */ 614 replaceFirstTextNodeChild: function(elem, html) { 615 var $icons = elem.find(">span.ui-icon,>ul.ui-menu").detach(); 616 617 elem 618 .empty() 619 .html(html) 620 .append($icons); 621 }, 622 /** Updates the menu item's title */ 623 updateTitle: function(item, title) { 624 if ( isLTE110 ) { // jQuery UI 1.10 and before used <a> tags 625 $.moogle.contextmenu.replaceFirstTextNodeChild($("a", item), title); 626 } else if ( isLTE111 ) { // jQuery UI 1.11 used no tag wrappers 627 $.moogle.contextmenu.replaceFirstTextNodeChild(item, title); 628 } else { // jQuery UI 1.12+ introduced <div> tag wrappers 629 $.moogle.contextmenu.replaceFirstTextNodeChild($("div", item), title); 630 } 631 } 632}); 633 634})); 635