diff options
Diffstat (limited to 'toolkit/modules/ClipboardContextMenu.sys.mjs')
-rw-r--r-- | toolkit/modules/ClipboardContextMenu.sys.mjs | 214 |
1 files changed, 214 insertions, 0 deletions
diff --git a/toolkit/modules/ClipboardContextMenu.sys.mjs b/toolkit/modules/ClipboardContextMenu.sys.mjs new file mode 100644 index 0000000000..011bfe64d7 --- /dev/null +++ b/toolkit/modules/ClipboardContextMenu.sys.mjs @@ -0,0 +1,214 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PromptUtils: "resource://gre/modules/PromptUtils.sys.mjs", +}); + +export var ClipboardContextMenu = { + MENU_POPUP_ID: "clipboardReadPasteMenuPopup", + + // EventListener interface. + handleEvent(aEvent) { + switch (aEvent.type) { + case "command": { + this.onCommand(); + break; + } + case "popuphiding": { + this.onPopupHiding(); + break; + } + case "keydown": { + this.onKeyDown(aEvent); + break; + } + } + }, + + _pasteMenuItemClicked: false, + + onCommand() { + // onPopupHiding is responsible for returning result by calling onComplete + // function. + this._pasteMenuItemClicked = true; + }, + + onPopupHiding() { + // Remove the listeners before potentially sending the async message + // below, because that might throw. + this._removeMenupopupEventListeners(); + this._clearDelayTimer(); + this._stopWatchingForSpammyActivation(); + + this._menupopup = null; + this._menuitem = null; + + let propBag = lazy.PromptUtils.objectToPropBag({ + ok: this._pasteMenuItemClicked, + }); + this._pendingRequest.resolve(propBag); + + // A result has already been responded to. Reset the state to properly + // handle further click or dismiss events. + this._pasteMenuItemClicked = false; + this._pendingRequest = null; + }, + + _lastBeepTime: 0, + + onKeyDown(aEvent) { + if (!this._menuitem.disabled) { + return; + } + + let accesskey = this._menuitem.getAttribute("accesskey"); + if ( + aEvent.key == accesskey.toLowerCase() || + aEvent.key == accesskey.toUpperCase() + ) { + if (Date.now() - this._lastBeepTime > 1000) { + Cc["@mozilla.org/sound;1"].getService(Ci.nsISound).beep(); + this._lastBeepTime = Date.now(); + } + this._refreshDelayTimer(); + } + }, + + _menupopup: null, + _menuitem: null, + _pendingRequest: null, + + confirmUserPaste(aWindowContext) { + return new Promise((resolve, reject) => { + if (!aWindowContext) { + reject( + Components.Exception("Null window context.", Cr.NS_ERROR_INVALID_ARG) + ); + return; + } + + let { document } = aWindowContext.browsingContext.topChromeWindow; + if (!document) { + reject( + Components.Exception( + "Unable to get chrome document.", + Cr.NS_ERROR_FAILURE + ) + ); + return; + } + + if (this._pendingRequest) { + reject( + Components.Exception( + "There is an ongoing request.", + Cr.NS_ERROR_FAILURE + ) + ); + return; + } + + this._pendingRequest = { resolve, reject }; + this._menupopup = this._getMenupopup(document); + this._menuitem = this._menupopup.firstElementChild; + this._addMenupopupEventListeners(); + + let mouseXInCSSPixels = {}; + let mouseYInCSSPixels = {}; + document.ownerGlobal.windowUtils.getLastOverWindowPointerLocationInCSSPixels( + mouseXInCSSPixels, + mouseYInCSSPixels + ); + + this._menuitem.disabled = true; + this._startWatchingForSpammyActivation(); + // `openPopup` is a no-op if the popup is already opened. + // That property is used when `navigator.clipboard.readText()` or + // `navigator.clipboard.read()`is called from two different frames, e.g. + // an iframe and the top level frame. In that scenario, the two frames + // correspond to different `navigator.clipboard` instances. When + // `readText()` or `read()` is called from both frames, an actor pair is + // instantiated for each of them. Both actor parents will call `openPopup` + // on the same `_menupopup` object. If the popup is already open, + // `openPopup` is a no-op. When the popup is clicked or dismissed both + // actor parents will receive the corresponding event. + this._menupopup.openPopup( + null, + "overlap" /* options */, + mouseXInCSSPixels.value, + mouseYInCSSPixels.value, + true /* isContextMenu */ + ); + + this._refreshDelayTimer(document); + }); + }, + + _addMenupopupEventListeners() { + this._menupopup.addEventListener("command", this); + this._menupopup.addEventListener("popuphiding", this); + }, + + _removeMenupopupEventListeners() { + this._menupopup.removeEventListener("command", this); + this._menupopup.removeEventListener("popuphiding", this); + }, + + _createMenupopup(aChromeDoc) { + let menuitem = aChromeDoc.createXULElement("menuitem"); + menuitem.id = "clipboardReadPasteMenuItem"; + aChromeDoc.l10n.setAttributes(menuitem, "text-action-paste"); + + let menupopup = aChromeDoc.createXULElement("menupopup"); + menupopup.id = this.MENU_POPUP_ID; + menupopup.appendChild(menuitem); + return menupopup; + }, + + _getMenupopup(aChromeDoc) { + let menupopup = aChromeDoc.getElementById(this.MENU_POPUP_ID); + if (menupopup == null) { + menupopup = this._createMenupopup(aChromeDoc); + const parent = + aChromeDoc.querySelector("popupset") || aChromeDoc.documentElement; + parent.appendChild(menupopup); + } + + return menupopup; + }, + + _startWatchingForSpammyActivation() { + let doc = this._menuitem.ownerDocument; + Services.els.addSystemEventListener(doc, "keydown", this, true); + }, + + _stopWatchingForSpammyActivation() { + let doc = this._menuitem.ownerDocument; + Services.els.removeSystemEventListener(doc, "keydown", this, true); + }, + + _delayTimer: null, + + _clearDelayTimer() { + if (this._delayTimer) { + let window = this._menuitem.ownerGlobal; + window.clearTimeout(this._delayTimer); + this._delayTimer = null; + } + }, + + _refreshDelayTimer() { + this._clearDelayTimer(); + + let window = this._menuitem.ownerGlobal; + let delay = Services.prefs.getIntPref("security.dialog_enable_delay"); + this._delayTimer = window.setTimeout(() => { + this._menuitem.disabled = false; + this._stopWatchingForSpammyActivation(); + this._delayTimer = null; + }, delay); + }, +}; |