1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
7 * Provide an alternative location for the application's context menu items
8 * on platforms that don't provide it.
10 * To mimic the behaviour of an OS-provided context menu, the menu is dismissed
11 * in three situations:
13 * 1. When the window loses focus (i.e, the user has clicked on another window
15 * 2. When the user selects an option from the menu.
16 * 3. When the user clicks on another part of the same window; this is achieved
17 * using an invisible screen element behind the menu, but in front of all
20 * TODO(jamiewalch): Fold this functionality into remoting.MenuButton.
24 /** @suppress {duplicate} */
25 var remoting
= remoting
|| {};
29 * @implements {remoting.WindowShape.ClientUI}
30 * @implements {remoting.ContextMenuAdapter}
31 * @param {HTMLElement} root The root of the context menu DOM.
33 remoting
.ContextMenuDom = function(root
) {
43 this.stub_
= /** @type {HTMLElement} */
44 (this.root_
.querySelector('.context-menu-stub'));
49 this.icon_
= /** @type {HTMLElement} */
50 (this.root_
.querySelector('.context-menu-icon'));
55 this.screen_
= /** @type {HTMLElement} */
56 (this.root_
.querySelector('.context-menu-screen'));
61 this.menu_
= /** @type {HTMLElement} */ (this.root_
.querySelector('ul'));
68 * @type {base.EventSourceImpl}
71 this.eventSource_
= new base
.EventSourceImpl();
76 this.eventName_
= '_click';
78 * Since the same element is used to lock the icon open and to drag it, we
79 * must keep track of drag events so that the corresponding click event can
85 this.stubDragged_
= false;
90 this.dragAndDrop_
= new remoting
.DragAndDrop(
91 this.stub_
, this.onDragUpdate_
.bind(this));
93 this.eventSource_
.defineEvents([this.eventName_
]);
94 this.root_
.addEventListener(
95 'transitionend', this.onTransitionEnd_
.bind(this), false);
96 this.stub_
.addEventListener('click', this.onStubClick_
.bind(this), false);
97 this.icon_
.addEventListener('click', this.onIconClick_
.bind(this), false);
98 this.screen_
.addEventListener('click', this.onIconClick_
.bind(this), false);
100 this.root_
.hidden
= false;
101 this.root_
.style
.bottom
= this.bottom_
+ 'px';
102 remoting
.windowShape
.addCallback(this);
106 * @param {Array<{left: number, top: number, width: number, height: number}>}
107 * rects List of rectangles.
109 remoting
.ContextMenuDom
.prototype.addToRegion = function(rects
) {
110 var rect
= /** @type {ClientRect} */ (this.root_
.getBoundingClientRect());
111 // Clip the menu position to the main window in case the screen size has
112 // changed or a recent drag event tried to move it out of bounds.
114 this.bottom_
+= rect
.top
;
115 this.root_
.style
.bottom
= this.bottom_
+ 'px';
116 rect
= this.root_
.getBoundingClientRect();
120 if (this.root_
.classList
.contains('menu-opened')) {
122 /** @type {ClientRect} */ (this.menu_
.getBoundingClientRect());
123 rects
.push(menuRect
);
128 * @param {string} id An identifier for the menu entry.
129 * @param {string} title The text to display in the menu.
130 * @param {boolean} isCheckable True if the state of this menu entry should
131 * have a check-box and manage its toggle state automatically. Note that
132 * checkable menu entries always start off unchecked.
133 * @param {string=} opt_parentId The id of the parent menu item for submenus.
135 remoting
.ContextMenuDom
.prototype.create = function(
136 id
, title
, isCheckable
, opt_parentId
) {
137 var menuEntry
= /** @type {HTMLElement} */ (document
.createElement('li'));
138 menuEntry
.innerText
= title
;
139 menuEntry
.setAttribute('data-id', id
);
141 menuEntry
.setAttribute('data-checkable', true);
143 menuEntry
.addEventListener('click', this.onClick_
.bind(this), false);
145 var insertBefore
= null;
147 var parent
= /** @type {HTMLElement} */
148 (this.menu_
.querySelector('[data-id="' + opt_parentId
+ '"]'));
149 base
.debug
.assert(parent
!= null);
150 base
.debug
.assert(!parent
.classList
.contains('menu-group-item'));
151 parent
.classList
.add('menu-group-header');
152 menuEntry
.classList
.add('menu-group-item');
153 insertBefore
= this.getInsertionPointForParent(
154 /** @type {string} */(opt_parentId
));
156 this.menu_
.insertBefore(menuEntry
, insertBefore
);
161 * @param {string} title
163 remoting
.ContextMenuDom
.prototype.updateTitle = function(id
, title
) {
164 var node
= this.menu_
.querySelector('[data-id="' + id
+ '"]');
166 node
.innerText
= title
;
172 * @param {boolean} checked
174 remoting
.ContextMenuDom
.prototype.updateCheckState = function(id
, checked
) {
175 var node
= /** @type {HTMLElement} */
176 (this.menu_
.querySelector('[data-id="' + id
+ '"]'));
179 node
.classList
.add('selected');
181 node
.classList
.remove('selected');
189 remoting
.ContextMenuDom
.prototype.remove = function(id
) {
190 var node
= this.menu_
.querySelector('[data-id="' + id
+ '"]');
192 this.menu_
.removeChild(node
);
197 * @param {function(OnClickData=):void} listener
199 remoting
.ContextMenuDom
.prototype.addListener = function(listener
) {
200 this.eventSource_
.addEventListener(this.eventName_
, listener
);
204 * @param {Event} event
207 remoting
.ContextMenuDom
.prototype.onClick_ = function(event
) {
208 var element
= /** @type {HTMLElement} */ (event
.target
);
209 if (element
.getAttribute('data-checkable')) {
210 element
.classList
.toggle('selected')
213 menuItemId
: element
.getAttribute('data-id'),
214 checked
: element
.classList
.contains('selected')
216 this.eventSource_
.raiseEvent(this.eventName_
, clickData
);
221 * Get the insertion point for the specified sub-menu. This is the menu item
222 * immediately following the last child of that menu group, or null if there
223 * are no menu items after that group.
225 * @param {string} parentId
228 remoting
.ContextMenuDom
.prototype.getInsertionPointForParent = function(
230 var parentNode
= this.menu_
.querySelector('[data-id="' + parentId
+ '"]');
231 base
.debug
.assert(parentNode
!= null);
232 var childNode
= /** @type {HTMLElement} */ (parentNode
.nextSibling
);
233 while (childNode
!= null && childNode
.classList
.contains('menu-group-item')) {
234 childNode
= childNode
.nextSibling
;
240 * Called when the CSS show/hide transition completes. Since this changes the
241 * visible dimensions of the context menu, the visible region of the window
242 * needs to be recomputed.
246 remoting
.ContextMenuDom
.prototype.onTransitionEnd_ = function() {
247 remoting
.windowShape
.updateClientWindowShape();
251 * Toggle the visibility of the context menu icon.
255 remoting
.ContextMenuDom
.prototype.onStubClick_ = function() {
256 if (this.stubDragged_
) {
257 this.stubDragged_
= false;
260 this.root_
.classList
.toggle('opened');
264 * Toggle the visibility of the context menu.
268 remoting
.ContextMenuDom
.prototype.onIconClick_ = function() {
269 this.showMenu_(!this.menu_
.classList
.contains('opened'));
273 * Explicitly show or hide the context menu.
275 * @param {boolean} show True to show the menu; false to hide it.
278 remoting
.ContextMenuDom
.prototype.showMenu_ = function(show
) {
280 // Ensure that the menu doesn't extend off the top or bottom of the
281 // screen by aligning it to the top or bottom of the icon, depending
282 // on the latter's vertical position.
284 /** @type {ClientRect} */ (this.menu_
.getBoundingClientRect());
285 if (menuRect
.bottom
> window
.innerHeight
) {
286 this.menu_
.classList
.add('menu-align-bottom');
288 this.menu_
.classList
.remove('menu-align-bottom');
291 /** @type {remoting.ContextMenuDom} */
293 var onBlur = function() {
294 that
.showMenu_(false);
295 window
.removeEventListener('blur', onBlur
, false);
297 window
.addEventListener('blur', onBlur
, false);
299 // Show the menu and prevent the icon from auto-hiding on mouse-out.
300 this.menu_
.classList
.add('opened');
301 this.root_
.classList
.add('menu-opened');
303 } else { // if (!show)
304 this.menu_
.classList
.remove('opened');
305 this.root_
.classList
.remove('menu-opened');
308 this.screen_
.hidden
= !show
;
309 remoting
.windowShape
.updateClientWindowShape();
313 * @param {number} deltaX
314 * @param {number} deltaY
317 remoting
.ContextMenuDom
.prototype.onDragUpdate_ = function(deltaX
, deltaY
) {
318 this.stubDragged_
= true;
319 this.bottom_
-= deltaY
;
320 this.root_
.style
.bottom
= this.bottom_
+ 'px';
321 // Deferring the window shape update until the DOM update has completed
322 // helps keep the position of the context menu consistent with the window
323 // shape (though it's still not perfect).
324 window
.requestAnimationFrame(
326 remoting
.windowShape
.updateClientWindowShape();