Bug 971662 - part 2 - Highlight nodes matching style-editor selectors on mouseover; r=harth

This commit is contained in:
Patrick Brosset 2014-08-20 22:41:07 +02:00
parent 556b135ea8
commit 582f6e4122
11 changed files with 463 additions and 23 deletions

View File

@ -33,6 +33,7 @@ const console = require("resource://gre/modules/devtools/Console.jsm").console;
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";
@ -111,18 +112,24 @@ StyleEditorUI.prototype = {
},
/**
* Initiates the style editor ui creation and the inspector front to get
* reference to the walker.
* Initiates the style editor ui creation, the inspector front to get
* reference to the walker and the selector highlighter if available
*/
initialize: function() {
let toolbox = gDevTools.getToolbox(this._target);
return toolbox.initInspector().then(() => {
return Task.spawn(function*() {
let toolbox = gDevTools.getToolbox(this._target);
yield toolbox.initInspector();
this._walker = toolbox.walker;
}).then(() => {
let hUtils = toolbox.highlighterUtils;
if (hUtils.hasCustomHighlighter(SELECTOR_HIGHLIGHTER_TYPE)) {
this._highlighter =
yield hUtils.getHighlighterByType(SELECTOR_HIGHLIGHTER_TYPE);
}
}.bind(this)).then(() => {
this.createUI();
this._debuggee.getStyleSheets().then((styleSheets) => {
this._resetStyleSheetList(styleSheets);
this._resetStyleSheetList(styleSheets);
this._target.on("will-navigate", this._clear);
this._target.on("navigate", this._onNewDocument);
});
@ -292,8 +299,8 @@ StyleEditorUI.prototype = {
file = savedFile;
}
let editor =
new StyleSheetEditor(styleSheet, this._window, file, isNew, this._walker);
let editor = new StyleSheetEditor(styleSheet, this._window, file, isNew,
this._walker, this._highlighter);
editor.on("property-change", this._summaryChange.bind(this, editor));
editor.on("media-rules-changed", this._updateMediaList.bind(this, editor));
@ -820,6 +827,11 @@ StyleEditorUI.prototype = {
},
destroy: function() {
if (this._highlighter) {
this._highlighter.finalize();
this._highlighter = null;
}
this._clearStyleSheetEditors();
let sidebar = this._panelDoc.querySelector(".splitview-controller");

View File

@ -20,6 +20,7 @@ Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/FileUtils.jsm");
Cu.import("resource://gre/modules/NetUtil.jsm");
Cu.import("resource://gre/modules/osfile.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/devtools/event-emitter.js");
Cu.import("resource:///modules/devtools/StyleEditorUtil.jsm");
@ -46,6 +47,10 @@ const MAX_CHECK_COUNT=10;
// The classname used to show a line that is not used
const UNUSED_CLASS = "cm-unused-line";
// How much time should the mouse be still before the selector at that position
// gets highlighted?
const SELECTOR_HIGHLIGHT_TIMEOUT = 500;
/**
* StyleSheetEditor controls the editor linked to a particular StyleSheet
* object.
@ -65,8 +70,11 @@ const UNUSED_CLASS = "cm-unused-line";
* Optional whether the sheet was created by the user
* @param {Walker} walker
* Optional walker used for selectors autocompletion
* @param {CustomHighlighterFront} highlighter
* Optional highlighter front for the SelectorHighligher used to
* highlight selectors
*/
function StyleSheetEditor(styleSheet, win, file, isNew, walker) {
function StyleSheetEditor(styleSheet, win, file, isNew, walker, highlighter) {
EventEmitter.decorate(this);
this.styleSheet = styleSheet;
@ -75,6 +83,7 @@ function StyleSheetEditor(styleSheet, win, file, isNew, walker) {
this._window = win;
this._isNew = isNew;
this.walker = walker;
this.highlighter = highlighter;
this._state = { // state to use when inputElement attaches
text: "",
@ -99,6 +108,7 @@ function StyleSheetEditor(styleSheet, win, file, isNew, walker) {
this.markLinkedFileBroken = this.markLinkedFileBroken.bind(this);
this.saveToFile = this.saveToFile.bind(this);
this.updateStyleSheet = this.updateStyleSheet.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._focusOnSourceEditorReady = false;
this.cssSheet.on("property-change", this._onPropertyChange);
@ -377,6 +387,10 @@ StyleSheetEditor.prototype = {
sourceEditor.setSelection(this._state.selection.start,
this._state.selection.end);
if (this.highlighter && this.walker) {
sourceEditor.container.addEventListener("mousemove", this._onMouseMove);
}
this.emit("source-editor-load");
});
},
@ -469,6 +483,52 @@ StyleSheetEditor.prototype = {
this.styleSheet.update(this._state.text, transitionsEnabled);
},
/**
* Handle mousemove events, calling _highlightSelectorAt after a delay only
* and reseting the delay everytime.
*/
_onMouseMove: function(e) {
this.highlighter.hide();
if (this.mouseMoveTimeout) {
this._window.clearTimeout(this.mouseMoveTimeout);
this.mouseMoveTimeout = null;
}
this.mouseMoveTimeout = this._window.setTimeout(() => {
this._highlightSelectorAt(e.clientX, e.clientY);
}, SELECTOR_HIGHLIGHT_TIMEOUT);
},
/**
* Highlight nodes matching the selector found at coordinates x,y in the
* editor, if any.
*
* @param {Number} x
* @param {Number} y
*/
_highlightSelectorAt: Task.async(function*(x, y) {
// Need to catch parsing exceptions as long as bug 1051900 isn't fixed
let info;
try {
let pos = this.sourceEditor.getPositionFromCoords({left: x, top: y});
info = this.sourceEditor.getInfoAt(pos);
} catch (e) {}
if (!info || info.state !== "selector") {
return;
}
let node = yield this.walker.getStyleSheetOwnerNode(this.styleSheet.actorID);
yield this.highlighter.show(node, {
selector: info.selector,
hideInfoBar: true,
showOnly: "border",
region: "border"
});
this.emit("node-highlighted");
}),
/**
* 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.
@ -639,6 +699,10 @@ StyleSheetEditor.prototype = {
this._sourceEditor.off("dirty-change", this._onPropertyChange);
this._sourceEditor.off("save", this.saveToFile);
this._sourceEditor.off("change", this.updateStyleSheet);
if (this.highlighter && this.walker && this._sourceEditor.container) {
this._sourceEditor.container.removeEventListener("mousemove",
this._onMouseMove);
}
this._sourceEditor.destroy();
}
this.cssSheet.off("property-change", this._onPropertyChange);

View File

@ -49,6 +49,7 @@ skip-if = os == "linux" || "mac" # bug 949355
[browser_styleeditor_enabled.js]
[browser_styleeditor_fetch-from-cache.js]
[browser_styleeditor_filesave.js]
[browser_styleeditor_highlight-selector.js]
[browser_styleeditor_import.js]
[browser_styleeditor_import_rule.js]
[browser_styleeditor_init.js]

View File

@ -0,0 +1,48 @@
/* vim: set ts=2 et sw=2 tw=80: */
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
// Test that hovering over a simple selector in the style-editor requests the
// highlighting of the corresponding nodes
waitForExplicitFinish();
const TEST_URL = "data:text/html;charset=utf8," +
"<style>div{color:red}</style><div>highlighter test</div>";
let test = asyncTest(function*() {
let {UI} = yield addTabAndOpenStyleEditors(1, null, TEST_URL);
let editor = UI.editors[0];
// Mock the highlighter so we can locally assert that things happened
// correctly instead of accessing the highlighter elements
editor.highlighter = {
isShown: false,
options: null,
show: function(node, options) {
this.isShown = true;
this.options = options;
return promise.resolve();
},
hide: function() {
this.isShown = false;
}
};
info("Expecting a node-highlighted event");
let onHighlighted = editor.once("node-highlighted");
info("Simulate a mousemove event on the div selector");
editor._onMouseMove({clientX: 40, clientY: 10});
yield onHighlighted;
ok(editor.highlighter.isShown, "The highlighter is now shown");
is(editor.highlighter.options.selector, "div", "The selector is correct");
info("Simulate a mousemove event elsewhere in the editor");
editor._onMouseMove({clientX: 0, clientY: 0});
ok(!editor.highlighter.isShown, "The highlighter is now hidden");
});

View File

@ -31,11 +31,15 @@ const HIGHLIGHTER_PICKED_TIMER = 1000;
const INFO_BAR_OFFSET = 5;
// The minimum distance a line should be before it has an arrow marker-end
const ARROW_LINE_MIN_DISTANCE = 10;
// How many maximum nodes can be highlighted at the same time by the
// SelectorHighlighter
const MAX_HIGHLIGHTED_ELEMENTS = 100;
// All possible highlighter classes
let HIGHLIGHTER_CLASSES = exports.HIGHLIGHTER_CLASSES = {
"BoxModelHighlighter": BoxModelHighlighter,
"CssTransformHighlighter": CssTransformHighlighter
"CssTransformHighlighter": CssTransformHighlighter,
"SelectorHighlighter": SelectorHighlighter
};
/**
@ -1224,6 +1228,69 @@ CssTransformHighlighter.prototype = Heritage.extend(XULBasedHighlighter.prototyp
}
});
/**
* The SelectorHighlighter runs a given selector through querySelectorAll on the
* document of the provided context node and then uses the BoxModelHighlighter
* to highlight the matching nodes
*/
function SelectorHighlighter(tabActor) {
this.tabActor = tabActor;
this._highlighters = [];
}
SelectorHighlighter.prototype = {
/**
* Show BoxModelHighlighter on each node that matches that provided selector.
* @param {DOMNode} node A context node that is used to get the document on
* which querySelectorAll should be executed. This node will NOT be
* highlighted.
* @param {Object} options Should at least contain the 'selector' option, a
* string that will be used in querySelectorAll. On top of this, all of the
* valid options to BoxModelHighlighter.show are also valid here.
*/
show: function(node, options={}) {
this.hide();
if (!isNodeValid(node) || !options.selector) {
return;
}
let nodes = [];
try {
nodes = [...node.ownerDocument.querySelectorAll(options.selector)];
} catch (e) {}
delete options.selector;
let i = 0;
for (let matchingNode of nodes) {
if (i >= MAX_HIGHLIGHTED_ELEMENTS) {
break;
}
let highlighter = new BoxModelHighlighter(this.tabActor);
if (options.fill) {
highlighter.regionFill[options.region || "border"] = options.fill;
}
highlighter.show(matchingNode, options);
this._highlighters.push(highlighter);
i ++;
}
},
hide: function() {
for (let highlighter of this._highlighters) {
highlighter.destroy();
}
this._highlighters = [];
},
destroy: function() {
this.hide();
this.tabActor = null;
}
};
/**
* The SimpleOutlineHighlighter is a class that has the same API than the
* BoxModelHighlighter, but adds a pseudo-class on the target element itself

View File

@ -2346,6 +2346,29 @@ var WalkerActor = protocol.ActorClass({
nodeFront: RetVal("nullable:disconnectedNode")
}
}),
/**
* Given an StyleSheetActor (identified by its ID), commonly used in the
* style-editor, get its ownerNode and return the corresponding walker's
* NodeActor
*/
getStyleSheetOwnerNode: method(function(styleSheetActorID) {
let styleSheetActor = this.conn.getActor(styleSheetActorID);
let ownerNode = styleSheetActor.ownerNode;
if (!styleSheetActor || !ownerNode) {
return null;
}
return this.attachElement(ownerNode);
}, {
request: {
styleSheetActorID: Arg(0, "string")
},
response: {
ownerNode: RetVal("nullable:disconnectedNode")
}
}),
});
/**
@ -2487,6 +2510,14 @@ var WalkerFront = exports.WalkerFront = protocol.FrontClass(WalkerActor, {
impl: "_getNodeActorFromObjectActor"
}),
getStyleSheetOwnerNode: protocol.custom(function(styleSheetActorID) {
return this._getStyleSheetOwnerNode(styleSheetActorID).then(response => {
return response ? response.node : null;
});
}, {
impl: "_getStyleSheetOwnerNode"
}),
_releaseFront: function(node, force) {
if (node.retained && !force) {
node.reparent(null);

View File

@ -115,7 +115,8 @@ RootActor.prototype = {
// (see server/actors/highlighter.js)
customHighlighters: [
"BoxModelHighlighter",
"CssTransformHighlighter"
"CssTransformHighlighter",
"SelectorHighlighter"
],
// Whether the inspector actor implements the getImageDataFromURL
// method that returns data-uris for image URLs. This is used for image

View File

@ -430,6 +430,8 @@ let StyleSheetActor = protocol.ActorClass({
return null;
},
get ownerNode() this.rawSheet.ownerNode,
/**
* URL of underlying stylesheet.
*/
@ -488,8 +490,7 @@ let StyleSheetActor = protocol.ActorClass({
return promise.resolve(rules);
}
let ownerNode = this.rawSheet.ownerNode;
if (!ownerNode) {
if (!this.ownerNode) {
return promise.resolve([]);
}
@ -500,12 +501,12 @@ let StyleSheetActor = protocol.ActorClass({
let deferred = promise.defer();
let onSheetLoaded = (event) => {
ownerNode.removeEventListener("load", onSheetLoaded, false);
this.ownerNode.removeEventListener("load", onSheetLoaded, false);
deferred.resolve(this.rawSheet.cssRules);
};
ownerNode.addEventListener("load", onSheetLoaded, false);
this.ownerNode.addEventListener("load", onSheetLoaded, false);
// cache so we don't add many listeners if this is called multiple times.
this._cssRules = deferred.promise;
@ -526,13 +527,12 @@ let StyleSheetActor = protocol.ActorClass({
}
let docHref;
let ownerNode = this.rawSheet.ownerNode;
if (ownerNode) {
if (ownerNode instanceof Ci.nsIDOMHTMLDocument) {
docHref = ownerNode.location.href;
if (this.ownerNode) {
if (this.ownerNode instanceof Ci.nsIDOMHTMLDocument) {
docHref = this.ownerNode.location.href;
}
else if (ownerNode.ownerDocument && ownerNode.ownerDocument.location) {
docHref = ownerNode.ownerDocument.location.href;
else if (this.ownerNode.ownerDocument && this.ownerNode.ownerDocument.location) {
docHref = this.ownerNode.ownerDocument.location.href;
}
}
@ -611,7 +611,7 @@ let StyleSheetActor = protocol.ActorClass({
if (!this.href) {
// this is an inline <style> sheet
let content = this.rawSheet.ownerNode.textContent;
let content = this.ownerNode.textContent;
this.text = content;
return promise.resolve(content);
}

View File

@ -45,6 +45,10 @@ skip-if = buildapp == 'mulet'
skip-if = buildapp == 'mulet'
[test_highlighter-csstransform_03.html]
skip-if = buildapp == 'mulet'
[test_highlighter-selector_01.html]
skip-if = buildapp == 'mulet'
[test_highlighter-selector_02.html]
skip-if = buildapp == 'mulet'
[test_inspector-changeattrs.html]
[test_inspector-changevalue.html]
[test_inspector-hide.html]

View File

@ -0,0 +1,112 @@
<!DOCTYPE HTML>
<html>
<!--
Test that the custom selector highlighter creates as many box-model highlighters
as there are nodes that match the given selector
-->
<head>
<meta charset="utf-8">
<title>Framerate actor test</title>
<script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
</head>
<body>
<div id="test-node">test node</div>
<ul>
<li class="item">item</li>
<li class="item">item</li>
<li class="item">item</li>
<li class="item">item</li>
<li class="item">item</li>
</ul>
<pre id="test">
<script type="application/javascript;version=1.8">
SimpleTest.waitForExplicitFinish();
window.onload = function() {
const Cu = Components.utils;
const Cc = Components.classes;
const Ci = Components.interfaces;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/devtools/Loader.jsm");
Cu.import("resource://gre/modules/devtools/dbg-client.jsm");
Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
Cu.import("resource://gre/modules/Task.jsm");
const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
const {InspectorFront} = devtools.require("devtools/server/actors/inspector");
const TEST_DATA = [{
selector: "#test-node",
containerCount: 1
}, {
selector: null,
containerCount: 0,
}, {
selector: undefined,
containerCount: 0,
}, {
selector: ".invalid-class",
containerCount: 0
}, {
selector: ".item",
containerCount: 5
}, {
selector: "#test-node, ul, .item",
containerCount: 7
}];
DebuggerServer.init(() => true);
DebuggerServer.addBrowserActors();
let client = new DebuggerClient(DebuggerServer.connectPipe());
client.connect(() => {
client.listTabs(response => {
let form = response.tabs[response.selected];
let front = InspectorFront(client, form);
Task.spawn(function*() {
let walker = yield front.getWalker();
let highlighter = yield front.getHighlighterByType("SelectorHighlighter");
let browser = Services.wm.getMostRecentWindow("navigator:browser")
.gBrowser.selectedBrowser;
// Remove left-over highlighter contains from previous tests
for (let container of browser.parentNode
.querySelectorAll(".highlighter-container")) {
container.remove();
}
// The node given to SelectorHighlighter's show method is only used to
// know which document should matching nodes be searched in
let node = document.body;
for (let {selector, containerCount} of TEST_DATA) {
info("Showing the highlighter on " + selector + ". Expecting " +
containerCount + " highlighter containers");
yield highlighter.show(walker.frontForRawNode(node), {selector});
let containers = browser.parentNode.querySelectorAll(
".highlighter-container");
is(containers.length, containerCount,
"The correct number of box-model highlighers were created");
yield highlighter.hide();
}
yield highlighter.finalize();
}).then(null, ok.bind(null, false)).then(() => {
client.close(() => {
DebuggerServer.destroy();
SimpleTest.finish();
});
});
});
});
}
</script>
</pre>
</body>
</html>

View File

@ -0,0 +1,100 @@
<!DOCTYPE HTML>
<html>
<!--
Test that the custom selector highlighter creates highlighters for nodes in the
right frame
-->
<head>
<meta charset="utf-8">
<title>Framerate actor test</title>
<script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
</head>
<body>
<div class="root-level-node"></div>
<iframe src="data:text/html;charset=utf8,<div class='sub-level-node'></div>"></iframe>
<pre id="test">
<script type="application/javascript;version=1.8">
SimpleTest.waitForExplicitFinish();
window.onload = function() {
const Cu = Components.utils;
const Cc = Components.classes;
const Ci = Components.interfaces;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/devtools/Loader.jsm");
Cu.import("resource://gre/modules/devtools/dbg-client.jsm");
Cu.import("resource://gre/modules/devtools/dbg-server.jsm");
Cu.import("resource://gre/modules/Task.jsm");
const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
const {InspectorFront} = devtools.require("devtools/server/actors/inspector");
const TEST_DATA = [{
contextNode: document.body,
selector: ".root-level-node",
containerCount: 1
}, {
contextNode: document.body,
selector: ".sub-level-node",
containerCount: 0
}, {
contextNode: document.querySelector("iframe").contentDocument.body,
selector: ".root-level-node",
containerCount: 0
}, {
contextNode: document.querySelector("iframe").contentDocument.body,
selector: ".sub-level-node",
containerCount: 1
}];
DebuggerServer.init(() => true);
DebuggerServer.addBrowserActors();
let client = new DebuggerClient(DebuggerServer.connectPipe());
client.connect(() => {
client.listTabs(response => {
let form = response.tabs[response.selected];
let front = InspectorFront(client, form);
Task.spawn(function*() {
let walker = yield front.getWalker();
let highlighter = yield front.getHighlighterByType("SelectorHighlighter");
let browser = Services.wm.getMostRecentWindow("navigator:browser")
.gBrowser.selectedBrowser;
// Remove left-over highlighter contains from previous tests
for (let container of browser.parentNode
.querySelectorAll(".highlighter-container")) {
container.remove();
}
for (let {contextNode, selector, containerCount} of TEST_DATA) {
info("Showing the highlighter on " + selector + ". Expecting " +
containerCount + " highlighter containers");
yield highlighter.show(walker.frontForRawNode(contextNode), {selector});
let containers = browser.parentNode.querySelectorAll(
".highlighter-container");
is(containers.length, containerCount,
"The correct number of box-model highlighers were created");
yield highlighter.hide();
}
yield highlighter.finalize();
}).then(null, ok.bind(null, false)).then(() => {
client.close(() => {
DebuggerServer.destroy();
SimpleTest.finish();
});
});
});
});
}
</script>
</pre>
</body>
</html>