Bug 1885602 - Part 5: Implement navigating to the SUMO help topic from the menu heade...
[gecko.git] / toolkit / modules / ClipboardContextMenu.sys.mjs
blobd66a2f466d5badb6956d6c7cff83d04e5634d60d
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const lazy = {};
6 ChromeUtils.defineESModuleGetters(lazy, {
7   PromptUtils: "resource://gre/modules/PromptUtils.sys.mjs",
8 });
10 export var ClipboardContextMenu = {
11   MENU_POPUP_ID: "clipboardReadPasteMenuPopup",
13   // EventListener interface.
14   handleEvent(aEvent) {
15     switch (aEvent.type) {
16       case "command": {
17         this.onCommand();
18         break;
19       }
20       case "popuphiding": {
21         this.onPopupHiding();
22         break;
23       }
24       case "keydown": {
25         this.onKeyDown(aEvent);
26         break;
27       }
28     }
29   },
31   _pasteMenuItemClicked: false,
33   onCommand() {
34     // onPopupHiding is responsible for returning result by calling onComplete
35     // function.
36     this._pasteMenuItemClicked = true;
37   },
39   onPopupHiding() {
40     // Remove the listeners before potentially sending the async message
41     // below, because that might throw.
42     this._removeMenupopupEventListeners();
43     this._clearDelayTimer();
44     this._stopWatchingForSpammyActivation();
46     this._menupopup = null;
47     this._menuitem = null;
49     let propBag = lazy.PromptUtils.objectToPropBag({
50       ok: this._pasteMenuItemClicked,
51     });
52     this._pendingRequest.resolve(propBag);
54     // A result has already been responded to. Reset the state to properly
55     // handle further click or dismiss events.
56     this._pasteMenuItemClicked = false;
57     this._pendingRequest = null;
58   },
60   _lastBeepTime: 0,
62   onKeyDown(aEvent) {
63     if (!this._menuitem.disabled) {
64       return;
65     }
67     let accesskey = this._menuitem.getAttribute("accesskey");
68     if (
69       aEvent.key == accesskey.toLowerCase() ||
70       aEvent.key == accesskey.toUpperCase()
71     ) {
72       if (Date.now() - this._lastBeepTime > 1000) {
73         Cc["@mozilla.org/sound;1"].getService(Ci.nsISound).beep();
74         this._lastBeepTime = Date.now();
75       }
76       this._refreshDelayTimer();
77     }
78   },
80   _menupopup: null,
81   _menuitem: null,
82   _pendingRequest: null,
84   confirmUserPaste(aWindowContext) {
85     return new Promise((resolve, reject) => {
86       if (!aWindowContext) {
87         reject(
88           Components.Exception("Null window context.", Cr.NS_ERROR_INVALID_ARG)
89         );
90         return;
91       }
93       let { document } = aWindowContext.browsingContext.topChromeWindow;
94       if (!document) {
95         reject(
96           Components.Exception(
97             "Unable to get chrome document.",
98             Cr.NS_ERROR_FAILURE
99           )
100         );
101         return;
102       }
104       if (this._pendingRequest) {
105         reject(
106           Components.Exception(
107             "There is an ongoing request.",
108             Cr.NS_ERROR_FAILURE
109           )
110         );
111         return;
112       }
114       this._pendingRequest = { resolve, reject };
115       this._menupopup = this._getMenupopup(document);
116       this._menuitem = this._menupopup.firstElementChild;
117       this._addMenupopupEventListeners();
119       let mouseXInCSSPixels = {};
120       let mouseYInCSSPixels = {};
121       document.ownerGlobal.windowUtils.getLastOverWindowPointerLocationInCSSPixels(
122         mouseXInCSSPixels,
123         mouseYInCSSPixels
124       );
126       this._menuitem.disabled = true;
127       this._startWatchingForSpammyActivation();
128       // `openPopup` is a no-op if the popup is already opened.
129       // That property is used when `navigator.clipboard.readText()` or
130       // `navigator.clipboard.read()`is called from two different frames, e.g.
131       // an iframe and the top level frame. In that scenario, the two frames
132       // correspond to different `navigator.clipboard` instances. When
133       // `readText()` or `read()` is called from both frames, an actor pair is
134       // instantiated for each of them. Both actor parents will call `openPopup`
135       // on the same `_menupopup` object. If the popup is already open,
136       // `openPopup` is a no-op. When the popup is clicked or dismissed both
137       // actor parents will receive the corresponding event.
138       this._menupopup.openPopup(
139         null,
140         "overlap" /* options */,
141         mouseXInCSSPixels.value,
142         mouseYInCSSPixels.value,
143         true /* isContextMenu */
144       );
146       this._refreshDelayTimer(document);
147     });
148   },
150   _addMenupopupEventListeners() {
151     this._menupopup.addEventListener("command", this);
152     this._menupopup.addEventListener("popuphiding", this);
153   },
155   _removeMenupopupEventListeners() {
156     this._menupopup.removeEventListener("command", this);
157     this._menupopup.removeEventListener("popuphiding", this);
158   },
160   _createMenupopup(aChromeDoc) {
161     let menuitem = aChromeDoc.createXULElement("menuitem");
162     menuitem.id = "clipboardReadPasteMenuItem";
163     aChromeDoc.l10n.setAttributes(menuitem, "text-action-paste");
165     let menupopup = aChromeDoc.createXULElement("menupopup");
166     menupopup.id = this.MENU_POPUP_ID;
167     menupopup.appendChild(menuitem);
168     return menupopup;
169   },
171   _getMenupopup(aChromeDoc) {
172     let menupopup = aChromeDoc.getElementById(this.MENU_POPUP_ID);
173     if (menupopup == null) {
174       menupopup = this._createMenupopup(aChromeDoc);
175       const parent =
176         aChromeDoc.querySelector("popupset") || aChromeDoc.documentElement;
177       parent.appendChild(menupopup);
178     }
180     return menupopup;
181   },
183   _startWatchingForSpammyActivation() {
184     let doc = this._menuitem.ownerDocument;
185     doc.addEventListener("keydown", this, {
186       capture: true,
187       mozSystemGroup: true,
188     });
189   },
191   _stopWatchingForSpammyActivation() {
192     let doc = this._menuitem.ownerDocument;
193     doc.removeEventListener("keydown", this, {
194       capture: true,
195       mozSystemGroup: true,
196     });
197   },
199   _delayTimer: null,
201   _clearDelayTimer() {
202     if (this._delayTimer) {
203       let window = this._menuitem.ownerGlobal;
204       window.clearTimeout(this._delayTimer);
205       this._delayTimer = null;
206     }
207   },
209   _refreshDelayTimer() {
210     this._clearDelayTimer();
212     let window = this._menuitem.ownerGlobal;
213     let delay = Services.prefs.getIntPref("security.dialog_enable_delay");
214     this._delayTimer = window.setTimeout(() => {
215       this._menuitem.disabled = false;
216       this._stopWatchingForSpammyActivation();
217       this._delayTimer = null;
218     }, delay);
219   },