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) {
34 /** @private {HTMLElement} */
36 /** @private {HTMLElement} */
37 this.stub_ = /** @type {HTMLElement} */
38 (this.root_.querySelector('.context-menu-stub'));
39 /** @private {HTMLElement} */
40 this.icon_ = /** @type {HTMLElement} */
41 (this.root_.querySelector('.context-menu-icon'));
42 /** @private {HTMLElement} */
43 this.screen_ = /** @type {HTMLElement} */
44 (this.root_.querySelector('.context-menu-screen'));
45 /** @private {HTMLElement} */
46 this.menu_ = /** @type {HTMLElement} */ (this.root_.querySelector('ul'));
47 /** @private {number} */
49 /** @private {base.EventSourceImpl} */
50 this.eventSource_ = new base.EventSourceImpl();
51 /** @private {string} */
52 this.eventName_ = '_click';
54 * Since the same element is used to lock the icon open and to drag it, we
55 * must keep track of drag events so that the corresponding click event can
60 this.stubDragged_ = false;
65 this.dragAndDrop_ = new remoting.DragAndDrop(
66 this.stub_, this.onDragUpdate_.bind(this));
68 this.eventSource_.defineEvents([this.eventName_]);
69 this.root_.addEventListener(
70 'transitionend', this.onTransitionEnd_.bind(this), false);
71 this.stub_.addEventListener('click', this.onStubClick_.bind(this), false);
72 this.icon_.addEventListener('click', this.onIconClick_.bind(this), false);
73 this.screen_.addEventListener('click', this.onIconClick_.bind(this), false);
75 this.root_.hidden = false;
76 this.root_.style.bottom = this.bottom_ + 'px';
77 remoting.windowShape.registerClientUI(this);
80 remoting.ContextMenuDom.prototype.dispose = function() {
81 remoting.windowShape.unregisterClientUI(this);
85 * @param {Array<{left: number, top: number, width: number, height: number}>}
86 * rects List of rectangles.
88 remoting.ContextMenuDom.prototype.addToRegion = function(rects) {
89 var rect = /** @type {ClientRect} */ (this.root_.getBoundingClientRect());
90 // Clip the menu position to the main window in case the screen size has
91 // changed or a recent drag event tried to move it out of bounds.
93 this.bottom_ += rect.top;
94 this.root_.style.bottom = this.bottom_ + 'px';
95 rect = this.root_.getBoundingClientRect();
99 if (this.root_.classList.contains('menu-opened')) {
101 /** @type {ClientRect} */ (this.menu_.getBoundingClientRect());
102 rects.push(menuRect);
107 * @param {string} id An identifier for the menu entry.
108 * @param {string} title The text to display in the menu.
109 * @param {boolean} isCheckable True if the state of this menu entry should
110 * have a check-box and manage its toggle state automatically. Note that
111 * checkable menu entries always start off unchecked.
112 * @param {string=} opt_parentId The id of the parent menu item for submenus.
114 remoting.ContextMenuDom.prototype.create = function(
115 id, title, isCheckable, opt_parentId) {
116 var menuEntry = /** @type {HTMLElement} */ (document.createElement('li'));
117 menuEntry.innerText = title;
118 menuEntry.setAttribute('data-id', id);
120 menuEntry.setAttribute('data-checkable', true);
122 menuEntry.addEventListener('click', this.onClick_.bind(this), false);
124 var insertBefore = null;
126 var parent = /** @type {HTMLElement} */
127 (this.menu_.querySelector('[data-id="' + opt_parentId + '"]'));
128 base.debug.assert(parent != null);
129 base.debug.assert(!parent.classList.contains('menu-group-item'));
130 parent.classList.add('menu-group-header');
131 menuEntry.classList.add('menu-group-item');
132 insertBefore = this.getInsertionPointForParent(
133 /** @type {string} */(opt_parentId));
135 this.menu_.insertBefore(menuEntry, insertBefore);
140 * @param {string} title
142 remoting.ContextMenuDom.prototype.updateTitle = function(id, title) {
143 var node = this.menu_.querySelector('[data-id="' + id + '"]');
145 node.innerText = title;
151 * @param {boolean} checked
153 remoting.ContextMenuDom.prototype.updateCheckState = function(id, checked) {
154 var node = /** @type {HTMLElement} */
155 (this.menu_.querySelector('[data-id="' + id + '"]'));
158 node.classList.add('selected');
160 node.classList.remove('selected');
168 remoting.ContextMenuDom.prototype.remove = function(id) {
169 var node = this.menu_.querySelector('[data-id="' + id + '"]');
171 this.menu_.removeChild(node);
176 * @param {function(OnClickData=):void} listener
178 remoting.ContextMenuDom.prototype.addListener = function(listener) {
179 this.eventSource_.addEventListener(this.eventName_, listener);
183 * @param {Event} event
186 remoting.ContextMenuDom.prototype.onClick_ = function(event) {
187 var element = /** @type {HTMLElement} */ (event.target);
188 if (element.getAttribute('data-checkable')) {
189 element.classList.toggle('selected')
192 menuItemId: element.getAttribute('data-id'),
193 checked: element.classList.contains('selected')
195 this.eventSource_.raiseEvent(this.eventName_, clickData);
200 * Get the insertion point for the specified sub-menu. This is the menu item
201 * immediately following the last child of that menu group, or null if there
202 * are no menu items after that group.
204 * @param {string} parentId
207 remoting.ContextMenuDom.prototype.getInsertionPointForParent = function(
209 var parentNode = this.menu_.querySelector('[data-id="' + parentId + '"]');
210 base.debug.assert(parentNode != null);
211 var childNode = /** @type {HTMLElement} */ (parentNode.nextSibling);
212 while (childNode != null && childNode.classList.contains('menu-group-item')) {
213 childNode = childNode.nextSibling;
219 * Called when the CSS show/hide transition completes. Since this changes the
220 * visible dimensions of the context menu, the visible region of the window
221 * needs to be recomputed.
225 remoting.ContextMenuDom.prototype.onTransitionEnd_ = function() {
226 remoting.windowShape.updateClientWindowShape();
230 * Toggle the visibility of the context menu icon.
234 remoting.ContextMenuDom.prototype.onStubClick_ = function() {
235 if (this.stubDragged_) {
236 this.stubDragged_ = false;
239 this.root_.classList.toggle('opened');
243 * Toggle the visibility of the context menu.
247 remoting.ContextMenuDom.prototype.onIconClick_ = function() {
248 this.showMenu_(!this.menu_.classList.contains('opened'));
252 * Explicitly show or hide the context menu.
254 * @param {boolean} show True to show the menu; false to hide it.
257 remoting.ContextMenuDom.prototype.showMenu_ = function(show) {
259 // Ensure that the menu doesn't extend off the top or bottom of the
260 // screen by aligning it to the top or bottom of the icon, depending
261 // on the latter's vertical position.
263 /** @type {ClientRect} */ (this.menu_.getBoundingClientRect());
264 if (menuRect.bottom > window.innerHeight) {
265 this.menu_.classList.add('menu-align-bottom');
267 this.menu_.classList.remove('menu-align-bottom');
270 /** @type {remoting.ContextMenuDom} */
272 var onBlur = function() {
273 that.showMenu_(false);
274 window.removeEventListener('blur', onBlur, false);
276 window.addEventListener('blur', onBlur, false);
278 // Show the menu and prevent the icon from auto-hiding on mouse-out.
279 this.menu_.classList.add('opened');
280 this.root_.classList.add('menu-opened');
282 } else { // if (!show)
283 this.menu_.classList.remove('opened');
284 this.root_.classList.remove('menu-opened');
287 this.screen_.hidden = !show;
288 remoting.windowShape.updateClientWindowShape();
292 * @param {number} deltaX
293 * @param {number} deltaY
296 remoting.ContextMenuDom.prototype.onDragUpdate_ = function(deltaX, deltaY) {
297 this.stubDragged_ = true;
298 this.bottom_ -= deltaY;
299 this.root_.style.bottom = this.bottom_ + 'px';
300 // Deferring the window shape update until the DOM update has completed
301 // helps keep the position of the context menu consistent with the window
302 // shape (though it's still not perfect).
303 window.requestAnimationFrame(
305 remoting.windowShape.updateClientWindowShape();