Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / remoting / webapp / app_remoting / js / context_menu_dom.js
blobca1f85f831032b64fae50f6676d58c923f5dc57c
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.
5 /**
6  * @fileoverview
7  * Provide an alternative location for the application's context menu items
8  * on platforms that don't provide it.
9  *
10  * To mimic the behaviour of an OS-provided context menu, the menu is dismissed
11  * in three situations:
12  *
13  * 1. When the window loses focus (i.e, the user has clicked on another window
14  *    or on the desktop).
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
18  *    other DOM.
19  *
20  * TODO(jamiewalch): Fold this functionality into remoting.MenuButton.
21  */
22 'use strict';
24 /** @suppress {duplicate} */
25 var remoting = remoting || {};
27 /**
28  * @constructor
29  * @implements {remoting.WindowShape.ClientUI}
30  * @implements {remoting.ContextMenuAdapter}
31  * @param {HTMLElement} root The root of the context menu DOM.
32  * @param {remoting.WindowShape} windowShape
33  */
34 remoting.ContextMenuDom = function(root, windowShape) {
35   /** @private {HTMLElement} */
36   this.root_ = root;
37   /** @private {HTMLElement} */
38   this.stub_ = /** @type {HTMLElement} */
39       (this.root_.querySelector('.context-menu-stub'));
40   /** @private {HTMLElement} */
41   this.icon_ = /** @type {HTMLElement} */
42       (this.root_.querySelector('.context-menu-icon'));
43   /** @private {HTMLElement} */
44   this.screen_ = /** @type {HTMLElement} */
45       (this.root_.querySelector('.context-menu-screen'));
46   /** @private {HTMLElement} */
47   this.menu_ = /** @type {HTMLElement} */ (this.root_.querySelector('ul'));
48   /** @private {number} */
49   this.bottom_ = 8;
50   /** @private {base.EventSourceImpl} */
51   this.eventSource_ = new base.EventSourceImpl();
52   /** @private {string} */
53   this.eventName_ = '_click';
54   /**
55    * Since the same element is used to lock the icon open and to drag it, we
56    * must keep track of drag events so that the corresponding click event can
57    * be ignored.
58    *
59    * @private {boolean}
60    */
61   this.stubDragged_ = false;
63   /** @private */
64   this.windowShape_ = windowShape;
66   /**
67    * @private
68    */
69   this.dragAndDrop_ = new remoting.DragAndDrop(
70       this.stub_, this.onDragUpdate_.bind(this));
72   this.eventSource_.defineEvents([this.eventName_]);
73   this.root_.addEventListener(
74       'transitionend', this.onTransitionEnd_.bind(this), false);
75   this.stub_.addEventListener('click', this.onStubClick_.bind(this), false);
76   this.icon_.addEventListener('click', this.onIconClick_.bind(this), false);
77   this.screen_.addEventListener('click', this.onIconClick_.bind(this), false);
79   this.root_.hidden = false;
80   this.root_.style.bottom = this.bottom_ + 'px';
81   this.windowShape_.registerClientUI(this);
84 remoting.ContextMenuDom.prototype.dispose = function() {
85   this.windowShape_.unregisterClientUI(this);
88 /**
89  * @param {Array<{left: number, top: number, width: number, height: number}>}
90  *     rects List of rectangles.
91  */
92 remoting.ContextMenuDom.prototype.addToRegion = function(rects) {
93   var rect = /** @type {ClientRect} */ (this.root_.getBoundingClientRect());
94   // Clip the menu position to the main window in case the screen size has
95   // changed or a recent drag event tried to move it out of bounds.
96   if (rect.top < 0) {
97     this.bottom_ += rect.top;
98     this.root_.style.bottom = this.bottom_ + 'px';
99     rect = this.root_.getBoundingClientRect();
100   }
102   rects.push(rect);
103   if (this.root_.classList.contains('menu-opened')) {
104     var menuRect =
105         /** @type {ClientRect} */ (this.menu_.getBoundingClientRect());
106     rects.push(menuRect);
107   }
111  * @param {string} id An identifier for the menu entry.
112  * @param {string} title The text to display in the menu.
113  * @param {boolean} isCheckable True if the state of this menu entry should
114  *     have a check-box and manage its toggle state automatically. Note that
115  *     checkable menu entries always start off unchecked.
116  * @param {string=} opt_parentId The id of the parent menu item for submenus.
117  */
118 remoting.ContextMenuDom.prototype.create = function(
119     id, title, isCheckable, opt_parentId) {
120   var menuEntry = /** @type {HTMLElement} */ (document.createElement('li'));
121   menuEntry.innerText = title;
122   menuEntry.setAttribute('data-id', id);
123   if (isCheckable) {
124     menuEntry.setAttribute('data-checkable', true);
125   }
126   menuEntry.addEventListener('click', this.onClick_.bind(this), false);
127   /** @type {Node} */
128   var insertBefore = null;
129   if (opt_parentId) {
130     var parent = /** @type {HTMLElement} */
131         (this.menu_.querySelector('[data-id="' + opt_parentId + '"]'));
132     console.assert(
133         parent != null,
134         'No parent match for [data-id="' + /** @type {string} */(opt_parentId) +
135         '"] in create().');
136     console.assert(!parent.classList.contains('menu-group-item'),
137                    'Nested sub-menus are not supported.');
138     parent.classList.add('menu-group-header');
139     menuEntry.classList.add('menu-group-item');
140     insertBefore = this.getInsertionPointForParent(
141         /** @type {string} */(opt_parentId));
142   }
143   this.menu_.insertBefore(menuEntry, insertBefore);
147  * @param {string} id
148  * @param {string} title
149  */
150 remoting.ContextMenuDom.prototype.updateTitle = function(id, title) {
151   var node = this.menu_.querySelector('[data-id="' + id + '"]');
152   if (node) {
153     node.innerText = title;
154   }
158  * @param {string} id
159  * @param {boolean} checked
160  */
161 remoting.ContextMenuDom.prototype.updateCheckState = function(id, checked) {
162   var node = /** @type {HTMLElement} */
163       (this.menu_.querySelector('[data-id="' + id + '"]'));
164   if (node) {
165     if (checked) {
166       node.classList.add('selected');
167     } else {
168       node.classList.remove('selected');
169     }
170   }
174  * @param {string} id
175  */
176 remoting.ContextMenuDom.prototype.remove = function(id) {
177   var node = this.menu_.querySelector('[data-id="' + id + '"]');
178   if (node) {
179     this.menu_.removeChild(node);
180   }
184  * @param {function(OnClickData=):void} listener
185  */
186 remoting.ContextMenuDom.prototype.addListener = function(listener) {
187   this.eventSource_.addEventListener(this.eventName_, listener);
191  * @param {Event} event
192  * @private
193  */
194 remoting.ContextMenuDom.prototype.onClick_ = function(event) {
195   var element = /** @type {HTMLElement} */ (event.target);
196   if (element.getAttribute('data-checkable')) {
197     element.classList.toggle('selected')
198   }
199   var clickData = {
200     menuItemId: element.getAttribute('data-id'),
201     checked: element.classList.contains('selected')
202   };
203   this.eventSource_.raiseEvent(this.eventName_, clickData);
204   this.onIconClick_();
208  * Get the insertion point for the specified sub-menu. This is the menu item
209  * immediately following the last child of that menu group, or null if there
210  * are no menu items after that group.
212  * @param {string} parentId
213  * @return {Node?}
214  */
215 remoting.ContextMenuDom.prototype.getInsertionPointForParent = function(
216     parentId) {
217   var parentNode = this.menu_.querySelector('[data-id="' + parentId + '"]');
218   console.assert(parentNode != null,
219                  'No parent match for [data-id="' + parentId +
220                  '"] in getInsertionPointForParent().');
221   var childNode = /** @type {HTMLElement} */ (parentNode.nextSibling);
222   while (childNode != null && childNode.classList.contains('menu-group-item')) {
223     childNode = childNode.nextSibling;
224   }
225   return childNode;
229  * Called when the CSS show/hide transition completes. Since this changes the
230  * visible dimensions of the context menu, the visible region of the window
231  * needs to be recomputed.
233  * @private
234  */
235 remoting.ContextMenuDom.prototype.onTransitionEnd_ = function() {
236   this.windowShape_.updateClientWindowShape();
240  * Toggle the visibility of the context menu icon.
242  * @private
243  */
244 remoting.ContextMenuDom.prototype.onStubClick_ = function() {
245   if (this.stubDragged_) {
246     this.stubDragged_ = false;
247     return;
248   }
249   this.root_.classList.toggle('opened');
253  * Toggle the visibility of the context menu.
255  * @private
256  */
257 remoting.ContextMenuDom.prototype.onIconClick_ = function() {
258   this.showMenu_(!this.menu_.classList.contains('opened'));
262  * Explicitly show or hide the context menu.
264  * @param {boolean} show True to show the menu; false to hide it.
265  * @private
266  */
267 remoting.ContextMenuDom.prototype.showMenu_ = function(show) {
268   if (show) {
269     // Ensure that the menu doesn't extend off the top or bottom of the
270     // screen by aligning it to the top or bottom of the icon, depending
271     // on the latter's vertical position.
272     var menuRect =
273         /** @type {ClientRect} */ (this.menu_.getBoundingClientRect());
274     if (menuRect.bottom > window.innerHeight) {
275       this.menu_.classList.add('menu-align-bottom');
276     } else {
277       this.menu_.classList.remove('menu-align-bottom');
278     }
280     /** @type {remoting.ContextMenuDom} */
281     var that = this;
282     var onBlur = function() {
283       that.showMenu_(false);
284       window.removeEventListener('blur', onBlur, false);
285     };
286     window.addEventListener('blur', onBlur, false);
288     // Show the menu and prevent the icon from auto-hiding on mouse-out.
289     this.menu_.classList.add('opened');
290     this.root_.classList.add('menu-opened');
292   } else {  // if (!show)
293     this.menu_.classList.remove('opened');
294     this.root_.classList.remove('menu-opened');
295   }
297   this.screen_.hidden = !show;
298   this.windowShape_.updateClientWindowShape();
302  * @param {number} deltaX
303  * @param {number} deltaY
304  * @private
305  */
306 remoting.ContextMenuDom.prototype.onDragUpdate_ = function(deltaX, deltaY) {
307   this.stubDragged_ = true;
308   this.bottom_ -= deltaY;
309   this.root_.style.bottom = this.bottom_ + 'px';
310   // Deferring the window shape update until the DOM update has completed
311   // helps keep the position of the context menu consistent with the window
312   // shape (though it's still not perfect).
313   window.requestAnimationFrame(
314       this.windowShape_.updateClientWindowShape.bind(this.windowShape_));