Bug 1325814 - Add extension API to find menu target
- Add info.targetElementId to menus.onShown event.
- Add info.targetElementId to menus.onClicked event.
- Add menus.getTargetElement API that is available to all contexts,
including content scripts, which allows extensions to get the DOM
element for a given targetElementId.
- Add new schema instead of re-using schemas/menus.json to avoid sending
too much schema data (of the existing menus API) to content processes.
MozReview-Commit-ID: 6Onf7jZlIho
--- a/browser/base/content/nsContextMenu.js
+++ b/browser/base/content/nsContextMenu.js
@@ -103,16 +103,17 @@ nsContextMenu.prototype = {
this.browser, aXulMenu);
} else {
this.hasPageMenu = PageMenuParent.buildAndAddToPopup(this.target, aXulMenu);
}
let subject = {
menu: aXulMenu,
tab: gBrowser ? gBrowser.getTabForBrowser(this.browser) : undefined,
+ timeStamp: this.timeStamp,
isContentSelected: this.isContentSelected,
inFrame: this.inFrame,
isTextSelected: this.isTextSelected,
onTextInput: this.onTextInput,
onLink: this.onLink,
onImage: this.onImage,
onVideo: this.onVideo,
onAudio: this.onAudio,
@@ -158,16 +159,17 @@ nsContextMenu.prototype = {
if (gContextMenuContentData) {
context = gContextMenuContentData.context;
gContextMenuContentData.context = null;
this.isRemote = gContextMenuContentData.isRemote;
}
this.shouldDisplay = context.shouldDisplay;
+ this.timeStamp = context.timeStamp;
// Assign what's _possibly_ needed from `context` sent by ContextMenu.jsm
// Keep this consistent with the similar code in ContextMenu's _setContext
this.bgImageURL = context.bgImageURL;
this.imageDescURL = context.imageDescURL;
this.imageInfo = context.imageInfo;
this.mediaURL = context.mediaURL;
this.webExtBrowserType = context.webExtBrowserType;
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/child/ext-browser-content-only.js
@@ -0,0 +1,11 @@
+"use strict";
+
+extensions.registerModules({
+ menusChild: {
+ url: "chrome://browser/content/child/ext-menus-child.js",
+ scopes: ["content_child"],
+ paths: [
+ ["menus"],
+ ],
+ },
+});
--- a/browser/components/extensions/child/ext-browser.js
+++ b/browser/components/extensions/child/ext-browser.js
@@ -33,16 +33,23 @@ extensions.registerModules({
menusInternal: {
url: "chrome://browser/content/child/ext-menus.js",
scopes: ["addon_child"],
paths: [
["contextMenus"],
["menus"],
],
},
+ menusChild: {
+ url: "chrome://browser/content/child/ext-menus-child.js",
+ scopes: ["addon_child", "devtools_child"],
+ paths: [
+ ["menus"],
+ ],
+ },
omnibox: {
url: "chrome://browser/content/child/ext-omnibox.js",
scopes: ["addon_child"],
paths: [
["omnibox"],
],
},
tabs: {
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/child/ext-menus-child.js
@@ -0,0 +1,26 @@
+"use strict";
+
+this.menusChild = class extends ExtensionAPI {
+ getAPI(context) {
+ return {
+ menus: {
+ getTargetElement(targetElementId) {
+ let tabChildGlobal = context.messageManager;
+ let {contextMenu} = tabChildGlobal;
+ let element;
+ if (contextMenu) {
+ let {lastMenuTarget} = contextMenu;
+ if (lastMenuTarget && Math.floor(lastMenuTarget.timeStamp) === targetElementId) {
+ element = lastMenuTarget.targetRef.get();
+ }
+ }
+ // TODO: Support shadow DOM (now we return null).
+ if (element && context.contentWindow.document.contains(element)) {
+ return element;
+ }
+ return null;
+ },
+ },
+ };
+ }
+};
--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -94,16 +94,20 @@
"identity": {
"url": "chrome://extensions/content/parent/ext-identity.js",
"schema": "chrome://extensions/content/schemas/identity.json",
"scopes": ["addon_parent"],
"paths": [
["identity"]
]
},
+ "menusChild": {
+ "schema": "chrome://browser/content/schemas/menus_child.json",
+ "scopes": ["addon_child", "content_child", "devtools_child"]
+ },
"menusInternal": {
"url": "chrome://browser/content/parent/ext-menus.js",
"schema": "chrome://browser/content/schemas/menus.json",
"scopes": ["addon_parent"],
"paths": [
["contextMenus"],
["menus"],
["menusInternal"]
--- a/browser/components/extensions/extensions-browser.manifest
+++ b/browser/components/extensions/extensions-browser.manifest
@@ -1,5 +1,6 @@
category webextension-modules browser chrome://browser/content/ext-browser.json
category webextension-scripts c-browser chrome://browser/content/parent/ext-browser.js
+category webextension-scripts-content browser chrome://browser/content/child/ext-browser-content-only.js
category webextension-scripts-devtools browser chrome://browser/content/child/ext-browser.js
category webextension-scripts-addon browser chrome://browser/content/child/ext-browser.js
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -32,15 +32,17 @@ browser.jar:
content/browser/parent/ext-pkcs11.js (parent/ext-pkcs11.js)
content/browser/parent/ext-search.js (parent/ext-search.js)
content/browser/parent/ext-sessions.js (parent/ext-sessions.js)
content/browser/parent/ext-sidebarAction.js (parent/ext-sidebarAction.js)
content/browser/parent/ext-tabs.js (parent/ext-tabs.js)
content/browser/parent/ext-url-overrides.js (parent/ext-url-overrides.js)
content/browser/parent/ext-windows.js (parent/ext-windows.js)
content/browser/child/ext-browser.js (child/ext-browser.js)
+ content/browser/child/ext-browser-content-only.js (child/ext-browser-content-only.js)
content/browser/child/ext-devtools-inspectedWindow.js (child/ext-devtools-inspectedWindow.js)
content/browser/child/ext-devtools-network.js (child/ext-devtools-network.js)
content/browser/child/ext-devtools-panels.js (child/ext-devtools-panels.js)
content/browser/child/ext-devtools.js (child/ext-devtools.js)
content/browser/child/ext-menus.js (child/ext-menus.js)
+ content/browser/child/ext-menus-child.js (child/ext-menus-child.js)
content/browser/child/ext-omnibox.js (child/ext-omnibox.js)
content/browser/child/ext-tabs.js (child/ext-tabs.js)
--- a/browser/components/extensions/parent/ext-menus.js
+++ b/browser/components/extensions/parent/ext-menus.js
@@ -465,32 +465,38 @@ const getMenuContexts = contextData => {
// New non-content contexts supported in Firefox are not part of "all".
if (!contextData.onBookmark && !contextData.onTab && !contextData.inToolsMenu) {
contexts.add("all");
}
return contexts;
};
-function addMenuEventInfo(info, contextData, includeSensitiveData) {
+function addMenuEventInfo(info, contextData, extension, includeSensitiveData) {
if (contextData.onVideo) {
info.mediaType = "video";
} else if (contextData.onAudio) {
info.mediaType = "audio";
} else if (contextData.onImage) {
info.mediaType = "image";
}
if (contextData.frameId !== undefined) {
info.frameId = contextData.frameId;
}
if (contextData.onBookmark) {
info.bookmarkId = contextData.bookmarkId;
}
info.editable = contextData.onEditable || false;
if (includeSensitiveData) {
+ // menus.getTargetElement requires the "menus" permission, so do not set
+ // targetElementId for extensions with only the "contextMenus" permission.
+ if (contextData.timeStamp && extension.hasPermission("menus")) {
+ // Convert to integer, in case the DOMHighResTimeStamp has a fractional part.
+ info.targetElementId = Math.floor(contextData.timeStamp);
+ }
if (contextData.onLink) {
info.linkText = contextData.linkText;
info.linkUrl = contextData.linkUrl;
}
if (contextData.onAudio || contextData.onImage || contextData.onVideo) {
info.srcUrl = contextData.srcUrl;
}
if (!contextData.onBookmark) {
@@ -667,17 +673,17 @@ MenuItem.prototype = {
getClickInfo(contextData, wasChecked) {
let info = {
menuItemId: this.id,
};
if (this.parent) {
info.parentMenuItemId = this.parentId;
}
- addMenuEventInfo(info, contextData, true);
+ addMenuEventInfo(info, contextData, this.extension, true);
if ((this.type === "checkbox") || (this.type === "radio")) {
info.checked = this.checked;
info.wasChecked = wasChecked;
}
return info;
},
@@ -828,17 +834,17 @@ this.menusInternal = class extends Exten
// The menus.onShown event is fired before the user has consciously
// interacted with an extension, so we require permissions before
// exposing sensitive contextual data.
let contextUrl = contextData.inFrame ? contextData.frameUrl : contextData.pageUrl;
let includeSensitiveData =
(nativeTab && extension.tabManager.hasActiveTabPermission(nativeTab)) ||
(contextUrl && extension.whiteListedHosts.matches(contextUrl));
- addMenuEventInfo(info, contextData, includeSensitiveData);
+ addMenuEventInfo(info, contextData, extension, includeSensitiveData);
let tab = nativeTab && extension.tabManager.convert(nativeTab);
fire.sync(info, tab);
};
gOnShownSubscribers.add(extension);
extension.on("webext-menu-shown", listener);
return () => {
gOnShownSubscribers.delete(extension);
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -11,16 +11,17 @@ browser.jar:
content/browser/schemas/devtools.json
content/browser/schemas/devtools_inspected_window.json
content/browser/schemas/devtools_network.json
content/browser/schemas/devtools_panels.json
content/browser/schemas/find.json
content/browser/schemas/geckoProfiler.json
content/browser/schemas/history.json
content/browser/schemas/menus.json
+ content/browser/schemas/menus_child.json
content/browser/schemas/omnibox.json
content/browser/schemas/page_action.json
content/browser/schemas/pkcs11.json
content/browser/schemas/search.json
content/browser/schemas/sessions.json
content/browser/schemas/sidebar_action.json
content/browser/schemas/tabs.json
content/browser/schemas/url_overrides.json
--- a/browser/components/extensions/schemas/menus.json
+++ b/browser/components/extensions/schemas/menus.json
@@ -130,16 +130,21 @@
},
"modifiers": {
"type": "array",
"items": {
"type": "string",
"enum": ["Shift", "Alt", "Command", "Ctrl", "MacCtrl"]
},
"description": "An array of keyboard modifiers that were held while the menu item was clicked."
+ },
+ "targetElementId": {
+ "type": "integer",
+ "optional": true,
+ "description": "An identifier of the clicked element, if any. Use menus.getTargetElement in the page to find the corresponding element."
}
}
}
],
"functions": [
{
"name": "create",
"type": "function",
@@ -452,16 +457,20 @@
},
"frameUrl": {
"type": "string",
"optional": true
},
"selectionText": {
"type": "string",
"optional": true
+ },
+ "targetElementId": {
+ "type": "integer",
+ "optional": true
}
}
},
{
"name": "tab",
"$ref": "tabs.Tab",
"description": "The details of the tab where the menu was opened."
}
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/menus_child.json
@@ -0,0 +1,29 @@
+[
+ {
+ "namespace": "menus",
+ "permissions": ["menus"],
+ "allowedContexts": ["content", "devtools"],
+ "description": "The part of the menus API that is available in all extension contexts, including content scripts.",
+ "functions": [
+ {
+ "name": "getTargetElement",
+ "type": "function",
+ "allowedContexts": ["content", "devtools"],
+ "description": "Retrieve the element that was associated with a recent contextmenu event.",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "The identifier of the clicked element, available as info.targetElementId in the menus.onShown, onClicked or onclick event.",
+ "name": "targetElementId"
+ }
+ ],
+ "returns": {
+ "type": "object",
+ "optional": true,
+ "isInstanceOf": "Element",
+ "additionalProperties": { "type": "any" }
+ }
+ }
+ ]
+ }
+]
--- a/browser/modules/ContextMenu.jsm
+++ b/browser/modules/ContextMenu.jsm
@@ -223,16 +223,17 @@ const messageListeners = {
class ContextMenu {
// PUBLIC
constructor(global) {
this.target = null;
this.context = null;
this.global = global;
this.content = global.content;
+ this.lastMenuTarget = null;
Object.keys(messageListeners).forEach(key =>
global.addMessageListener(key, messageListeners[key].bind(this))
);
}
/**
* Returns the event target of the context menu, using a locally stored
@@ -665,16 +666,17 @@ class ContextMenu {
delete context.linkURI;
}
_setContext(aEvent) {
this.context = Object.create(null);
const context = this.context;
+ context.timeStamp = aEvent.timeStamp;
context.screenX = aEvent.screenX;
context.screenY = aEvent.screenY;
context.mozInputSource = aEvent.mozInputSource;
const node = aEvent.composedTarget;
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
@@ -743,16 +745,23 @@ class ContextMenu {
context.inSyntheticDoc = context.target.ownerDocument.mozSyntheticDocument;
context.shouldInitInlineSpellCheckerUINoChildren = false;
context.shouldInitInlineSpellCheckerUIWithChildren = false;
let editFlags = SpellCheckHelper.isEditable(context.target, this.content);
this._setContextForNodesNoChildren(editFlags);
this._setContextForNodesWithChildren(editFlags);
+
+ this.lastMenuTarget = {
+ // Remember the node for extensions.
+ targetRef: Cu.getWeakReference(node),
+ // The timestamp is used to verify that the target wasn't changed since the observed menu event.
+ timeStamp: context.timeStamp,
+ };
}
/**
* Sets up the parts of the context menu for when when nodes have no children.
*
* @param {Integer} editFlags The edit flags for the node. See SpellCheckHelper
* for the details.
*/
--- a/toolkit/components/extensions/Schemas.jsm
+++ b/toolkit/components/extensions/Schemas.jsm
@@ -1734,17 +1734,18 @@ class ObjectType extends Type {
if (Object.keys(this.properties).length ||
this.patternProperties.length ||
!(this.additionalProperties instanceof AnyType)) {
throw new Error("InternalError: isInstanceOf can only be used " +
"with objects that are otherwise unrestricted");
}
}
- if (ChromeUtils.getClassName(value) !== this.isInstanceOf) {
+ if (ChromeUtils.getClassName(value) !== this.isInstanceOf &&
+ (this.isInstanceOf !== "Element" || value.nodeType !== 1)) {
return context.error(`Object must be an instance of ${this.isInstanceOf}`,
`be an instance of ${this.isInstanceOf}`);
}
// This is kind of a hack, but we can't normalize things that
// aren't JSON, so we just return them.
return this.postprocess({value}, context);
}