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
5var EXPORTED_SYMBOLS = ["PageMenuParent", "PageMenuChild"];
6
7function PageMenu() {}
8
9PageMenu.prototype = {
10  PAGEMENU_ATTR: "pagemenu",
11  GENERATEDITEMID_ATTR: "generateditemid",
12
13  _popup: null,
14
15  // Only one of builder or browser will end up getting set.
16  _builder: null,
17  _browser: null,
18
19  // Given a target node, get the context menu for it or its ancestor.
20  getContextMenu(aTarget) {
21    let target = aTarget;
22    while (target) {
23      let contextMenu = target.contextMenu;
24      if (contextMenu) {
25        return contextMenu;
26      }
27      target = target.parentNode;
28    }
29
30    return null;
31  },
32
33  // Given a target node, generate a JSON object for any context menu
34  // associated with it, or null if there is no context menu.
35  maybeBuild(aTarget) {
36    let pageMenu = this.getContextMenu(aTarget);
37    if (!pageMenu) {
38      return null;
39    }
40
41    pageMenu.sendShowEvent();
42    // the show event is not cancelable, so no need to check a result here
43
44    this._builder = pageMenu.createBuilder();
45    if (!this._builder) {
46      return null;
47    }
48
49    pageMenu.build(this._builder);
50
51    // This serializes then parses again, however this could be avoided in
52    // the single-process case with further improvement.
53    let menuString = this._builder.toJSONString();
54    if (!menuString) {
55      return null;
56    }
57
58    return JSON.parse(menuString);
59  },
60
61  // Given a JSON menu object and popup, add the context menu to the popup.
62  buildAndAttachMenuWithObject(aMenu, aBrowser, aPopup) {
63    if (!aMenu) {
64      return false;
65    }
66
67    let insertionPoint = this.getInsertionPoint(aPopup);
68    if (!insertionPoint) {
69      return false;
70    }
71
72    let fragment = aPopup.ownerDocument.createDocumentFragment();
73    this.buildXULMenu(aMenu, fragment);
74
75    let pos = insertionPoint.getAttribute(this.PAGEMENU_ATTR);
76    if (pos == "start") {
77      insertionPoint.insertBefore(fragment, insertionPoint.firstElementChild);
78    } else if (pos.startsWith("#")) {
79      insertionPoint.insertBefore(fragment, insertionPoint.querySelector(pos));
80    } else {
81      insertionPoint.appendChild(fragment);
82    }
83
84    this._browser = aBrowser;
85    this._popup = aPopup;
86
87    this._popup.addEventListener("command", this);
88    this._popup.addEventListener("popuphidden", this);
89
90    return true;
91  },
92
93  // Construct the XUL menu structure for a given JSON object.
94  buildXULMenu(aNode, aElementForAppending) {
95    let document = aElementForAppending.ownerDocument;
96
97    let children = aNode.children;
98    for (let child of children) {
99      let menuitem;
100      switch (child.type) {
101        case "menuitem":
102          if (!child.id) {
103            continue; // Ignore children without ids
104          }
105
106          menuitem = document.createXULElement("menuitem");
107          if (child.checkbox) {
108            menuitem.setAttribute("type", "checkbox");
109            if (child.checked) {
110              menuitem.setAttribute("checked", "true");
111            }
112          }
113
114          if (child.label) {
115            menuitem.setAttribute("label", child.label);
116          }
117          if (child.icon) {
118            menuitem.setAttribute("image", child.icon);
119            menuitem.className = "menuitem-iconic";
120          }
121          if (child.disabled) {
122            menuitem.setAttribute("disabled", true);
123          }
124
125          break;
126
127        case "separator":
128          menuitem = document.createXULElement("menuseparator");
129          break;
130
131        case "menu":
132          menuitem = document.createXULElement("menu");
133          if (child.label) {
134            menuitem.setAttribute("label", child.label);
135          }
136
137          let menupopup = document.createXULElement("menupopup");
138          menuitem.appendChild(menupopup);
139
140          this.buildXULMenu(child, menupopup);
141          break;
142      }
143
144      menuitem.setAttribute(this.GENERATEDITEMID_ATTR, child.id ? child.id : 0);
145      aElementForAppending.appendChild(menuitem);
146    }
147  },
148
149  // Called when the generated menuitem is executed.
150  handleEvent(event) {
151    let type = event.type;
152    let target = event.target;
153    if (type == "command" && target.hasAttribute(this.GENERATEDITEMID_ATTR)) {
154      // If a builder is assigned, call click on it directly. Otherwise, this is
155      // likely a menu with data from another process, so send a message to the
156      // browser to execute the menuitem.
157      if (this._builder) {
158        this._builder.click(target.getAttribute(this.GENERATEDITEMID_ATTR));
159      } else if (this._browser) {
160        let win = target.ownerGlobal;
161        let windowUtils = win.windowUtils;
162        win.gContextMenu.doCustomCommand(
163          target.getAttribute(this.GENERATEDITEMID_ATTR),
164          windowUtils.isHandlingUserInput
165        );
166      }
167    } else if (type == "popuphidden" && this._popup == target) {
168      this.removeGeneratedContent(this._popup);
169
170      this._popup.removeEventListener("popuphidden", this);
171      this._popup.removeEventListener("command", this);
172
173      this._popup = null;
174      this._builder = null;
175      this._browser = null;
176    }
177  },
178
179  // Get the first child of the given element with the given tag name.
180  getImmediateChild(element, tag) {
181    let child = element.firstElementChild;
182    while (child) {
183      if (child.localName == tag) {
184        return child;
185      }
186      child = child.nextElementSibling;
187    }
188    return null;
189  },
190
191  // Return the location where the generated items should be inserted into the
192  // given popup. They should be inserted as the next sibling of the returned
193  // element.
194  getInsertionPoint(aPopup) {
195    if (aPopup.hasAttribute(this.PAGEMENU_ATTR)) {
196      return aPopup;
197    }
198
199    let element = aPopup.firstElementChild;
200    while (element) {
201      if (element.localName == "menu") {
202        let popup = this.getImmediateChild(element, "menupopup");
203        if (popup) {
204          let result = this.getInsertionPoint(popup);
205          if (result) {
206            return result;
207          }
208        }
209      }
210      element = element.nextElementSibling;
211    }
212
213    return null;
214  },
215
216  // Remove the generated content from the given popup.
217  removeGeneratedContent(aPopup) {
218    let ungenerated = [];
219    ungenerated.push(aPopup);
220
221    let count;
222    while (0 != (count = ungenerated.length)) {
223      let last = count - 1;
224      let element = ungenerated[last];
225      ungenerated.splice(last, 1);
226
227      let i = element.children.length;
228      while (i-- > 0) {
229        let child = element.children[i];
230        if (!child.hasAttribute(this.GENERATEDITEMID_ATTR)) {
231          ungenerated.push(child);
232          continue;
233        }
234        element.removeChild(child);
235      }
236    }
237  },
238};
239
240// This object is expected to be used from a parent process.
241function PageMenuParent() {}
242
243PageMenuParent.prototype = {
244  __proto__: PageMenu.prototype,
245  /*
246   * Given a JSON menu object and popup, add the context menu to the popup.
247   * aBrowser should be the browser containing the page the context menu is
248   * displayed for, which may be null.
249   *
250   * Returns true if custom menu items were present.
251   */
252  addToPopup(aMenu, aBrowser, aPopup) {
253    return this.buildAndAttachMenuWithObject(aMenu, aBrowser, aPopup);
254  },
255};
256
257// This object is expected to be used from a child process.
258function PageMenuChild() {}
259
260PageMenuChild.prototype = {
261  __proto__: PageMenu.prototype,
262
263  /*
264   * Given a target node, return a JSON object for the custom menu commands. The
265   * object will consist of a hierarchical structure of menus, menuitems or
266   * separators. Supported properties of each are:
267   *   Menu: children, label, type="menu"
268   *   Menuitems: checkbox, checked, disabled, icon, label, type="menuitem"
269   *   Separators: type="separator"
270   *
271   * In addition, the id of each item will be used to identify the item
272   * when it is executed. The type will either be 'menu', 'menuitem' or
273   * 'separator'. The toplevel node will be a menu with a children property. The
274   * children property of a menu is an array of zero or more other items.
275   *
276   * If there is no menu associated with aTarget, null will be returned.
277   */
278  build(aTarget) {
279    return this.maybeBuild(aTarget);
280  },
281
282  /*
283   * Given the id of a menu, execute the command associated with that menu. It
284   * is assumed that only one command will be executed so the builder is
285   * cleared afterwards.
286   */
287  executeMenu(aId) {
288    if (this._builder) {
289      this._builder.click(aId);
290      this._builder = null;
291    }
292  },
293};
294