sintonia/airtime_mvc/public/js/contextmenu/jquery.contextMenu.js

1459 lines
56 KiB
JavaScript

/*
* jQuery contextMenu - Plugin for simple contextMenu handling
*
* Version: 1.5.8
*
* Authors: Rodney Rehm, Addy Osmani (patches for FF)
* Web: http://medialize.github.com/jQuery-contextMenu/
*
* Licensed under
* MIT License http://www.opensource.org/licenses/mit-license
* GPL v3 http://opensource.org/licenses/GPL-3.0
*
*/
(function($, undefined){
// TODO: -
// ARIA stuff: menuitem, menuitemcheckbox und menuitemradio
// create <menu> structure if $.support[htmlCommand || htmlMenuitem] and !opt.disableNative
// determine html5 compatibility
$.support.htmlMenuitem = ('HTMLMenuItemElement' in window);
$.support.htmlCommand = ('HTMLCommandElement' in window);
var // currently active contextMenu trigger
$currentTrigger = null,
// is contextMenu initialized with at least one menu?
initialized = false,
// flag stating to ignore the contextmenu event
ignoreThisClick = false,
// window handle
$win = $(window),
// number of registered menus
counter = 0,
// mapping selector to namespace
namespaces = {},
// mapping namespace to options
menus = {},
// custom command type handlers
types = {},
// default values
defaults = {
// selector of contextMenu trigger
selector: null,
// where to append the menu to
appendTo: null,
// method to trigger context menu ["right", "left", "hover"]
trigger: "right",
// hide menu when mouse leaves trigger / menu elements
autoHide: false,
// ignore right click triggers for left, hover or custom activation
ignoreRightClick: false,
// ms to wait before showing a hover-triggered context menu
delay: 200,
// determine position to show menu at
determinePosition: function($menu) {
// position to the lower middle of the trigger element
if ($.ui && $.ui.position) {
// .position() is provided as a jQuery UI utility
// (...and it won't work on hidden elements)
$menu.css('display', 'block').position({
my: "center top",
at: "center bottom",
of: this,
offset: "0 5",
collision: "fit"
}).css('display', 'none');
} else {
// determine contextMenu position
var offset = this.offset();
offset.top += this.outerHeight();
offset.left += this.outerWidth() / 2 - $menu.outerWidth() / 2;
$menu.css(offset);
}
},
// position menu
position: function(opt, x, y) {
var $this = this,
offset;
// determine contextMenu position
if (!x && !y) {
opt.determinePosition.call(this, opt.$menu);
return;
} else if (x === "maintain" && y === "maintain") {
// x and y must not be changed (after re-show on command click)
offset = opt.$menu.position();
} else {
// x and y are given (by mouse event)
var triggerIsFixed = opt.$trigger.parents().andSelf()
.filter(function() {
return $(this).css('position') == "fixed";
}).length;
if (triggerIsFixed) {
y -= $win.scrollTop();
x -= $win.scrollLeft();
}
offset = {top: y, left: x};
}
// correct offset if viewport demands it
var bottom = $win.scrollTop() + $win.height(),
right = $win.scrollLeft() + $win.width(),
height = opt.$menu.height(),
width = opt.$menu.width();
if (offset.top + height > bottom) {
offset.top -= height;
}
if (offset.left + width > right) {
offset.left -= width;
}
opt.$menu.css(offset);
},
// position the sub-menu
positionSubmenu: function($menu) {
if ($.ui && $.ui.position) {
// .position() is provided as a jQuery UI utility
// (...and it won't work on hidden elements)
$menu.css('display', 'block').position({
my: "left top",
at: "right top",
of: this,
collision: "fit"
}).css('display', '');
} else {
// determine contextMenu position
var offset = this.offset();
offset.top += 0;
offset.left += this.outerWidth();
$menu.css(offset);
}
},
// offset to add to zIndex
zIndex: 1,
// show hide animation settings
animation: {
duration: 50,
show: 'slideDown',
hide: 'slideUp'
},
// events
events: {
show: $.noop,
hide: $.noop
},
// default callback
callback: null,
// list of contextMenu items
items: {}
},
// mouse position for hover activation
hoveract = {
timer: null,
pageX: null,
pageY: null
},
// determine zIndex
zindex = function($t) {
var zin = 0,
$tt = $t;
while (true) {
zin = Math.max(zin, parseInt($tt.css('z-index'), 10) || 0);
$tt = $tt.parent();
if (!$tt || !$tt.length || $tt.prop('nodeName').toLowerCase() == 'body') {
break;
}
}
return zin;
},
// event handlers
handle = {
// abort anything
abortevent: function(e){
e.preventDefault();
e.stopImmediatePropagation();
},
// contextmenu show dispatcher
contextmenu: function(e) {
var $this = $(this);
// disable actual context-menu
e.preventDefault();
e.stopImmediatePropagation();
// ignore right click trigger
if (ignoreThisClick) {
ignoreThisClick = false;
return;
}
if (!$this.hasClass('context-menu-disabled')) {
// theoretically need to fire a show event at <menu>
// http://www.whatwg.org/specs/web-apps/current-work/multipage/interactive-elements.html#context-menus
// var evt = jQuery.Event("show", { data: data, pageX: e.pageX, pageY: e.pageY, relatedTarget: this });
// e.data.$menu.trigger(evt);
$currentTrigger = $this;
if (e.data.build) {
// dynamically build menu on invocation
$.extend(true, e.data, defaults, e.data.build($currentTrigger, e) || {});
op.create(e.data);
}
// show menu
op.show.call($this, e.data, e.pageX, e.pageY);
}
},
// contextMenu left-click trigger
click: function(e) {
e.preventDefault();
e.stopImmediatePropagation();
$(this).trigger(jQuery.Event("contextmenu", { data: e.data, pageX: e.pageX, pageY: e.pageY }));
},
// contextMenu right-click trigger
mousedown: function(e) {
// register mouse down
var $this = $(this);
// hide any previous menus
if ($currentTrigger && $currentTrigger.length && !$currentTrigger.is($this)) {
$currentTrigger.data('contextMenu').$menu.trigger('contextmenu:hide');
}
// activate on right click
if (e.button == 2) {
$currentTrigger = $this.data('contextMenuActive', true);
}
},
// contextMenu right-click trigger
mouseup: function(e) {
// show menu
var $this = $(this);
if ($this.data('contextMenuActive') && $currentTrigger && $currentTrigger.length && $currentTrigger.is($this) && !$this.hasClass('context-menu-disabled')) {
e.preventDefault();
e.stopImmediatePropagation();
$currentTrigger = $this;
$this.trigger(jQuery.Event("contextmenu", { data: e.data, pageX: e.pageX, pageY: e.pageY }));
}
$this.removeData('contextMenuActive');
},
// contextMenu hover trigger
mouseenter: function(e) {
var $this = $(this),
$related = $(e.relatedTarget),
$document = $(document);
// abort if we're coming from a menu
if ($related.is('.context-menu-list') || $related.closest('.context-menu-list').length) {
return;
}
// abort if a menu is shown
if ($currentTrigger && $currentTrigger.length) {
return;
}
hoveract.pageX = e.pageX;
hoveract.pageY = e.pageY;
hoveract.data = e.data;
$document.on('mousemove.contextMenuShow', handle.mousemove);
hoveract.timer = setTimeout(function() {
hoveract.timer = null;
$document.off('mousemove.contextMenuShow');
$currentTrigger = $this;
$this.trigger(jQuery.Event("contextmenu", { data: hoveract.data, pageX: hoveract.pageX, pageY: hoveract.pageY }));
}, e.data.delay );
},
// contextMenu hover trigger
mousemove: function(e) {
hoveract.pageX = e.pageX;
hoveract.pageY = e.pageY;
},
// contextMenu hover trigger
mouseleave: function(e) {
// abort if we're leaving for a menu
var $related = $(e.relatedTarget);
if ($related.is('.context-menu-list') || $related.closest('.context-menu-list').length) {
return;
}
try {
clearTimeout(hoveract.timer);
} catch(e) {}
hoveract.timer = null;
},
// ignore right click trigger
ignoreRightClick: function(e) {
if (e.button == 2) {
ignoreThisClick = true;
}
},
// click on layer to hide contextMenu
layerClick: function(e) {
var $this = $(this),
root = $this.data('contextMenuRoot');
e.preventDefault();
e.stopImmediatePropagation();
$this.remove();
root.$menu.trigger('contextmenu:hide');
/* (Airtime) added this to allow user to exit out of menu.
* if ignoreThisClick remains false, every right click
* thereafter continues to show the menu
*/
if (handle.ignoreRightClick) {
if (e.button == 2) {
ignoreThisClick = true;
}
}
},
// key handled :hover
keyStop: function(e, opt) {
if (!opt.isInput) {
e.preventDefault();
}
e.stopPropagation();
},
key: function(e) {
var opt = $currentTrigger.data('contextMenu') || {},
$children = opt.$menu.children(),
$round;
switch (e.keyCode) {
case 9:
case 38: // up
handle.keyStop(e, opt);
// if keyCode is [38 (up)] or [9 (tab) with shift]
if (opt.isInput) {
if (e.keyCode == 9 && e.shiftKey) {
e.preventDefault();
opt.$selected && opt.$selected.find('input, textarea, select').blur();
opt.$menu.trigger('prevcommand');
return;
} else if (e.keyCode == 38 && opt.$selected.find('input, textarea, select').prop('type') == 'checkbox') {
// checkboxes don't capture this key
e.preventDefault();
return;
}
} else if (e.keyCode != 9 || e.shiftKey) {
opt.$menu.trigger('prevcommand');
return;
}
case 9: // tab
case 40: // down
handle.keyStop(e, opt);
if (opt.isInput) {
if (e.keyCode == 9) {
e.preventDefault();
opt.$selected && opt.$selected.find('input, textarea, select').blur();
opt.$menu.trigger('nextcommand');
return;
} else if (e.keyCode == 40 && opt.$selected.find('input, textarea, select').prop('type') == 'checkbox') {
// checkboxes don't capture this key
e.preventDefault();
return;
}
} else {
opt.$menu.trigger('nextcommand');
return;
}
break;
case 37: // left
handle.keyStop(e, opt);
if (opt.isInput || !opt.$selected || !opt.$selected.length) {
break;
}
if (!opt.$selected.parent().hasClass('context-menu-root')) {
var $parent = opt.$selected.parent().parent();
opt.$selected.trigger('contextmenu:blur');
opt.$selected = $parent;
return;
}
break;
case 39: // right
handle.keyStop(e, opt);
if (opt.isInput || !opt.$selected || !opt.$selected.length) {
break;
}
var itemdata = opt.$selected.data('contextMenu') || {};
if (itemdata.$menu && opt.$selected.hasClass('context-menu-submenu')) {
opt.$selected = null;
itemdata.$selected = null;
itemdata.$menu.trigger('nextcommand');
return;
}
break;
case 35: // end
case 36: // home
if (opt.$selected && opt.$selected.find('input, textarea, select').length) {
return;
} else {
(opt.$selected && opt.$selected.parent() || opt.$menu)
.children(':not(.disabled, .not-selectable)')[e.keyCode == 36 ? 'first' : 'last']()
.trigger('contextmenu:focus');
e.preventDefault();
return;
}
break;
case 13: // enter
handle.keyStop(e, opt);
if (opt.isInput) {
if (opt.$selected && !opt.$selected.is('textarea, select')) {
e.preventDefault();
return;
}
break;
}
opt.$selected && opt.$selected.trigger('mouseup');
return;
case 32: // space
case 33: // page up
case 34: // page down
// prevent browser from scrolling down while menu is visible
handle.keyStop(e, opt);
return;
case 27: // esc
handle.keyStop(e, opt);
opt.$menu.trigger('contextmenu:hide');
return;
default: // 0-9, a-z
var k = (String.fromCharCode(e.keyCode)).toUpperCase();
if (opt.accesskeys[k]) {
// according to the specs accesskeys must be invoked immediately
opt.accesskeys[k].$node.trigger(opt.accesskeys[k].$menu
? 'contextmenu:focus'
: 'mouseup'
);
return;
}
break;
}
// pass event to selected item,
// stop propagation to avoid endless recursion
e.stopPropagation();
opt.$selected && opt.$selected.trigger(e);
},
// select previous possible command in menu
prevItem: function(e) {
e.stopPropagation();
var opt = $(this).data('contextMenu') || {};
// obtain currently selected menu
if (opt.$selected) {
var $s = opt.$selected;
opt = opt.$selected.parent().data('contextMenu') || {};
opt.$selected = $s;
}
var $children = opt.$menu.children(),
$prev = !opt.$selected || !opt.$selected.prev().length ? $children.last() : opt.$selected.prev(),
$round = $prev;
// skip disabled
while ($prev.hasClass('disabled') || $prev.hasClass('not-selectable')) {
if ($prev.prev().length) {
$prev = $prev.prev();
} else {
$prev = $children.last();
}
if ($prev.is($round)) {
// break endless loop
return;
}
}
// leave current
if (opt.$selected) {
handle.itemMouseleave.call(opt.$selected.get(0), e);
}
// activate next
handle.itemMouseenter.call($prev.get(0), e);
// focus input
var $input = $prev.find('input, textarea, select');
if ($input.length) {
$input.focus();
}
},
// select next possible command in menu
nextItem: function(e) {
e.stopPropagation();
var opt = $(this).data('contextMenu') || {};
// obtain currently selected menu
if (opt.$selected) {
var $s = opt.$selected;
opt = opt.$selected.parent().data('contextMenu') || {};
opt.$selected = $s;
}
var $children = opt.$menu.children(),
$next = !opt.$selected || !opt.$selected.next().length ? $children.first() : opt.$selected.next(),
$round = $next;
// skip disabled
while ($next.hasClass('disabled') || $next.hasClass('not-selectable')) {
if ($next.next().length) {
$next = $next.next();
} else {
$next = $children.first();
}
if ($next.is($round)) {
// break endless loop
return;
}
}
// leave current
if (opt.$selected) {
handle.itemMouseleave.call(opt.$selected.get(0), e);
}
// activate next
handle.itemMouseenter.call($next.get(0), e);
// focus input
var $input = $next.find('input, textarea, select');
if ($input.length) {
$input.focus();
}
},
// flag that we're inside an input so the key handler can act accordingly
focusInput: function(e) {
var $this = $(this).closest('.context-menu-item'),
data = $this.data(),
opt = data.contextMenu,
root = data.contextMenuRoot;
root.$selected = opt.$selected = $this;
root.isInput = opt.isInput = true;
},
// flag that we're inside an input so the key handler can act accordingly
blurInput: function(e) {
var $this = $(this).closest('.context-menu-item'),
data = $this.data(),
opt = data.contextMenu,
root = data.contextMenuRoot;
root.isInput = opt.isInput = false;
},
// :hover on menu
menuMouseenter: function(e) {
var root = $(this).data().contextMenuRoot;
root.hovering = true;
},
// :hover on menu
menuMouseleave: function(e) {
var root = $(this).data().contextMenuRoot;
if (root.$layer && root.$layer.is(e.relatedTarget)) {
root.hovering = false;
}
},
// :hover done manually so key handling is possible
itemMouseenter: function(e) {
var $this = $(this),
data = $this.data(),
opt = data.contextMenu,
root = data.contextMenuRoot;
root.hovering = true;
// abort if we're re-entering
if (e && root.$layer && root.$layer.is(e.relatedTarget)) {
e.preventDefault();
e.stopImmediatePropagation();
}
// make sure only one item is selected
(opt.$menu ? opt : root).$menu
.children('.hover').trigger('contextmenu:blur');
if ($this.hasClass('disabled') || $this.hasClass('not-selectable')) {
opt.$selected = null;
return;
}
$this.trigger('contextmenu:focus');
},
// :hover done manually so key handling is possible
itemMouseleave: function(e) {
var $this = $(this),
data = $this.data(),
opt = data.contextMenu,
root = data.contextMenuRoot;
if (root !== opt && root.$layer && root.$layer.is(e.relatedTarget)) {
root.$selected && root.$selected.trigger('contextmenu:blur');
e.preventDefault();
e.stopImmediatePropagation();
root.$selected = opt.$selected = opt.$node;
return;
}
$this.trigger('contextmenu:blur');
},
// contextMenu item click
itemClick: function(e) {
var $this = $(this),
data = $this.data(),
opt = data.contextMenu,
root = data.contextMenuRoot,
key = data.contextMenuKey,
callback;
// abort if the key is unknown or disabled
if (!opt.items[key] || $this.hasClass('disabled')) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
if ($.isFunction(root.callbacks[key])) {
// item-specific callback
callback = root.callbacks[key];
} else if ($.isFunction(root.callback)) {
// default callback
callback = root.callback;
} else {
// no callback, no action
return;
}
// hide menu if callback doesn't stop that
if (callback.call(root.$trigger, key, root) !== false) {
root.$menu.trigger('contextmenu:hide');
} else {
op.update.call(root.$trigger, root);
}
},
// ignore click events on input elements
inputClick: function(e) {
e.stopImmediatePropagation();
},
// hide <menu>
hideMenu: function(e) {
var root = $(this).data('contextMenuRoot');
op.hide.call(root.$trigger, root);
},
// focus <command>
focusItem: function(e) {
e.stopPropagation();
var $this = $(this),
data = $this.data(),
opt = data.contextMenu,
root = data.contextMenuRoot;
$this.addClass('hover')
.siblings('.hover').trigger('contextmenu:blur');
// remember selected
opt.$selected = root.$selected = $this;
// position sub-menu - do after show so dumb $.ui.position can keep up
if (opt.$node) {
root.positionSubmenu.call(opt.$node, opt.$menu);
}
},
// blur <command>
blurItem: function(e) {
e.stopPropagation();
var $this = $(this),
data = $this.data(),
opt = data.contextMenu,
root = data.contextMenuRoot;
$this.removeClass('hover');
opt.$selected = null;
}
},
// operations
op = {
show: function(opt, x, y) {
var $this = $(this),
offset,
css = {};
// hide any open menus
$('#context-menu-layer').trigger('mousedown');
// show event
if (opt.events.show.call($this, opt) === false) {
$currentTrigger = null;
return;
}
// backreference for callbacks
opt.$trigger = $this;
// create or update context menu
op.update.call($this, opt);
// position menu
opt.position.call($this, opt, x, y);
// make sure we're in front
if (opt.zIndex) {
css.zIndex = zindex($this) + opt.zIndex;
}
// add layer
op.layer.call(opt.$menu, opt, css.zIndex);
// adjust sub-menu zIndexes
opt.$menu.find('ul').css('zIndex', css.zIndex + 1);
// position and show context menu
opt.$menu.css( css )[opt.animation.show](opt.animation.duration);
// make options available
$this.data('contextMenu', opt);
// register key handler
$(document).off('keydown.contextMenu').on('keydown.contextMenu', handle.key);
// register autoHide handler
if (opt.autoHide) {
// trigger element coordinates
var pos = $this.position();
pos.right = pos.left + $this.outerWidth();
pos.bottom = pos.top + this.outerHeight();
// mouse position handler
$(document).on('mousemove.contextMenuAutoHide', function(e) {
if (opt.$layer && !opt.hovering && (!(e.pageX >= pos.left && e.pageX <= pos.right) || !(e.pageY >= pos.top && e.pageY <= pos.bottom))) {
// if mouse in menu...
opt.$layer.trigger('mousedown');
}
});
}
},
hide: function(opt) {
var $this = $(this);
if (!opt) {
opt = $this.data('contextMenu') || {};
}
// hide event
if (opt.events && opt.events.hide.call($this, opt) === false) {
return;
}
if (opt.$layer) {
try {
opt.$layer.remove();
delete opt.$layer;
} catch(e) {
opt.$layer = null;
}
}
// remove handle
$currentTrigger = null;
// remove selected
opt.$menu.find('.hover').trigger('contextmenu:blur');
opt.$selected = null;
// unregister key and mouse handlers
//$(document).off('.contextMenuAutoHide keydown.contextMenu'); // http://bugs.jquery.com/ticket/10705
$(document).off('.contextMenuAutoHide').off('keydown.contextMenu');
// hide menu
opt.$menu && opt.$menu[opt.animation.hide](opt.animation.duration);
// tear down dynamically built menu
if (opt.build) {
opt.$menu.remove();
$.each(opt, function(key, value) {
switch (key) {
case 'ns':
case 'selector':
case 'build':
case 'trigger':
case 'ignoreRightClick':
return true;
default:
opt[key] = undefined;
try {
delete opt[key];
} catch (e) {}
return true;
}
});
}
},
create: function(opt, root) {
if (root === undefined) {
root = opt;
}
// create contextMenu
opt.$menu = $('<ul class="context-menu-list ' + (opt.className || "") + '"></ul>').data({
'contextMenu': opt,
'contextMenuRoot': root
});
$.each(['callbacks', 'commands', 'inputs'], function(i,k){
opt[k] = {};
if (!root[k]) {
root[k] = {};
}
});
root.accesskeys || (root.accesskeys = {});
// create contextMenu items
$.each(opt.items, function(key, item){
var $t = $('<li class="context-menu-item ' + (item.className || "") +'"></li>'),
$label = null,
$input = null;
item.$node = $t.data({
'contextMenu': opt,
'contextMenuRoot': root,
'contextMenuKey': key
});
// register accesskey
// NOTE: the accesskey attribute should be applicable to any element, but Safari5 and Chrome13 still can't do that
if (item.accesskey) {
var aks = splitAccesskey(item.accesskey);
for (var i=0, ak; ak = aks[i]; i++) {
if (!root.accesskeys[ak]) {
root.accesskeys[ak] = item;
item._name = item.name.replace(new RegExp('(' + ak + ')', 'i'), '<span class="context-menu-accesskey">$1</span>');
break;
}
}
}
if (typeof item == "string") {
$t.addClass('context-menu-separator not-selectable');
} else if (item.type && types[item.type]) {
// run custom type handler
types[item.type].call($t, item, opt, root);
// register commands
$.each([opt, root], function(i,k){
k.commands[key] = item;
if ($.isFunction(item.callback)) {
k.callbacks[key] = item.callback;
}
});
} else {
// add label for input
if (item.type == 'html') {
$t.addClass('context-menu-html not-selectable');
} else if (item.type) {
$label = $('<label></label>').appendTo($t);
$('<span></span>').html(item._name || item.name).appendTo($label);
$t.addClass('context-menu-input');
opt.hasTypes = true;
$.each([opt, root], function(i,k){
k.commands[key] = item;
k.inputs[key] = item;
});
} else if (item.items) {
item.type = 'sub';
}
switch (item.type) {
case 'text':
$input = $('<input type="text" value="1" name="context-menu-input-'+ key +'" value="">')
.val(item.value || "").appendTo($label);
break;
case 'textarea':
$input = $('<textarea name="context-menu-input-'+ key +'"></textarea>')
.val(item.value || "").appendTo($label);
if (item.height) {
$input.height(item.height);
}
break;
case 'checkbox':
$input = $('<input type="checkbox" value="1" name="context-menu-input-'+ key +'" value="">')
.val(item.value || "").prop("checked", !!item.selected).prependTo($label);
break;
case 'radio':
$input = $('<input type="radio" value="1" name="context-menu-input-'+ item.radio +'" value="">')
.val(item.value || "").prop("checked", !!item.selected).prependTo($label);
break;
case 'select':
$input = $('<select name="context-menu-input-'+ key +'">').appendTo($label);
if (item.options) {
$.each(item.options, function(value, text) {
$('<option></option>').val(value).text(text).appendTo($input);
});
$input.val(item.selected);
}
break;
case 'sub':
$('<span></span>').html(item._name || item.name).appendTo($t);
item.appendTo = item.$node;
op.create(item, root);
$t.data('contextMenu', item).addClass('context-menu-submenu');
item.callback = null;
break;
case 'html':
$(item.html).appendTo($t);
break;
default:
$.each([opt, root], function(i,k){
k.commands[key] = item;
if ($.isFunction(item.callback)) {
k.callbacks[key] = item.callback;
}
});
$('<span></span>').html(item._name || item.name || "").appendTo($t);
break;
}
// disable key listener in <input>
if (item.type && item.type != 'sub' && item.type != 'html') {
$input
.on('focus', handle.focusInput)
.on('blur', handle.blurInput);
if (item.events) {
$input.on(item.events);
}
}
// add icons
if (item.icon) {
$t.addClass("icon icon-" + item.icon);
}
}
// cache contained elements
item.$input = $input;
item.$label = $label;
// attach item to menu
$t.appendTo(opt.$menu);
// Disable text selection
if (!opt.hasTypes) {
if($.browser.msie) {
$t.on('selectstart.disableTextSelect', handle.abortevent);
} else if(!$.browser.mozilla) {
$t.on('mousedown.disableTextSelect', handle.abortevent);
}
}
});
// attach contextMenu to <body> (to bypass any possible overflow:hidden issues on parents of the trigger element)
if (!opt.$node) {
opt.$menu.css('display', 'none').addClass('context-menu-root');
}
opt.$menu.appendTo(opt.appendTo || document.body);
},
update: function(opt, root) {
var $this = this;
if (root === undefined) {
root = opt;
// determine widths of submenus, as CSS won't grow them automatically
// position:absolute > position:absolute; min-width:100; max-width:200; results in width: 100;
// kinda sucks hard...
opt.$menu.find('ul').andSelf().css({position: 'static', display: 'block'}).each(function(){
var $this = $(this);
$this.width($this.css('position', 'absolute').width())
.css('position', 'static');
}).css({position: '', display: ''});
}
// re-check disabled for each item
opt.$menu.children().each(function(){
var $item = $(this),
key = $item.data('contextMenuKey'),
item = opt.items[key],
disabled = ($.isFunction(item.disabled) && item.disabled.call($this, key, root)) || item.disabled === true;
// dis- / enable item
$item[disabled ? 'addClass' : 'removeClass']('disabled');
if (item.type) {
// dis- / enable input elements
$item.find('input, select, textarea').prop('disabled', disabled);
// update input states
switch (item.type) {
case 'text':
case 'textarea':
item.$input.val(item.value || "");
break;
case 'checkbox':
case 'radio':
item.$input.val(item.value || "").prop('checked', !!item.selected);
break;
case 'select':
item.$input.val(item.selected || "");
break;
}
}
if (item.$menu) {
// update sub-menu
op.update.call($this, item, root);
}
});
},
layer: function(opt, zIndex) {
// add transparent layer for click area
// filter and background for Internet Explorer, Issue #23
return opt.$layer = $('<div id="context-menu-layer" style="position:fixed; z-index:' + zIndex + '; top:0; left:0; opacity: 0; filter: alpha(opacity=0); background-color: #000;"></div>')
.css({height: $win.height(), width: $win.width(), display: 'block'})
.data('contextMenuRoot', opt)
.insertBefore(this)
.on('mousedown', handle.layerClick);
}
};
// split accesskey according to http://www.whatwg.org/specs/web-apps/current-work/multipage/editing.html#assigned-access-key
function splitAccesskey(val) {
var t = val.split(/\s+/),
keys = [];
for (var i=0, k; k = t[i]; i++) {
k = k[0].toUpperCase(); // first character only
// theoretically non-accessible characters should be ignored, but different systems, different keyboard layouts, ... screw it.
// a map to look up already used access keys would be nice
keys.push(k);
}
return keys;
}
// handle contextMenu triggers
$.fn.contextMenu = function(operation) {
if (operation === undefined) {
this.first().trigger('contextmenu');
} else if (operation.x && operation.y) {
this.first().trigger(jQuery.Event("contextmenu", {pageX: operation.x, pageY: operation.y}));
} else if (operation === "hide") {
var $menu = this.data('contextMenu').$menu;
$menu && $menu.trigger('contextmenu:hide');
} else if (operation) {
this.removeClass('context-menu-disabled');
} else if (!operation) {
this.addClass('context-menu-disabled');
}
return this;
};
// manage contextMenu instances
$.contextMenu = function(operation, options) {
if (typeof operation != 'string') {
options = operation;
operation = 'create';
}
if (typeof options == 'string') {
options = {selector: options};
} else if (options === undefined) {
options = {};
}
// merge with default options
var o = $.extend(true, {}, defaults, options || {}),
$body = $body = $(document);
switch (operation) {
case 'create':
// no selector no joy
if (!o.selector) {
throw new Error('No selector specified');
}
// make sure internal classes are not bound to
if (o.selector.match(/.context-menu-(list|item|input)($|\s)/)) {
throw new Error('Cannot bind to selector "' + o.selector + '" as it contains a reserved className');
}
if (!o.build && (!o.items || $.isEmptyObject(o.items))) {
throw new Error('No Items sepcified');
}
counter ++;
o.ns = '.contextMenu' + counter;
namespaces[o.selector] = o.ns;
menus[o.ns] = o;
if (!initialized) {
// make sure item click is registered first
$body
.on({
'contextmenu:hide.contextMenu': handle.hideMenu,
'prevcommand.contextMenu': handle.prevItem,
'nextcommand.contextMenu': handle.nextItem,
'contextmenu.contextMenu': handle.abortevent,
'mouseenter.contextMenu': handle.menuMouseenter,
'mouseleave.contextMenu': handle.menuMouseleave
}, '.context-menu-list')
.on('mouseup.contextMenu', '.context-menu-input', handle.inputClick)
.on({
'mouseup.contextMenu': handle.itemClick,
'contextmenu:focus.contextMenu': handle.focusItem,
'contextmenu:blur.contextMenu': handle.blurItem,
'contextmenu.contextMenu': handle.abortevent,
'mouseenter.contextMenu': handle.itemMouseenter,
'mouseleave.contextMenu': handle.itemMouseleave
}, '.context-menu-item');
initialized = true;
}
// engage native contextmenu event
$body
.on('contextmenu' + o.ns, o.selector, o, handle.contextmenu);
switch (o.trigger) {
case 'hover':
$body
.on('mouseenter' + o.ns, o.selector, o, handle.mouseenter)
.on('mouseleave' + o.ns, o.selector, o, handle.mouseleave);
break;
case 'left':
$body.on('click' + o.ns, o.selector, o, handle.click);
break;
/*
default:
// http://www.quirksmode.org/dom/events/contextmenu.html
$body
.on('mousedown' + o.ns, o.selector, o, handle.mousedown)
.on('mouseup' + o.ns, o.selector, o, handle.mouseup);
break;
*/
}
if (o.trigger != 'hover' && o.ignoreRightClick) {
$body.on('mousedown' + o.ns, o.selector, handle.ignoreRightClick);
}
// create menu
if (!o.build) {
op.create(o);
}
break;
case 'destroy':
if (!o.selector) {
$body.off('.contextMenu .contextMenuAutoHide');
$.each(namespaces, function(key, value) {
$body.off(value);
});
namespaces = {};
menus = {};
counter = 0;
initialized = false;
$('.context-menu-list').remove();
} else if (namespaces[o.selector]) {
try {
if (menus[namespaces[o.selector]].$menu) {
menus[namespaces[o.selector]].$menu.remove();
}
delete menus[namespaces[o.selector]];
} catch(e) {
menus[namespaces[o.selector]] = null;
}
$body.off(namespaces[o.selector]);
}
break;
case 'html5':
// if <command> or <menuitem> are not handled by the browser,
// or options was a bool true,
// initialize $.contextMenu for them
if ((!$.support.htmlCommand && !$.support.htmlMenuitem) || (typeof options == "boolean" && options)) {
$('menu[type="context"]').each(function() {
if (this.id) {
$.contextMenu({
selector: '[contextmenu=' + this.id +']',
items: $.contextMenu.fromMenu(this)
});
}
}).css('display', 'none');
}
break;
default:
throw new Error('Unknown operation "' + operation + '"');
}
return this;
};
// import values into <input> commands
$.contextMenu.setInputValues = function(opt, data) {
if (data === undefined) {
data = {};
}
$.each(opt.inputs, function(key, item) {
switch (item.type) {
case 'text':
case 'textarea':
item.value = data[key] || "";
break;
case 'checkbox':
item.selected = data[key] ? true : false;
break;
case 'radio':
item.selected = (data[item.radio] || "") == item.value ? true : false;
break;
case 'select':
item.selected = data[key] || "";
break;
}
});
};
// export values from <input> commands
$.contextMenu.getInputValues = function(opt, data) {
if (data === undefined) {
data = {};
}
$.each(opt.inputs, function(key, item) {
switch (item.type) {
case 'text':
case 'textarea':
case 'select':
data[key] = item.$input.val();
break;
case 'checkbox':
data[key] = item.$input.prop('checked');
break;
case 'radio':
if (item.$input.prop('checked')) {
data[item.radio] = item.value;
}
break;
}
});
return data;
};
// find <label for="xyz">
function inputLabel(node) {
return (node.id && $('label[for="'+ node.id +'"]').val()) || node.name;
}
// convert <menu> to items object
function menuChildren(items, $children, counter) {
if (!counter) {
counter = 0;
}
$children.each(function() {
var $node = $(this),
node = this,
nodeName = this.nodeName.toLowerCase(),
label,
item;
// extract <label><input>
if (nodeName == 'label' && $node.find('input, textarea, select').length) {
label = $node.text();
$node = $node.children().first();
node = $node.get(0);
nodeName = node.nodeName.toLowerCase();
}
/*
* <menu> accepts flow-content as children. that means <embed>, <canvas> and such are valid menu items.
* Not being the sadistic kind, $.contextMenu only accepts:
* <command>, <menuitem>, <hr>, <span>, <p> <input [text, radio, checkbox]>, <textarea>, <select> and of course <menu>.
* Everything else will be imported as an html node, which is not interfaced with contextMenu.
*/
// http://www.whatwg.org/specs/web-apps/current-work/multipage/commands.html#concept-command
switch (nodeName) {
// http://www.whatwg.org/specs/web-apps/current-work/multipage/interactive-elements.html#the-menu-element
case 'menu':
item = {name: $node.attr('label'), items: {}};
menuChildren(item.items, $node.children(), counter);
break;
// http://www.whatwg.org/specs/web-apps/current-work/multipage/commands.html#using-the-a-element-to-define-a-command
case 'a':
// http://www.whatwg.org/specs/web-apps/current-work/multipage/commands.html#using-the-button-element-to-define-a-command
case 'button':
item = {
name: $node.text(),
disabled: !!$node.attr('disabled'),
callback: (function(){ return function(){ $node.click(); }; })()
};
break;
// http://www.whatwg.org/specs/web-apps/current-work/multipage/commands.html#using-the-command-element-to-define-a-command
case 'menuitem':
case 'command':
switch ($node.attr('type')) {
case undefined:
case 'command':
case 'menuitem':
item = {
name: $node.attr('label'),
disabled: !!$node.attr('disabled'),
callback: (function(){ return function(){ $node.click(); }; })()
};
break;
case 'checkbox':
item = {
type: 'checkbox',
disabled: !!$node.attr('disabled'),
name: $node.attr('label'),
selected: !!$node.attr('checked')
};
break;
case 'radio':
item = {
type: 'radio',
disabled: !!$node.attr('disabled'),
name: $node.attr('label'),
radio: $node.attr('radiogroup'),
value: $node.attr('id'),
selected: !!$node.attr('checked')
};
break;
default:
item = undefined;
}
break;
case 'hr':
item = '-------';
break;
case 'input':
switch ($node.attr('type')) {
case 'text':
item = {
type: 'text',
name: label || inputLabel(node),
disabled: !!$node.attr('disabled'),
value: $node.val()
};
break;
case 'checkbox':
item = {
type: 'checkbox',
name: label || inputLabel(node),
disabled: !!$node.attr('disabled'),
selected: !!$node.attr('checked')
};
break;
case 'radio':
item = {
type: 'radio',
name: label || inputLabel(node),
disabled: !!$node.attr('disabled'),
radio: !!$node.attr('name'),
value: $node.val(),
selected: !!$node.attr('checked')
};
break;
default:
item = undefined;
break;
}
break;
case 'select':
item = {
type: 'select',
name: label || inputLabel(node),
disabled: !!$node.attr('disabled'),
selected: $node.val(),
options: {}
};
$node.children().each(function(){
item.options[this.value] = $(this).text();
});
break;
case 'textarea':
item = {
type: 'textarea',
name: label || inputLabel(node),
disabled: !!$node.attr('disabled'),
value: $node.val()
};
break;
case 'label':
break;
default:
item = {type: 'html', html: $node.clone(true)};
break;
}
if (item) {
counter++;
items['key' + counter] = item;
}
});
}
// convert html5 menu
$.contextMenu.fromMenu = function(element) {
var $this = $(element),
items = {};
menuChildren(items, $this.children());
return items;
};
// make defaults accessible
$.contextMenu.defaults = defaults;
$.contextMenu.types = types;
})(jQuery);