Skip to content

Commit 1d083ef

Browse files
committed
We officially have one of the best web-based Context Menus
1 parent 1904b92 commit 1d083ef

File tree

3 files changed

+352
-326
lines changed

3 files changed

+352
-326
lines changed

src/UI/UIContextMenu.js

+352-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,335 @@
1717
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1818
*/
1919

20+
21+
/**
22+
* menu-aim is a jQuery plugin for dropdown menus that can differentiate
23+
* between a user trying hover over a dropdown item vs trying to navigate into
24+
* a submenu's contents.
25+
*
26+
* menu-aim assumes that you have are using a menu with submenus that expand
27+
* to the menu's right. It will fire events when the user's mouse enters a new
28+
* dropdown item *and* when that item is being intentionally hovered over.
29+
*
30+
* __________________________
31+
* | Monkeys >| Gorilla |
32+
* | Gorillas >| Content |
33+
* | Chimps >| Here |
34+
* |___________|____________|
35+
*
36+
* In the above example, "Gorillas" is selected and its submenu content is
37+
* being shown on the right. Imagine that the user's cursor is hovering over
38+
* "Gorillas." When they move their mouse into the "Gorilla Content" area, they
39+
* may briefly hover over "Chimps." This shouldn't close the "Gorilla Content"
40+
* area.
41+
*
42+
* This problem is normally solved using timeouts and delays. menu-aim tries to
43+
* solve this by detecting the direction of the user's mouse movement. This can
44+
* make for quicker transitions when navigating up and down the menu. The
45+
* experience is hopefully similar to amazon.com/'s "Shop by Department"
46+
* dropdown.
47+
*
48+
* Use like so:
49+
*
50+
* $("#menu").menuAim({
51+
* activate: $.noop, // fired on row activation
52+
* deactivate: $.noop // fired on row deactivation
53+
* });
54+
*
55+
* ...to receive events when a menu's row has been purposefully (de)activated.
56+
*
57+
* The following options can be passed to menuAim. All functions execute with
58+
* the relevant row's HTML element as the execution context ('this'):
59+
*
60+
* .menuAim({
61+
* // Function to call when a row is purposefully activated. Use this
62+
* // to show a submenu's content for the activated row.
63+
* activate: function() {},
64+
*
65+
* // Function to call when a row is deactivated.
66+
* deactivate: function() {},
67+
*
68+
* // Function to call when mouse enters a menu row. Entering a row
69+
* // does not mean the row has been activated, as the user may be
70+
* // mousing over to a submenu.
71+
* enter: function() {},
72+
*
73+
* // Function to call when mouse exits a menu row.
74+
* exit: function() {},
75+
*
76+
* // Selector for identifying which elements in the menu are rows
77+
* // that can trigger the above events. Defaults to "> li".
78+
* rowSelector: "> li",
79+
*
80+
* // You may have some menu rows that aren't submenus and therefore
81+
* // shouldn't ever need to "activate." If so, filter submenu rows w/
82+
* // this selector. Defaults to "*" (all elements).
83+
* submenuSelector: "*",
84+
*
85+
* // Direction the submenu opens relative to the main menu. Can be
86+
* // left, right, above, or below. Defaults to "right".
87+
* submenuDirection: "right"
88+
* });
89+
*
90+
* https://github.com/kamens/jQuery-menu-aim
91+
*/
92+
(function ($) {
93+
94+
$.fn.menuAim = function (opts) {
95+
// Initialize menu-aim for all elements in jQuery collection
96+
this.each(function () {
97+
init.call(this, opts);
98+
});
99+
100+
return this;
101+
};
102+
103+
function init(opts) {
104+
var $menu = $(this),
105+
activeRow = null,
106+
mouseLocs = [],
107+
lastDelayLoc = null,
108+
timeoutId = null,
109+
options = $.extend({
110+
rowSelector: "> li",
111+
submenuSelector: "*",
112+
submenuDirection: $.noop,
113+
tolerance: 75, // bigger = more forgivey when entering submenu
114+
enter: $.noop,
115+
exit: $.noop,
116+
activate: $.noop,
117+
deactivate: $.noop,
118+
exitMenu: $.noop
119+
}, opts);
120+
121+
var MOUSE_LOCS_TRACKED = 3, // number of past mouse locations to track
122+
DELAY = 300; // ms delay when user appears to be entering submenu
123+
124+
/**
125+
* Keep track of the last few locations of the mouse.
126+
*/
127+
var mousemoveDocument = function (e) {
128+
mouseLocs.push({ x: e.pageX, y: e.pageY });
129+
130+
if (mouseLocs.length > MOUSE_LOCS_TRACKED) {
131+
mouseLocs.shift();
132+
}
133+
};
134+
135+
/**
136+
* Cancel possible row activations when leaving the menu entirely
137+
*/
138+
var mouseleaveMenu = function () {
139+
if (timeoutId) {
140+
clearTimeout(timeoutId);
141+
}
142+
143+
// If exitMenu is supplied and returns true, deactivate the
144+
// currently active row on menu exit.
145+
if (options.exitMenu(this)) {
146+
if (activeRow) {
147+
options.deactivate(activeRow);
148+
}
149+
150+
activeRow = null;
151+
}
152+
};
153+
154+
/**
155+
* Trigger a possible row activation whenever entering a new row.
156+
*/
157+
var mouseenterRow = function () {
158+
if (timeoutId) {
159+
// Cancel any previous activation delays
160+
clearTimeout(timeoutId);
161+
}
162+
163+
options.enter(this);
164+
possiblyActivate(this);
165+
},
166+
mouseleaveRow = function () {
167+
options.exit(this);
168+
};
169+
170+
/*
171+
* Immediately activate a row if the user clicks on it.
172+
*/
173+
var clickRow = function () {
174+
activate(this);
175+
};
176+
177+
/**
178+
* Activate a menu row.
179+
*/
180+
var activate = function (row) {
181+
if (row == activeRow) {
182+
return;
183+
}
184+
185+
if (activeRow) {
186+
options.deactivate(activeRow);
187+
}
188+
189+
190+
options.activate(row);
191+
activeRow = row;
192+
};
193+
194+
/**
195+
* Possibly activate a menu row. If mouse movement indicates that we
196+
* shouldn't activate yet because user may be trying to enter
197+
* a submenu's content, then delay and check again later.
198+
*/
199+
var possiblyActivate = function (row) {
200+
var delay = activationDelay();
201+
202+
if (delay) {
203+
timeoutId = setTimeout(function () {
204+
possiblyActivate(row);
205+
}, delay);
206+
} else {
207+
activate(row);
208+
}
209+
};
210+
211+
/**
212+
* Return the amount of time that should be used as a delay before the
213+
* currently hovered row is activated.
214+
*
215+
* Returns 0 if the activation should happen immediately. Otherwise,
216+
* returns the number of milliseconds that should be delayed before
217+
* checking again to see if the row should be activated.
218+
*/
219+
var activationDelay = function () {
220+
if (!activeRow || !$(activeRow).is(options.submenuSelector)) {
221+
// If there is no other submenu row already active, then
222+
// go ahead and activate immediately.
223+
return 0;
224+
}
225+
226+
var offset = $menu.offset(),
227+
upperLeft = {
228+
x: offset.left,
229+
y: offset.top - options.tolerance
230+
},
231+
upperRight = {
232+
x: offset.left + $menu.outerWidth(),
233+
y: upperLeft.y
234+
},
235+
lowerLeft = {
236+
x: offset.left,
237+
y: offset.top + $menu.outerHeight() + options.tolerance
238+
},
239+
lowerRight = {
240+
x: offset.left + $menu.outerWidth(),
241+
y: lowerLeft.y
242+
},
243+
loc = mouseLocs[mouseLocs.length - 1],
244+
prevLoc = mouseLocs[0];
245+
246+
if (!loc) {
247+
return 0;
248+
}
249+
250+
if (!prevLoc) {
251+
prevLoc = loc;
252+
}
253+
254+
if (prevLoc.x < offset.left || prevLoc.x > lowerRight.x ||
255+
prevLoc.y < offset.top || prevLoc.y > lowerRight.y) {
256+
// If the previous mouse location was outside of the entire
257+
// menu's bounds, immediately activate.
258+
return 0;
259+
}
260+
261+
if (lastDelayLoc &&
262+
loc.x == lastDelayLoc.x && loc.y == lastDelayLoc.y) {
263+
// If the mouse hasn't moved since the last time we checked
264+
// for activation status, immediately activate.
265+
return 0;
266+
}
267+
268+
// Detect if the user is moving towards the currently activated
269+
// submenu.
270+
//
271+
// If the mouse is heading relatively clearly towards
272+
// the submenu's content, we should wait and give the user more
273+
// time before activating a new row. If the mouse is heading
274+
// elsewhere, we can immediately activate a new row.
275+
//
276+
// We detect this by calculating the slope formed between the
277+
// current mouse location and the upper/lower right points of
278+
// the menu. We do the same for the previous mouse location.
279+
// If the current mouse location's slopes are
280+
// increasing/decreasing appropriately compared to the
281+
// previous's, we know the user is moving toward the submenu.
282+
//
283+
// Note that since the y-axis increases as the cursor moves
284+
// down the screen, we are looking for the slope between the
285+
// cursor and the upper right corner to decrease over time, not
286+
// increase (somewhat counterintuitively).
287+
function slope(a, b) {
288+
return (b.y - a.y) / (b.x - a.x);
289+
};
290+
291+
var decreasingCorner = upperRight,
292+
increasingCorner = lowerRight;
293+
294+
// Our expectations for decreasing or increasing slope values
295+
// depends on which direction the submenu opens relative to the
296+
// main menu. By default, if the menu opens on the right, we
297+
// expect the slope between the cursor and the upper right
298+
// corner to decrease over time, as explained above. If the
299+
// submenu opens in a different direction, we change our slope
300+
// expectations.
301+
if (options.submenuDirection() == "left") {
302+
decreasingCorner = lowerLeft;
303+
increasingCorner = upperLeft;
304+
} else if (options.submenuDirection() == "below") {
305+
decreasingCorner = lowerRight;
306+
increasingCorner = lowerLeft;
307+
} else if (options.submenuDirection() == "above") {
308+
decreasingCorner = upperLeft;
309+
increasingCorner = upperRight;
310+
}
311+
312+
var decreasingSlope = slope(loc, decreasingCorner),
313+
increasingSlope = slope(loc, increasingCorner),
314+
prevDecreasingSlope = slope(prevLoc, decreasingCorner),
315+
prevIncreasingSlope = slope(prevLoc, increasingCorner);
316+
317+
if (decreasingSlope < prevDecreasingSlope &&
318+
increasingSlope > prevIncreasingSlope) {
319+
// Mouse is moving from previous location towards the
320+
// currently activated submenu. Delay before activating a
321+
// new menu row, because user may be moving into submenu.
322+
lastDelayLoc = loc;
323+
return DELAY;
324+
}
325+
326+
lastDelayLoc = null;
327+
return 0;
328+
};
329+
330+
$menu.on('mouseenter', function(e) {
331+
if($menu.find('.context-menu-item-active').length === 0 && $menu.find('.has-open-context-menu-submenu').length === 0)
332+
activeRow = null;
333+
})
334+
/**
335+
* Hook up initial menu events
336+
*/
337+
$menu
338+
.mouseleave(mouseleaveMenu)
339+
.find(options.rowSelector)
340+
.mouseenter(mouseenterRow)
341+
.mouseleave(mouseleaveRow)
342+
.click(clickRow);
343+
344+
$(document).mousemove(mousemoveDocument);
345+
346+
};
347+
})(jQuery);
348+
20349
function UIContextMenu(options){
21350
$('.window-active .window-app-iframe').css('pointer-events', 'none');
22351

@@ -184,8 +513,9 @@ function UIContextMenu(options){
184513
// just passing over the item, the submenu doesn't open immediately.
185514
let submenu_delay_timer;
186515

187-
// Initialize the menuAim plugin (../libs/jquery.menu-aim.js)
516+
// Initialize the menuAim plugin
188517
$(contextMenu).menuAim({
518+
rowSelector: ".context-menu-item",
189519
submenuSelector: ".context-menu-item-submenu",
190520
submenuDirection: function(){
191521
// If not submenu
@@ -198,6 +528,10 @@ function UIContextMenu(options){
198528
}
199529
}
200530
},
531+
enter: function (e) {
532+
// activate items
533+
// this.activate(e);
534+
},
201535
// activates item when mouse enters depending on mouse position and direction
202536
activate: function (e) {
203537
// activate items
@@ -258,6 +592,7 @@ function UIContextMenu(options){
258592
// remove `has-open-context-menu-submenu` class from the parent menu item
259593
$(e).removeClass('has-open-context-menu-submenu');
260594
}
595+
261596
}
262597
});
263598

@@ -307,6 +642,14 @@ function UIContextMenu(options){
307642
return false;
308643
})
309644

645+
$(contextMenu).on("mouseleave", function (e) {
646+
$(contextMenu).find('.context-menu-item').removeClass('context-menu-item-active');
647+
clearTimeout(submenu_delay_timer);
648+
})
649+
650+
$(contextMenu).on("mouseenter", function (e) {
651+
})
652+
310653
return {
311654
cancel: (cancel_options) => {
312655
cancel_options_ = cancel_options;
@@ -344,7 +687,14 @@ $(document).on('mouseenter', '.context-menu', function(e){
344687
})
345688

346689
$(document).on('mouseenter', '.context-menu-item', function(e){
347-
select_ctxmenu_item(this);
690+
// select_ctxmenu_item(this);
348691
})
349692

693+
$(document).on('mouseenter', '.context-menu-divider', function(e){
694+
// unselect all items
695+
$(this).siblings('.context-menu-item:not(.has-open-context-menu-submenu)').removeClass('context-menu-item-active');
696+
})
697+
698+
$(document)
699+
350700
export default UIContextMenu;

0 commit comments

Comments
 (0)