Bug 1492797 - Autocomplete for classes panel. r=rcaliman,jdescottes.

Differential Revision: https://phabricator.services.mozilla.com/D71160
This commit is contained in:
Nicolas Chevobbe 2020-07-21 18:03:13 +00:00
Родитель f7e210941d
Коммит ebc4f4e1ab
9 изменённых файлов: 635 добавлений и 13 удалений

Просмотреть файл

@ -24,9 +24,15 @@ loader.lazyRequireGetter(
* PageStyleFront, the front object for the PageStyleActor
*/
class PageStyleFront extends FrontClassWithSpec(pageStyleSpec) {
_attributesCache = new Map();
constructor(conn, targetFront, parentFront) {
super(conn, targetFront, parentFront);
this.inspector = this.getParent();
this._clearAttributesCache = this._clearAttributesCache.bind(this);
this.on("stylesheet-updated", this._clearAttributesCache);
this.walker.on("new-mutations", this._clearAttributesCache);
}
form(form) {
@ -76,6 +82,56 @@ class PageStyleFront extends FrontClassWithSpec(pageStyleSpec) {
return ret.entries[0];
});
}
/**
* Get an array of existing attribute values in a node document, given an attribute type.
*
* @param {String} search: A string to filter attribute value on.
* @param {String} attributeType: The type of attribute we want to retrieve the values.
* @param {Element} node: The element we want to get possible attributes for. This will
* be used to get the document where the search is happening.
* @returns {Array<String>} An array of strings
*/
async getAttributesInOwnerDocument(search, attributeType, node) {
if (!attributeType) {
throw new Error("`type` should not be empty");
}
if (!this._form.traits.getAttributesInOwnerDocument || !search) {
return [];
}
const lcFilter = search.toLowerCase();
// If the new filter includes the string that was used on our last trip to the server,
// we can filter the cached results instead of calling the server again.
if (
this._attributesCache &&
this._attributesCache.has(attributeType) &&
search.startsWith(this._attributesCache.get(attributeType).search)
) {
const cachedResults = this._attributesCache
.get(attributeType)
.results.filter(item => item.toLowerCase().startsWith(lcFilter));
this.emitForTests(
"getAttributesInOwnerDocument-cache-hit",
cachedResults
);
return cachedResults;
}
const results = await super.getAttributesInOwnerDocument(
search,
attributeType,
node
);
this._attributesCache.set(attributeType, { search, results });
return results;
}
_clearAttributesCache() {
this._attributesCache.clear();
}
}
exports.PageStyleFront = PageStyleFront;

Просмотреть файл

@ -193,6 +193,24 @@ class ClassList {
}
}
}
/**
* Get the available classNames in the document where the current selected node lives:
* - the one already used on elements of the document
* - the one defined in Stylesheets of the document
*
* @param {String} filter: A string the classNames should start with (an insensitive
* case matching will be done).
* @returns {Promise<Array<String>>} A promise that resolves with an array of strings
* matching the passed filter.
*/
getClassNames(filter) {
return this.currentNode.inspectorFront.pageStyle.getAttributesInOwnerDocument(
filter,
"class",
this.currentNode
);
}
}
module.exports = ClassList;

Просмотреть файл

@ -5,6 +5,8 @@ support-files =
doc_blob_stylesheet.html
doc_copystyles.css
doc_copystyles.html
doc_class_panel_autocomplete_stylesheet.css
doc_class_panel_autocomplete.html
doc_cssom.html
doc_custom.html
doc_edit_imported_selector.html
@ -41,6 +43,7 @@ skip-if = !debug && ((os == 'linux' && bits == 64 && os_version == '18.04') || o
[browser_rules_authored_override.js]
[browser_rules_blob_stylesheet.js]
[browser_rules_class_panel_add.js]
[browser_rules_class_panel_autocomplete.js]
[browser_rules_class_panel_content.js]
[browser_rules_class_panel_edit.js]
[browser_rules_class_panel_invalid_nodes.js]

Просмотреть файл

@ -0,0 +1,226 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
// Test that the autocomplete for the class panel input behaves as expected. The test also
// checks that we're using the cache to retrieve the data when we can do so, and that the
// cache gets cleared, and we're getting data from the server, when there's mutation on
// the page.
const TEST_URI = `${URL_ROOT}doc_class_panel_autocomplete.html`;
add_task(async function() {
await addTab(TEST_URI);
const { inspector, view } = await openRuleView();
const { addEl: textInput } = view.classListPreviewer;
info("Open the class panel");
view.showClassPanel();
textInput.focus();
info("Type a letter and check that the popup has the expected items");
const allClasses = [
"auto-body-class-1",
"auto-body-class-2",
"auto-bold",
"auto-cssom-primary-color",
"auto-div-class-1",
"auto-div-class-2",
"auto-html-class-1",
"auto-html-class-2",
"auto-inline-class-1",
"auto-inline-class-2",
"auto-inline-class-3",
"auto-inline-class-4",
"auto-inline-class-5",
"auto-stylesheet-class-1",
"auto-stylesheet-class-2",
"auto-stylesheet-class-3",
"auto-stylesheet-class-4",
"auto-stylesheet-class-5",
];
const { autocompletePopup } = view.classListPreviewer;
let onPopupOpened = autocompletePopup.once("popup-opened");
EventUtils.synthesizeKey("a", {}, view.styleWindow);
await onPopupOpened;
await checkAutocompleteItems(
autocompletePopup,
allClasses,
"The autocomplete popup has all the classes used in the DOM and in stylesheets"
);
info(
"Test that typing more letters filters the autocomplete popup and uses the cache mechanism"
);
const onCacheHit = inspector.inspectorFront.pageStyle.once(
"getAttributesInOwnerDocument-cache-hit"
);
EventUtils.sendString("uto-b", view.styleWindow);
await checkAutocompleteItems(
autocompletePopup,
allClasses.filter(cls => cls.startsWith("auto-b")),
"The autocomplete popup was filtered with the content of the input"
);
await onCacheHit;
ok(true, "The results were retrieved from the cache mechanism");
info("Test that autocomplete shows up-to-date results");
// Modify the content page and assert that the new class is displayed in the
// autocomplete if the user types a new letter.
const onNewMutation = inspector.inspectorFront.walker.once("new-mutations");
await ContentTask.spawn(gBrowser.selectedBrowser, null, async function() {
content.document.body.classList.add("auto-body-added-by-script");
});
await onNewMutation;
// input is now auto-body
onPopupOpened = autocompletePopup.once("popup-opened");
EventUtils.sendString("ody", view.styleWindow);
await onPopupOpened;
await checkAutocompleteItems(
autocompletePopup,
[
...allClasses.filter(cls => cls.startsWith("auto-body")),
"auto-body-added-by-script",
].sort(),
"The autocomplete popup was filtered with the content of the input"
);
info(
"Test that typing a letter that won't match any of the item closes the popup"
);
// input is now auto-bodyy
let onPopupClosed = autocompletePopup.once("popup-closed");
EventUtils.synthesizeKey("y", {}, view.styleWindow);
await onPopupClosed;
ok(true, "The popup was closed as expected");
await checkAutocompleteItems(autocompletePopup, [], "The popup was cleared");
info("Clear the input and try to autocomplete again");
textInput.select();
EventUtils.synthesizeKey("KEY_Backspace", {}, view.styleWindow);
// Wait a bit so the debounced function can be executed
await wait(200);
onPopupOpened = autocompletePopup.once("popup-opened");
EventUtils.synthesizeKey("a", {}, view.styleWindow);
await onPopupOpened;
await checkAutocompleteItems(
autocompletePopup,
[...allClasses, "auto-body-added-by-script"].sort(),
"The autocomplete popup was updated with the new class added to the DOM"
);
info("Test keyboard shortcut when the popup is displayed");
// Escape to hide
onPopupClosed = autocompletePopup.once("popup-closed");
EventUtils.synthesizeKey("KEY_Escape", {}, view.styleWindow);
await onPopupClosed;
ok(true, "The popup was closed when hitting escape");
// Ctrl + space to show again
onPopupOpened = autocompletePopup.once("popup-opened");
EventUtils.synthesizeKey(" ", { ctrlKey: true }, view.styleWindow);
await onPopupOpened;
ok(true, "Popup was opened again with Ctrl+Space");
await checkAutocompleteItems(
autocompletePopup,
[...allClasses, "auto-body-added-by-script"].sort()
);
// Arrow left to hide
onPopupClosed = autocompletePopup.once("popup-closed");
EventUtils.synthesizeKey("KEY_ArrowLeft", {}, view.styleWindow);
await onPopupClosed;
ok(true, "The popup was closed as when hitting ArrowLeft");
// Arrow right and Ctrl + space to show again, and Arrow Right to accept
onPopupOpened = autocompletePopup.once("popup-opened");
EventUtils.synthesizeKey("KEY_ArrowRight", {}, view.styleWindow);
EventUtils.synthesizeKey(" ", { ctrlKey: true }, view.styleWindow);
await onPopupOpened;
onPopupClosed = autocompletePopup.once("popup-closed");
EventUtils.synthesizeKey("KEY_ArrowRight", {}, view.styleWindow);
await onPopupClosed;
is(
textInput.value,
"auto-body-added-by-script",
"ArrowRight puts the selected item in the input and closes the popup"
);
// Backspace to show the list again
onPopupOpened = autocompletePopup.once("popup-opened");
EventUtils.synthesizeKey("KEY_Backspace", {}, view.styleWindow);
await onPopupOpened;
is(
textInput.value,
"auto-body-added-by-scrip",
"ArrowRight puts the selected item in the input and closes the popup"
);
await checkAutocompleteItems(
autocompletePopup,
["auto-body-added-by-script"],
"The autocomplete does show the matching items after hitting backspace"
);
// Enter to accept
onPopupClosed = autocompletePopup.once("popup-closed");
EventUtils.synthesizeKey("KEY_Enter", {}, view.styleWindow);
await onPopupClosed;
is(
textInput.value,
"auto-body-added-by-script",
"Enter puts the selected item in the input and closes the popup"
);
// Backspace to show again
onPopupOpened = autocompletePopup.once("popup-opened");
EventUtils.synthesizeKey("KEY_Backspace", {}, view.styleWindow);
await onPopupOpened;
is(
textInput.value,
"auto-body-added-by-scrip",
"ArrowRight puts the selected item in the input and closes the popup"
);
await checkAutocompleteItems(
autocompletePopup,
["auto-body-added-by-script"],
"The autocomplete does show the matching items after hitting backspace"
);
// Tab to accept
onPopupClosed = autocompletePopup.once("popup-closed");
EventUtils.synthesizeKey("KEY_Tab", {}, view.styleWindow);
await onPopupClosed;
is(
textInput.value,
"auto-body-added-by-script",
"Tab puts the selected item in the input and closes the popup"
);
});
async function checkAutocompleteItems(
autocompletePopup,
expectedItems,
assertionMessage
) {
await waitForSuccess(
() =>
getAutocompleteItems(autocompletePopup).length === expectedItems.length
);
const items = getAutocompleteItems(autocompletePopup);
const formatList = list => `\n${list.join("\n")}\n`;
is(formatList(items), formatList(expectedItems), assertionMessage);
}
function getAutocompleteItems(autocompletePopup) {
return Array.from(autocompletePopup._panel.querySelectorAll("li")).map(
el => el.textContent
);
}

Просмотреть файл

@ -0,0 +1,40 @@
<!-- Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ -->
<html class="auto-html-class-1 auto-html-class-2 auto-bold">
<head>
<title>Class panel autocomplete test</title>
<link href="./doc_class_panel_autocomplete_stylesheet.css" rel="stylesheet" type="text/css">
<style>
.auto-inline-class-1 {
padding: 1em;
}
.auto-inline-class-2 {
padding: 2em;
}
.auto-inline-class-3 {
padding: 3em;
}
.auto-inline-class-1,
div.auto-inline-class-2,
p:first-of-type.auto-inline-class-3,
.auto-inline-class-4 {
background-color: blue;
}
:root .auto-bold .auto-inline-class-5 {
font-size: bold;
}
</style>
<script defer>
"use strict";
const x = document.styleSheets[0];
x.insertRule(".auto-cssom-primary-color { color: tomato; }", 1);
</script>
</head>
<body class="auto-body-class-1 auto-body-class-2 auto-bold">
<div id="auto-div-id-1" class="auto-div-class-1 auto-div-class-2 auto-bold"> the ocean </div>
<div id="auto-div-id-2" class="auto-div-class-1 auto-div-class-2 auto-bold"> roaring </div>
</body>
</html>

Просмотреть файл

@ -0,0 +1,20 @@
.auto-stylesheet-class-1 {
padding: 1em;
}
.auto-stylesheet-class-2 {
padding: 2em;
}
.auto-stylesheet-class-3 {
padding: 3em;
}
.auto-stylesheet-class-1,
div.auto-stylesheet-class-2,
p:first-of-type.auto-stylesheet-class-3,
.auto-stylesheet-class-4 {
background-color: blue;
}
:root .auto-bold .auto-stylesheet-class-5 {
font-size: bold;
}

Просмотреть файл

@ -5,11 +5,13 @@
"use strict";
const ClassList = require("devtools/client/inspector/rules/models/class-list");
const { LocalizationHelper } = require("devtools/shared/l10n");
const { LocalizationHelper } = require("devtools/shared/l10n");
const L10N = new LocalizationHelper(
"devtools/client/locales/inspector.properties"
);
const AutocompletePopup = require("devtools/client/shared/autocomplete-popup");
const { debounce } = require("devtools/shared/debounce");
/**
* This UI widget shows a textfield and a series of checkboxes in the rule-view. It is
@ -30,6 +32,11 @@ class ClassListPreviewer {
this.onNewSelection = this.onNewSelection.bind(this);
this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
this.onKeyPress = this.onKeyPress.bind(this);
this.onAddElementInputModified = debounce(
this.onAddElementInputModified,
75,
this
);
this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
// Create the add class text field.
@ -41,6 +48,7 @@ class ClassListPreviewer {
L10N.getStr("inspector.classPanel.newClass.placeholder")
);
this.addEl.addEventListener("keypress", this.onKeyPress);
this.addEl.addEventListener("input", this.onAddElementInputModified);
this.containerEl.appendChild(this.addEl);
// Create the class checkboxes container.
@ -48,6 +56,22 @@ class ClassListPreviewer {
this.classesEl.classList.add("classes");
this.containerEl.appendChild(this.classesEl);
// Create the autocomplete popup
this.autocompletePopup = new AutocompletePopup(this.inspector.toolbox.doc, {
listId: "inspector_classListPreviewer_autocompletePopupListBox",
position: "bottom",
autoSelect: false,
useXulWrapper: true,
input: this.addEl,
onClick: (e, item) => {
if (item) {
this.addEl.value = item.label;
this.autocompletePopup.hidePopup();
this.autocompletePopup.clearItems();
}
},
});
// Start listening for interesting events.
this.inspector.selection.on("new-node-front", this.onNewSelection);
this.containerEl.addEventListener("input", this.onCheckBoxChanged);
@ -59,8 +83,11 @@ class ClassListPreviewer {
destroy() {
this.inspector.selection.off("new-node-front", this.onNewSelection);
this.addEl.removeEventListener("keypress", this.onKeyPress);
this.addEl.removeEventListener("input", this.onAddElementInputModified);
this.containerEl.removeEventListener("input", this.onCheckBoxChanged);
this.autocompletePopup.destroy();
this.containerEl.innerHTML = "";
this.model.destroy();
@ -155,22 +182,77 @@ class ClassListPreviewer {
}
onKeyPress(event) {
if (event.key !== "Enter" || this.addEl.value === "") {
// If the popup is already open, all the keyboard interaction are handled
// directly by the popup component.
if (this.autocompletePopup.isOpen) {
return;
}
this.model
.addClassName(this.addEl.value)
.then(() => {
this.render();
this.addEl.value = "";
})
.catch(e => {
// Only log the error if the panel wasn't destroyed in the meantime.
if (this.containerEl) {
console.error(e);
}
// Open the autocomplete popup on Ctrl+Space / ArrowDown (when the input isn't empty)
if (
(this.addEl.value && event.key === " " && event.ctrlKey) ||
event.key === "ArrowDown"
) {
this.onAddElementInputModified();
return;
}
if (this.addEl.value !== "" && event.key === "Enter") {
this.addClassName(this.addEl.value);
}
}
async onAddElementInputModified() {
const newValue = this.addEl.value;
// if the input is empty, let's close the popup, if it was open.
if (newValue === "") {
if (this.autocompletePopup.isOpen) {
this.autocompletePopup.hidePopup();
this.autocompletePopup.clearItems();
}
return;
}
// Otherwise, we need to update the popup items to match the new input.
let items = [];
try {
const classNames = await this.model.getClassNames(newValue);
items = classNames.map(className => {
return {
preLabel: className.substring(0, newValue.length),
label: className,
};
});
} catch (e) {
// If there was an error while retrieving the classNames, we'll simply NOT show the
// popup, which is okay.
console.warn("Error when calling getClassNames", e);
}
if (
items.length == 0 ||
(items.length == 1 && items[0].label === newValue)
) {
this.autocompletePopup.clearItems();
this.autocompletePopup.hidePopup();
} else {
this.autocompletePopup.setItems(items);
this.autocompletePopup.openPopup();
}
}
async addClassName(className) {
try {
await this.model.addClassName(className);
this.render();
this.addEl.value = "";
} catch (e) {
// Only log the error if the panel wasn't destroyed in the meantime.
if (this.containerEl) {
console.error(e);
}
}
}
onNewSelection() {

Просмотреть файл

@ -202,6 +202,8 @@ var PageStyleActor = protocol.ActorClassWithSpec(pageStyleSpec, {
// expected support of font-stretch at CSS Fonts Level 4.
fontWeightLevel4:
CSS.supports("font-weight: 1") && CSS.supports("font-stretch: 100%"),
// Introduced in Firefox 80.
getAttributesInOwnerDocument: true,
},
};
},
@ -1200,6 +1202,171 @@ var PageStyleActor = protocol.ActorClassWithSpec(pageStyleSpec, {
rule.refresh();
}
},
/**
* Get an array of existing attribute values in a node document.
*
* @param {String} search: A string to filter attribute value on.
* @param {String} attributeType: The type of attribute we want to retrieve the values.
* @param {Element} node: The element we want to get possible attributes for. This will
* be used to get the document where the search is happening.
* @returns {Array<String>} An array of strings
*/
getAttributesInOwnerDocument(search, attributeType, node) {
if (!search) {
throw new Error("search is mandatory");
}
// In a non-fission world, a node from an iframe shares the same `rootNode` as a node
// in the top-level document. So here we need to retrieve the document from the node
// in parameter in order to retrieve the right document.
// This may change once we have a dedicated walker for every target in a tab, as we'll
// be able to directly talk to the "right" walker actor.
const targetDocument = node.rawNode.ownerDocument;
// We store the result in a Set which will contain the attribute value
const result = new Set();
const lcSearch = search.toLowerCase();
this._collectAttributesFromDocumentDOM(
result,
lcSearch,
attributeType,
targetDocument
);
this._collectAttributesFromDocumentStyleSheets(
result,
lcSearch,
attributeType,
targetDocument
);
return Array.from(result).sort();
},
/**
* Collect attribute values from the document DOM tree, matching the passed filter and
* type, to the result Set.
*
* @param {Set<String>} result: A Set to which the results will be added.
* @param {String} search: A string to filter attribute value on.
* @param {String} attributeType: The type of attribute we want to retrieve the values.
* @param {Document} targetDocument: The document the search occurs in.
*/
_collectAttributesFromDocumentDOM(
result,
search,
attributeType,
targetDocument
) {
// In order to retrieve attributes from DOM elements in the document, we're going to
// do a query on the root node using attributes selector, to directly get the elements
// matching the attributes we're looking for.
// For classes, we need something a bit different as the className we're looking
// for might not be the first in the attribute value, meaning we can't use the
// "attribute starts with X" selector.
const attributeSelectorPositionChar = attributeType === "class" ? "*" : "^";
const selector = `[${attributeType}${attributeSelectorPositionChar}=${search} i]`;
const matchingElements = targetDocument.querySelectorAll(selector);
for (const element of matchingElements) {
// For class attribute, we need to add the elements of the classList that match
// the filter string.
if (attributeType === "class") {
for (const cls of element.classList) {
if (!result.has(cls) && cls.toLowerCase().startsWith(search)) {
result.add(cls);
}
}
} else {
const { value } = element.attributes[attributeType];
// For other attributes, we can directly use the attribute value.
result.add(value);
}
}
},
/**
* Collect attribute values from the document stylesheets, matching the passed filter
* and type, to the result Set.
*
* @param {Set<String>} result: A Set to which the results will be added.
* @param {String} search: A string to filter attribute value on.
* @param {String} attributeType: The type of attribute we want to retrieve the values.
* It only supports "class" and "id" at the moment.
* @param {Document} targetDocument: The document the search occurs in.
*/
_collectAttributesFromDocumentStyleSheets(
result,
search,
attributeType,
targetDocument
) {
if (attributeType !== "class" && attributeType !== "id") {
return;
}
// We loop through all the stylesheets and their rules, and then use the lexer to only
// get the attributes we're looking for.
for (const styleSheet of targetDocument.styleSheets) {
for (const rule of styleSheet.rules) {
this._collectAttributesFromRule(result, rule, search, attributeType);
}
}
},
/**
* Collect attribute values from the rule, matching the passed filter and type, to the
* result Set.
*
* @param {Set<String>} result: A Set to which the results will be added.
* @param {Rule} rule: The rule the search occurs in.
* @param {String} search: A string to filter attribute value on.
* @param {String} attributeType: The type of attribute we want to retrieve the values.
* It only supports "class" and "id" at the moment.
*/
_collectAttributesFromRule(result, rule, search, attributeType) {
const shouldRetrieveClasses = attributeType === "class";
const shouldRetrieveIds = attributeType === "id";
const { selectorText } = rule;
// If there's no selectorText, or if the selectorText does not include the
// filter, we can bail out.
if (!selectorText || !selectorText.toLowerCase().includes(search)) {
return;
}
// Check if we should parse the selectorText (do we need to check for class/id and
// if so, does the selector contains class/id related chars).
const parseForClasses =
shouldRetrieveClasses &&
selectorText.toLowerCase().includes(`.${search}`);
const parseForIds =
shouldRetrieveIds && selectorText.toLowerCase().includes(`#${search}`);
if (!parseForClasses && !parseForIds) {
return;
}
const lexer = getCSSLexer(selectorText);
let token;
while ((token = lexer.nextToken())) {
if (
token.tokenType === "symbol" &&
((shouldRetrieveClasses && token.text === ".") ||
(shouldRetrieveIds && token.text === "#"))
) {
token = lexer.nextToken();
if (
token.tokenType === "ident" &&
token.text.toLowerCase().startsWith(search)
) {
result.add(token.text);
}
}
}
},
});
exports.PageStyleActor = PageStyleActor;

Просмотреть файл

@ -188,6 +188,16 @@ const pageStyleSpec = generateActorSpec({
},
response: RetVal("appliedStylesReturn"),
},
getAttributesInOwnerDocument: {
request: {
search: Arg(0, "string"),
attributeType: Arg(1, "string"),
node: Arg(2, "nullable:domnode"),
},
response: {
attributes: RetVal("array:string"),
},
},
},
});