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