diff options
Diffstat (limited to 'application/basilisk/components/extensions/ext-contextMenus.js')
-rw-r--r-- | application/basilisk/components/extensions/ext-contextMenus.js | 640 |
1 files changed, 640 insertions, 0 deletions
diff --git a/application/basilisk/components/extensions/ext-contextMenus.js b/application/basilisk/components/extensions/ext-contextMenus.js new file mode 100644 index 000000000..34a828f13 --- /dev/null +++ b/application/basilisk/components/extensions/ext-contextMenus.js @@ -0,0 +1,640 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Cu.import("resource://gre/modules/ExtensionUtils.jsm"); +Cu.import("resource://gre/modules/MatchPattern.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +var { + EventManager, + ExtensionError, + IconDetails, +} = ExtensionUtils; + +const ACTION_MENU_TOP_LEVEL_LIMIT = 6; + +// Map[Extension -> Map[ID -> MenuItem]] +// Note: we want to enumerate all the menu items so +// this cannot be a weak map. +var gContextMenuMap = new Map(); + +// Map[Extension -> MenuItem] +var gRootItems = new Map(); + +// If id is not specified for an item we use an integer. +var gNextMenuItemID = 0; + +// Used to assign unique names to radio groups. +var gNextRadioGroupID = 0; + +// The max length of a menu item's label. +var gMaxLabelLength = 64; + +var gMenuBuilder = { + // When a new contextMenu is opened, this function is called and + // we populate the |xulMenu| with all the items from extensions + // to be displayed. We always clear all the items again when + // popuphidden fires. + build(contextData) { + let firstItem = true; + let xulMenu = contextData.menu; + xulMenu.addEventListener("popuphidden", this); + this.xulMenu = xulMenu; + for (let [, root] of gRootItems) { + let rootElement = this.buildElementWithChildren(root, contextData); + if (!rootElement.firstChild || !rootElement.firstChild.childNodes.length) { + // If the root has no visible children, there is no reason to show + // the root menu item itself either. + continue; + } + rootElement.setAttribute("ext-type", "top-level-menu"); + rootElement = this.removeTopLevelMenuIfNeeded(rootElement); + + // Display the extension icon on the root element. + if (root.extension.manifest.icons) { + let parentWindow = contextData.menu.ownerGlobal; + let extension = root.extension; + + let {icon} = IconDetails.getPreferredIcon(extension.manifest.icons, extension, + 16 * parentWindow.devicePixelRatio); + + // The extension icons in the manifest are not pre-resolved, since + // they're sometimes used by the add-on manager when the extension is + // not enabled, and its URLs are not resolvable. + let resolvedURL = root.extension.baseURI.resolve(icon); + + if (rootElement.localName == "menu") { + rootElement.setAttribute("class", "menu-iconic"); + } else if (rootElement.localName == "menuitem") { + rootElement.setAttribute("class", "menuitem-iconic"); + } + rootElement.setAttribute("image", resolvedURL); + } + + if (firstItem) { + firstItem = false; + const separator = xulMenu.ownerDocument.createElement("menuseparator"); + this.itemsToCleanUp.add(separator); + xulMenu.append(separator); + } + + xulMenu.appendChild(rootElement); + this.itemsToCleanUp.add(rootElement); + } + }, + + // Builds a context menu for browserAction and pageAction buttons. + buildActionContextMenu(contextData) { + const {menu} = contextData; + + contextData.tab = TabManager.activeTab; + contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec; + + const root = gRootItems.get(contextData.extension); + const children = this.buildChildren(root, contextData); + const visible = children.slice(0, ACTION_MENU_TOP_LEVEL_LIMIT); + + if (visible.length) { + this.xulMenu = menu; + menu.addEventListener("popuphidden", this); + + const separator = menu.ownerDocument.createElement("menuseparator"); + menu.insertBefore(separator, menu.firstChild); + this.itemsToCleanUp.add(separator); + + for (const child of visible) { + this.itemsToCleanUp.add(child); + menu.insertBefore(child, separator); + } + } + }, + + buildElementWithChildren(item, contextData) { + const element = this.buildSingleElement(item, contextData); + const children = this.buildChildren(item, contextData); + if (children.length) { + element.firstChild.append(...children); + } + return element; + }, + + buildChildren(item, contextData) { + let groupName; + let children = []; + for (let child of item.children) { + if (child.type == "radio" && !child.groupName) { + if (!groupName) { + groupName = `webext-radio-group-${gNextRadioGroupID++}`; + } + child.groupName = groupName; + } else { + groupName = null; + } + + if (child.enabledForContext(contextData)) { + children.push(this.buildElementWithChildren(child, contextData)); + } + } + return children; + }, + + removeTopLevelMenuIfNeeded(element) { + // If there is only one visible top level element we don't need the + // root menu element for the extension. + let menuPopup = element.firstChild; + if (menuPopup && menuPopup.childNodes.length == 1) { + let onlyChild = menuPopup.firstChild; + onlyChild.remove(); + return onlyChild; + } + + return element; + }, + + buildSingleElement(item, contextData) { + let doc = contextData.menu.ownerDocument; + let element; + if (item.children.length > 0) { + element = this.createMenuElement(doc, item); + } else if (item.type == "separator") { + element = doc.createElement("menuseparator"); + } else { + element = doc.createElement("menuitem"); + } + + return this.customizeElement(element, item, contextData); + }, + + createMenuElement(doc, item) { + let element = doc.createElement("menu"); + // Menu elements need to have a menupopup child for its menu items. + let menupopup = doc.createElement("menupopup"); + element.appendChild(menupopup); + return element; + }, + + customizeElement(element, item, contextData) { + let label = item.title; + if (label) { + if (contextData.isTextSelected && label.indexOf("%s") > -1) { + let selection = contextData.selectionText; + // The rendering engine will truncate the title if it's longer than 64 characters. + // But if it makes sense let's try truncate selection text only, to handle cases like + // 'look up "%s" in MyDictionary' more elegantly. + let maxSelectionLength = gMaxLabelLength - label.length + 2; + if (maxSelectionLength > 4) { + selection = selection.substring(0, maxSelectionLength - 3) + "..."; + } + label = label.replace(/%s/g, selection); + } + + element.setAttribute("label", label); + } + + if (item.type == "checkbox") { + element.setAttribute("type", "checkbox"); + if (item.checked) { + element.setAttribute("checked", "true"); + } + } else if (item.type == "radio") { + element.setAttribute("type", "radio"); + element.setAttribute("name", item.groupName); + if (item.checked) { + element.setAttribute("checked", "true"); + } + } + + if (!item.enabled) { + element.setAttribute("disabled", "true"); + } + + element.addEventListener("command", event => { // eslint-disable-line mozilla/balanced-listeners + if (event.target !== event.currentTarget) { + return; + } + const wasChecked = item.checked; + if (item.type == "checkbox") { + item.checked = !item.checked; + } else if (item.type == "radio") { + // Deselect all radio items in the current radio group. + for (let child of item.parent.children) { + if (child.type == "radio" && child.groupName == item.groupName) { + child.checked = false; + } + } + // Select the clicked radio item. + item.checked = true; + } + + item.tabManager.addActiveTabPermission(); + + let tab = item.tabManager.convert(contextData.tab); + let info = item.getClickInfo(contextData, wasChecked); + item.extension.emit("webext-contextmenu-menuitem-click", info, tab); + }); + + return element; + }, + + handleEvent(event) { + if (this.xulMenu != event.target || event.type != "popuphidden") { + return; + } + + delete this.xulMenu; + let target = event.target; + target.removeEventListener("popuphidden", this); + for (let item of this.itemsToCleanUp) { + item.remove(); + } + this.itemsToCleanUp.clear(); + }, + + itemsToCleanUp: new Set(), +}; + +// Called from pageAction or browserAction popup. +global.actionContextMenu = function(contextData) { + gMenuBuilder.buildActionContextMenu(contextData); +}; + +function getContexts(contextData) { + let contexts = new Set(); + + if (contextData.inFrame) { + contexts.add("frame"); + } + + if (contextData.isTextSelected) { + contexts.add("selection"); + } + + if (contextData.onLink) { + contexts.add("link"); + } + + if (contextData.onEditableArea) { + contexts.add("editable"); + } + + if (contextData.onPassword) { + contexts.add("password"); + } + + if (contextData.onImage) { + contexts.add("image"); + } + + if (contextData.onVideo) { + contexts.add("video"); + } + + if (contextData.onAudio) { + contexts.add("audio"); + } + + if (contextData.onPageAction) { + contexts.add("page_action"); + } + + if (contextData.onBrowserAction) { + contexts.add("browser_action"); + } + + if (contexts.size === 0) { + contexts.add("page"); + } + + if (contextData.onTab) { + contexts.add("tab"); + } else { + contexts.add("all"); + } + + return contexts; +} + +function MenuItem(extension, createProperties, isRoot = false) { + this.extension = extension; + this.children = []; + this.parent = null; + this.tabManager = TabManager.for(extension); + + this.setDefaults(); + this.setProps(createProperties); + + if (!this.hasOwnProperty("_id")) { + this.id = gNextMenuItemID++; + } + // If the item is not the root and has no parent + // it must be a child of the root. + if (!isRoot && !this.parent) { + this.root.addChild(this); + } +} + +MenuItem.prototype = { + setProps(createProperties) { + for (let propName in createProperties) { + if (createProperties[propName] === null) { + // Omitted optional argument. + continue; + } + this[propName] = createProperties[propName]; + } + + if (createProperties.documentUrlPatterns != null) { + this.documentUrlMatchPattern = new MatchPattern(this.documentUrlPatterns); + } + + if (createProperties.targetUrlPatterns != null) { + this.targetUrlMatchPattern = new MatchPattern(this.targetUrlPatterns); + } + + // If a child MenuItem does not specify any contexts, then it should + // inherit the contexts specified from its parent. + if (createProperties.parentId && !createProperties.contexts) { + this.contexts = this.parent.contexts; + } + }, + + setDefaults() { + this.setProps({ + type: "normal", + checked: false, + contexts: ["all"], + enabled: true, + }); + }, + + set id(id) { + if (this.hasOwnProperty("_id")) { + throw new Error("Id of a MenuItem cannot be changed"); + } + let isIdUsed = gContextMenuMap.get(this.extension).has(id); + if (isIdUsed) { + throw new Error("Id already exists"); + } + this._id = id; + }, + + get id() { + return this._id; + }, + + ensureValidParentId(parentId) { + if (parentId === undefined) { + return; + } + let menuMap = gContextMenuMap.get(this.extension); + if (!menuMap.has(parentId)) { + throw new Error("Could not find any MenuItem with id: " + parentId); + } + for (let item = menuMap.get(parentId); item; item = item.parent) { + if (item === this) { + throw new ExtensionError("MenuItem cannot be an ancestor (or self) of its new parent."); + } + } + }, + + set parentId(parentId) { + this.ensureValidParentId(parentId); + + if (this.parent) { + this.parent.detachChild(this); + } + + if (parentId === undefined) { + this.root.addChild(this); + } else { + let menuMap = gContextMenuMap.get(this.extension); + menuMap.get(parentId).addChild(this); + } + }, + + get parentId() { + return this.parent ? this.parent.id : undefined; + }, + + addChild(child) { + if (child.parent) { + throw new Error("Child MenuItem already has a parent."); + } + this.children.push(child); + child.parent = this; + }, + + detachChild(child) { + let idx = this.children.indexOf(child); + if (idx < 0) { + throw new Error("Child MenuItem not found, it cannot be removed."); + } + this.children.splice(idx, 1); + child.parent = null; + }, + + get root() { + let extension = this.extension; + if (!gRootItems.has(extension)) { + let root = new MenuItem(extension, + {title: extension.name}, + /* isRoot = */ true); + gRootItems.set(extension, root); + } + + return gRootItems.get(extension); + }, + + remove() { + if (this.parent) { + this.parent.detachChild(this); + } + let children = this.children.slice(0); + for (let child of children) { + child.remove(); + } + + let menuMap = gContextMenuMap.get(this.extension); + menuMap.delete(this.id); + if (this.root == this) { + gRootItems.delete(this.extension); + } + }, + + getClickInfo(contextData, wasChecked) { + let mediaType; + if (contextData.onVideo) { + mediaType = "video"; + } + if (contextData.onAudio) { + mediaType = "audio"; + } + if (contextData.onImage) { + mediaType = "image"; + } + + let info = { + menuItemId: this.id, + editable: contextData.onEditableArea || contextData.onPassword, + }; + + function setIfDefined(argName, value) { + if (value !== undefined) { + info[argName] = value; + } + } + + setIfDefined("parentMenuItemId", this.parentId); + setIfDefined("mediaType", mediaType); + setIfDefined("linkUrl", contextData.linkUrl); + setIfDefined("srcUrl", contextData.srcUrl); + setIfDefined("pageUrl", contextData.pageUrl); + setIfDefined("frameUrl", contextData.frameUrl); + setIfDefined("selectionText", contextData.selectionText); + + if ((this.type === "checkbox") || (this.type === "radio")) { + info.checked = this.checked; + info.wasChecked = wasChecked; + } + + return info; + }, + + enabledForContext(contextData) { + let contexts = getContexts(contextData); + if (!this.contexts.some(n => contexts.has(n))) { + return false; + } + + let docPattern = this.documentUrlMatchPattern; + let pageURI = Services.io.newURI(contextData.pageUrl); + if (docPattern && !docPattern.matches(pageURI)) { + return false; + } + + let targetPattern = this.targetUrlMatchPattern; + if (targetPattern) { + let targetUrls = []; + if (contextData.onImage || contextData.onAudio || contextData.onVideo) { + // TODO: double check if srcUrl is always set when we need it + targetUrls.push(contextData.srcUrl); + } + if (contextData.onLink) { + targetUrls.push(contextData.linkUrl); + } + if (!targetUrls.some(targetUrl => targetPattern.matches(NetUtil.newURI(targetUrl)))) { + return false; + } + } + + return true; + }, +}; + +// While any extensions are active, this Tracker registers to observe/listen +// for contex-menu events from both content and chrome. +const contextMenuTracker = { + register() { + Services.obs.addObserver(this, "on-build-contextmenu", false); + for (const window of WindowListManager.browserWindows()) { + this.onWindowOpen(window); + } + WindowListManager.addOpenListener(this.onWindowOpen); + }, + + unregister() { + Services.obs.removeObserver(this, "on-build-contextmenu"); + for (const window of WindowListManager.browserWindows()) { + const menu = window.document.getElementById("tabContextMenu"); + menu.removeEventListener("popupshowing", this); + } + WindowListManager.removeOpenListener(this.onWindowOpen); + }, + + observe(subject, topic, data) { + subject = subject.wrappedJSObject; + gMenuBuilder.build(subject); + }, + + onWindowOpen(window) { + const menu = window.document.getElementById("tabContextMenu"); + menu.addEventListener("popupshowing", contextMenuTracker); + }, + + handleEvent(event) { + const menu = event.target; + if (menu.id === "tabContextMenu") { + const trigger = menu.triggerNode; + const tab = trigger.localName === "tab" ? trigger : TabManager.activeTab; + const pageUrl = tab.linkedBrowser.currentURI.spec; + gMenuBuilder.build({menu, tab, pageUrl, onTab: true}); + } + }, +}; + +var gExtensionCount = 0; +/* eslint-disable mozilla/balanced-listeners */ +extensions.on("startup", (type, extension) => { + gContextMenuMap.set(extension, new Map()); + if (++gExtensionCount == 1) { + contextMenuTracker.register(); + } +}); + +extensions.on("shutdown", (type, extension) => { + gContextMenuMap.delete(extension); + gRootItems.delete(extension); + if (--gExtensionCount == 0) { + contextMenuTracker.unregister(); + } +}); +/* eslint-enable mozilla/balanced-listeners */ + +extensions.registerSchemaAPI("contextMenus", "addon_parent", context => { + let {extension} = context; + return { + contextMenus: { + createInternal: function(createProperties) { + // Note that the id is required by the schema. If the addon did not set + // it, the implementation of contextMenus.create in the child should + // have added it. + let menuItem = new MenuItem(extension, createProperties); + gContextMenuMap.get(extension).set(menuItem.id, menuItem); + }, + + update: function(id, updateProperties) { + let menuItem = gContextMenuMap.get(extension).get(id); + if (menuItem) { + menuItem.setProps(updateProperties); + } + }, + + remove: function(id) { + let menuItem = gContextMenuMap.get(extension).get(id); + if (menuItem) { + menuItem.remove(); + } + }, + + removeAll: function() { + let root = gRootItems.get(extension); + if (root) { + root.remove(); + } + }, + + onClicked: new EventManager(context, "contextMenus.onClicked", fire => { + let listener = (event, info, tab) => { + fire(info, tab); + }; + + extension.on("webext-contextmenu-menuitem-click", listener); + return () => { + extension.off("webext-contextmenu-menuitem-click", listener); + }; + }).api(), + }, + }; +}); |