Bug 1726776 - [devtools] Fix StyleSheet editor selector highlighter when Fission is enabled. r=bomsy.

The highlighters weren't working in remote frame as the StyleSheetEditor
was always trying to use the top-level walker and highlighter fronts.
To fix that, we retrieve the appropriate fronts given the stylesheet
resource.
The existing test is augmented, which meant making the highlighterTestActor
slightly better.

Differential Revision: https://phabricator.services.mozilla.com/D128354
This commit is contained in:
Nicolas Chevobbe 2021-10-27 12:05:30 +00:00
parent 8a15b54b15
commit 662a550b33
6 changed files with 344 additions and 135 deletions

View File

@ -48,10 +48,33 @@ const dumpn = msg => {
* @param {String} actorID
*/
function getHighlighterCanvasFrameHelper(conn, actorID) {
// Retrieve the CustomHighlighterActor by its actorID:
const actor = conn.getActor(actorID);
if (actor && actor._highlighter) {
return actor._highlighter.markup;
if (!actor) {
return null;
}
// Retrieve the sub class instance specific to each highlighter type:
let highlighter = actor.instance;
// SelectorHighlighter and TabbingOrderHighlighter can hold multiple highlighters.
// For now, only retrieve the first highlighter.
if (
highlighter._highlighters &&
Array.isArray(highlighter._highlighters) &&
highlighter._highlighters.length > 0
) {
highlighter = highlighter._highlighters[0];
}
// Now, `highlighter` should be a final highlighter class, exposing
// `CanvasFrameAnonymousContentHelper` via a `markup` attribute.
if (highlighter.markup) {
return highlighter.markup;
}
// Here we didn't find any highlighter; it can happen if the actor is a
// FontsHighlighter (which does not use a CanvasFrameAnonymousContentHelper).
return null;
}
@ -232,10 +255,12 @@ var HighlighterTestActor = protocol.ActorClassWithSpec(highlighterTestSpec, {
*/
getHighlighterAttribute: function(nodeID, name, actorID) {
const helper = getHighlighterCanvasFrameHelper(this.conn, actorID);
if (helper) {
return helper.getAttributeForElement(nodeID, name);
if (!helper) {
throw new Error(`Highlighter not found`);
}
return null;
return helper.getAttributeForElement(nodeID, name);
},
/**
@ -481,17 +506,25 @@ class HighlighterTestFront extends protocol.FrontClassWithSpec(
/**
* Is the highlighter currently visible on the page?
*/
isHighlighting() {
async isHighlighting() {
// Once the highlighter is hidden, the reference to it is lost.
// Assume it is not highlighting.
if (!this.highlighter) {
return false;
}
return this.getHighlighterNodeAttribute(
"box-model-elements",
"hidden"
).then(value => value === null);
try {
const hidden = await this.getHighlighterNodeAttribute(
"box-model-elements",
"hidden"
);
return hidden === null;
} catch (e) {
if (e.message.match(/Highlighter not found/)) {
return false;
}
throw e;
}
}
/**
@ -601,6 +634,10 @@ class HighlighterTestFront extends protocol.FrontClassWithSpec(
"d"
);
if (!d) {
return null;
}
const polygons = d.match(/M[^M]+/g);
if (!polygons) {
return null;

View File

@ -1692,8 +1692,9 @@ async function getBrowsingContextInFrames(browsingContext, selectors) {
);
}
while (selectors.length) {
const selector = selectors.shift();
const clonedSelectors = [...selectors];
while (clonedSelectors.length) {
const selector = clonedSelectors.shift();
context = await SpecialPowers.spawn(context, [selector], _selector => {
return content.document.querySelector(_selector).browsingContext;
});

View File

@ -54,7 +54,6 @@ loader.lazyRequireGetter(
const LOAD_ERROR = "error-load";
const STYLE_EDITOR_TEMPLATE = "stylesheet";
const SELECTOR_HIGHLIGHTER_TYPE = "SelectorHighlighter";
const PREF_MEDIA_SIDEBAR = "devtools.styleeditor.showMediaSidebar";
const PREF_SIDEBAR_WIDTH = "devtools.styleeditor.mediaSidebarWidth";
const PREF_NAV_WIDTH = "devtools.styleeditor.navSidebarWidth";
@ -101,7 +100,6 @@ function StyleEditorUI(toolbox, commands, panelDoc, cssProperties) {
this._updateContextMenuItems = this._updateContextMenuItems.bind(this);
this._openLinkNewTab = this._openLinkNewTab.bind(this);
this._copyUrl = this._copyUrl.bind(this);
this._onTargetAvailable = this._onTargetAvailable.bind(this);
this._onResourceAvailable = this._onResourceAvailable.bind(this);
this._onResourceUpdated = this._onResourceUpdated.bind(this);
@ -140,11 +138,6 @@ StyleEditorUI.prototype = {
async initialize() {
this.createUI();
await this._commands.targetCommand.watchTargets(
[this._commands.targetCommand.TYPES.FRAME],
this._onTargetAvailable
);
await this._toolbox.resourceCommand.watchResources(
[this._toolbox.resourceCommand.TYPES.DOCUMENT_EVENT],
{ onAvailable: this._onResourceAvailable }
@ -161,25 +154,6 @@ StyleEditorUI.prototype = {
await this._waitForLoadingStyleSheets();
},
async initializeHighlighter(targetFront) {
const inspectorFront = await targetFront.getFront("inspector");
this._walker = inspectorFront.walker;
try {
this._highlighter = await inspectorFront.getHighlighterByType(
SELECTOR_HIGHLIGHTER_TYPE
);
} catch (e) {
// The selectorHighlighter can't always be instantiated, for example
// it doesn't work with XUL windows (until bug 1094959 gets fixed);
// or the selectorHighlighter doesn't exist on the backend.
console.warn(
"The selectorHighlighter couldn't be instantiated, " +
"elements matching hovered selectors will not be highlighted"
);
}
},
/**
* Build the initial UI and wire buttons with event handlers.
*/
@ -414,8 +388,6 @@ StyleEditorUI.prototype = {
const editor = new StyleSheetEditor(
resource,
this._window,
this._walker,
this._highlighter,
this._getNextFriendlyIndex(resource)
);
@ -431,7 +403,16 @@ StyleEditorUI.prototype = {
this.editors.push(editor);
await editor.fetchSource();
try {
await editor.fetchSource();
} catch (e) {
// if the editor was destroyed while fetching dependencies, we don't want to go further.
if (!this.editors.includes(editor)) {
return null;
}
throw e;
}
this._sourceLoaded(editor);
if (resource.fileName) {
@ -780,6 +761,11 @@ StyleEditorUI.prototype = {
* to be used.
*/
_selectEditor: function(editor, line, col) {
// Don't go further if the editor was destroyed in the meantime
if (!this.editors.includes(editor)) {
return null;
}
line = line || 0;
col = col || 0;
@ -789,6 +775,10 @@ StyleEditorUI.prototype = {
});
const summaryPromise = this.getEditorSummary(editor).then(summary => {
// Don't go further if the editor was destroyed in the meantime
if (!this.editors.includes(editor)) {
throw new Error("Editor was destroyed");
}
this._view.activeSummary = summary;
});
@ -1249,18 +1239,7 @@ StyleEditorUI.prototype = {
}
},
async _onTargetAvailable({ targetFront }) {
if (targetFront.isTopLevel) {
await this.initializeHighlighter(targetFront);
}
},
destroy: function() {
this._commands.targetCommand.unwatchTargets(
[this._commands.targetCommand.TYPES.FRAME],
this._onTargetAvailable
);
this._toolbox.resourceCommand.unwatchResources(
[
this._toolbox.resourceCommand.TYPES.DOCUMENT_EVENT,

View File

@ -25,6 +25,7 @@ const {
const LOAD_ERROR = "error-load";
const SAVE_ERROR = "error-save";
const SELECTOR_HIGHLIGHTER_TYPE = "SelectorHighlighter";
// max update frequency in ms (avoid potential typing lag and/or flicker)
// @see StyleEditor.updateStylesheet
@ -65,22 +66,11 @@ const STYLE_SHEET_UPDATE_CAUSED_BY_STYLE_EDITOR = "styleeditor";
* The STYLESHEET resource which is received from resource command.
* @param {DOMWindow} win
* panel window for style editor
* @param {Walker} walker
* Optional walker used for selectors autocompletion
* @param {CustomHighlighterFront} highlighter
* Optional highlighter front for the SelectorHighligher used to
* highlight selectors
* @param {Number} styleSheetFriendlyIndex
* Optional Integer representing the index of the current stylesheet
* among all stylesheets of its type (inline or user-created)
*/
function StyleSheetEditor(
resource,
win,
walker,
highlighter,
styleSheetFriendlyIndex
) {
function StyleSheetEditor(resource, win, styleSheetFriendlyIndex) {
EventEmitter.decorate(this);
this._resource = resource;
@ -88,8 +78,6 @@ function StyleSheetEditor(
this.sourceEditor = null;
this._window = win;
this._isNew = this.styleSheet.isNew;
this.walker = walker;
this.highlighter = highlighter;
this.styleSheetFriendlyIndex = styleSheetFriendlyIndex;
// True when we've called update() on the style sheet.
@ -455,9 +443,9 @@ StyleSheetEditor.prototype = {
* @return {Promise}
* Promise that will resolve when the style editor is loaded.
*/
load: function(inputElement, cssProperties) {
load: async function(inputElement, cssProperties) {
if (this._isDestroyed) {
return Promise.reject(
throw new Error(
"Won't load source editor as the style sheet has " +
"already been removed from Style Editor."
);
@ -465,6 +453,7 @@ StyleSheetEditor.prototype = {
this._inputElement = inputElement;
const walker = await this.getWalker();
const config = {
value: this._state.text,
lineNumbers: true,
@ -474,49 +463,45 @@ StyleSheetEditor.prototype = {
extraKeys: this._getKeyBindings(),
contextMenu: "sourceEditorContextMenu",
autocomplete: Services.prefs.getBoolPref(AUTOCOMPLETION_PREF),
autocompleteOpts: { walker: this.walker, cssProperties },
autocompleteOpts: { walker, cssProperties },
cssProperties,
};
const sourceEditor = (this._sourceEditor = new Editor(config));
sourceEditor.on("dirty-change", this.onPropertyChange);
return sourceEditor.appendTo(inputElement).then(() => {
sourceEditor.on("saveRequested", this.saveToFile);
await sourceEditor.appendTo(inputElement);
if (!this.styleSheet.isOriginalSource) {
sourceEditor.on("change", this.updateStyleSheet);
}
sourceEditor.on("saveRequested", this.saveToFile);
this.sourceEditor = sourceEditor;
if (!this.styleSheet.isOriginalSource) {
sourceEditor.on("change", this.updateStyleSheet);
}
if (this._focusOnSourceEditorReady) {
this._focusOnSourceEditorReady = false;
sourceEditor.focus();
}
this.sourceEditor = sourceEditor;
sourceEditor.setSelection(
this._state.selection.start,
this._state.selection.end
if (this._focusOnSourceEditorReady) {
this._focusOnSourceEditorReady = false;
sourceEditor.focus();
}
sourceEditor.setSelection(
this._state.selection.start,
this._state.selection.end
);
const highlighter = await this.getHighlighter();
if (highlighter && walker && sourceEditor.container?.contentWindow) {
sourceEditor.container.contentWindow.addEventListener(
"mousemove",
this._onMouseMove
);
}
if (
this.highlighter &&
this.walker &&
sourceEditor.container &&
sourceEditor.container.contentWindow
) {
sourceEditor.container.contentWindow.addEventListener(
"mousemove",
this._onMouseMove
);
}
// Add the commands controller for the source-editor.
sourceEditor.insertCommandsController();
// Add the commands controller for the source-editor.
sourceEditor.insertCommandsController();
this.emit("source-editor-load");
});
this.emit("source-editor-load");
},
/**
@ -634,7 +619,11 @@ StyleSheetEditor.prototype = {
* and reseting the delay everytime.
*/
_onMouseMove: function(e) {
this.highlighter.hide();
// As we only want to hide an existing highlighter, we can use this.highlighter directly
// (and not this.getHighlighter).
if (this.highlighter) {
this.highlighter.hide();
}
if (this.mouseMoveTimeout) {
this._window.clearTimeout(this.mouseMoveTimeout);
@ -660,8 +649,12 @@ StyleSheetEditor.prototype = {
return;
}
const node = await this.walker.getStyleSheetOwnerNode(this.resourceId);
await this.highlighter.show(node, {
const onGetHighlighter = this.getHighlighter();
const walker = await this.getWalker();
const node = await walker.getStyleSheetOwnerNode(this.resourceId);
const highlighter = await onGetHighlighter;
await highlighter.show(node, {
selector: info.selector,
hideInfoBar: true,
showOnly: "border",
@ -671,6 +664,50 @@ StyleSheetEditor.prototype = {
this.emit("node-highlighted");
},
/**
* Returns the walker front associated with this._resource target.
*
* @returns {Promise<WalkerFront>}
*/
async getWalker() {
if (this.walker) {
return this.walker;
}
const { targetFront } = this._resource;
const inspectorFront = await targetFront.getFront("inspector");
this.walker = inspectorFront.walker;
return this.walker;
},
/**
* Returns or creates the selector highlighter associated with this._resource target.
*
* @returns {CustomHighlighterFront|null}
*/
async getHighlighter() {
if (this.highlighter) {
return this.highlighter;
}
const walker = await this.getWalker();
try {
this.highlighter = await walker.parentFront.getHighlighterByType(
SELECTOR_HIGHLIGHTER_TYPE
);
return this.highlighter;
} catch (e) {
// The selectorHighlighter can't always be instantiated, for example
// it doesn't work with XUL windows (until bug 1094959 gets fixed);
// or the selectorHighlighter doesn't exist on the backend.
console.warn(
"The selectorHighlighter couldn't be instantiated, " +
"elements matching hovered selectors will not be highlighted"
);
}
return null;
},
/**
* Save the editor contents into a file and set savedFile property.
* A file picker UI will open if file is not set and editor is not headless.
@ -871,12 +908,7 @@ StyleSheetEditor.prototype = {
this._sourceEditor.off("dirty-change", this.onPropertyChange);
this._sourceEditor.off("saveRequested", this.saveToFile);
this._sourceEditor.off("change", this.updateStyleSheet);
if (
this.highlighter &&
this.walker &&
this._sourceEditor.container &&
this._sourceEditor.container.contentWindow
) {
if (this._sourceEditor.container?.contentWindow) {
this._sourceEditor.container.contentWindow.removeEventListener(
"mousemove",
this._onMouseMove

View File

@ -18,12 +18,16 @@ add_task(async function() {
ok(true, `Three style sheets for ${PARENT_PROCESS_URI}`);
info("Navigate to a page that runs in the child process");
const onEditorReady = ui.editors[0].getSourceEditor();
await navigateToAndWaitForStyleSheets(CONTENT_PROCESS_URI, ui, 2);
// We also have to wait for the toolbox to complete the target switching
// in order to avoid pending requests during test teardown.
ok(true, `Two sheets present for ${CONTENT_PROCESS_URI}`);
ok(
ui.editors.every(
editor => editor._resource.nodeHref == CONTENT_PROCESS_URI
),
`Two sheets present for ${CONTENT_PROCESS_URI}`
);
info("Wait until the editor is ready");
await onEditorReady;
await waitFor(() => ui.selectedEditor?.sourceEditor);
});

View File

@ -4,42 +4,198 @@
"use strict";
// Test that hovering over a simple selector in the style-editor requests the
// highlighting of the corresponding nodes
// highlighting of the corresponding nodes, even in remote iframes.
const REMOTE_IFRAME_URL = `https://example.org/document-builder.sjs?html=
<style>h2{color:cyan}</style>
<h2>highlighter test</h2>`;
const TOP_LEVEL_URL = `https://example.com/document-builder.sjs?html=
<style>h1{color:red}</style>
<h1>highlighter test</h1>
<iframe src='${REMOTE_IFRAME_URL}'></iframe>`;
add_task(async function() {
const url = TEST_BASE_HTTP + "selector-highlighter.html";
const { ui } = await openStyleEditorForURL(url);
const editor = ui.editors[0];
const { ui } = await openStyleEditorForURL(TOP_LEVEL_URL);
// Mock the highlighter so we can locally assert that things happened
// correctly instead of accessing the highlighter elements
editor.highlighter = {
isShown: false,
options: null,
info(
"Wait until both stylesheet are loaded and ready to handle mouse events"
);
await waitFor(() => ui.editors.length == 2);
const topLevelStylesheetEditor = ui.editors.find(e =>
e._resource.nodeHref.startsWith("https://example.com")
);
const iframeStylesheetEditor = ui.editors.find(e =>
e._resource.nodeHref.startsWith("https://example.org")
);
show: function(node, options) {
this.isShown = true;
this.options = options;
return Promise.resolve();
},
await ui.selectStyleSheet(topLevelStylesheetEditor.styleSheet);
await waitFor(() => topLevelStylesheetEditor.highlighter);
hide: function() {
this.isShown = false;
},
};
info("Check that highlighting works on the top-level document");
const topLevelHighlighterTestFront = await topLevelStylesheetEditor._resource.targetFront.getFront(
"highlighterTest"
);
topLevelHighlighterTestFront.highlighter =
topLevelStylesheetEditor.highlighter;
info("Expecting a node-highlighted event");
const onHighlighted = editor.once("node-highlighted");
let onHighlighted = topLevelStylesheetEditor.once("node-highlighted");
info("Simulate a mousemove event on the div selector");
editor._onMouseMove({ clientX: 56, clientY: 10 });
info("Simulate a mousemove event on the h1 selector");
// mousemove event listeners is set on editor.sourceEditor, which is not defined right away.
await waitFor(() => !!topLevelStylesheetEditor.sourceEditor);
let selectorEl = querySelectorCodeMirrorCssRuleSelectorToken(
topLevelStylesheetEditor
);
EventUtils.synthesizeMouseAtCenter(
selectorEl,
{ type: "mousemove" },
selectorEl.ownerDocument.defaultView
);
await onHighlighted;
ok(editor.highlighter.isShown, "The highlighter is now shown");
is(editor.highlighter.options.selector, "div", "The selector is correct");
ok(
await topLevelHighlighterTestFront.isNodeRectHighlighted(
await getElementNodeRectWithinTarget(["h1"])
),
"The highlighter's outline corresponds to the h1 node"
);
info(
"Simulate a mousemove event on the property name to hide the highlighter"
);
EventUtils.synthesizeMouseAtCenter(
querySelectorCodeMirrorCssPropertyNameToken(topLevelStylesheetEditor),
{ type: "mousemove" },
selectorEl.ownerDocument.defaultView
);
await waitFor(async () => !topLevelStylesheetEditor.highlighter.isShown());
let isVisible = await topLevelHighlighterTestFront.isHighlighting();
is(isVisible, false, "The highlighter is now hidden");
info("Check that highlighting works on the iframe document");
await ui.selectStyleSheet(iframeStylesheetEditor.styleSheet);
await waitFor(() => iframeStylesheetEditor.highlighter);
const iframeHighlighterTestFront = await iframeStylesheetEditor._resource.targetFront.getFront(
"highlighterTest"
);
iframeHighlighterTestFront.highlighter = iframeStylesheetEditor.highlighter;
info("Expecting a node-highlighted event");
onHighlighted = iframeStylesheetEditor.once("node-highlighted");
info("Simulate a mousemove event on the h2 selector");
// mousemove event listeners is set on editor.sourceEditor, which is not defined right away.
await waitFor(() => !!iframeStylesheetEditor.sourceEditor);
selectorEl = querySelectorCodeMirrorCssRuleSelectorToken(
iframeStylesheetEditor
);
EventUtils.synthesizeMouseAtCenter(
selectorEl,
{ type: "mousemove" },
selectorEl.ownerDocument.defaultView
);
await onHighlighted;
isVisible = await iframeHighlighterTestFront.isHighlighting();
ok(isVisible, "The highlighter is shown");
ok(
await iframeHighlighterTestFront.isNodeRectHighlighted(
await getElementNodeRectWithinTarget(["iframe", "h2"])
),
"The highlighter's outline corresponds to the h2 node"
);
info("Simulate a mousemove event elsewhere in the editor");
editor._onMouseMove({ clientX: 16, clientY: 0 });
EventUtils.synthesizeMouseAtCenter(
querySelectorCodeMirrorCssPropertyNameToken(iframeStylesheetEditor),
{ type: "mousemove" },
selectorEl.ownerDocument.defaultView
);
ok(!editor.highlighter.isShown, "The highlighter is now hidden");
await waitFor(async () => !topLevelStylesheetEditor.highlighter.isShown());
isVisible = await iframeHighlighterTestFront.isHighlighting();
is(isVisible, false, "The highlighter is now hidden");
});
function querySelectorCodeMirrorCssRuleSelectorToken(stylesheetEditor) {
// CSS Rules selector (e.g. `h1`) are displayed in a .cm-tag span
return querySelectorCodeMirror(stylesheetEditor, ".cm-tag");
}
function querySelectorCodeMirrorCssPropertyNameToken(stylesheetEditor) {
// properties name (e.g. `color`) are displayed in a .cm-property span
return querySelectorCodeMirror(stylesheetEditor, ".cm-property");
}
function querySelectorCodeMirror(stylesheetEditor, selector) {
return stylesheetEditor.sourceEditor.codeMirror
.getWrapperElement()
.querySelector(selector);
}
/**
* Return the bounds of the element matching the selector, relatively to the target bounds
* (e.g. if Fission is enabled, it's related to the iframe bound, if Fission is disabled,
* it's related to the top level document).
*
* @param {Array<string>} selectors: Arrays of CSS selectors from the root document to the node.
* The last CSS selector of the array is for the node in its frame doc.
* The before-last CSS selector is for the frame in its parent frame, etc...
* Ex: ["frame.first-frame", ..., "frame.last-frame", ".target-node"]
* @returns {Object} with left/top/width/height properties representing the node bounds
*/
async function getElementNodeRectWithinTarget(selectors) {
// Retrieve the browsing context in which the element is
const inBCSelector = selectors.pop();
const frameSelectors = selectors;
const bc =
frameSelectors.length > 0
? await getBrowsingContextInFrames(
gBrowser.selectedBrowser.browsingContext,
frameSelectors
)
: gBrowser.selectedBrowser.browsingContext;
// Get the element bounds within the Firefox window
const elementBounds = await SpecialPowers.spawn(
bc,
[inBCSelector],
_selector => {
const el = content.document.querySelector(_selector);
const {
left,
top,
width,
height,
} = el.getBoxQuadsFromWindowOrigin()[0].getBounds();
return { left, top, width, height };
}
);
// Then we need to offset the element bounds from a frame bounds
// When fission/EFT is enabled, the highlighter is only shown within the iframe bounds.
// So we only need to retrieve the element bounds within the iframe.
// Otherwise, we retrieve the top frame bounds
const relativeBrowsingContext =
isFissionEnabled() || isEveryFrameTargetEnabled()
? bc
: gBrowser.selectedBrowser.browsingContext;
const relativeDocumentBounds = await SpecialPowers.spawn(
relativeBrowsingContext,
[],
() =>
content.document.documentElement
.getBoxQuadsFromWindowOrigin()[0]
.getBounds()
);
// Adjust the element bounds based on the relative document bounds
elementBounds.left = elementBounds.left - relativeDocumentBounds.left;
elementBounds.top = elementBounds.top - relativeDocumentBounds.top;
return elementBounds;
}