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 @@ -33,6 +33,7 @@ module.exports = [
{ id: 'simple-context-menu', text: 'Simple Context Menu', url: '/demo.html' },
{ id: 'fontawesome-icons', text: 'FontAwesome icons', url: '/demo/fontawesome-icons.html' },
{ id: 'accesskeys', text: 'Accesskeys', url: '/demo/accesskeys.html' },
{ id: 'animation', text: 'Animation Options', url: '/demo/animation.html' },
{ id: 'async-create', text: 'Create Context Menu (asynchronous)', url: '/demo/async-create.html' },
{ id: 'async-promise', text: 'Create Context Menu (promise)', url: '/demo/async-promise.html' },
{ id: 'callback', text: "Command's action (callbacks)", url: '/demo/callback.html' },
Expand Down
1 change: 1 addition & 0 deletions documentation/demo.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ title: jQuery contextMenu — Demo gallery
* [Disabled Callback Command](demo/disabled-callback.html)
* [Changing Command's disabled status](demo/disabled-changing.html)
* [Accesskeys](demo/accesskeys.html)
* [Animation Options](demo/animation.html)
* [Submenus](demo/sub-menus.html)
* [Input Commands](demo/input.html)
* [Custom Command Types](demo/custom-command.html)
Expand Down
57 changes: 57 additions & 0 deletions documentation/demo/animation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
currentMenu: animation
---

# Demo: Animation Options

<!-- 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 -->

Both elements below share the same menu. It fades in slowly and out quickly, and because
`animateOnReopen` is `false` it simply moves to the new position when you right click the
other element while the menu is still open.

<span class="context-menu-one btn btn-neutral">right click me</span>
<span class="context-menu-one btn btn-neutral">and then right click me</span>

## Example code

<script type="text/javascript" class="showcase">
$(function(){
$.contextMenu({
selector: '.context-menu-one',
animation: {
// used for the show animation
showDuration: 400,
// used for the hide animation
hideDuration: 100,
// both fall back to `duration` when not set
duration: 250,
show: 'fadeIn',
hide: 'fadeOut',
// don't replay the show animation when this menu is already visible
animateOnReopen: false
},
callback: function(key, options) {
var m = "clicked: " + key;
window.console && console.log(m) || alert(m);
},
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"}
}
});
});
</script>

## Example HTML
<div style="display:none;" class="showcase" data-showcase-import=".context-menu-one"></div>
30 changes: 28 additions & 2 deletions documentation/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,13 +318,39 @@ $.contextMenu({

Animation properties take effect on showing and hiding the menu. Duration specifies the duration of the animation in milliseconds. `show` and `hide` specify [jQuery methods](http://api.jquery.com/category/effects/) to show and hide elements.

`animation`: `object` default: `{duration: 500, show: 'slideDown', hide: 'slideUp'}`
`animation`: `object` default: `{duration: 50, showDuration: null, hideDuration: null, animateOnReopen: true, show: 'slideDown', hide: 'slideUp'}`

Value | Description
---- | ----
`animation.duration` | Duration in milliseconds, used for both the show and the hide animation
`animation.showDuration` | Optional duration for the show animation only. Falls back to `animation.duration` when `null`
`animation.hideDuration` | Optional duration for the hide animation only. Falls back to `animation.duration` when `null`
`animation.animateOnReopen` | Whether the show animation is replayed when the menu that is opened is already visible. Set to `false` to only move it
`animation.show` | [jQuery method](http://api.jquery.com/category/effects/) used to show the menu
`animation.hide` | [jQuery method](http://api.jquery.com/category/effects/) used to hide the menu

`animateOnReopen` applies to that one menu only. Opening the same menu again, for example by right-clicking another element that shares it, moves the menu to the new position without replaying the show animation when it is set to `false`. A different menu opening while another one is closing is always animated.

#### Example
```javascript
$.contextMenu({
selector: 'span.context-menu',
animation: `{duration: 250, show: 'fadeIn', hide: 'fadeOut'}`
animation: {duration: 250, show: 'fadeIn', hide: 'fadeOut'}
});
```

```javascript
$.contextMenu({
selector: 'span.context-menu',
animation: {
// fade in slowly, but disappear quickly
showDuration: 400,
hideDuration: 100,
show: 'fadeIn',
hide: 'fadeOut',
// don't animate again when the menu is already on screen
animateOnReopen: false
}
});
```

Expand Down
101 changes: 96 additions & 5 deletions src/jquery.contextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,19 @@
zIndex: 1,
// show hide animation settings
animation: {
// duration used for both the show and the hide animation,
// unless overridden by showDuration/hideDuration below
duration: 50,
// optional per-direction durations. When null (the default)
// `duration` is used, so configs that only set `duration`
// keep behaving exactly as before.
showDuration: null,
hideDuration: null,
// whether the show animation is replayed when the very same
// menu is already on screen and gets re-opened (e.g. by
// right-clicking another trigger sharing that menu). Set to
// false to just move the menu instead of animating it again.
animateOnReopen: true,
show: 'slideDown',
hide: 'slideUp'
},
Expand Down Expand Up @@ -760,10 +772,22 @@
}
}

var openTargetMenu;
if (target && triggerAction) {
root.$trigger.one('contextmenu:hidden', function () {
openTargetMenu = function () {
$(target).contextMenu({x: x, y: y, button: button});
});
};

// 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;
}
}

// See the comment above isNearRecentSelectChange() /
Expand All @@ -776,6 +800,10 @@
// 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();
}
}
}, 50);
},
Expand Down Expand Up @@ -1282,7 +1310,16 @@
op = {
show: function (opt, x, y) {
var $trigger = $(this),
css = {};
css = {},
// Whether this very menu element is already on screen, i.e.
// it is being re-opened (and repositioned) rather than shown
// for the first time. This has to be sampled before any open
// menu is hidden below, because that hide only *starts* an
// animation - the menu stays visible while it runs.
// Deliberately checks opt.$menu itself instead of "is any
// menu open": another menu closing and this one opening is a
// genuine show, not a re-open.
isReopen = !!(opt.$menu && opt.$menu.length && opt.$menu.is(':visible'));

// hide any open menus
if ($('#context-menu-layer').length > 0)
Expand Down Expand Up @@ -1326,7 +1363,24 @@
opt.$menu.find('ul').css('zIndex', css.zIndex + 1);

// position and show context menu
opt.$menu.css(css)[opt.animation.show](opt.animation.duration, function () {
var $menu = opt.$menu.css(css),
showDuration = animationDuration(opt.animation, 'showDuration');

if (isReopen && opt.animation.animateOnReopen === false) {
// The menu is already on screen and merely moves to a new
// position, so don't replay the show animation. Any hide
// animation started by the re-open is jumped to its end
// (running its completion callbacks) and the menu is put
// back into its shown state synchronously, so it never
// gets a chance to be painted as hidden. The show effect
// is still invoked - on an already visible element it is a
// no-op apart from its callback - so custom effects keep
// being called exactly once per show.
$menu.stop(true, true).show();
showDuration = 0;
}

$menu[opt.animation.show](showDuration, function () {
$trigger.trigger('contextmenu:visible');

var rootShowTimestamp = Date.now();
Expand Down Expand Up @@ -1414,7 +1468,7 @@
$(document).off('.contextMenuAutoHide').off('keydown.contextMenu');
// hide menu
if (opt.$menu) {
opt.$menu[opt.animation.hide](opt.animation.duration, function () {
opt.$menu[opt.animation.hide](animationDuration(opt.animation, 'hideDuration'), function () {
// tear down dynamically built menu after animation is completed.
if (opt.build) {
opt.$menu.remove();
Expand Down Expand Up @@ -2182,6 +2236,43 @@
}
};

// true if opening the menu on `target` puts the very same menu that `root`
// is currently showing back on screen, and that menu is configured not to
// animate such a re-open (animation.animateOnReopen: false)
function reopensSameMenuWithoutAnimation(root, target) {
// menus built on invocation get a brand new menu element every time, so
// they are never re-opened - they're torn down and built up again, which
// has to wait for the hide to finish
if (!root || root.build || !root.animation || root.animation.animateOnReopen !== false || !root.selector) {
return false;
}

// `selector` is either a selector string or, for menus registered on an
// element/jQuery object, the element(s) themselves - closest() takes both
var $trigger = $(target).closest(root.selector);
if (!$trigger.length) {
return false;
}

// the same selector can be registered more than once, each registration
// with its own `context` and its own menu, so matching the selector alone
// isn't enough to tell that `target` is served by this very menu
return !root.context || root.context === $trigger[0] || $.contains(root.context, $trigger[0]);
}

// resolve the duration to use for one direction of the show/hide animation:
// the per-direction override (animation.showDuration / animation.hideDuration)
// when it is set, the shared animation.duration otherwise. Any value jQuery
// accepts as a duration is passed through, including 0 and "fast"/"slow".
function animationDuration(animation, key) {
if (!animation) {
return undefined;
}

var duration = animation[key];
return (duration === null || typeof duration === 'undefined') ? animation.duration : duration;
}

// true if target is inside one of root's sub-menus that were detached to
// <body> by op.detachSubmenus() (see #775) - used where code otherwise
// relies on root.$menu[0].contains(target) to detect clicks/targets
Expand Down
57 changes: 57 additions & 0 deletions test/specs/animation-reopen.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const { test, expect } = require('@playwright/test');
const { fixture } = require('../support/helpers');

// https://github.com/swisnl/jQuery-contextMenu/issues/739
// The animation demo uses fadeIn/fadeOut with animation.animateOnReopen: false,
// so right clicking the second trigger while the menu is open should move the
// menu instead of fading it out and back in.
test.describe('Test animation.animateOnReopen (#739)', () => {
test('re-opening the same menu on another trigger does not replay the animation', async ({ page }) => {
await page.goto(fixture('animation.html'));

const triggers = page.locator('.context-menu-one');
await triggers.nth(0).click({ button: 'right' });
await expect(page.locator('.context-menu-root')).toBeVisible();

// wait for the (400ms) fade in of the first open to finish
await expect
.poll(() => page.evaluate(() => window.getComputedStyle(document.querySelector('.context-menu-root')).opacity))
.toBe('1');

const firstPosition = await page.locator('.context-menu-root').boundingBox();

// sample the menu while the second right click is handled
await page.evaluate(() => {
window.__menuSamples = [];
window.__sampler = setInterval(() => {
const menu = document.querySelector('.context-menu-root');
if (!menu) {
window.__menuSamples.push({ display: 'removed', opacity: '0' });
return;
}
const style = window.getComputedStyle(menu);
window.__menuSamples.push({ display: style.display, opacity: style.opacity });
}, 5);
});

// the transparent modal layer covers the second trigger, so click through it
// with raw mouse coordinates, exactly like a user would
const second = await triggers.nth(1).boundingBox();
await page.mouse.click(second.x + second.width / 2, second.y + second.height / 2, { button: 'right' });
await page.waitForTimeout(500);

const samples = await page.evaluate(() => {
clearInterval(window.__sampler);
return window.__menuSamples;
});

expect(samples.length).toBeGreaterThan(10);
const faded = samples.filter((s) => s.display === 'none' || s.display === 'removed' || Number(s.opacity) < 1);
expect(faded, 'menu should never fade out or disappear while being re-opened').toEqual([]);

// ... and it really did move to the second trigger
await expect(page.locator('.context-menu-root')).toBeVisible();
const secondPosition = await page.locator('.context-menu-root').boundingBox();
expect(secondPosition.x).not.toBe(firstPosition.x);
});
});
Loading
Loading