зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1492797 - Autocomplete for classes panel. r=rcaliman,jdescottes.
Differential Revision: https://phabricator.services.mozilla.com/D71160
This commit is contained in:
Родитель
f7e210941d
Коммит
ebc4f4e1ab
|
@ -24,9 +24,15 @@ loader.lazyRequireGetter(
|
||||||
* PageStyleFront, the front object for the PageStyleActor
|
* PageStyleFront, the front object for the PageStyleActor
|
||||||
*/
|
*/
|
||||||
class PageStyleFront extends FrontClassWithSpec(pageStyleSpec) {
|
class PageStyleFront extends FrontClassWithSpec(pageStyleSpec) {
|
||||||
|
_attributesCache = new Map();
|
||||||
|
|
||||||
constructor(conn, targetFront, parentFront) {
|
constructor(conn, targetFront, parentFront) {
|
||||||
super(conn, targetFront, parentFront);
|
super(conn, targetFront, parentFront);
|
||||||
this.inspector = this.getParent();
|
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) {
|
form(form) {
|
||||||
|
@ -76,6 +82,56 @@ class PageStyleFront extends FrontClassWithSpec(pageStyleSpec) {
|
||||||
return ret.entries[0];
|
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;
|
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;
|
module.exports = ClassList;
|
||||||
|
|
|
@ -5,6 +5,8 @@ support-files =
|
||||||
doc_blob_stylesheet.html
|
doc_blob_stylesheet.html
|
||||||
doc_copystyles.css
|
doc_copystyles.css
|
||||||
doc_copystyles.html
|
doc_copystyles.html
|
||||||
|
doc_class_panel_autocomplete_stylesheet.css
|
||||||
|
doc_class_panel_autocomplete.html
|
||||||
doc_cssom.html
|
doc_cssom.html
|
||||||
doc_custom.html
|
doc_custom.html
|
||||||
doc_edit_imported_selector.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_authored_override.js]
|
||||||
[browser_rules_blob_stylesheet.js]
|
[browser_rules_blob_stylesheet.js]
|
||||||
[browser_rules_class_panel_add.js]
|
[browser_rules_class_panel_add.js]
|
||||||
|
[browser_rules_class_panel_autocomplete.js]
|
||||||
[browser_rules_class_panel_content.js]
|
[browser_rules_class_panel_content.js]
|
||||||
[browser_rules_class_panel_edit.js]
|
[browser_rules_class_panel_edit.js]
|
||||||
[browser_rules_class_panel_invalid_nodes.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";
|
"use strict";
|
||||||
|
|
||||||
const ClassList = require("devtools/client/inspector/rules/models/class-list");
|
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(
|
const L10N = new LocalizationHelper(
|
||||||
"devtools/client/locales/inspector.properties"
|
"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
|
* 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.onNewSelection = this.onNewSelection.bind(this);
|
||||||
this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
|
this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
|
||||||
this.onKeyPress = this.onKeyPress.bind(this);
|
this.onKeyPress = this.onKeyPress.bind(this);
|
||||||
|
this.onAddElementInputModified = debounce(
|
||||||
|
this.onAddElementInputModified,
|
||||||
|
75,
|
||||||
|
this
|
||||||
|
);
|
||||||
this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
|
this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
|
||||||
|
|
||||||
// Create the add class text field.
|
// Create the add class text field.
|
||||||
|
@ -41,6 +48,7 @@ class ClassListPreviewer {
|
||||||
L10N.getStr("inspector.classPanel.newClass.placeholder")
|
L10N.getStr("inspector.classPanel.newClass.placeholder")
|
||||||
);
|
);
|
||||||
this.addEl.addEventListener("keypress", this.onKeyPress);
|
this.addEl.addEventListener("keypress", this.onKeyPress);
|
||||||
|
this.addEl.addEventListener("input", this.onAddElementInputModified);
|
||||||
this.containerEl.appendChild(this.addEl);
|
this.containerEl.appendChild(this.addEl);
|
||||||
|
|
||||||
// Create the class checkboxes container.
|
// Create the class checkboxes container.
|
||||||
|
@ -48,6 +56,22 @@ class ClassListPreviewer {
|
||||||
this.classesEl.classList.add("classes");
|
this.classesEl.classList.add("classes");
|
||||||
this.containerEl.appendChild(this.classesEl);
|
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.
|
// Start listening for interesting events.
|
||||||
this.inspector.selection.on("new-node-front", this.onNewSelection);
|
this.inspector.selection.on("new-node-front", this.onNewSelection);
|
||||||
this.containerEl.addEventListener("input", this.onCheckBoxChanged);
|
this.containerEl.addEventListener("input", this.onCheckBoxChanged);
|
||||||
|
@ -59,8 +83,11 @@ class ClassListPreviewer {
|
||||||
destroy() {
|
destroy() {
|
||||||
this.inspector.selection.off("new-node-front", this.onNewSelection);
|
this.inspector.selection.off("new-node-front", this.onNewSelection);
|
||||||
this.addEl.removeEventListener("keypress", this.onKeyPress);
|
this.addEl.removeEventListener("keypress", this.onKeyPress);
|
||||||
|
this.addEl.removeEventListener("input", this.onAddElementInputModified);
|
||||||
this.containerEl.removeEventListener("input", this.onCheckBoxChanged);
|
this.containerEl.removeEventListener("input", this.onCheckBoxChanged);
|
||||||
|
|
||||||
|
this.autocompletePopup.destroy();
|
||||||
|
|
||||||
this.containerEl.innerHTML = "";
|
this.containerEl.innerHTML = "";
|
||||||
|
|
||||||
this.model.destroy();
|
this.model.destroy();
|
||||||
|
@ -155,22 +182,77 @@ class ClassListPreviewer {
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyPress(event) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.model
|
// Open the autocomplete popup on Ctrl+Space / ArrowDown (when the input isn't empty)
|
||||||
.addClassName(this.addEl.value)
|
if (
|
||||||
.then(() => {
|
(this.addEl.value && event.key === " " && event.ctrlKey) ||
|
||||||
this.render();
|
event.key === "ArrowDown"
|
||||||
this.addEl.value = "";
|
) {
|
||||||
})
|
this.onAddElementInputModified();
|
||||||
.catch(e => {
|
return;
|
||||||
// Only log the error if the panel wasn't destroyed in the meantime.
|
}
|
||||||
if (this.containerEl) {
|
|
||||||
console.error(e);
|
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() {
|
onNewSelection() {
|
||||||
|
|
|
@ -202,6 +202,8 @@ var PageStyleActor = protocol.ActorClassWithSpec(pageStyleSpec, {
|
||||||
// expected support of font-stretch at CSS Fonts Level 4.
|
// expected support of font-stretch at CSS Fonts Level 4.
|
||||||
fontWeightLevel4:
|
fontWeightLevel4:
|
||||||
CSS.supports("font-weight: 1") && CSS.supports("font-stretch: 100%"),
|
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();
|
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;
|
exports.PageStyleActor = PageStyleActor;
|
||||||
|
|
||||||
|
|
|
@ -188,6 +188,16 @@ const pageStyleSpec = generateActorSpec({
|
||||||
},
|
},
|
||||||
response: RetVal("appliedStylesReturn"),
|
response: RetVal("appliedStylesReturn"),
|
||||||
},
|
},
|
||||||
|
getAttributesInOwnerDocument: {
|
||||||
|
request: {
|
||||||
|
search: Arg(0, "string"),
|
||||||
|
attributeType: Arg(1, "string"),
|
||||||
|
node: Arg(2, "nullable:domnode"),
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
attributes: RetVal("array:string"),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Загрузка…
Ссылка в новой задаче