menu.js (6762B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); 8 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 9 10 /** 11 * A partial implementation of the Menu API provided by electron: 12 * https://github.com/electron/electron/blob/master/docs/api/menu.md. 13 * 14 * Extra features: 15 * - Emits an 'open' and 'close' event when the menu is opened/closed 16 17 * @param String id (non standard) 18 * Needed so tests can confirm the XUL implementation is working 19 */ 20 function Menu({ id = null } = {}) { 21 this.menuitems = []; 22 this.id = id; 23 24 Object.defineProperty(this, "items", { 25 get() { 26 return this.menuitems; 27 }, 28 }); 29 30 EventEmitter.decorate(this); 31 } 32 33 /** 34 * Add an item to the end of the Menu 35 * 36 * @param {MenuItem} menuItem 37 */ 38 Menu.prototype.append = function (menuItem) { 39 this.menuitems.push(menuItem); 40 }; 41 42 /** 43 * Remove all items from the Menu 44 */ 45 Menu.prototype.clear = function () { 46 this.menuitems = []; 47 }; 48 49 /** 50 * Add an item to a specified position in the menu 51 * 52 * @param {int} _pos 53 * @param {MenuItem} _menuItem 54 */ 55 Menu.prototype.insert = function (_pos, _menuItem) { 56 throw Error("Not implemented"); 57 }; 58 59 /** 60 * Show the Menu next to the provided target. Anchor point is bottom-left. 61 * 62 * @param {Element} target 63 * The element to use as anchor. 64 */ 65 Menu.prototype.popupAtTarget = function (target) { 66 const rect = target.getBoundingClientRect(); 67 const doc = target.ownerDocument; 68 const defaultView = doc.defaultView; 69 const x = rect.left + defaultView.mozInnerScreenX; 70 const y = rect.bottom + defaultView.mozInnerScreenY; 71 72 this.popup(x, y, doc); 73 }; 74 75 /** 76 * Hide an existing menu, if there's any. 77 * 78 * @param {Document} doc 79 * The document that should own the context menu. 80 */ 81 Menu.prototype.hide = function (doc) { 82 const win = doc.defaultView; 83 doc = DevToolsUtils.getTopWindow(win).document; 84 const popup = doc.querySelector('popupset menupopup[menu-api="true"]'); 85 if (!popup) { 86 return; 87 } 88 popup.hidePopup(); 89 }; 90 91 /** 92 * Show the Menu at a specified location on the screen 93 * 94 * Missing features: 95 * - browserWindow - BrowserWindow (optional) - Default is null. 96 * - positioningItem Number - (optional) OS X 97 * 98 * @param {int} screenX 99 * @param {int} screenY 100 * @param {Document} doc 101 * The document that should own the context menu. 102 */ 103 Menu.prototype.popup = function (screenX, screenY, doc) { 104 // See bug 1285229, on Windows, opening the same popup multiple times in a 105 // row ends up duplicating the popup. The newly inserted popup doesn't 106 // dismiss the old one. So remove any previously displayed popup before 107 // opening a new one. 108 this.hide(doc); 109 110 // The context-menu will be created in the topmost window to preserve keyboard 111 // navigation (see Bug 1543940). 112 // Keep a reference on the window owning the menu to hide the popup on unload. 113 const win = doc.defaultView; 114 const topWin = DevToolsUtils.getTopWindow(win); 115 116 // Convert coordinates from win's CSS coordinate space to topWin's 117 const winToTopWinCssScale = win.devicePixelRatio / topWin.devicePixelRatio; 118 screenX = screenX * winToTopWinCssScale; 119 screenY = screenY * winToTopWinCssScale; 120 121 doc = topWin.document; 122 123 let popupset = doc.querySelector("popupset"); 124 if (!popupset) { 125 popupset = doc.createXULElement("popupset"); 126 doc.documentElement.appendChild(popupset); 127 } 128 129 const popup = doc.createXULElement("menupopup"); 130 popup.setAttribute("menu-api", "true"); 131 popup.setAttribute("consumeoutsideclicks", "false"); 132 popup.setAttribute("incontentshell", "false"); 133 134 if (this.id) { 135 popup.id = this.id; 136 } 137 this._createMenuItems(popup); 138 139 // The context menu will be created in the topmost chrome window. Hide it manually when 140 // the owner document is unloaded. 141 const onWindowUnload = () => popup.hidePopup(); 142 win.addEventListener("unload", onWindowUnload); 143 144 // Remove the menu from the DOM once it's hidden. 145 popup.addEventListener("popuphidden", e => { 146 if (e.target === popup) { 147 win.removeEventListener("unload", onWindowUnload); 148 popup.remove(); 149 this.emit("close"); 150 } 151 }); 152 153 popup.addEventListener("popupshown", e => { 154 if (e.target === popup) { 155 this.emit("open"); 156 } 157 }); 158 159 popupset.appendChild(popup); 160 popup.openPopupAtScreen(screenX, screenY, true); 161 }; 162 163 Menu.prototype._createMenuItems = function (parent) { 164 const doc = parent.ownerDocument; 165 this.menuitems.forEach(item => { 166 if (!item.visible) { 167 return; 168 } 169 170 if (item.submenu) { 171 const menupopup = doc.createXULElement("menupopup"); 172 menupopup.setAttribute("incontentshell", "false"); 173 174 item.submenu._createMenuItems(menupopup); 175 176 const menu = doc.createXULElement("menu"); 177 menu.appendChild(menupopup); 178 applyItemAttributesToNode(item, menu); 179 parent.appendChild(menu); 180 } else if (item.type === "separator") { 181 const menusep = doc.createXULElement("menuseparator"); 182 parent.appendChild(menusep); 183 } else { 184 const menuitem = doc.createXULElement("menuitem"); 185 applyItemAttributesToNode(item, menuitem); 186 187 menuitem.addEventListener("command", () => { 188 item.click(); 189 }); 190 menuitem.addEventListener("DOMMenuItemActive", () => { 191 item.hover(); 192 }); 193 194 parent.appendChild(menuitem); 195 } 196 }); 197 }; 198 199 Menu.getMenuElementById = function (id, doc) { 200 const menuDoc = DevToolsUtils.getTopWindow(doc.defaultView).document; 201 return menuDoc.getElementById(id); 202 }; 203 204 Menu.setApplicationMenu = () => { 205 throw Error("Not implemented"); 206 }; 207 208 Menu.sendActionToFirstResponder = () => { 209 throw Error("Not implemented"); 210 }; 211 212 Menu.buildFromTemplate = () => { 213 throw Error("Not implemented"); 214 }; 215 216 function applyItemAttributesToNode(item, node) { 217 if (item.l10nID) { 218 node.ownerDocument.l10n.setAttributes(node, item.l10nID); 219 } else { 220 node.setAttribute("label", item.label); 221 if (item.accelerator) { 222 node.setAttribute("acceltext", item.accelerator); 223 } 224 if (item.accesskey) { 225 node.setAttribute("accesskey", item.accesskey); 226 } 227 } 228 if (item.type === "checkbox") { 229 node.setAttribute("type", "checkbox"); 230 } 231 if (item.type === "radio") { 232 node.setAttribute("type", "radio"); 233 } 234 if (item.disabled) { 235 node.setAttribute("disabled", "true"); 236 } 237 if (item.checked) { 238 node.setAttribute("checked", "true"); 239 } 240 if (item.image) { 241 node.setAttribute("image", item.image); 242 } 243 if (item.id) { 244 node.id = item.id; 245 } 246 } 247 248 module.exports = Menu;