/* 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/. */ "use strict"; loader.lazyRequireGetter( this, "EventEmitter", "resource://devtools/shared/event-emitter.js" ); /** * Client-side NodePicker module. * To be used by inspector front when it needs to select DOM elements. * * NodePicker is a proxy for the node picker functionality from WalkerFront instances * of all available InspectorFronts. It is a single point of entry for the client to: * - invoke actions to start and stop picking nodes on all walkers * - listen to node picker events from all walkers and relay them to subscribers * * * @param {Commands} commands * The commands object with all interfaces defined from devtools/shared/commands/ */ class NodePicker extends EventEmitter { constructor(commands) { super(); this.commands = commands; this.targetCommand = commands.targetCommand; // Whether or not the node picker is active. this.isPicking = false; // Whether to focus the top-level frame before picking nodes. this.doFocus = false; } // The set of inspector fronts corresponding to the targets where picking happens. #currentInspectorFronts = new Set(); /** * Start/stop the element picker on the debuggee target. * * @param {Boolean} doFocus * Optionally focus the content area once the picker is activated. * @return Promise that resolves when done */ togglePicker = doFocus => { if (this.isPicking) { return this.stop({ canceled: true }); } return this.start(doFocus); }; /** * This DOCUMENT_EVENT resource callback is only used for webextension targets * to workaround the fact that some navigations will not create/destroy any * target (eg when jumping from a background document to a popup document). **/ #onWebExtensionDocumentEventAvailable = async resources => { const { DOCUMENT_EVENT } = this.commands.resourceCommand.TYPES; for (const resource of resources) { if ( resource.resourceType == DOCUMENT_EVENT && resource.name === "dom-complete" && resource.targetFront.isTopLevel && // When switching frames for a webextension target, a first dom-complete // resource is emitted when we start watching the new docshell, in the // WindowGlobalTargetActor progress listener. // // However here, we are expecting the "fake" dom-complete resource // emitted specifically from the webextension target actor, when the // new docshell is finally recognized to be linked to the target's // webextension. This resource is emitted from `_changeTopLevelDocument` // and is the only one which will have `isFrameSwitching` set to true. // // It also emitted after the one for the new docshell, so to avoid // stopping and starting the node-picker twice, we filter out the first // resource, which does not have `isFrameSwitching` set. resource.isFrameSwitching ) { const inspectorFront = await resource.targetFront.getFront("inspector"); // When a webextension target navigates, it will typically be between // documents which are not under the same root (fallback-document, // devtools-panel, popup). Even though we are not switching targets, we // need to restart the node picker. await inspectorFront.walker.cancelPick(); await inspectorFront.walker.pick(this.doFocus); this.emitForTests("node-picker-webextension-target-restarted"); } } }; /** * Tell the walker front corresponding to the given inspector front to enter node * picking mode (listen for mouse movements over its nodes) and set event listeners * associated with node picking: hover node, pick node, preview, cancel. See WalkerSpec. * * @param {InspectorFront} inspectorFront * @return {Promise} */ #onInspectorFrontAvailable = async inspectorFront => { this.#currentInspectorFronts.add(inspectorFront); // watchFront may notify us about inspector fronts that aren't initialized yet, // so ensure waiting for initialization in order to have a defined `walker` attribute. await inspectorFront.initialize(); const { walker } = inspectorFront; walker.on("picker-node-hovered", this.#onHovered); walker.on("picker-node-picked", this.#onPicked); walker.on("picker-node-previewed", this.#onPreviewed); walker.on("picker-node-canceled", this.#onCanceled); await walker.pick(this.doFocus); this.emitForTests("inspector-front-ready-for-picker", walker); }; /** * Tell the walker front corresponding to the given inspector front to exit the node * picking mode and remove all event listeners associated with node picking. * * @param {InspectorFront} inspectorFront * @param {Boolean} isDestroyCodePath * Optional. If true, we assume that's when the toolbox closes * and we should avoid doing any RDP request. * @return {Promise} */ #onInspectorFrontDestroyed = async ( inspectorFront, { isDestroyCodepath } = {} ) => { this.#currentInspectorFronts.delete(inspectorFront); const { walker } = inspectorFront; if (!walker) { return; } walker.off("picker-node-hovered", this.#onHovered); walker.off("picker-node-picked", this.#onPicked); walker.off("picker-node-previewed", this.#onPreviewed); walker.off("picker-node-canceled", this.#onCanceled); // Only do a RDP request if we stop the node picker from a user action. // Avoid doing one when we close the toolbox, in this scenario // the walker actor on the server side will automatically cancel the node picking. if (!isDestroyCodepath) { await walker.cancelPick(); } }; /** * While node picking, we want each target's walker fronts to listen for mouse * movements over their nodes and emit events. Walker fronts are obtained from * inspector fronts so we watch for the creation and destruction of inspector fronts * in order to add or remove the necessary event listeners. * * @param {TargetFront} targetFront * @return {Promise} */ #onTargetAvailable = async ({ targetFront }) => { targetFront.watchFronts( "inspector", this.#onInspectorFrontAvailable, this.#onInspectorFrontDestroyed ); }; /** * Start the element picker. * This will instruct walker fronts of all available targets (and those of targets * created while node picking is active) to listen for mouse movements over their nodes * and trigger events when a node is hovered or picked. * * @param {Boolean} doFocus * Optionally focus the content area once the picker is activated. */ start = async doFocus => { if (this.isPicking) { return; } this.isPicking = true; this.doFocus = doFocus; this.emit("picker-starting"); this.targetCommand.watchTargets({ types: this.targetCommand.ALL_TYPES, onAvailable: this.#onTargetAvailable, }); if (this.targetCommand.descriptorFront.isWebExtension) { await this.commands.resourceCommand.watchResources( [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: this.#onWebExtensionDocumentEventAvailable, } ); } this.emit("picker-started"); }; /** * Stop the element picker. Note that the picker is automatically stopped when * an element is picked. * * @param {Boolean} isDestroyCodePath * Optional. If true, we assume that's when the toolbox closes * and we should avoid doing any RDP request. * @param {Boolean} canceled * Optional. If true, emit an additional event to notify that the * picker was canceled, ie stopped without selecting a node. */ stop = async ({ isDestroyCodepath, canceled } = {}) => { if (!this.isPicking) { return; } this.isPicking = false; this.doFocus = false; this.targetCommand.unwatchTargets({ types: this.targetCommand.ALL_TYPES, onAvailable: this.#onTargetAvailable, }); if (this.targetCommand.descriptorFront.isWebExtension) { this.commands.resourceCommand.unwatchResources( [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: this.#onWebExtensionDocumentEventAvailable, } ); } const promises = []; for (const inspectorFront of this.#currentInspectorFronts) { promises.push( this.#onInspectorFrontDestroyed(inspectorFront, { isDestroyCodepath, }) ); } await Promise.all(promises); this.#currentInspectorFronts.clear(); this.emit("picker-stopped"); if (canceled) { this.emit("picker-node-canceled"); } }; destroy() { // Do not await for stop as the isDestroy argument will make this method synchronous // and we want to avoid having an async destroy this.stop({ isDestroyCodepath: true }); this.targetCommand = null; this.commands = null; } /** * When a node is hovered by the mouse when the highlighter is in picker mode * * @param {Object} data * Information about the node being hovered */ #onHovered = data => { this.emit("picker-node-hovered", data.node); // We're going to cleanup references for all the other walkers, so that if we hover // back the same node, we will receive a new `picker-node-hovered` event. for (const inspectorFront of this.#currentInspectorFronts) { if (inspectorFront.walker !== data.node.walkerFront) { inspectorFront.walker.clearPicker(); } } }; /** * When a node has been picked while the highlighter is in picker mode * * @param {Object} data * Information about the picked node */ #onPicked = data => { this.emit("picker-node-picked", data.node); return this.stop(); }; /** * When a node has been shift-clicked (previewed) while the highlighter is in * picker mode * * @param {Object} data * Information about the picked node */ #onPreviewed = data => { this.emit("picker-node-previewed", data.node); }; /** * When the picker is canceled, stop the picker, and make sure the toolbox * gets the focus. */ #onCanceled = () => { return this.stop({ canceled: true }); }; } module.exports = NodePicker;