Bug 1874684 - Part 4: Prefer const references instead of copying Instant values....
[gecko.git] / devtools / client / inspector / node-picker.js
blobca837b12f157ee5db670494f713975bc50bb2bc6
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 "use strict";
7 loader.lazyRequireGetter(
8   this,
9   "EventEmitter",
10   "resource://devtools/shared/event-emitter.js"
13 /**
14  * Client-side NodePicker module.
15  * To be used by inspector front when it needs to select DOM elements.
16  *
17  * NodePicker is a proxy for the node picker functionality from WalkerFront instances
18  * of all available InspectorFronts. It is a single point of entry for the client to:
19  * - invoke actions to start and stop picking nodes on all walkers
20  * - listen to node picker events from all walkers and relay them to subscribers
21  *
22  *
23  * @param {Commands} commands
24  *        The commands object with all interfaces defined from devtools/shared/commands/
25  */
26 class NodePicker extends EventEmitter {
27   constructor(commands) {
28     super();
29     this.commands = commands;
30     this.targetCommand = commands.targetCommand;
32     // Whether or not the node picker is active.
33     this.isPicking = false;
34     // Whether to focus the top-level frame before picking nodes.
35     this.doFocus = false;
36   }
38   // The set of inspector fronts corresponding to the targets where picking happens.
39   #currentInspectorFronts = new Set();
41   /**
42    * Start/stop the element picker on the debuggee target.
43    *
44    * @param {Boolean} doFocus
45    *        Optionally focus the content area once the picker is activated.
46    * @return Promise that resolves when done
47    */
48   togglePicker = doFocus => {
49     if (this.isPicking) {
50       return this.stop({ canceled: true });
51     }
52     return this.start(doFocus);
53   };
55   /**
56    * This DOCUMENT_EVENT resource callback is only used for webextension targets
57    * to workaround the fact that some navigations will not create/destroy any
58    * target (eg when jumping from a background document to a popup document).
59    **/
60   #onWebExtensionDocumentEventAvailable = async resources => {
61     const { DOCUMENT_EVENT } = this.commands.resourceCommand.TYPES;
63     for (const resource of resources) {
64       if (
65         resource.resourceType == DOCUMENT_EVENT &&
66         resource.name === "dom-complete" &&
67         resource.targetFront.isTopLevel &&
68         // When switching frames for a webextension target, a first dom-complete
69         // resource is emitted when we start watching the new docshell, in the
70         // WindowGlobalTargetActor progress listener.
71         //
72         // However here, we are expecting the "fake" dom-complete resource
73         // emitted specifically from the webextension target actor, when the
74         // new docshell is finally recognized to be linked to the target's
75         // webextension. This resource is emitted from `_changeTopLevelDocument`
76         // and is the only one which will have `isFrameSwitching` set to true.
77         //
78         // It also emitted after the one for the new docshell, so to avoid
79         // stopping and starting the node-picker twice, we filter out the first
80         // resource, which does not have `isFrameSwitching` set.
81         resource.isFrameSwitching
82       ) {
83         const inspectorFront = await resource.targetFront.getFront("inspector");
84         // When a webextension target navigates, it will typically be between
85         // documents which are not under the same root (fallback-document,
86         // devtools-panel, popup). Even though we are not switching targets, we
87         // need to restart the node picker.
88         await inspectorFront.walker.cancelPick();
89         await inspectorFront.walker.pick(this.doFocus);
90         this.emitForTests("node-picker-webextension-target-restarted");
91       }
92     }
93   };
95   /**
96    * Tell the walker front corresponding to the given inspector front to enter node
97    * picking mode (listen for mouse movements over its nodes) and set event listeners
98    * associated with node picking: hover node, pick node, preview, cancel. See WalkerSpec.
99    *
100    * @param {InspectorFront} inspectorFront
101    * @return {Promise}
102    */
103   #onInspectorFrontAvailable = async inspectorFront => {
104     this.#currentInspectorFronts.add(inspectorFront);
105     // watchFront may notify us about inspector fronts that aren't initialized yet,
106     // so ensure waiting for initialization in order to have a defined `walker` attribute.
107     await inspectorFront.initialize();
108     const { walker } = inspectorFront;
109     walker.on("picker-node-hovered", this.#onHovered);
110     walker.on("picker-node-picked", this.#onPicked);
111     walker.on("picker-node-previewed", this.#onPreviewed);
112     walker.on("picker-node-canceled", this.#onCanceled);
113     await walker.pick(this.doFocus);
115     this.emitForTests("inspector-front-ready-for-picker", walker);
116   };
118   /**
119    * Tell the walker front corresponding to the given inspector front to exit the node
120    * picking mode and remove all event listeners associated with node picking.
121    *
122    * @param {InspectorFront} inspectorFront
123    * @param {Boolean} isDestroyCodePath
124    *        Optional. If true, we assume that's when the toolbox closes
125    *        and we should avoid doing any RDP request.
126    * @return {Promise}
127    */
128   #onInspectorFrontDestroyed = async (
129     inspectorFront,
130     { isDestroyCodepath } = {}
131   ) => {
132     this.#currentInspectorFronts.delete(inspectorFront);
134     const { walker } = inspectorFront;
135     if (!walker) {
136       return;
137     }
139     walker.off("picker-node-hovered", this.#onHovered);
140     walker.off("picker-node-picked", this.#onPicked);
141     walker.off("picker-node-previewed", this.#onPreviewed);
142     walker.off("picker-node-canceled", this.#onCanceled);
143     // Only do a RDP request if we stop the node picker from a user action.
144     // Avoid doing one when we close the toolbox, in this scenario
145     // the walker actor on the server side will automatically cancel the node picking.
146     if (!isDestroyCodepath) {
147       await walker.cancelPick();
148     }
149   };
151   /**
152    * While node picking, we want each target's walker fronts to listen for mouse
153    * movements over their nodes and emit events. Walker fronts are obtained from
154    * inspector fronts so we watch for the creation and destruction of inspector fronts
155    * in order to add or remove the necessary event listeners.
156    *
157    * @param {TargetFront} targetFront
158    * @return {Promise}
159    */
160   #onTargetAvailable = async ({ targetFront }) => {
161     targetFront.watchFronts(
162       "inspector",
163       this.#onInspectorFrontAvailable,
164       this.#onInspectorFrontDestroyed
165     );
166   };
168   /**
169    * Start the element picker.
170    * This will instruct walker fronts of all available targets (and those of targets
171    * created while node picking is active) to listen for mouse movements over their nodes
172    * and trigger events when a node is hovered or picked.
173    *
174    * @param {Boolean} doFocus
175    *        Optionally focus the content area once the picker is activated.
176    */
177   start = async doFocus => {
178     if (this.isPicking) {
179       return;
180     }
181     this.isPicking = true;
182     this.doFocus = doFocus;
184     this.emit("picker-starting");
186     this.targetCommand.watchTargets({
187       types: this.targetCommand.ALL_TYPES,
188       onAvailable: this.#onTargetAvailable,
189     });
191     if (this.targetCommand.descriptorFront.isWebExtension) {
192       await this.commands.resourceCommand.watchResources(
193         [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT],
194         {
195           onAvailable: this.#onWebExtensionDocumentEventAvailable,
196         }
197       );
198     }
200     this.emit("picker-started");
201   };
203   /**
204    * Stop the element picker. Note that the picker is automatically stopped when
205    * an element is picked.
206    *
207    * @param {Boolean} isDestroyCodePath
208    *        Optional. If true, we assume that's when the toolbox closes
209    *        and we should avoid doing any RDP request.
210    * @param {Boolean} canceled
211    *        Optional. If true, emit an additional event to notify that the
212    *        picker was canceled, ie stopped without selecting a node.
213    */
214   stop = async ({ isDestroyCodepath, canceled } = {}) => {
215     if (!this.isPicking) {
216       return;
217     }
218     this.isPicking = false;
219     this.doFocus = false;
221     this.targetCommand.unwatchTargets({
222       types: this.targetCommand.ALL_TYPES,
223       onAvailable: this.#onTargetAvailable,
224     });
226     if (this.targetCommand.descriptorFront.isWebExtension) {
227       this.commands.resourceCommand.unwatchResources(
228         [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT],
229         {
230           onAvailable: this.#onWebExtensionDocumentEventAvailable,
231         }
232       );
233     }
235     const promises = [];
236     for (const inspectorFront of this.#currentInspectorFronts) {
237       promises.push(
238         this.#onInspectorFrontDestroyed(inspectorFront, {
239           isDestroyCodepath,
240         })
241       );
242     }
243     await Promise.all(promises);
245     this.#currentInspectorFronts.clear();
247     this.emit("picker-stopped");
249     if (canceled) {
250       this.emit("picker-node-canceled");
251     }
252   };
254   destroy() {
255     // Do not await for stop as the isDestroy argument will make this method synchronous
256     // and we want to avoid having an async destroy
257     this.stop({ isDestroyCodepath: true });
258     this.targetCommand = null;
259     this.commands = null;
260   }
262   /**
263    * When a node is hovered by the mouse when the highlighter is in picker mode
264    *
265    * @param {Object} data
266    *        Information about the node being hovered
267    */
268   #onHovered = data => {
269     this.emit("picker-node-hovered", data.node);
271     // We're going to cleanup references for all the other walkers, so that if we hover
272     // back the same node, we will receive a new `picker-node-hovered` event.
273     for (const inspectorFront of this.#currentInspectorFronts) {
274       if (inspectorFront.walker !== data.node.walkerFront) {
275         inspectorFront.walker.clearPicker();
276       }
277     }
278   };
280   /**
281    * When a node has been picked while the highlighter is in picker mode
282    *
283    * @param {Object} data
284    *        Information about the picked node
285    */
286   #onPicked = data => {
287     this.emit("picker-node-picked", data.node);
288     return this.stop();
289   };
291   /**
292    * When a node has been shift-clicked (previewed) while the highlighter is in
293    * picker mode
294    *
295    * @param {Object} data
296    *        Information about the picked node
297    */
298   #onPreviewed = data => {
299     this.emit("picker-node-previewed", data.node);
300   };
302   /**
303    * When the picker is canceled, stop the picker, and make sure the toolbox
304    * gets the focus.
305    */
306   #onCanceled = () => {
307     return this.stop({ canceled: true });
308   };
311 module.exports = NodePicker;