Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/_data/nav.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ module.exports = [
{ id: 'menu-title-fontawesome', text: 'Menu title with Font Awesome icons', url: '/demo/menu-title-fontawesome.html' },
{ id: 'menu-promise', text: 'Menu with promise', url: '/demo/menu-promise.html' },
{ id: 'on-dom-element', text: 'Context Menu on DOM Element', url: '/demo/on-dom-element.html' },
{ id: 'nested-triggers-autohide', text: 'Nested Triggers With Autohide', url: '/demo/nested-triggers-autohide.html' },
{ id: 'sub-menus', text: 'Submenus', url: '/demo/sub-menus.html' },
{ id: 'sub-menus-promise', text: 'Submenus (promise)', url: '/demo/sub-menus-promise.html' },
{ id: 'trigger-custom', text: 'Custom Activated Context Menu', url: '/demo/trigger-custom.html' },
Expand Down
1 change: 1 addition & 0 deletions documentation/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ title: jQuery contextMenu — Demo gallery
* [Swipe Trigger](demo/trigger-swipe.html)
* [Hover Activated Context Menu](demo/trigger-hover.html)
* [Hover Activated Context Menu With Autohide](demo/trigger-hover-autohide.html)
* [Nested Triggers With Autohide](demo/nested-triggers-autohide.html)
* [Custom Activated Context Menu](demo/trigger-custom.html)
* [Disabled Menu](demo/disabled-menu.html)
* [Disabled Command](demo/disabled.html)
Expand Down
65 changes: 65 additions & 0 deletions documentation/demo/nested-triggers-autohide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
currentMenu: nested-triggers-autohide
---

# Demo: Nested Triggers With Autohide

<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->


- [Example code](#example-code)
- [Example HTML](#example-html)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

<span class="context-menu-two btn btn-neutral" style="display: block; width: 480px; height: 240px;">
<span class="context-menu-one btn btn-neutral">left click me!</span>
<br>
right click anywhere in this box
</span>

<div style="height: 240px;"></div>

## Example code

<script type="text/javascript" class="showcase">
$(function(){
var items = {
"edit": {name: "Edit", icon: "edit"},
"cut": {name: "Cut", icon: "cut"},
"copy": {name: "Copy", icon: "copy"},
"paste": {name: "Paste", icon: "paste"},
"delete": {name: "Delete", icon: "delete"},
"sep1": "---------",
"quit": {name: "Quit", icon: function($element, key, item){ return 'context-menu-icon context-menu-icon-quit'; }}
};

$.contextMenu({
selector: '.context-menu-one',
className: 'menu-one',
trigger: 'left',
autoHide: true,
callback: function(key, options) {
var m = "clicked: " + key;
window.console && console.log(m) || alert(m);
},
items: items
});

$.contextMenu({
selector: '.context-menu-two',
className: 'menu-two',
trigger: 'right',
autoHide: true,
callback: function(key, options) {
var m = "clicked: " + key;
window.console && console.log(m) || alert(m);
},
items: items
});
});
</script>

## Example HTML
<div style="display:none;" class="showcase" data-showcase-import=".context-menu-two"></div>
149 changes: 129 additions & 20 deletions src/jquery.contextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@

var // currently active contextMenu trigger
$currentTrigger = null,
// options object of the menu that currently owns the document-level
// keyboard/autoHide handlers, see op.show() and op.hide()
documentHandlerOwner = null,
// is contextMenu initialized with at least one menu?
initialized = false,
// window handle
Expand Down Expand Up @@ -440,6 +443,52 @@
pageX: null,
pageY: null
},
// Which registered menu, if any, does a click with this mouse button on
// this element activate? Returns {el, options} for the innermost match,
// mirroring how a bubbling event reaches the closest delegated handler
// first. Used by handle.layerClick(), which has to decide whether the
// click that just dismissed the open menu should also open another one.
// It used to decide that from the *open* menu's own `trigger` option,
// which silently swallowed the click whenever the element under the
// cursor was registered with a different trigger - e.g. a
// `trigger: 'left'` element nested inside a `trigger: 'right'` element,
// where the outer menu is open.
// See https://github.com/swisnl/jQuery-contextMenu/issues/727
matchTriggerForClick = function (target, button) {
var wanted = button === 0 ? 'left' : (button === 2 ? 'right' : null),
match = null;

if (!target || !wanted) {
return null;
}

$.each(menus, function (ns, o) {
if (!o || o.trigger !== wanted || !o.selector) {
return true;
}

var el = $(target).closest(o.selector)[0];
if (!el) {
return true;
}

// Selector-string registrations are delegated from o.context, so
// they only apply to elements inside it. Element/jQuery-object
// selectors are bound to the element(s) directly and aren't
// scoped that way (see the 'create' operation).
if (typeof o.selector === 'string' && o.context && o.context !== el && !$.contains(o.context, el)) {
return true;
}

if (!match || $.contains(match.el, el)) {
match = {el: el, options: o};
}

return true;
});

return match;
},
// determine zIndex
zindex = function ($t) {
var zin = 0,
Expand Down Expand Up @@ -721,8 +770,15 @@
var triggerAction = ((root.trigger === 'left' && button === 0) || (root.trigger === 'right' && button === 2));

// find the element that would've been clicked, wasn't the layer in the way
if (document.elementFromPoint && root.$layer) {
root.$layer.hide();
// Something else - autoHide's own timer, most likely - may have closed
// the menu during the delay above, in which case root.$layer is already
// gone even though the layer element itself lingers in the document for
// another few milliseconds. Look the layer up by id in that case, so we
// still resolve what the click actually landed on instead of reporting
// the layer itself. See https://github.com/swisnl/jQuery-contextMenu/issues/727
if (document.elementFromPoint) {
var $blockingLayer = root.$layer && root.$layer.length ? root.$layer : $('#context-menu-layer');
$blockingLayer.hide();
target = document.elementFromPoint(x - $win.scrollLeft(), y - $win.scrollTop());

// also need to try and focus this element if we're in a contenteditable area,
Expand All @@ -745,7 +801,7 @@
e.target = target;
}
$(target).trigger(e);
root.$layer.show();
$blockingLayer.show();
}

if (root.hideOnSecondTrigger && triggerAction && root.$menu !== null && typeof root.$menu !== 'undefined') {
Expand Down Expand Up @@ -783,20 +839,52 @@
}

var openTargetMenu;
if (target && triggerAction) {
openTargetMenu = function () {
// Re-open on whatever the click landed on, once the current
// menu is out of the way. `triggerAction` describes the menu
// that is closing, so it can't be the whole answer here:
// clicking a trigger registered with a *different* trigger
// option (a left-click trigger nested inside a right-click
// one, say) is a perfectly valid activation for that other
// menu. See https://github.com/swisnl/jQuery-contextMenu/issues/727
var match = matchTriggerForClick(target, button);
if (target && (triggerAction || match)) {
var showTarget = function () {
if (match) {
// Dispatch straight to the menu we matched, the way
// handle.click does. Going through $.fn.contextMenu
// would trigger a *bubbling* synthetic 'contextmenu'
// event instead, which any application listener on an
// ancestor cannot tell apart from a real right-click -
// the leak fixed in
// https://github.com/swisnl/jQuery-contextMenu/issues/754
handle.contextmenu.call(match.el, $.Event('contextmenu', {
data: match.options,
pageX: x,
pageY: y,
mouseButton: button,
target: match.el,
currentTarget: match.el
}));
return;
}

$(target).contextMenu({x: x, y: y, button: button});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid bubbling a contextmenu for nested left clicks

When a right-click menu is open and the user left-clicks a nested left-trigger, showTarget calls $.fn.contextMenu, whose coordinate overload triggers a bubbling synthetic contextmenu event. Any application contextmenu listener on an ancestor of the nested trigger therefore fires for a plain left click before the plugin's delegated document handler can stop it. Dispatch the matched menu directly, as handle.click does, rather than reintroducing the synthetic event leakage that direct dispatch was designed to avoid.

Useful? React with 👍 / 👎.

};

// Re-opening the very same menu on another trigger normally
// waits for the hide animation to finish before showing it
// again, which is what makes the menu collapse and expand
// again. With animation.animateOnReopen disabled, show it
// right away instead, so op.show() just moves the menu that
// is still on screen (see #739).
if (!reopensSameMenuWithoutAnimation(root, target)) {
root.$trigger.one('contextmenu:hidden', openTargetMenu);
openTargetMenu = null;
// Normally we have to wait for the open menu to finish hiding
// before showing the next one. When it is already gone - it may
// have been closed by autoHide while this click was pending -
// 'contextmenu:hidden' will never fire again, so show right away
// instead of waiting for an event that isn't coming.
// Nor when the very same menu is being re-opened with
// animation.animateOnReopen disabled (see #739): there is
// deliberately no hide animation to wait for in that case,
// op.show() just moves the menu that is still on screen.
if (root.$trigger && root.$trigger.hasClass('context-menu-active')
&& !reopensSameMenuWithoutAnimation(root, target)) {
root.$trigger.one('contextmenu:hidden', showTarget);
} else {
openTargetMenu = showTarget;
}
}

Expand All @@ -810,10 +898,14 @@
// select's own horizontal span.
if (root !== null && typeof root !== 'undefined' && root.$menu !== null && typeof root.$menu !== 'undefined' && !isNearRecentSelectChange(root, x, y)) {
root.$menu.trigger('contextmenu:hide');
}

if (openTargetMenu) {
openTargetMenu();
}
// Show the next menu after the current one has been told to
// hide. Outside the block above on purpose: when the menu was
// already gone there is nothing to hide, but the click still
// has to open what it landed on (see #727).
if (openTargetMenu) {
openTargetMenu();
}
}, 50);
},
Expand Down Expand Up @@ -1426,6 +1518,10 @@
.data('contextMenu', opt)
.addClass('context-menu-active');

// this menu now owns the document-level handlers registered below,
// so a *previous* menu still finishing its teardown knows not to
// unbind them again. See op.hide().
documentHandlerOwner = opt;
// register key handler
$(document).off('keydown.contextMenu').on('keydown.contextMenu', handle.key);
// register autoHide handler
Expand Down Expand Up @@ -1480,8 +1576,11 @@
}
}

// remove handle
$currentTrigger = null;
// remove handle, unless another menu has already taken over as the
// current one - see the note further down about overlapping hide/show
if (!$currentTrigger || !$currentTrigger.length || $currentTrigger.is($trigger)) {
$currentTrigger = null;
}
// remove selected
opt.$menu.find('.' + opt.classNames.hover).trigger('contextmenu:blur');
opt.$selected = null;
Expand All @@ -1497,7 +1596,15 @@
}
// unregister key and mouse handlers
// $(document).off('.contextMenuAutoHide keydown.contextMenu'); // http://bugs.jquery.com/ticket/10705
$(document).off('.contextMenuAutoHide').off('keydown.contextMenu');
// Only when they still belong to this menu: hiding one menu can overlap
// with showing another (clicking a nested trigger registered with a
// different `trigger` option, say), and blindly unbinding here would
// strip the freshly opened menu of its keyboard and autoHide handlers.
// See https://github.com/swisnl/jQuery-contextMenu/issues/727
if (documentHandlerOwner === opt) {
documentHandlerOwner = null;
$(document).off('.contextMenuAutoHide').off('keydown.contextMenu');
}
// hide menu
if (opt.$menu) {
opt.$menu[opt.animation.hide](animationDuration(opt.animation, 'hideDuration'), function () {
Expand Down Expand Up @@ -2768,6 +2875,8 @@
elementSelectors = [];
counter = 0;
initialized = false;
// the document-level handlers just went away with everything else
documentHandlerOwner = null;

$('#context-menu-layer, .context-menu-list').remove();
} else if (useElementSelector) {
Expand Down
Loading
Loading