trilium/public/libraries/jquery.ui-contextmenu.js

632 lines
20 KiB
JavaScript

/*******************************************************************************
* jquery.ui-contextmenu.js plugin.
*
* jQuery plugin that provides a context menu (based on the jQueryUI menu widget).
*
* @see https://github.com/mar10/jquery-ui-contextmenu
*
* Copyright (c) 2013-2017, Martin Wendt (http://wwWendt.de). Licensed MIT.
*/
(function( factory ) {
"use strict";
if ( typeof define === "function" && define.amd ) {
// AMD. Register as an anonymous module.
define([ "jquery", "jquery-ui/ui/widgets/menu" ], factory );
} else {
// Browser globals
factory( jQuery );
}
}(function( $ ) {
"use strict";
var supportSelectstart = "onselectstart" in document.createElement("div"),
match = $.ui.menu.version.match(/^(\d)\.(\d+)/),
uiVersion = {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10)
},
isLTE110 = ( uiVersion.major < 2 && uiVersion.minor <= 10 ),
isLTE111 = ( uiVersion.major < 2 && uiVersion.minor <= 11 );
$.widget("moogle.contextmenu", {
version: "@VERSION",
options: {
addClass: "ui-contextmenu", // Add this class to the outer <ul>
closeOnWindowBlur: true, // Close menu when window loses focus
autoFocus: false, // Set keyboard focus to first entry on open
autoTrigger: true, // open menu on browser's `contextmenu` event
delegate: null, // selector
hide: { effect: "fadeOut", duration: "fast" },
ignoreParentSelect: true, // Don't trigger 'select' for sub-menu parents
menu: null, // selector or jQuery pointing to <UL>, or a definition hash
position: null, // popup positon
preventContextMenuForPopup: false, // prevent opening the browser's system
// context menu on menu entries
preventSelect: false, // disable text selection of target
show: { effect: "slideDown", duration: "fast" },
taphold: false, // open menu on taphold events (requires external plugins)
uiMenuOptions: {}, // Additional options, used when UI Menu is created
// Events:
beforeOpen: $.noop, // menu about to open; return `false` to prevent opening
blur: $.noop, // menu option lost focus
close: $.noop, // menu was closed
create: $.noop, // menu was initialized
createMenu: $.noop, // menu was initialized (original UI Menu)
focus: $.noop, // menu option got focus
open: $.noop, // menu was opened
select: $.noop // menu option was selected; return `false` to prevent closing
},
/** Constructor */
_create: function() {
var cssText, eventNames, targetId,
opts = this.options;
this.$headStyle = null;
this.$menu = null;
this.menuIsTemp = false;
this.currentTarget = null;
this.extraData = {};
this.previousFocus = null;
if (opts.delegate == null) {
$.error("ui-contextmenu: Missing required option `delegate`.");
}
if (opts.preventSelect) {
// Create a global style for all potential menu targets
// If the contextmenu was bound to `document`, we apply the
// selector relative to the <body> tag instead
targetId = ($(this.element).is(document) ? $("body")
: this.element).uniqueId().attr("id");
cssText = "#" + targetId + " " + opts.delegate + " { " +
"-webkit-user-select: none; " +
"-khtml-user-select: none; " +
"-moz-user-select: none; " +
"-ms-user-select: none; " +
"user-select: none; " +
"}";
this.$headStyle = $("<style class='moogle-contextmenu-style' />")
.prop("type", "text/css")
.appendTo("head");
try {
this.$headStyle.html(cssText);
} catch ( e ) {
// issue #47: fix for IE 6-8
this.$headStyle[0].styleSheet.cssText = cssText;
}
// TODO: the selectstart is not supported by FF?
if (supportSelectstart) {
this.element.on("selectstart" + this.eventNamespace, opts.delegate,
function(event) {
event.preventDefault();
});
}
}
this._createUiMenu(opts.menu);
eventNames = "contextmenu" + this.eventNamespace;
if (opts.taphold) {
eventNames += " taphold" + this.eventNamespace;
}
this.element.on(eventNames, opts.delegate, $.proxy(this._openMenu, this));
},
/** Destructor, called on $().contextmenu("destroy"). */
_destroy: function() {
this.element.off(this.eventNamespace);
this._createUiMenu(null);
if (this.$headStyle) {
this.$headStyle.remove();
this.$headStyle = null;
}
},
/** (Re)Create jQuery UI Menu. */
_createUiMenu: function(menuDef) {
var ct, ed,
opts = this.options;
// Remove temporary <ul> if any
if (this.isOpen()) {
// #58: 'replaceMenu' in beforeOpen causing select: to lose ui.target
ct = this.currentTarget;
ed = this.extraData;
// close without animation, to force async mode
this._closeMenu(true);
this.currentTarget = ct;
this.extraData = ed;
}
if (this.menuIsTemp) {
this.$menu.remove(); // this will also destroy ui.menu
} else if (this.$menu) {
this.$menu
.menu("destroy")
.removeClass(this.options.addClass)
.hide();
}
this.$menu = null;
this.menuIsTemp = false;
// If a menu definition array was passed, create a hidden <ul>
// and generate the structure now
if ( !menuDef ) {
return;
} else if ($.isArray(menuDef)) {
this.$menu = $.moogle.contextmenu.createMenuMarkup(menuDef);
this.menuIsTemp = true;
}else if ( typeof menuDef === "string" ) {
this.$menu = $(menuDef);
} else {
this.$menu = menuDef;
}
// Create - but hide - the jQuery UI Menu widget
this.$menu
.hide()
.addClass(opts.addClass)
// Create a menu instance that delegates events to our widget
.menu($.extend(true, {}, opts.uiMenuOptions, {
items: "> :not(.ui-widget-header)",
blur: $.proxy(opts.blur, this),
create: $.proxy(opts.createMenu, this),
focus: $.proxy(opts.focus, this),
select: $.proxy(function(event, ui) {
// User selected a menu entry
var retval,
isParent = $.moogle.contextmenu.isMenu(ui.item),
actionHandler = ui.item.data("actionHandler");
ui.cmd = ui.item.attr("data-command");
ui.target = $(this.currentTarget);
ui.extraData = this.extraData;
// ignore clicks, if they only open a sub-menu
if ( !isParent || !opts.ignoreParentSelect) {
retval = this._trigger.call(this, "select", event, ui);
if ( actionHandler ) {
retval = actionHandler.call(this, event, ui);
}
if ( retval !== false ) {
this._closeMenu.call(this);
}
event.preventDefault();
}
}, this)
}));
},
/** Open popup (called on 'contextmenu' event). */
_openMenu: function(event, recursive) {
var res, promise, ui,
opts = this.options,
posOption = opts.position,
self = this,
manualTrigger = !!event.isTrigger;
if ( !opts.autoTrigger && !manualTrigger ) {
// ignore browser's `contextmenu` events
return;
}
// Prevent browser from opening the system context menu
event.preventDefault();
this.currentTarget = event.target;
this.extraData = event._extraData || {};
ui = { menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData,
originalEvent: event, result: null };
if ( !recursive ) {
res = this._trigger("beforeOpen", event, ui);
promise = (ui.result && $.isFunction(ui.result.promise)) ? ui.result : null;
ui.result = null;
if ( res === false ) {
this.currentTarget = null;
return false;
} else if ( promise ) {
// Handler returned a Deferred or Promise. Delay menu open until
// the promise is resolved
promise.done(function() {
self._openMenu(event, true);
});
this.currentTarget = null;
return false;
}
ui.menu = this.$menu; // Might have changed in beforeOpen
}
// Register global event handlers that close the dropdown-menu
$(document).on("keydown" + this.eventNamespace, function(event) {
if ( event.which === $.ui.keyCode.ESCAPE ) {
self._closeMenu();
}
}).on("mousedown" + this.eventNamespace + " touchstart" + this.eventNamespace,
function(event) {
// Close menu when clicked outside menu
if ( !$(event.target).closest(".ui-menu-item").length ) {
self._closeMenu();
}
});
$(window).on("blur" + this.eventNamespace, function(event) {
if ( opts.closeOnWindowBlur ) {
self._closeMenu();
}
});
// required for custom positioning (issue #18 and #13).
if ($.isFunction(posOption)) {
posOption = posOption(event, ui);
}
posOption = $.extend({
my: "left top",
at: "left bottom",
// if called by 'open' method, event does not have pageX/Y
of: (event.pageX === undefined) ? event.target : event,
collision: "fit"
}, posOption);
// Update entry statuses from callbacks
this._updateEntries(this.$menu);
// Finally display the popup
this.$menu
.show() // required to fix positioning error
.css({
position: "absolute",
left: 0,
top: 0
}).position(posOption)
.hide(); // hide again, so we can apply nice effects
if ( opts.preventContextMenuForPopup ) {
this.$menu.on("contextmenu" + this.eventNamespace, function(event) {
event.preventDefault();
});
}
this._show(this.$menu, opts.show, function() {
var $first;
// Set focus to first active menu entry
if ( opts.autoFocus ) {
self.previousFocus = $(event.target);
// self.$menu.focus();
$first = self.$menu
.children("li.ui-menu-item")
.not(".ui-state-disabled")
.first();
self.$menu.menu("focus", null, $first).focus();
}
self._trigger.call(self, "open", event, ui);
});
},
/** Close popup. */
_closeMenu: function(immediately) {
var self = this,
hideOpts = immediately ? false : this.options.hide,
ui = { menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData };
// Note: we don't want to unbind the 'contextmenu' event
$(document)
.off("mousedown" + this.eventNamespace)
.off("touchstart" + this.eventNamespace)
.off("keydown" + this.eventNamespace);
$(window)
.off("blur" + this.eventNamespace);
self.currentTarget = null; // issue #44 after hide animation is too late
self.extraData = {};
if ( this.$menu ) { // #88: widget might have been destroyed already
this.$menu
.off("contextmenu" + this.eventNamespace);
this._hide(this.$menu, hideOpts, function() {
if ( self.previousFocus ) {
self.previousFocus.focus();
self.previousFocus = null;
}
self._trigger("close", null, ui);
});
} else {
self._trigger("close", null, ui);
}
},
/** Handle $().contextmenu("option", key, value) calls. */
_setOption: function(key, value) {
switch (key) {
case "menu":
this.replaceMenu(value);
break;
}
$.Widget.prototype._setOption.apply(this, arguments);
},
/** Return ui-menu entry (<LI> tag). */
_getMenuEntry: function(cmd) {
return this.$menu.find("li[data-command=" + cmd + "]");
},
/** Close context menu. */
close: function() {
if (this.isOpen()) {
this._closeMenu();
}
},
/* Apply status callbacks when menu is opened. */
_updateEntries: function() {
var self = this,
ui = {
menu: this.$menu, target: $(this.currentTarget), extraData: this.extraData };
$.each(this.$menu.find(".ui-menu-item"), function(i, o) {
var $entry = $(o),
fn = $entry.data("disabledHandler"),
res = fn ? fn({ type: "disabled" }, ui) : null;
ui.item = $entry;
ui.cmd = $entry.attr("data-command");
// Evaluate `disabled()` callback
if ( res != null ) {
self.enableEntry(ui.cmd, !res);
self.showEntry(ui.cmd, res !== "hide");
}
// Evaluate `title()` callback
fn = $entry.data("titleHandler"),
res = fn ? fn({ type: "title" }, ui) : null;
if ( res != null ) {
self.setTitle(ui.cmd, "" + res);
}
// Evaluate `tooltip()` callback
fn = $entry.data("tooltipHandler"),
res = fn ? fn({ type: "tooltip" }, ui) : null;
if ( res != null ) {
$entry.attr("title", "" + res);
}
});
},
/** Enable or disable the menu command. */
enableEntry: function(cmd, flag) {
this._getMenuEntry(cmd).toggleClass("ui-state-disabled", (flag === false));
},
/** Return ui-menu entry (LI tag) as jQuery object. */
getEntry: function(cmd) {
return this._getMenuEntry(cmd);
},
/** Return ui-menu entry wrapper as jQuery object.
UI 1.10: this is the <a> tag inside the LI
UI 1.11: this is the LI istself
UI 1.12: this is the <div> tag inside the LI
*/
getEntryWrapper: function(cmd) {
return this._getMenuEntry(cmd).find(">[role=menuitem]").addBack("[role=menuitem]");
},
/** Return Menu element (UL). */
getMenu: function() {
return this.$menu;
},
/** Return true if menu is open. */
isOpen: function() {
// return this.$menu && this.$menu.is(":visible");
return !!this.$menu && !!this.currentTarget;
},
/** Open context menu on a specific target (must match options.delegate)
* Optional `extraData` is passed to event handlers as `ui.extraData`.
*/
open: function(targetOrEvent, extraData) {
// Fake a 'contextmenu' event
extraData = extraData || {};
var isEvent = (targetOrEvent && targetOrEvent.type && targetOrEvent.target),
event = isEvent ? targetOrEvent : {},
target = isEvent ? targetOrEvent.target : targetOrEvent,
e = jQuery.Event("contextmenu", {
target: $(target).get(0),
pageX: event.pageX,
pageY: event.pageY,
originalEvent: isEvent ? targetOrEvent : undefined,
_extraData: extraData
});
return this.element.trigger(e);
},
/** Replace the menu altogether. */
replaceMenu: function(data) {
this._createUiMenu(data);
},
/** Redefine a whole menu entry. */
setEntry: function(cmd, entry) {
var $ul,
$entryLi = this._getMenuEntry(cmd);
if (typeof entry === "string") {
window.console && window.console.warn(
"setEntry(cmd, t) with a plain string title is deprecated since v1.18." +
"Use setTitle(cmd, '" + entry + "') instead.");
return this.setTitle(cmd, entry);
}
$entryLi.empty();
entry.cmd = entry.cmd || cmd;
$.moogle.contextmenu.createEntryMarkup(entry, $entryLi);
if ($.isArray(entry.children)) {
$ul = $("<ul/>").appendTo($entryLi);
$.moogle.contextmenu.createMenuMarkup(entry.children, $ul);
}
// #110: jQuery UI 1.12: refresh only works when this class is not set:
$entryLi.removeClass("ui-menu-item");
this.getMenu().menu("refresh");
},
/** Set icon (pass null to remove). */
setIcon: function(cmd, icon) {
return this.updateEntry(cmd, { uiIcon: icon });
},
/** Set title. */
setTitle: function(cmd, title) {
return this.updateEntry(cmd, { title: title });
},
// /** Set tooltip (pass null to remove). */
// setTooltip: function(cmd, tooltip) {
// this._getMenuEntry(cmd).attr("title", tooltip);
// },
/** Show or hide the menu command. */
showEntry: function(cmd, flag) {
this._getMenuEntry(cmd).toggle(flag !== false);
},
/** Redefine selective attributes of a menu entry. */
updateEntry: function(cmd, entry) {
var $icon, $wrapper,
$entryLi = this._getMenuEntry(cmd);
if ( entry.title !== undefined ) {
$.moogle.contextmenu.updateTitle($entryLi, "" + entry.title);
}
if ( entry.tooltip !== undefined ) {
if ( entry.tooltip === null ) {
$entryLi.removeAttr("title");
} else {
$entryLi.attr("title", entry.tooltip);
}
}
if ( entry.uiIcon !== undefined ) {
$wrapper = this.getEntryWrapper(cmd),
$icon = $wrapper.find("span.ui-icon").not(".ui-menu-icon");
$icon.remove();
if ( entry.uiIcon ) {
$wrapper.append($("<span class='ui-icon' />").addClass(entry.uiIcon));
}
}
if ( entry.hide !== undefined ) {
$entryLi.toggle(!entry.hide);
} else if ( entry.show !== undefined ) {
// Note: `show` is an undocumented variant. `hide: false` is preferred
$entryLi.toggle(!!entry.show);
}
// if ( entry.isHeader !== undefined ) {
// $entryLi.toggleClass("ui-widget-header", !!entry.isHeader);
// }
if ( entry.data !== undefined ) {
$entryLi.data(entry.data);
}
// Set/clear class names, but handle ui-state-disabled separately
if ( entry.disabled === undefined ) {
entry.disabled = $entryLi.hasClass("ui-state-disabled");
}
if ( entry.setClass ) {
if ( $entryLi.hasClass("ui-menu-item") ) {
entry.setClass += " ui-menu-item";
}
$entryLi.removeClass();
$entryLi.addClass(entry.setClass);
} else if ( entry.addClass ) {
$entryLi.addClass(entry.addClass);
}
$entryLi.toggleClass("ui-state-disabled", !!entry.disabled);
// // #110: jQuery UI 1.12: refresh only works when this class is not set:
// $entryLi.removeClass("ui-menu-item");
// this.getMenu().menu("refresh");
}
});
/*
* Global functions
*/
$.extend($.moogle.contextmenu, {
/** Convert a menu description into a into a <li> content. */
createEntryMarkup: function(entry, $parentLi) {
var $wrapper = null;
$parentLi.attr("data-command", entry.cmd);
if ( !/[^\-\u2014\u2013\s]/.test( entry.title ) ) {
// hyphen, em dash, en dash: separator as defined by UI Menu 1.10
$parentLi.text(entry.title);
} else {
if ( isLTE110 ) {
// jQuery UI Menu 1.10 or before required an `<a>` tag
$wrapper = $("<a/>", {
html: "" + entry.title,
href: "#"
}).appendTo($parentLi);
} else if ( isLTE111 ) {
// jQuery UI Menu 1.11 preferes to avoid `<a>` tags or <div> wrapper
$parentLi.html("" + entry.title);
$wrapper = $parentLi;
} else {
// jQuery UI Menu 1.12 introduced `<div>` wrappers
$wrapper = $("<div/>", {
html: "" + entry.title
}).appendTo($parentLi);
}
if ( entry.uiIcon ) {
$wrapper.append($("<span class='ui-icon' />").addClass(entry.uiIcon));
}
// Store option callbacks in entry's data
$.each( [ "action", "disabled", "title", "tooltip" ], function(i, attr) {
if ( $.isFunction(entry[attr]) ) {
$parentLi.data(attr + "Handler", entry[attr]);
}
});
if ( entry.disabled === true ) {
$parentLi.addClass("ui-state-disabled");
}
if ( entry.isHeader ) {
$parentLi.addClass("ui-widget-header");
}
if ( entry.addClass ) {
$parentLi.addClass(entry.addClass);
}
if ( $.isPlainObject(entry.data) ) {
$parentLi.data(entry.data);
}
if ( typeof entry.tooltip === "string" ) {
$parentLi.attr("title", entry.tooltip);
}
}
},
/** Convert a nested array of command objects into a <ul> structure. */
createMenuMarkup: function(options, $parentUl) {
var i, menu, $ul, $li;
if ( $parentUl == null ) {
$parentUl = $("<ul class='ui-helper-hidden' />").appendTo("body");
}
for (i = 0; i < options.length; i++) {
menu = options[i];
$li = $("<li/>").appendTo($parentUl);
$.moogle.contextmenu.createEntryMarkup(menu, $li);
if ( $.isArray(menu.children) ) {
$ul = $("<ul/>").appendTo($li);
$.moogle.contextmenu.createMenuMarkup(menu.children, $ul);
}
}
return $parentUl;
},
/** Returns true if the menu item has child menu items */
isMenu: function(item) {
if ( isLTE110 ) {
return item.has(">a[aria-haspopup='true']").length > 0;
} else if ( isLTE111 ) { // jQuery UI 1.11 used no tag wrappers
return item.is("[aria-haspopup='true']");
} else {
return item.has(">div[aria-haspopup='true']").length > 0;
}
},
/** Replace the title of elem', but retain icons andchild entries. */
replaceFirstTextNodeChild: function(elem, html) {
var $icons = elem.find(">span.ui-icon,>ul.ui-menu").detach();
elem
.empty()
.html(html)
.append($icons);
},
/** Updates the menu item's title */
updateTitle: function(item, title) {
if ( isLTE110 ) { // jQuery UI 1.10 and before used <a> tags
$.moogle.contextmenu.replaceFirstTextNodeChild($("a", item), title);
} else if ( isLTE111 ) { // jQuery UI 1.11 used no tag wrappers
$.moogle.contextmenu.replaceFirstTextNodeChild(item, title);
} else { // jQuery UI 1.12+ introduced <div> tag wrappers
$.moogle.contextmenu.replaceFirstTextNodeChild($("div", item), title);
}
}
});
}));