Skip to content

Commit 62b3dd6

Browse files
committed
feat: css class code hints from html files
1 parent d792a8c commit 62b3dd6

File tree

3 files changed

+203
-7
lines changed

3 files changed

+203
-7
lines changed

src/LiveDevelopment/LiveDevMultiBrowser.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ define(function (require, exports, module) {
604604
* @param {Document} doc
605605
*/
606606
function _onDirtyFlagChange(event, doc) {
607-
if (!isActive() || !_server || !_liveDocument) {
607+
if (!isActive() || !_server || !_liveDocument || !_liveDocument.isRelated) {
608608
return;
609609
}
610610

src/extensions/default/HTMLCodeHints/main.js

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ define(function (require, exports, module) {
3030
PreferencesManager = brackets.getModule("preferences/PreferencesManager"),
3131
Strings = brackets.getModule("strings"),
3232
NewFileContentManager = brackets.getModule("features/NewFileContentManager"),
33+
CSSUtils = brackets.getModule("language/CSSUtils"),
34+
StringMatch = brackets.getModule("utils/StringMatch"),
3335
HTMLTags = require("text!HtmlTags.json"),
3436
HTMLAttributes = require("text!HtmlAttributes.json"),
3537
HTMLTemplate = require("text!template.html"),
@@ -219,6 +221,52 @@ define(function (require, exports, module) {
219221
});
220222
};
221223

224+
const MAX_CLASS_HINTS = 250;
225+
function formatHints(hints) {
226+
StringMatch.basicMatchSort(hints);
227+
if(hints.length > MAX_CLASS_HINTS) {
228+
hints = hints.splice(0, MAX_CLASS_HINTS);
229+
}
230+
return hints.map(function (token) {
231+
let $hintObj = $("<span>").addClass("brackets-html-hints brackets-hints");
232+
233+
// highlight the matched portion of each hint
234+
if (token.stringRanges) {
235+
token.stringRanges.forEach(function (item) {
236+
if (item.matched) {
237+
$hintObj.append($("<span>")
238+
.text(item.text)
239+
.addClass("matched-hint"));
240+
} else {
241+
$hintObj.append(item.text);
242+
}
243+
});
244+
} else {
245+
$hintObj.text(token.label);
246+
}
247+
$hintObj.attr("data-val", token.label);
248+
return $hintObj;
249+
});
250+
}
251+
252+
function _getAllClassHints(query) {
253+
let queryStr = query.queryStr;
254+
// "class1 class2" have multiple classes. the last part is the query to hint
255+
const segments = queryStr.split(" ");
256+
queryStr = segments[segments.length-1];
257+
const deferred = $.Deferred();
258+
CSSUtils.getAllCssSelectorsInProject({includeClasses: true}).then(hints=>{
259+
const result = $.map(hints, function (pvalue) {
260+
pvalue = pvalue.slice(1); // remove.
261+
return StringMatch.stringMatch(pvalue, queryStr, { preferPrefixMatches: true });
262+
});
263+
const validHints = formatHints(result);
264+
validHints.alreadyMatched = true;
265+
deferred.resolve(validHints);
266+
}).catch(console.error);
267+
return deferred;
268+
}
269+
222270
/**
223271
* Helper function that determines the possible value hints for a given html tag/attribute name pair
224272
*
@@ -242,6 +290,10 @@ define(function (require, exports, module) {
242290
// "script/type", "link/type" and "button/type".
243291
var hints = [];
244292

293+
if(attrName === "class") {
294+
return _getAllClassHints(query);
295+
}
296+
245297
var tagPlusAttr = tagName + "/" + attrName,
246298
attrInfo = attributes[tagPlusAttr] || attributes[attrName];
247299

@@ -326,10 +378,10 @@ define(function (require, exports, module) {
326378
}
327379

328380
// If we're at an attribute value, check if it's an attribute name that has hintable values.
329-
if (this.tagInfo.attr.name) {
330-
var hints = this._getValueHintsForAttr({queryStr: query},
331-
this.tagInfo.tagName,
332-
this.tagInfo.attr.name);
381+
const attrName = this.tagInfo.attr.name;
382+
if (attrName && attrName !== "class") { // class hints are always computed later
383+
let hints = this._getValueHintsForAttr({queryStr: query},
384+
this.tagInfo.tagName, attrName);
333385
if (hints instanceof Array) {
334386
// If we got synchronous hints, check if we have something we'll actually use
335387
var i, foundPrefix = false;
@@ -452,7 +504,7 @@ define(function (require, exports, module) {
452504
hints.done(function (asyncHints) {
453505
deferred.resolveWith(this, [{
454506
hints: asyncHints,
455-
match: query.queryStr,
507+
match: asyncHints.alreadyMatched? null: query.queryStr,
456508
selectInitial: true,
457509
handleWideResults: false
458510
}]);
@@ -487,6 +539,7 @@ define(function (require, exports, module) {
487539
replaceExistingOne = this.tagInfo.attr.valueAssigned,
488540
endQuote = "",
489541
shouldReplace = true,
542+
positionWithinAttributeVal = false,
490543
textAfterCursor;
491544

492545
if (tokenType === HTMLUtils.ATTR_NAME) {
@@ -518,6 +571,18 @@ define(function (require, exports, module) {
518571
charCount = this.tagInfo.attr.value.length;
519572
}
520573

574+
if(this.tagInfo.attr.name === "class") {
575+
// css class hints
576+
completion = completion.data("val");
577+
// "anotherClass class<cursor>name" . completion = classics , we have to match a prefix after space
578+
const textBeforeCursor = this.tagInfo.attr.value.slice(0, offset);
579+
let lastSegment = textBeforeCursor.split(" ");
580+
lastSegment = lastSegment[lastSegment.length-1];
581+
offset = lastSegment.length;
582+
charCount = offset;
583+
positionWithinAttributeVal = true;
584+
}
585+
521586
if (!this.tagInfo.attr.hasEndQuote) {
522587
endQuote = this.tagInfo.attr.quoteChar;
523588
if (endQuote) {
@@ -542,7 +607,10 @@ define(function (require, exports, module) {
542607
}
543608
}
544609

545-
if (insertedName) {
610+
if(positionWithinAttributeVal){
611+
this.editor.setCursorPos(start.line, start.ch + completion.length);
612+
// we're now inside the double-quotes we just inserted
613+
} else if (insertedName) {
546614
this.editor.setCursorPos(start.line, start.ch + completion.length - 1);
547615

548616
// Since we're now inside the double-quotes we just inserted,

src/language/CSSUtils.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
*/
2121

2222
/*jslint regexp: true */
23+
/*global jsPromise*/
2324

2425
/**
2526
* Set of utilities for simple parsing of CSS text.
@@ -30,6 +31,7 @@ define(function (require, exports, module) {
3031
var CodeMirror = require("thirdparty/CodeMirror/lib/codemirror"),
3132
Async = require("utils/Async"),
3233
DocumentManager = require("document/DocumentManager"),
34+
AppInit = require("utils/AppInit"),
3335
EditorManager = require("editor/EditorManager"),
3436
HTMLUtils = require("language/HTMLUtils"),
3537
LanguageManager = require("language/LanguageManager"),
@@ -1807,6 +1809,131 @@ define(function (require, exports, module) {
18071809
return (allSelectors.length ? allSelectors[0].selectorGroup || allSelectors[0].selector : "");
18081810
}
18091811

1812+
function _extractSelectorSet(selectorList) {
1813+
const regex = /[{}!]/;
1814+
const selectors = new Set();
1815+
if(!selectorList){
1816+
return selectors;
1817+
}
1818+
for(let item of selectorList) {
1819+
if(regex.test(item.selector)){
1820+
// this happens for scss selectors like #${var}-something. we ignore that for now instead of resolving
1821+
continue;
1822+
}
1823+
selectors.add(extractSelectorBase(item.selector)); // x:hover or x::some -> x
1824+
}
1825+
return selectors;
1826+
}
1827+
1828+
const CSSSelectorCache = new Map();
1829+
1830+
function _projectFileChanged(_evt, entry) {
1831+
if(!entry){
1832+
return;
1833+
}
1834+
let changedPath = entry.fullPath;
1835+
if(entry.isFile) {
1836+
CSSSelectorCache.delete(changedPath);
1837+
} else if(entry.isDirectory) {
1838+
changedPath = Phoenix.VFS.ensureTrailingSlash(changedPath);
1839+
const cachedFilePaths = Array.from(CSSSelectorCache.keys());
1840+
for(let filePath of cachedFilePaths) {
1841+
if(filePath.startsWith(changedPath)){
1842+
CSSSelectorCache.delete(filePath);
1843+
}
1844+
}
1845+
}
1846+
}
1847+
1848+
function _loadFileAndScanCSSSelectorCached(fullPath) {
1849+
return new Promise(resolve=>{
1850+
DocumentManager.getDocumentForPath(fullPath)
1851+
.done(function (doc) {
1852+
// Find all matching rules for the given CSS file's content, and add them to the
1853+
// overall search result
1854+
let selectors;
1855+
const cachedSelectors = CSSSelectorCache.get(fullPath);
1856+
if(cachedSelectors){
1857+
selectors = cachedSelectors;
1858+
} else {
1859+
selectors = extractAllSelectors(doc.getText(), doc.getLanguage().getMode());
1860+
selectors = _extractSelectorSet(selectors);
1861+
CSSSelectorCache.set(fullPath, selectors);
1862+
}
1863+
resolve(selectors);
1864+
})
1865+
.fail(function (error) {
1866+
console.warn("Unable to read " + fullPath + " during CSS selector search:", error);
1867+
resolve(new Set()); // still resolve, so the overall result doesn't reject
1868+
});
1869+
});
1870+
}
1871+
1872+
function extractSelectorBase(selector) {
1873+
// Use a regular expression to find the base part of the selector before any spaces, combinators,
1874+
// pseudo-classes, or attribute selectors
1875+
const match = selector.match(/^[^\s>+~:\[]+/);
1876+
// Return the match if found, otherwise return the original selector if no ':' or '::' is present
1877+
selector = match ? match[0] : selector;
1878+
if(selector.startsWith(".")) {
1879+
// Eg .class1.class2 type selector, we have to consider this too, so we always take the first segment only
1880+
selector = "." + selector.split(".")[1];
1881+
}
1882+
return selector;
1883+
}
1884+
1885+
function getAllCssSelectorsInProject(options = {
1886+
includeClasses: true,
1887+
includeIDs: true
1888+
}) {
1889+
return new Promise(resolve=>{
1890+
ProjectManager.getAllFiles(ProjectManager.getLanguageFilter(["css", "less", "scss"]))
1891+
.done(function (cssFiles) {
1892+
// Create an array of promises from the array of cssFiles
1893+
const promises = cssFiles.map(fileInfo => _loadFileAndScanCSSSelectorCached(fileInfo.fullPath));
1894+
const mergedSets = new Set();
1895+
// Use Promise.allSettled to handle all promises
1896+
Promise.allSettled(promises)
1897+
.then(results => {
1898+
results.forEach((result, index) => {
1899+
if (result.status === 'fulfilled') {
1900+
result.value.forEach(value => {
1901+
if((options.includeClasses && value.startsWith(".")) ||
1902+
(options.includeIDs && value.startsWith("#"))) {
1903+
mergedSets.add(value);
1904+
}
1905+
});
1906+
} else {
1907+
console.error(`Error collect css selectors from file ${cssFiles[index].fullPath}:`,
1908+
result.reason);
1909+
}
1910+
});
1911+
resolve(Array.from(mergedSets.keys()));
1912+
});
1913+
});
1914+
});
1915+
}
1916+
1917+
async function _populateSelectorCache() {
1918+
const cssFiles = await jsPromise(ProjectManager.getAllFiles(
1919+
ProjectManager.getLanguageFilter(["css", "less", "scss"])));
1920+
for(let cssFile of cssFiles){
1921+
await _loadFileAndScanCSSSelectorCached(cssFile.fullPath); // this is serial to not hog processor
1922+
}
1923+
}
1924+
1925+
AppInit.appReady(function () {
1926+
ProjectManager.on(ProjectManager.EVENT_PROJECT_FILE_CHANGED, _projectFileChanged);
1927+
ProjectManager.on(ProjectManager.EVENT_PROJECT_OPEN, ()=>{
1928+
CSSSelectorCache.clear();
1929+
setTimeout(_populateSelectorCache, 2000);
1930+
});
1931+
setTimeout(_populateSelectorCache, 2000);
1932+
DocumentManager.on(DocumentManager.EVENT_DOCUMENT_CHANGE, function (event, doc, _changelist) {
1933+
CSSSelectorCache.delete(doc.file.fullPath);
1934+
});
1935+
});
1936+
18101937
exports._findAllMatchingSelectorsInText = _findAllMatchingSelectorsInText; // For testing only
18111938
exports.findMatchingRules = findMatchingRules;
18121939
exports.extractAllSelectors = extractAllSelectors;
@@ -1817,6 +1944,7 @@ define(function (require, exports, module) {
18171944
exports.getRangeSelectors = getRangeSelectors;
18181945
exports.getCompleteSelectors = getCompleteSelectors;
18191946
exports.isCSSPreprocessorFile = isCSSPreprocessorFile;
1947+
exports.getAllCssSelectorsInProject = getAllCssSelectorsInProject;
18201948

18211949
exports.SELECTOR = SELECTOR;
18221950
exports.PROP_NAME = PROP_NAME;

0 commit comments

Comments
 (0)