| OLD | NEW |
| (Empty) |
| 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. | |
| 4 | |
| 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'; | |
| 23 | |
| 24 /** @suppress {duplicate} */ | |
| 25 var remoting = remoting || {}; | |
| 26 | |
| 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; | |
| 62 | |
| 63 /** @private */ | |
| 64 this.windowShape_ = windowShape; | |
| 65 | |
| 66 /** | |
| 67 * @private | |
| 68 */ | |
| 69 this.dragAndDrop_ = new remoting.DragAndDrop( | |
| 70 this.stub_, this.onDragUpdate_.bind(this)); | |
| 71 | |
| 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); | |
| 78 | |
| 79 this.root_.hidden = false; | |
| 80 this.root_.style.bottom = this.bottom_ + 'px'; | |
| 81 this.windowShape_.registerClientUI(this); | |
| 82 }; | |
| 83 | |
| 84 remoting.ContextMenuDom.prototype.dispose = function() { | |
| 85 this.windowShape_.unregisterClientUI(this); | |
| 86 }; | |
| 87 | |
| 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 } | |
| 101 | |
| 102 rects.push(rect); | |
| 103 if (this.root_.classList.contains('menu-opened')) { | |
| 104 var menuRect = this.menu_.getBoundingClientRect(); | |
| 105 rects.push(menuRect); | |
| 106 } | |
| 107 }; | |
| 108 | |
| 109 /** | |
| 110 * @param {string} id An identifier for the menu entry. | |
| 111 * @param {string} title The text to display in the menu. | |
| 112 * @param {boolean} isCheckable True if the state of this menu entry should | |
| 113 * have a check-box and manage its toggle state automatically. Note that | |
| 114 * checkable menu entries always start off unchecked. | |
| 115 * @param {string=} opt_parentId The id of the parent menu item for submenus. | |
| 116 */ | |
| 117 remoting.ContextMenuDom.prototype.create = function( | |
| 118 id, title, isCheckable, opt_parentId) { | |
| 119 var menuEntry = /** @type {HTMLElement} */ (document.createElement('li')); | |
| 120 menuEntry.innerText = title; | |
| 121 menuEntry.setAttribute('data-id', id); | |
| 122 if (isCheckable) { | |
| 123 menuEntry.setAttribute('data-checkable', true); | |
| 124 } | |
| 125 menuEntry.addEventListener('click', this.onClick_.bind(this), false); | |
| 126 /** @type {Node} */ | |
| 127 var insertBefore = null; | |
| 128 if (opt_parentId) { | |
| 129 var parent = /** @type {HTMLElement} */ | |
| 130 (this.menu_.querySelector('[data-id="' + opt_parentId + '"]')); | |
| 131 console.assert( | |
| 132 parent != null, | |
| 133 'No parent match for [data-id="' + /** @type {string} */(opt_parentId) + | |
| 134 '"] in create().'); | |
| 135 console.assert(!parent.classList.contains('menu-group-item'), | |
| 136 'Nested sub-menus are not supported.'); | |
| 137 parent.classList.add('menu-group-header'); | |
| 138 menuEntry.classList.add('menu-group-item'); | |
| 139 insertBefore = this.getInsertionPointForParent( | |
| 140 /** @type {string} */(opt_parentId)); | |
| 141 } | |
| 142 this.menu_.insertBefore(menuEntry, insertBefore); | |
| 143 }; | |
| 144 | |
| 145 /** | |
| 146 * @param {string} id | |
| 147 * @param {string} title | |
| 148 */ | |
| 149 remoting.ContextMenuDom.prototype.updateTitle = function(id, title) { | |
| 150 var node = this.menu_.querySelector('[data-id="' + id + '"]'); | |
| 151 if (node) { | |
| 152 node.innerText = title; | |
| 153 } | |
| 154 }; | |
| 155 | |
| 156 /** | |
| 157 * @param {string} id | |
| 158 * @param {boolean} checked | |
| 159 */ | |
| 160 remoting.ContextMenuDom.prototype.updateCheckState = function(id, checked) { | |
| 161 var node = /** @type {HTMLElement} */ | |
| 162 (this.menu_.querySelector('[data-id="' + id + '"]')); | |
| 163 if (node) { | |
| 164 if (checked) { | |
| 165 node.classList.add('selected'); | |
| 166 } else { | |
| 167 node.classList.remove('selected'); | |
| 168 } | |
| 169 } | |
| 170 }; | |
| 171 | |
| 172 /** | |
| 173 * @param {string} id | |
| 174 */ | |
| 175 remoting.ContextMenuDom.prototype.remove = function(id) { | |
| 176 var node = this.menu_.querySelector('[data-id="' + id + '"]'); | |
| 177 if (node) { | |
| 178 this.menu_.removeChild(node); | |
| 179 } | |
| 180 }; | |
| 181 | |
| 182 /** | |
| 183 * @param {function(OnClickData=):void} listener | |
| 184 */ | |
| 185 remoting.ContextMenuDom.prototype.addListener = function(listener) { | |
| 186 this.eventSource_.addEventListener(this.eventName_, listener); | |
| 187 }; | |
| 188 | |
| 189 /** | |
| 190 * @param {Event} event | |
| 191 * @private | |
| 192 */ | |
| 193 remoting.ContextMenuDom.prototype.onClick_ = function(event) { | |
| 194 var element = /** @type {HTMLElement} */ (event.target); | |
| 195 if (element.getAttribute('data-checkable')) { | |
| 196 element.classList.toggle('selected') | |
| 197 } | |
| 198 var clickData = { | |
| 199 menuItemId: element.getAttribute('data-id'), | |
| 200 checked: element.classList.contains('selected') | |
| 201 }; | |
| 202 this.eventSource_.raiseEvent(this.eventName_, clickData); | |
| 203 this.onIconClick_(); | |
| 204 }; | |
| 205 | |
| 206 /** | |
| 207 * Get the insertion point for the specified sub-menu. This is the menu item | |
| 208 * immediately following the last child of that menu group, or null if there | |
| 209 * are no menu items after that group. | |
| 210 * | |
| 211 * @param {string} parentId | |
| 212 * @return {Node?} | |
| 213 */ | |
| 214 remoting.ContextMenuDom.prototype.getInsertionPointForParent = function( | |
| 215 parentId) { | |
| 216 var parentNode = this.menu_.querySelector('[data-id="' + parentId + '"]'); | |
| 217 console.assert(parentNode != null, | |
| 218 'No parent match for [data-id="' + parentId + | |
| 219 '"] in getInsertionPointForParent().'); | |
| 220 var childNode = /** @type {HTMLElement} */ (parentNode.nextSibling); | |
| 221 while (childNode != null && childNode.classList.contains('menu-group-item')) { | |
| 222 childNode = childNode.nextSibling; | |
| 223 } | |
| 224 return childNode; | |
| 225 }; | |
| 226 | |
| 227 /** | |
| 228 * Called when the CSS show/hide transition completes. Since this changes the | |
| 229 * visible dimensions of the context menu, the visible region of the window | |
| 230 * needs to be recomputed. | |
| 231 * | |
| 232 * @private | |
| 233 */ | |
| 234 remoting.ContextMenuDom.prototype.onTransitionEnd_ = function() { | |
| 235 this.windowShape_.updateClientWindowShape(); | |
| 236 }; | |
| 237 | |
| 238 /** | |
| 239 * Toggle the visibility of the context menu icon. | |
| 240 * | |
| 241 * @private | |
| 242 */ | |
| 243 remoting.ContextMenuDom.prototype.onStubClick_ = function() { | |
| 244 if (this.stubDragged_) { | |
| 245 this.stubDragged_ = false; | |
| 246 return; | |
| 247 } | |
| 248 this.root_.classList.toggle('opened'); | |
| 249 }; | |
| 250 | |
| 251 /** | |
| 252 * Toggle the visibility of the context menu. | |
| 253 * | |
| 254 * @private | |
| 255 */ | |
| 256 remoting.ContextMenuDom.prototype.onIconClick_ = function() { | |
| 257 this.showMenu_(!this.menu_.classList.contains('opened')); | |
| 258 }; | |
| 259 | |
| 260 /** | |
| 261 * Explicitly show or hide the context menu. | |
| 262 * | |
| 263 * @param {boolean} show True to show the menu; false to hide it. | |
| 264 * @private | |
| 265 */ | |
| 266 remoting.ContextMenuDom.prototype.showMenu_ = function(show) { | |
| 267 if (show) { | |
| 268 // Ensure that the menu doesn't extend off the top or bottom of the | |
| 269 // screen by aligning it to the top or bottom of the icon, depending | |
| 270 // on the latter's vertical position. | |
| 271 var menuRect = | |
| 272 /** @type {ClientRect} */ (this.menu_.getBoundingClientRect()); | |
| 273 if (menuRect.bottom > window.innerHeight) { | |
| 274 this.menu_.classList.add('menu-align-bottom'); | |
| 275 } else { | |
| 276 this.menu_.classList.remove('menu-align-bottom'); | |
| 277 } | |
| 278 | |
| 279 /** @type {remoting.ContextMenuDom} */ | |
| 280 var that = this; | |
| 281 var onBlur = function() { | |
| 282 that.showMenu_(false); | |
| 283 window.removeEventListener('blur', onBlur, false); | |
| 284 }; | |
| 285 window.addEventListener('blur', onBlur, false); | |
| 286 | |
| 287 // Show the menu and prevent the icon from auto-hiding on mouse-out. | |
| 288 this.menu_.classList.add('opened'); | |
| 289 this.root_.classList.add('menu-opened'); | |
| 290 | |
| 291 } else { // if (!show) | |
| 292 this.menu_.classList.remove('opened'); | |
| 293 this.root_.classList.remove('menu-opened'); | |
| 294 } | |
| 295 | |
| 296 this.screen_.hidden = !show; | |
| 297 this.windowShape_.updateClientWindowShape(); | |
| 298 }; | |
| 299 | |
| 300 /** | |
| 301 * @param {number} deltaX | |
| 302 * @param {number} deltaY | |
| 303 * @private | |
| 304 */ | |
| 305 remoting.ContextMenuDom.prototype.onDragUpdate_ = function(deltaX, deltaY) { | |
| 306 this.stubDragged_ = true; | |
| 307 this.bottom_ -= deltaY; | |
| 308 this.root_.style.bottom = this.bottom_ + 'px'; | |
| 309 // Deferring the window shape update until the DOM update has completed | |
| 310 // helps keep the position of the context menu consistent with the window | |
| 311 // shape (though it's still not perfect). | |
| 312 window.requestAnimationFrame( | |
| 313 this.windowShape_.updateClientWindowShape.bind(this.windowShape_)); | |
| 314 }; | |
| OLD | NEW |