Mercurial > addons > firefox-addons > tab-mover
comparison background.js @ 23:4704e5216412
Create menus on-the-fly
Refactor and eliminate the window tracking code by using the onShown/onHidden
events available in Firefox 60 in order to create menu entries on-the-fly.
Switch from the Firefox-specific contextMenu to the menu API.
author | Guido Berhoerster <guido+tab-mover@berhoerster.name> |
---|---|
date | Sun, 25 Nov 2018 13:27:47 +0100 |
parents | 6b4680867e49 |
children | f418a6305f17 |
comparison
equal
deleted
inserted
replaced
22:70de81c7c512 | 23:4704e5216412 |
---|---|
1 /* | 1 /* |
2 * Copyright (C) 2017 Guido Berhoerster <guido+tab-mover@berhoerster.name> | 2 * Copyright (C) 2018 Guido Berhoerster <guido+tab-mover@berhoerster.name> |
3 * | 3 * |
4 * This Source Code Form is subject to the terms of the Mozilla Public | 4 * This Source Code Form is subject to the terms of the Mozilla Public |
5 * License, v. 2.0. If a copy of the MPL was not distributed with this | 5 * License, v. 2.0. If a copy of the MPL was not distributed with this |
6 * file, You can obtain one at http://mozilla.org/MPL/2.0/. | 6 * file, You can obtain one at http://mozilla.org/MPL/2.0/. |
7 */ | 7 */ |
8 | 8 |
9 'use strict'; | 9 'use strict'; |
10 | 10 |
11 function createContextMenuItem(createProperties) { | 11 const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'ftp:']); |
12 var windowMenuIds = []; | |
13 var lastMenuInstanceId = 0; | |
14 var nextMenuInstanceId = 1; | |
15 | |
16 function createMenuItem(createProperties) { | |
12 return new Promise((resolve, reject) => { | 17 return new Promise((resolve, reject) => { |
13 browser.contextMenus.create(createProperties, () => { | 18 let id = browser.menus.create(createProperties, () => { |
14 if (browser.runtime.lastError) { | 19 if (browser.runtime.lastError) { |
15 reject(browser.runtime.lastError); | 20 reject(browser.runtime.lastError); |
16 } else { | 21 } else { |
17 resolve(); | 22 resolve(id); |
18 } | 23 } |
19 }); | 24 }); |
20 }); | 25 }); |
21 } | 26 } |
22 | 27 |
23 const Observable = (superclass) => class extends superclass { | 28 async function moveTab(tab, targetWindowId) { |
24 constructor(...args) { | 29 browser.tabs.move(tab.id, {windowId: targetWindowId, index: -1}); |
25 super(...args); | 30 } |
26 | 31 |
27 this._observers = new Map(); | 32 async function reopenTab(tab, targetWindowId) { |
33 if (!ALLOWED_PROTOCOLS.has(new URL(tab.url).protocol)) { | |
34 // privileged tab URL which cannot be reopened | |
35 return; | |
28 } | 36 } |
37 await browser.tabs.create({ | |
38 url: tab.url, | |
39 windowId: targetWindowId | |
40 }); | |
41 browser.tabs.remove(tab.id); | |
42 } | |
29 | 43 |
30 addObserver(eventName, observer) { | 44 async function onMenuShown(info, tab) { |
31 if (!this._observers.has(eventName)) { | 45 let menuInstanceId = nextMenuInstanceId++; |
32 this._observers.set(eventName, new Set()); | 46 lastMenuInstanceId = menuInstanceId; |
47 let targetWindows = await browser.windows.getAll({ | |
48 populate: true, | |
49 windowTypes: ['normal'] | |
50 }); | |
51 let creatingMenus = []; | |
52 let moveMenuItems = 0; | |
53 let reopenMenuItems = 0; | |
54 for (let targetWindow of targetWindows) { | |
55 if (targetWindow.id === tab.windowId) { | |
56 // ignore active window | |
57 continue; | |
33 } | 58 } |
34 | 59 if (tab.incognito === targetWindow.incognito) { |
35 this._observers.get(eventName).add(observer); | 60 creatingMenus.push(createMenuItem({ |
36 } | 61 onclick: (info, tab) => moveTab(tab, targetWindow.id), |
37 | 62 parentId: 'move-menu', |
38 deleteObserver(eventName, observer) { | 63 title: targetWindow.title |
39 if (this._observers.has(eventName)) { | 64 })); |
40 this._observers.get(eventName).delete(observer); | 65 moveMenuItems++; |
66 } else { | |
67 creatingMenus.push(createMenuItem({ | |
68 onclick: (info, tab) => reopenTab(tab, targetWindow.id), | |
69 parentId: 'reopen-menu', | |
70 title: targetWindow.title | |
71 })); | |
72 reopenMenuItems++; | |
41 } | 73 } |
42 } | 74 } |
75 let updatingMenus = [ | |
76 browser.menus.update('move-menu', {enabled: moveMenuItems > 0}), | |
77 browser.menus.update('reopen-menu', {enabled: reopenMenuItems > 0}) | |
78 ]; | |
79 await Promise.all([...creatingMenus, ...updatingMenus]); | |
80 let newWindowMenuIds = await Promise.all(creatingMenus); | |
81 if (menuInstanceId !== lastMenuInstanceId) { | |
82 // menu has been closed and opened again, remove the items of this | |
83 // instance again | |
84 for (let menuId of newWindowMenuIds) { | |
85 browser.menus.remove(menuId); | |
86 } | |
87 return; | |
88 } | |
89 windowMenuIds = newWindowMenuIds; | |
90 browser.menus.refresh(); | |
91 } | |
43 | 92 |
44 notifyObservers(eventName, ...args) { | 93 async function onMenuHidden() { |
45 if (!this._observers.has(eventName)) { | 94 lastMenuInstanceId = 0; |
46 return; | 95 browser.menus.update('move-menu', {enabled: false}); |
47 } | 96 browser.menus.update('reopen-menu', {enabled: false}); |
48 | 97 for (let menuId of windowMenuIds) { |
49 for (let observer of this._observers.get(eventName)) { | 98 browser.menus.remove(menuId); |
50 observer(eventName, ...args); | |
51 } | |
52 } | 99 } |
53 } | 100 } |
54 | 101 |
55 class WindowsModel extends Observable(Object) { | 102 (async () => { |
56 constructor() { | 103 await Promise.all([ |
57 super(); | 104 // create submenus |
58 | 105 createMenuItem({ |
59 this.windows = new Map(); | 106 id: 'move-menu', |
60 this.focusedWindowId = browser.windows.WINDOW_ID_NONE; | 107 title: browser.i18n.getMessage('moveToWindowMenu'), |
61 } | 108 enabled: false, |
62 | 109 contexts: ['tab'] |
63 getWindow(id) { | 110 }), |
64 return this.windows.get(id); | 111 createMenuItem({ |
65 } | 112 id: 'reopen-menu', |
66 | 113 title: browser.i18n.getMessage('reopenInWindowMenu'), |
67 getAllWindows() { | 114 enabled: false, |
68 return this.windows.values(); | 115 contexts: ['tab'] |
69 } | 116 }) |
70 | 117 ]); |
71 getfocusedWindowId() { | 118 browser.menus.onShown.addListener(onMenuShown); |
72 return this.focusedWindowId; | 119 browser.menus.onHidden.addListener(onMenuHidden); |
73 } | 120 })(); |
74 | |
75 openWindow(id, incognito = false) { | |
76 this.windows.set(id, { | |
77 id, | |
78 title: browser.i18n.getMessage(incognito ? | |
79 'defaultIncognitoWindowTitle' : 'defaultWindowTitle', id), | |
80 incognito | |
81 }); | |
82 | |
83 this.notifyObservers('window-opened', id); | |
84 } | |
85 | |
86 updateWindowTitle(id, title) { | |
87 if (!this.windows.has(id)) { | |
88 return; | |
89 } | |
90 | |
91 let windowInfo = this.windows.get(id) | |
92 windowInfo.title = browser.i18n.getMessage(windowInfo.incognito ? | |
93 'incognitoWindowTitle' : 'windowTitle', title); | |
94 | |
95 this.notifyObservers('window-title-updated', id, title); | |
96 } | |
97 | |
98 focusWindow(id) { | |
99 this.focusedWindowId = this.windows.has(id) ? id : | |
100 browser.windows.WINDOW_ID_NONE; | |
101 | |
102 this.notifyObservers('window-focus-changed', id); | |
103 } | |
104 | |
105 closeWindow(id) { | |
106 if (!this.windows.has(id)) { | |
107 return; | |
108 } | |
109 | |
110 this.windows.delete(id); | |
111 | |
112 if (id === this.focusedWindowId) { | |
113 this.focusedWindowId = browser.windows.WINDOW_ID_NONE; | |
114 } | |
115 | |
116 this.notifyObservers('window-closed', id); | |
117 } | |
118 } | |
119 | |
120 class MenuView { | |
121 constructor(model) { | |
122 this.model = model; | |
123 this.moveMenuIds = new Set(); | |
124 this.reopenMenuIds = new Set(); | |
125 this.menuContexts = ['tab']; | |
126 | |
127 browser.runtime.getBrowserInfo().then(browserInfo => { | |
128 // Firefox before version 53 does not support tab context menus | |
129 let majorVersion = browserInfo.version.match(/^\d+/); | |
130 if (majorVersion !== null && majorVersion < 53) { | |
131 this.menuContexts = ['all']; | |
132 } | |
133 | |
134 return Promise.all([ | |
135 // create submenus | |
136 createContextMenuItem({ | |
137 id: 'move-menu', | |
138 title: browser.i18n.getMessage('moveToWindowMenu'), | |
139 enabled: false, | |
140 contexts: this.menuContexts | |
141 }), | |
142 createContextMenuItem({ | |
143 id: 'reopen-menu', | |
144 title: browser.i18n.getMessage('reopenInWindowMenu'), | |
145 enabled: false, | |
146 contexts: this.menuContexts | |
147 }) | |
148 ]); | |
149 }).then(values => { | |
150 this.model.addObserver('window-opened', | |
151 this.onWindowOpened.bind(this)); | |
152 this.model.addObserver('window-title-updated', | |
153 this.onWindowTitleUpdated.bind(this)); | |
154 this.model.addObserver('window-focus-changed', | |
155 this.onWindowFocusChanged.bind(this)); | |
156 this.model.addObserver('window-closed', | |
157 this.onWindowClosed.bind(this)); | |
158 }).catch(error => { | |
159 console.log('Error:', error); | |
160 }); | |
161 } | |
162 | |
163 enableMenus() { | |
164 return Promise.all([ | |
165 browser.contextMenus.update('move-menu', { | |
166 enabled: this.moveMenuIds.size > 0 | |
167 }), | |
168 browser.contextMenus.update('reopen-menu', { | |
169 enabled: this.reopenMenuIds.size > 0 | |
170 }) | |
171 ]); | |
172 } | |
173 | |
174 onWindowOpened(eventName, windowId) { | |
175 let focusedWindowId = this.model.getfocusedWindowId(); | |
176 if (focusedWindowId === browser.windows.WINDOW_ID_NONE) { | |
177 // no window is focused so there is no need to update the menu | |
178 return; | |
179 } | |
180 | |
181 let menuId = String(windowId); | |
182 let windowInfo = this.model.getWindow(windowId); | |
183 let incognito = this.model.getWindow(focusedWindowId).incognito; | |
184 | |
185 if (incognito !== windowInfo.incognito) { | |
186 this.reopenMenuIds.add(menuId); | |
187 } else { | |
188 this.moveMenuIds.add(menuId); | |
189 } | |
190 | |
191 createContextMenuItem({ | |
192 id: menuId, | |
193 title: windowInfo.title, | |
194 contexts: this.menuContexts, | |
195 parentId: (incognito !== windowInfo.incognito) ? | |
196 'reopen-menu' : 'move-menu' | |
197 }).then(() => { | |
198 return this.enableMenus(); | |
199 }).catch(error => { | |
200 console.log('Error:', error); | |
201 }); | |
202 } | |
203 | |
204 onWindowTitleUpdated(eventName, windowId, title) { | |
205 if (this.model.getfocusedWindowId() === | |
206 browser.windows.WINDOW_ID_NONE) { | |
207 // no window is focused so there is no need to update the menu | |
208 return; | |
209 } | |
210 | |
211 browser.contextMenus.update(String(windowId), {title}).catch(error => { | |
212 console.log('Error:', error); | |
213 }); | |
214 } | |
215 | |
216 onWindowFocusChanged(eventName, newWindowId) { | |
217 let promises = [ | |
218 // disable submenus | |
219 browser.contextMenus.update('move-menu', { | |
220 enabled: false | |
221 }), | |
222 browser.contextMenus.update('reopen-menu', { | |
223 enabled: false | |
224 }) | |
225 ]; | |
226 | |
227 if (newWindowId === browser.windows.WINDOW_ID_NONE) { | |
228 // just disable the submenus if focus moved to a window not tracked | |
229 Promise.all(promises).catch(error => { | |
230 console.log('Error:', error); | |
231 }); | |
232 return; | |
233 } | |
234 | |
235 Promise.all(promises).then(values => { | |
236 // remove all submenu items | |
237 let promises = new Array(...this.moveMenuIds, | |
238 ...this.reopenMenuIds).map(menuId => { | |
239 this.moveMenuIds.delete(menuId) || | |
240 this.reopenMenuIds.delete(menuId); | |
241 | |
242 return browser.contextMenus.remove(menuId); | |
243 }); | |
244 | |
245 return Promise.all(promises); | |
246 }).then(values => { | |
247 let incognito = this.model.getWindow(newWindowId).incognito; | |
248 | |
249 // rebuild submenus | |
250 let promises = []; | |
251 for (let windowInfo of this.model.getAllWindows()) { | |
252 if (windowInfo.id === newWindowId) { | |
253 // skip the currently focused window | |
254 continue; | |
255 } | |
256 | |
257 let menuId = String(windowInfo.id); | |
258 if (incognito !== windowInfo.incognito) { | |
259 this.reopenMenuIds.add(menuId); | |
260 } else { | |
261 this.moveMenuIds.add(menuId); | |
262 } | |
263 | |
264 // create menu item | |
265 promises.push(createContextMenuItem({ | |
266 id: menuId, | |
267 title: windowInfo.title, | |
268 contexts: this.menuContexts, | |
269 parentId: (incognito !== windowInfo.incognito) ? | |
270 'reopen-menu' : 'move-menu' | |
271 })); | |
272 } | |
273 | |
274 return Promise.all(promises); | |
275 }).then(values => { | |
276 return this.enableMenus(); | |
277 }).catch(error => { | |
278 console.log('Error:', error); | |
279 }); | |
280 } | |
281 | |
282 onWindowClosed(eventName, windowId) { | |
283 if (this.model.getfocusedWindowId() === | |
284 browser.windows.WINDOW_ID_NONE) { | |
285 return; | |
286 } | |
287 | |
288 let menuId = String(windowId); | |
289 | |
290 this.moveMenuIds.delete(menuId) || this.reopenMenuIds.delete(menuId); | |
291 | |
292 browser.contextMenus.remove(menuId).then(() => { | |
293 return this.enableMenus(); | |
294 }).catch(error => { | |
295 console.log('Error:', error); | |
296 }); | |
297 } | |
298 } | |
299 | |
300 class Presenter { | |
301 constructor(model, view) { | |
302 this.model = model; | |
303 this.view = view; | |
304 | |
305 browser.windows.getAll({windowTypes: ['normal']}).then(windows => { | |
306 // populate model with existing windows | |
307 for (let windowInfo of windows) { | |
308 this.onWindowCreated(windowInfo); | |
309 | |
310 if (windowInfo.focused) { | |
311 this.onWindowFocusChanged(windowInfo.id); | |
312 } | |
313 } | |
314 | |
315 browser.windows.onCreated | |
316 .addListener(this.onWindowCreated.bind(this)); | |
317 browser.windows.onRemoved | |
318 .addListener(this.onWindowRemoved.bind(this)); | |
319 browser.windows.onFocusChanged | |
320 .addListener(this.onWindowFocusChanged.bind(this)); | |
321 browser.contextMenus.onClicked | |
322 .addListener(this.onMenuItemClicked.bind(this)); | |
323 }).catch(error => { | |
324 console.log('Error:', error); | |
325 }); | |
326 } | |
327 | |
328 onWindowCreated(windowInfo) { | |
329 // only track normal windows | |
330 if (windowInfo.type !== 'normal') { | |
331 return; | |
332 } | |
333 | |
334 this.model.openWindow(windowInfo.id, windowInfo.incognito); | |
335 | |
336 // get the window title and update the model | |
337 browser.tabs.query({ | |
338 active: true, | |
339 windowId: windowInfo.id | |
340 }).then(tabs => { | |
341 this.model.updateWindowTitle(tabs[0].windowId, tabs[0].title) | |
342 }).catch(error => { | |
343 console.log('Error:', error); | |
344 }); | |
345 } | |
346 | |
347 onWindowRemoved(windowId) { | |
348 this.model.closeWindow(windowId); | |
349 } | |
350 | |
351 onWindowFocusChanged(windowId) { | |
352 let prevFocusedWindowId = this.model.getfocusedWindowId(); | |
353 if (prevFocusedWindowId !== browser.windows.WINDOW_ID_NONE) { | |
354 // get title of the previously focused window and update the model | |
355 browser.tabs.query({ | |
356 active: true, | |
357 windowId: prevFocusedWindowId | |
358 }).then(tabs => { | |
359 this.model.updateWindowTitle(tabs[0].windowId, tabs[0].title) | |
360 }).catch(error => { | |
361 console.log('Error:', error); | |
362 }); | |
363 } | |
364 | |
365 this.model.focusWindow(windowId); | |
366 } | |
367 | |
368 onMenuItemClicked(info, tab) { | |
369 var windowId = parseInt(info.menuItemId); | |
370 | |
371 if (info.parentMenuItemId === 'move-menu') { | |
372 // move tab from the current window to the selected window | |
373 browser.tabs.move(tab.id, { | |
374 windowId, | |
375 index: -1 | |
376 }).catch(error => { | |
377 console.log('Error:', error); | |
378 }); | |
379 } else { | |
380 // open the URL of the current tab in the destination window | |
381 browser.tabs.create({ | |
382 url: tab.url, | |
383 windowId, | |
384 }).then(newTab => { | |
385 // close the current tab | |
386 return browser.tabs.remove(tab.id); | |
387 }).then(() => { | |
388 // get the new title of the destination window | |
389 return browser.tabs.query({ | |
390 active: true, | |
391 windowId | |
392 }); | |
393 }).then(tabs => { | |
394 this.model.updateWindowTitle(windowId, tabs[0].title) | |
395 }).catch(error => { | |
396 console.log('Error:', error); | |
397 }); | |
398 } | |
399 } | |
400 } | |
401 | |
402 let windowsModel = new WindowsModel(); | |
403 let menuView = new MenuView(windowsModel); | |
404 let presenter = new Presenter(windowsModel, menuView); |