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 |