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);