summaryrefslogtreecommitdiffstats
path: root/toolkit/jetpack/sdk/content/context-menu.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/jetpack/sdk/content/context-menu.js')
-rw-r--r--toolkit/jetpack/sdk/content/context-menu.js408
1 files changed, 408 insertions, 0 deletions
diff --git a/toolkit/jetpack/sdk/content/context-menu.js b/toolkit/jetpack/sdk/content/context-menu.js
new file mode 100644
index 000000000..2955e2f09
--- /dev/null
+++ b/toolkit/jetpack/sdk/content/context-menu.js
@@ -0,0 +1,408 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { Class } = require("../core/heritage");
+const self = require("../self");
+const { WorkerChild } = require("./worker-child");
+const { getInnerId } = require("../window/utils");
+const { Ci } = require("chrome");
+const { Services } = require("resource://gre/modules/Services.jsm");
+const system = require('../system/events');
+const { process } = require('../remote/child');
+
+// These functions are roughly copied from sdk/selection which doesn't work
+// in the content process
+function getElementWithSelection(window) {
+ let element = Services.focus.getFocusedElementForWindow(window, false, {});
+ if (!element)
+ return null;
+
+ try {
+ // Accessing selectionStart and selectionEnd on e.g. a button
+ // results in an exception thrown as per the HTML5 spec. See
+ // http://www.whatwg.org/specs/web-apps/current-work/multipage/association-of-controls-and-forms.html#textFieldSelection
+
+ let { value, selectionStart, selectionEnd } = element;
+
+ let hasSelection = typeof value === "string" &&
+ !isNaN(selectionStart) &&
+ !isNaN(selectionEnd) &&
+ selectionStart !== selectionEnd;
+
+ return hasSelection ? element : null;
+ }
+ catch (err) {
+ console.exception(err);
+ return null;
+ }
+}
+
+function safeGetRange(selection, rangeNumber) {
+ try {
+ let { rangeCount } = selection;
+ let range = null;
+
+ for (let rangeNumber = 0; rangeNumber < rangeCount; rangeNumber++ ) {
+ range = selection.getRangeAt(rangeNumber);
+
+ if (range && range.toString())
+ break;
+
+ range = null;
+ }
+
+ return range;
+ }
+ catch (e) {
+ return null;
+ }
+}
+
+function getSelection(window) {
+ let selection = window.getSelection();
+ let range = safeGetRange(selection);
+ if (range)
+ return range.toString();
+
+ let node = getElementWithSelection(window);
+ if (!node)
+ return null;
+
+ return node.value.substring(node.selectionStart, node.selectionEnd);
+}
+
+//These are used by PageContext.isCurrent below. If the popupNode or any of
+//its ancestors is one of these, Firefox uses a tailored context menu, and so
+//the page context doesn't apply.
+const NON_PAGE_CONTEXT_ELTS = [
+ Ci.nsIDOMHTMLAnchorElement,
+ Ci.nsIDOMHTMLAppletElement,
+ Ci.nsIDOMHTMLAreaElement,
+ Ci.nsIDOMHTMLButtonElement,
+ Ci.nsIDOMHTMLCanvasElement,
+ Ci.nsIDOMHTMLEmbedElement,
+ Ci.nsIDOMHTMLImageElement,
+ Ci.nsIDOMHTMLInputElement,
+ Ci.nsIDOMHTMLMapElement,
+ Ci.nsIDOMHTMLMediaElement,
+ Ci.nsIDOMHTMLMenuElement,
+ Ci.nsIDOMHTMLObjectElement,
+ Ci.nsIDOMHTMLOptionElement,
+ Ci.nsIDOMHTMLSelectElement,
+ Ci.nsIDOMHTMLTextAreaElement,
+];
+
+// List all editable types of inputs. Or is it better to have a list
+// of non-editable inputs?
+var editableInputs = {
+ email: true,
+ number: true,
+ password: true,
+ search: true,
+ tel: true,
+ text: true,
+ textarea: true,
+ url: true
+};
+
+var CONTEXTS = {};
+
+var Context = Class({
+ initialize: function(id) {
+ this.id = id;
+ },
+
+ adjustPopupNode: function adjustPopupNode(popupNode) {
+ return popupNode;
+ },
+
+ // Gets state to pass through to the parent process for the node the user
+ // clicked on
+ getState: function(popupNode) {
+ return false;
+ }
+});
+
+// Matches when the context-clicked node doesn't have any of
+// NON_PAGE_CONTEXT_ELTS in its ancestors
+CONTEXTS.PageContext = Class({
+ extends: Context,
+
+ getState: function(popupNode) {
+ // If there is a selection in the window then this context does not match
+ if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed)
+ return false;
+
+ // If the clicked node or any of its ancestors is one of the blocked
+ // NON_PAGE_CONTEXT_ELTS then this context does not match
+ while (!(popupNode instanceof Ci.nsIDOMDocument)) {
+ if (NON_PAGE_CONTEXT_ELTS.some(type => popupNode instanceof type))
+ return false;
+
+ popupNode = popupNode.parentNode;
+ }
+
+ return true;
+ }
+});
+
+// Matches when there is an active selection in the window
+CONTEXTS.SelectionContext = Class({
+ extends: Context,
+
+ getState: function(popupNode) {
+ if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed)
+ return true;
+
+ try {
+ // The node may be a text box which has selectionStart and selectionEnd
+ // properties. If not this will throw.
+ let { selectionStart, selectionEnd } = popupNode;
+ return !isNaN(selectionStart) && !isNaN(selectionEnd) &&
+ selectionStart !== selectionEnd;
+ }
+ catch (e) {
+ return false;
+ }
+ }
+});
+
+// Matches when the context-clicked node or any of its ancestors matches the
+// selector given
+CONTEXTS.SelectorContext = Class({
+ extends: Context,
+
+ initialize: function initialize(id, selector) {
+ Context.prototype.initialize.call(this, id);
+ this.selector = selector;
+ },
+
+ adjustPopupNode: function adjustPopupNode(popupNode) {
+ let selector = this.selector;
+
+ while (!(popupNode instanceof Ci.nsIDOMDocument)) {
+ if (popupNode.matches(selector))
+ return popupNode;
+
+ popupNode = popupNode.parentNode;
+ }
+
+ return null;
+ },
+
+ getState: function(popupNode) {
+ return !!this.adjustPopupNode(popupNode);
+ }
+});
+
+// Matches when the page url matches any of the patterns given
+CONTEXTS.URLContext = Class({
+ extends: Context,
+
+ getState: function(popupNode) {
+ return popupNode.ownerDocument.URL;
+ }
+});
+
+// Matches when the user-supplied predicate returns true
+CONTEXTS.PredicateContext = Class({
+ extends: Context,
+
+ getState: function(node) {
+ let window = node.ownerDocument.defaultView;
+ let data = {};
+
+ data.documentType = node.ownerDocument.contentType;
+
+ data.documentURL = node.ownerDocument.location.href;
+ data.targetName = node.nodeName.toLowerCase();
+ data.targetID = node.id || null ;
+
+ if ((data.targetName === 'input' && editableInputs[node.type]) ||
+ data.targetName === 'textarea') {
+ data.isEditable = !node.readOnly && !node.disabled;
+ }
+ else {
+ data.isEditable = node.isContentEditable;
+ }
+
+ data.selectionText = getSelection(window, "TEXT");
+
+ data.srcURL = node.src || null;
+ data.value = node.value || null;
+
+ while (!data.linkURL && node) {
+ data.linkURL = node.href || null;
+ node = node.parentNode;
+ }
+
+ return data;
+ },
+});
+
+function instantiateContext({ id, type, args }) {
+ if (!(type in CONTEXTS)) {
+ console.error("Attempt to use unknown context " + type);
+ return;
+ }
+ return new CONTEXTS[type](id, ...args);
+}
+
+var ContextWorker = Class({
+ implements: [ WorkerChild ],
+
+ // Calls the context workers context listeners and returns the first result
+ // that is either a string or a value that evaluates to true. If all of the
+ // listeners returned false then returns false. If there are no listeners,
+ // returns true (show the menu item by default).
+ getMatchedContext: function getCurrentContexts(popupNode) {
+ let results = this.sandbox.emitSync("context", popupNode);
+ if (!results.length)
+ return true;
+ return results.reduce((val, result) => val || result);
+ },
+
+ // Emits a click event in the worker's port. popupNode is the node that was
+ // context-clicked, and clickedItemData is the data of the item that was
+ // clicked.
+ fireClick: function fireClick(popupNode, clickedItemData) {
+ this.sandbox.emitSync("click", popupNode, clickedItemData);
+ }
+});
+
+// Gets the item's content script worker for a window, creating one if necessary
+// Once created it will be automatically destroyed when the window unloads.
+// If there is not content scripts for the item then null will be returned.
+function getItemWorkerForWindow(item, window) {
+ if (!item.contentScript && !item.contentScriptFile)
+ return null;
+
+ let id = getInnerId(window);
+ let worker = item.workerMap.get(id);
+
+ if (worker)
+ return worker;
+
+ worker = ContextWorker({
+ id: item.id,
+ window,
+ manager: item.manager,
+ contentScript: item.contentScript,
+ contentScriptFile: item.contentScriptFile,
+ onDetach: function() {
+ item.workerMap.delete(id);
+ }
+ });
+
+ item.workerMap.set(id, worker);
+
+ return worker;
+}
+
+// A very simple remote proxy for every item. It's job is to provide data for
+// the main process to use to determine visibility state and to call into
+// content scripts when clicked.
+var RemoteItem = Class({
+ initialize: function(options, manager) {
+ this.id = options.id;
+ this.contexts = options.contexts.map(instantiateContext);
+ this.contentScript = options.contentScript;
+ this.contentScriptFile = options.contentScriptFile;
+
+ this.manager = manager;
+
+ this.workerMap = new Map();
+ keepAlive.set(this.id, this);
+ },
+
+ destroy: function() {
+ for (let worker of this.workerMap.values()) {
+ worker.destroy();
+ }
+ keepAlive.delete(this.id);
+ },
+
+ activate: function(popupNode, data) {
+ let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView);
+ if (!worker)
+ return;
+
+ for (let context of this.contexts)
+ popupNode = context.adjustPopupNode(popupNode);
+
+ worker.fireClick(popupNode, data);
+ },
+
+ // Fills addonInfo with state data to send through to the main process
+ getContextState: function(popupNode, addonInfo) {
+ if (!(self.id in addonInfo)) {
+ addonInfo[self.id] = {
+ processID: process.id,
+ items: {}
+ };
+ }
+
+ let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView);
+ let contextStates = {};
+ for (let context of this.contexts)
+ contextStates[context.id] = context.getState(popupNode);
+
+ addonInfo[self.id].items[this.id] = {
+ // It isn't ideal to create a PageContext for every item but there isn't
+ // a good shared place to do it.
+ pageContext: (new CONTEXTS.PageContext()).getState(popupNode),
+ contextStates,
+ hasWorker: !!worker,
+ workerContext: worker ? worker.getMatchedContext(popupNode) : true
+ }
+ }
+});
+exports.RemoteItem = RemoteItem;
+
+// Holds remote items for this frame.
+var keepAlive = new Map();
+
+// Called to create remote proxies for items. If they already exist we destroy
+// and recreate. This can happen if the item changes in some way or in odd
+// timing cases where the frame script is create around the same time as the
+// item is created in the main process
+process.port.on('sdk/contextmenu/createitems', (process, items) => {
+ for (let itemoptions of items) {
+ let oldItem = keepAlive.get(itemoptions.id);
+ if (oldItem) {
+ oldItem.destroy();
+ }
+
+ let item = new RemoteItem(itemoptions, this);
+ }
+});
+
+process.port.on('sdk/contextmenu/destroyitems', (process, items) => {
+ for (let id of items) {
+ let item = keepAlive.get(id);
+ item.destroy();
+ }
+});
+
+var lastPopupNode = null;
+
+system.on('content-contextmenu', ({ subject }) => {
+ let { event: { target: popupNode }, addonInfo } = subject.wrappedJSObject;
+ lastPopupNode = popupNode;
+
+ for (let item of keepAlive.values()) {
+ item.getContextState(popupNode, addonInfo);
+ }
+}, true);
+
+process.port.on('sdk/contextmenu/activateitems', (process, items, data) => {
+ for (let id of items) {
+ let item = keepAlive.get(id);
+ if (!item)
+ continue;
+
+ item.activate(lastPopupNode, data);
+ }
+});