From 4c3d87e214900c43b24c727143e684447013eeb6 Mon Sep 17 00:00:00 2001 From: Rob Wu Date: Wed, 5 Sep 2018 20:02:53 +0200 Subject: [PATCH] Add example for menus.getTargetElement API (#369) --- menu-remove-element/README.md | 36 +++++++ menu-remove-element/background.js | 35 +++++++ menu-remove-element/contentscript.js | 82 ++++++++++++++++ menu-remove-element/manifest.json | 16 ++++ .../menusGetTargetElementPolyfill.js | 35 +++++++ menu-remove-element/popup.html | 7 ++ menu-remove-element/popup.js | 93 +++++++++++++++++++ 7 files changed, 304 insertions(+) create mode 100644 menu-remove-element/README.md create mode 100644 menu-remove-element/background.js create mode 100644 menu-remove-element/contentscript.js create mode 100644 menu-remove-element/manifest.json create mode 100644 menu-remove-element/menusGetTargetElementPolyfill.js create mode 100644 menu-remove-element/popup.html create mode 100644 menu-remove-element/popup.js diff --git a/menu-remove-element/README.md b/menu-remove-element/README.md new file mode 100644 index 0000000..748b695 --- /dev/null +++ b/menu-remove-element/README.md @@ -0,0 +1,36 @@ +# menu-remove-element + +## What it does + +This extension adds a menu item that's shown when the context menu is opened in a document. +Upon click, a panel is shown, where the clicked element and its ancestor elements are displayed in a list. +Upon hovering over these elements, the corresponding elements in the document are visually highlighted. +When one of the list items are clicked, the element is removed from the page. + +## What it shows + +This extension demonstrates the following APIs: + +- [`menus.create`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/menus/create) +- [`menus.onClicked`](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/menus/onClicked) and in particular its [`info.targetElementId`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/OnClickData) property. +- [`menus.getTargetElement`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/getTargetElement) +- [`pageAction.openPopup`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/openPopup) +- [`pageAction.show`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/show) and [`pageAction.hide`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/hide) +- [`tabs.executeScript`](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs.executeScript) + +The `pageAction.openPopup` method requires user interaction, which is satisfied by calling the method from the `menus.onClicked` event. + +The `activeTab` permission is used to unlock access to the page upon clicking the menu item, +so that the `info.targetElementId` becomes available and `tabs.executeScript` can be used to run a content script in a specific frame in the tab. + +The `menus.getTargetElement` API and `info.targetElementId` were introduced in Firefox 63. + +## Polyfill + +In browsers that do not support this API, an extension has to register a content script that listens to the "contextmenu" event, as shown in `menusGetTargetElementPolyfill.js`. + +This example includes the polyfill to demonstrate how one can create an extension that is compatible with browsers that do not support a new API. +If you are not interested in supporting browsers that lack the `browser.menus.getTargetElement` API, modify the example as follows: + +- Remove `menusGetTargetElementPolyfill.js` +- Modify popup.js and remove the entire if-block that starts with `if (!browser.menus.getTargetElement) {`. diff --git a/menu-remove-element/background.js b/menu-remove-element/background.js new file mode 100644 index 0000000..5de779c --- /dev/null +++ b/menu-remove-element/background.js @@ -0,0 +1,35 @@ +"use strict"; + +var popupParameters; + +browser.menus.create({ + id: "remove_element", + title: "Remove element", + documentUrlPatterns: ["https://*/*", "http://*/*"], + contexts: ["audio", "editable", "frame", "image", "link", "page", "password", "video"], +}); + +browser.menus.onClicked.addListener(async (info, tab) => { + popupParameters = { + tabId: tab.id, + frameId: info.frameId, + targetElementId: info.targetElementId, + }; + // Show our extension panel (popup.html) using pageAction.openPopup. + // This panel can only be shown when the pageAction button is enabled, + // so we temporarily enable it via pageAction.show(). + // + // Even though pageAction.show is an asynchronous API, we do not use "await" + // before continuing with the execution of the code, because the + // pageAction.openPopup method requires a user gesture, and user gestures are + // lost when a function returns or waits on a promise. + browser.pageAction.show(tab.id); + await browser.pageAction.openPopup(); + await browser.pageAction.hide(tab.id); +}); + +browser.runtime.onMessage.addListener(async (msg) => { + if (msg === "getPopupParameters") { + return popupParameters; + } +}); diff --git a/menu-remove-element/contentscript.js b/menu-remove-element/contentscript.js new file mode 100644 index 0000000..cd6ad2d --- /dev/null +++ b/menu-remove-element/contentscript.js @@ -0,0 +1,82 @@ +"use strict"; +(() => { + if (window.hasRunContentScriptOnce === true) return; + window.hasRunContentScriptOnce = true; + + browser.runtime.onConnect.addListener(port => { + if (port.name !== "portFromPopup") return; + let targetElements; + + port.onMessage.addListener(msg => { + if (msg.action === "getElementDescriptions") { + let elem = browser.menus.getTargetElement(msg.targetElementId); + setTargetElement(elem); + } else if (msg.action === "highlightElement") { + let element = targetElements[msg.elementIndex]; + if (element) highlightElement(element); + else removeHighlights(); + } else if (msg.action === "removeElement") { + let element = targetElements[msg.elementIndex]; + if (element) { + // When an element is removed, all of its descendants are removed too. + // Update the UI, to show all nodes starting from the parent element. + let parentElement = element.parentElement; + element.remove(); + setTargetElement(parentElement); + } + } + }); + port.onDisconnect.addListener(() => { + // Clean up when the port is disconnected (e.g. popup was closed). + removeHighlights(); + }); + + function setTargetElement(elem) { + targetElements = []; + while (elem) { + targetElements.unshift(elem); + elem = elem.parentElement; + } + + // Reply with some description of the elements, so that the available + // elements can be shown in the popup's UI. + let descriptions = targetElements.map(elem => { + // For example, take the first 100 characters of the HTML element. + return elem.cloneNode().outerHTML.slice(0, 100); + }); + port.postMessage({ + action: "elementDescriptions", + descriptions, + }); + } + }); + + + var highlightedBox; + function highlightElement(element) { + removeHighlights(); + let boundingRect = element.getBoundingClientRect(); + highlightedBox = document.createElement("div"); + highlightedBox.style.outline = "2px dotted red"; + highlightedBox.style.margin = "0"; + highlightedBox.style.border = "0"; + highlightedBox.style.padding = "0"; + highlightedBox.style.backgroundColor = "rgba(100, 0, 0, 0.3)"; + highlightedBox.style.pointerEvents = "none"; + highlightedBox.style.zIndex = "2147483647"; + highlightedBox.style.position = "fixed"; + highlightedBox.style.top = boundingRect.top + "px"; + highlightedBox.style.left = boundingRect.left + "px"; + highlightedBox.style.width = boundingRect.width + "px"; + highlightedBox.style.height = boundingRect.height + "px"; + + (document.body || document.documentElement).appendChild(highlightedBox); + } + + function removeHighlights() { + if (highlightedBox) { + highlightedBox.remove(); + highlightedBox = null; + } + } +})(); diff --git a/menu-remove-element/manifest.json b/menu-remove-element/manifest.json new file mode 100644 index 0000000..e991c61 --- /dev/null +++ b/menu-remove-element/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "Remove element on click", + "description": "Adds a context menu item that shows a panel upon click, from where you can choose to remove the clicked element.", + "version": "1", + "manifest_version": 2, + "background": { + "scripts": ["background.js"] + }, + "page_action": { + "default_popup": "popup.html" + }, + "permissions": [ + "menus", + "activeTab" + ] +} diff --git a/menu-remove-element/menusGetTargetElementPolyfill.js b/menu-remove-element/menusGetTargetElementPolyfill.js new file mode 100644 index 0000000..6e651f0 --- /dev/null +++ b/menu-remove-element/menusGetTargetElementPolyfill.js @@ -0,0 +1,35 @@ +"use strict"; + +// The browser.menus.getTargetElement API allows extensions to identify the +// clicked element that matches a menus.onClicked or menus.onShown event, as of +// Firefox 63. +// +// This polyfill script approximates the behavior, by returning the target of +// the most recently observed contextmenu event in this document. +// +// Limitations: +// - Cannot return the menu target before this script has run. +// In contrast, the real menus.getTargetElement API will return the element +// even if the extension did not run any scripts at the time of the click. +// +// - Does not offer the guarantee that the element matches the menus.onClicked +// or menus.onShown event. +// In contrast, the real menus.getTargetElement API only returns an element +// if the given first parameter matches the info.targetElementId integer from +// the menus.onClicked or menus.onShown events. +if (!browser.menus || !browser.menus.getTargetElement) { + var menuTarget = null; + let cleanupIfNeeded = () => { + if (menuTarget && !document.contains(menuTarget)) menuTarget = null; + }; + document.addEventListener("contextmenu", (event) => { + menuTarget = event.target; + }, true); + document.addEventListener("visibilitychange", cleanupIfNeeded, true); + browser.menus = browser.menus || {}; + browser.menus.getTargetElement = () => { + cleanupIfNeeded(); + return menuTarget; + }; + true; // Used by popup.js, as variable didUsePolyfill. +} diff --git a/menu-remove-element/popup.html b/menu-remove-element/popup.html new file mode 100644 index 0000000..7c31206 --- /dev/null +++ b/menu-remove-element/popup.html @@ -0,0 +1,7 @@ + + + +
+ Open a menu in the current tab and right-click on the "Remove element" option. +
+ diff --git a/menu-remove-element/popup.js b/menu-remove-element/popup.js new file mode 100644 index 0000000..bd48f8e --- /dev/null +++ b/menu-remove-element/popup.js @@ -0,0 +1,93 @@ +"use strict"; + +(async () => { + const popupParameters = await browser.runtime.sendMessage("getPopupParameters"); + let {tabId, frameId, targetElementId} = popupParameters; + + // Ensure that the popup is opened for the currently active tab, + // to prevent users from interacting with hidden tabs. + await assertIsCurrentTab(tabId); + + // The browser.menus.getTargetElement API is only available in Firefox 63+. + if (!browser.menus.getTargetElement) { + let [didUsePolyfill] = await browser.tabs.executeScript(tabId, { + runAt: "document_start", + frameId, + file: "menusGetTargetElementPolyfill.js", + }); + if (didUsePolyfill === true) { + console.log("Registered a polyfill for browser.menus.getTargetElement - re-open the menu to see it in action."); + let outputStatus = document.getElementById("outputStatus") + outputStatus.textContent = ` + This extension requires the browser.menus.getTargetElement API, + which is only available as of Firefox 63. + To see the expected behavior, please re-open the menu. + `; + return; + } + } + + // Inject script in page (requires activeTab permission). + await browser.tabs.executeScript(tabId, { + runAt: "document_start", + frameId, + file: "contentscript.js", + }); + + let port = browser.tabs.connect(tabId, { + name: "portFromPopup", + frameId, + }); + port.onMessage.addListener(msg => { + if (msg.action === "elementDescriptions") { + renderElementDescriptions(port, msg.descriptions); + } + }); + port.onDisconnect.addListener(() => { + let outputStatus = document.getElementById("outputStatus") + outputStatus.textContent = `The port to the page was closed.${port.error ? "Reason: " + port.error.message : ""}`; + }); + port.postMessage({ + action: "getElementDescriptions", + targetElementId, + }); +})(); + +async function assertIsCurrentTab(tabId) { + let [currentTab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + if (currentTab.id !== tabId) { + throw new Error("The given tab ID is not the currently active tab"); + } +} + +function renderElementDescriptions(port, descriptions) { + let outputStatus = document.getElementById("outputStatus") + if (!descriptions.length) { + outputStatus.textContent = "Cannot find the target element. Please re-open the menu to try again."; + return; + } + let list = document.createElement("ul"); + descriptions.forEach((description, elementIndex) => { + let item = document.createElement("li"); + item.textContent = description; + item.tabIndex = 1; + item.onclick = () => { + port.postMessage({ + action: "removeElement", + elementIndex: elementIndex, + }); + }; + item.onmouseenter = () => { + port.postMessage({ + action: "highlightElement", + elementIndex: elementIndex, + }); + }; + list.appendChild(item); + }); + outputStatus.textContent = "Click on any item to remove the element and its descendants:"; + outputStatus.appendChild(list); +}