diff --git a/src/brackets.js b/src/brackets.js index 75aabed6d2..372b5315ff 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -121,6 +121,7 @@ define(function (require, exports, module) { // load modules for later use require("utils/Global"); require("editor/CSSInlineEditor"); + require("preferences/AllPreferences"); require("project/WorkingSetSort"); require("search/QuickOpen"); require("search/QuickOpenHelper"); diff --git a/src/extensions/default/CSSCodeHints/main.js b/src/extensions/default/CSSCodeHints/main.js index 9d7e0c13ba..44a3d0d9a0 100644 --- a/src/extensions/default/CSSCodeHints/main.js +++ b/src/extensions/default/CSSCodeHints/main.js @@ -35,9 +35,17 @@ define(function (require, exports, module) { KeyEvent = brackets.getModule("utils/KeyEvent"), LiveDevelopment = brackets.getModule("LiveDevelopment/main"), Metrics = brackets.getModule("utils/Metrics"), + AllPreferences = brackets.getModule("preferences/AllPreferences"), CSSProperties = require("text!CSSProperties.json"), properties = JSON.parse(CSSProperties); + /** + * Emmet API: + * This provides a function to expand abbreviations into full CSS properties. + */ + const EXPAND_ABBR = Phoenix.libs.Emmet.expand; + let enabled = true; // whether Emmet is enabled or not in preferences + require("./css-lint"); const BOOSTED_PROPERTIES = [ @@ -60,6 +68,13 @@ define(function (require, exports, module) { const cssWideKeywords = ['initial', 'inherit', 'unset', 'var()', 'calc()']; let computedProperties, computedPropertyKeys; + // Stores a list of all CSS properties along with their corresponding MDN URLs. + // This is used by Emmet code hints to ensure users can still access MDN documentation. + // the Emmet icon serves as a clickable link that redirects to the MDN page for the property (if available). + // This object follows the structure: + // { PROPERTY_NAME: MDN_URL } + const MDN_PROPERTIES_URLS = {}; + PreferencesManager.definePreference("codehint.CssPropHints", "boolean", true, { description: Strings.DESCRIPTION_CSS_PROP_HINTS }); @@ -248,7 +263,7 @@ define(function (require, exports, module) { } /** - * Returns a list of availble CSS propertyname or -value hints if possible for the current + * Returns a list of available CSS property name or -value hints if possible for the current * editor context. * * @param {Editor} implicitChar @@ -374,11 +389,97 @@ define(function (require, exports, module) { const propertyKey = computedPropertyKeys[resultItem.sourceIndex]; if(properties[propertyKey] && properties[propertyKey].MDN_URL){ resultItem.MDN_URL = properties[propertyKey].MDN_URL; + MDN_PROPERTIES_URLS[propertyKey] = resultItem.MDN_URL; + } + } + + // pushedHints stores all the hints that will be displayed to the user + let pushedHints = formatHints(result); + + // make sure that emmet feature is on in preferences + if(enabled) { + + // needle gives the current word before cursor, make sure that it exists + // also needle shouldn't contain `-`, because for example if user typed: + // `box-siz` then in that case it is very obvious that user wants to type `box-sizing` + // but emmet expands it `box: siz;`. So we prevent calling emmet when needle has `-`. + if(needle && !needle.includes('-')) { + + // wrapped in try catch block because EXPAND_ABBR might throw error when it gets unexpected + // characters such as `, =, etc + try { + let expandedAbbr = EXPAND_ABBR(needle, { syntax: "css", type: "stylesheet" }); + if(expandedAbbr && isEmmetExpandable(needle, expandedAbbr)) { + + // if the expandedAbbr doesn't have any numbers, we should split the expandedAbbr to, + // get its first word before `:`. + // For instance, `m` expands to `margin: ;`. Here the `: ;` is unnecessary. + // Also, `bgc` expands to `background-color: #fff;`. Here we don't need the `: #fff;` + // as we have cssIntelligence to display hints based on the property + if(!isEmmetAbbrNumeric(expandedAbbr)) { + expandedAbbr = expandedAbbr.split(':')[0]; + } + + // token is required for highlighting the matched part. It gives access to + // stringRanges property. Refer to `formatHints()` function in this file for more detail + const [token] = StringMatch.codeHintsSort(needle, [expandedAbbr]); + + // this displays an emmet icon at the side of the hint + // this gives an idea to the user that the hint is coming from Emmet + let $icon = $(`Emmet`); + + // if MDN_URL is available for the property, add the href attribute to redirect to mdn + if(MDN_PROPERTIES_URLS[expandedAbbr]) { + $icon.attr("href", MDN_PROPERTIES_URLS[expandedAbbr]); + $icon.attr("title", Strings.DOCS_MORE_LINK_MDN_TITLE); + } + + const $emmetHintObj = $("") + .addClass("brackets-css-hints brackets-hints") + .attr("data-val", expandedAbbr); + + // for highlighting the already-typed characters + if (token.stringRanges) { + token.stringRanges.forEach(function (range) { + if (range.matched) { + $emmetHintObj.append($("") + .text(range.text) + .addClass("matched-hint")); + } else { + $emmetHintObj.append(range.text); + } + }); + } else { + // fallback + $emmetHintObj.text(expandedAbbr); + } + + // add the emmet icon to the final hint object + $emmetHintObj.append($icon); + + if(pushedHints) { + + // to remove duplicate hints. one comes from emmet and other from default css hints. + // we remove the default css hints and push emmet hint at the beginning. + for(let i = 0; i < pushedHints.length; i++) { + if(pushedHints[i][0].getAttribute('data-val') === expandedAbbr) { + pushedHints.splice(i, 1); + break; + } + } + pushedHints.unshift($emmetHintObj); + } else { + pushedHints = $emmetHintObj; + } + } + } catch (e) { + // pass + } } } return { - hints: formatHints(result), + hints: pushedHints, match: null, // the CodeHintManager should not format the results selectInitial: selectInitial, handleWideResults: false @@ -387,6 +488,34 @@ define(function (require, exports, module) { return null; }; + /** + * Checks whether the emmet abbr should be expanded or not. + * For instance: EXPAND_ABBR function always expands a value passed to it. + * if we pass 'xyz', then there's no CSS property matching to it, but it still expands this to `xyz: ;`. + * So, make sure that `needle + ': ;'` doesn't add to expandedAbbr + * + * @param {String} needle the word before the cursor + * @param {String} expandedAbbr the expanded abbr returned by EXPAND_ABBR emmet api + * @returns {boolean} true if emmet should be expanded, otherwise false + */ + function isEmmetExpandable(needle, expandedAbbr) { + return needle + ': ;' !== expandedAbbr; + } + + /** + * Checks whether the expandedAbbr has any number. + * For instance: `m0` expands to `margin: 0;`, so we need to display the whole thing in the code hint + * Here, we also make sure that abbreviations which has `#`, `,` should not be included, because + * `color` expands to `color: #000;` or `color: rgb(0, 0, 0)`. So this actually has numbers, but we don't want to display this. + * + * @param {String} expandedAbbr the expanded abbr returned by EXPAND_ABBR emmet api + * @returns {boolean} true if expandedAbbr has numbers (and doesn't include '#') otherwise false. + */ + function isEmmetAbbrNumeric(expandedAbbr) { + return expandedAbbr.match(/\d/) !== null && !expandedAbbr.includes('#') && !expandedAbbr.includes(','); + } + + const HISTORY_PREFIX = "Live_hint_"; let hintSessionId = 0, isInLiveHighlightSession = false; @@ -578,13 +707,32 @@ define(function (require, exports, module) { this.editor.setCursorPos(newCursor); } + // If the cursor is just after a semicolon that means that, + // the CSS property is fully specified, + // so we don't need to continue showing hints for its value. + const cursorPos = this.editor.getCursorPos(); + if(this.editor.getCharacterAtPosition({line: cursorPos.line, ch: cursorPos.ch - 1}) === ';') { + keepHints = false; + } + return keepHints; }; + /** + * Checks for preference changes, to enable/disable Emmet + */ + function preferenceChanged() { + enabled = PreferencesManager.get(AllPreferences.EMMET); + } + + AppInit.appReady(function () { var cssPropHints = new CssPropHints(); CodeHintManager.registerHintProvider(cssPropHints, ["css", "scss", "less"], 1); + PreferencesManager.on("change", AllPreferences.EMMET, preferenceChanged); + preferenceChanged(); + // For unit testing exports.cssPropHintProvider = cssPropHints; }); diff --git a/src/extensions/default/CSSCodeHints/unittests.js b/src/extensions/default/CSSCodeHints/unittests.js index 683440247c..b6e411b7d2 100644 --- a/src/extensions/default/CSSCodeHints/unittests.js +++ b/src/extensions/default/CSSCodeHints/unittests.js @@ -110,6 +110,10 @@ define(function (require, exports, module) { expect(hintList[0]).toBe(expectedFirstHint); } + function verifySecondAttrHint(hintList, expectedSecondHint) { + expect(hintList.indexOf("div")).toBe(-1); + expect(hintList[1]).toBe(expectedSecondHint); + } function selectHint(provider, expectedHint, implicitChar) { var hintList = expectHints(provider, implicitChar); @@ -170,8 +174,16 @@ define(function (require, exports, module) { testEditor.setCursorPos({ line: 6, ch: 2 }); var hintList = expectHints(CSSCodeHints.cssPropHintProvider); - verifyAttrHints(hintList, "background-color"); // filtered on "b" , - // background color should come at top as its boosted for UX + verifyAttrHints(hintList, "bottom"); // filtered on "b" , + // bottom should come at top as it is coming from emmet, and it has the highest priority + }); + + it("should list the second prop-name hint starting with 'b'", function () { + testEditor.setCursorPos({ line: 6, ch: 2 }); + + var hintList = expectHints(CSSCodeHints.cssPropHintProvider); + verifySecondAttrHint(hintList, "background-color"); // filtered on "b" , + // background-color should be displayed at second. as first will be bottom coming from emmet }); it("should list all prop-name hints starting with 'bord' ", function () { @@ -244,6 +256,15 @@ define(function (require, exports, module) { testDocument = null; }); + it("should expand m0 to margin: 0; when Emmet hint is used", function () { + testDocument.replaceRange("m0", { line: 6, ch: 2 }); + testEditor.setCursorPos({ line: 6, ch: 4 }); + + selectHint(CSSCodeHints.cssPropHintProvider, "margin: 0;"); + expect(testDocument.getLine(6)).toBe(" margin: 0;"); + }); + + it("should insert colon prop-name selected", function () { // insert semicolon after previous rule to avoid incorrect tokenizing testDocument.replaceRange(";", { line: 6, ch: 2 }); @@ -459,7 +480,14 @@ define(function (require, exports, module) { testEditor.setCursorPos({ line: 6, ch: 2 }); var hintList = expectHints(CSSCodeHints.cssPropHintProvider); - verifyAttrHints(hintList, "background-color"); // filtered on "b" + verifyAttrHints(hintList, "bottom"); // filtered on "b" + }); + + it("should list the second prop-name hint starting with 'b' for style value context", function () { + testEditor.setCursorPos({ line: 6, ch: 2 }); + + var hintList = expectHints(CSSCodeHints.cssPropHintProvider); + verifySecondAttrHint(hintList, "background-color"); // second result when filtered on "b" }); it("should list all prop-name hints starting with 'bord' for style value context", function () { diff --git a/src/preferences/AllPreferences.js b/src/preferences/AllPreferences.js new file mode 100644 index 0000000000..ad6223603f --- /dev/null +++ b/src/preferences/AllPreferences.js @@ -0,0 +1,50 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * Original work Copyright (c) 2012 - 2021 Adobe Systems Incorporated. All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/* + * This file houses all the preferences used across Phoenix. + * + * To use: + * ``` + * const AllPreferences = brackets.getModule("preferences/AllPreferences"); + * function preferenceChanged() { + enabled = PreferencesManager.get(AllPreferences.EMMET); + } + * PreferencesManager.on("change", AllPreferences.EMMET, preferenceChanged); + preferenceChanged(); + * ``` + */ + +define(function (require, exports, module) { + const PreferencesManager = require("preferences/PreferencesManager"); + const Strings = require("strings"); + + // list of all the preferences + const PREFERENCES_LIST = { + EMMET: "emmet" + }; + + PreferencesManager.definePreference(PREFERENCES_LIST.EMMET, "boolean", true, { + description: Strings.DESCRIPTION_EMMET + }); + + module.exports = PREFERENCES_LIST; +}); diff --git a/src/styles/brackets_core_ui_variables.less b/src/styles/brackets_core_ui_variables.less index 9326487c3b..2ee87d8bd9 100644 --- a/src/styles/brackets_core_ui_variables.less +++ b/src/styles/brackets_core_ui_variables.less @@ -272,3 +272,7 @@ @dark-bc-codehint-desc: #2c2c2c; @dark-bc-codehint-desc-type-details: #46a0f5; @dark-bc-codehint-desc-documentation:#b1b1b1; + +// CSS Codehint icon +@css-codehint-icon: #2ea56c; +@dark-css-codehint-icon: #146a41; diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index 166cd46d8b..bc8203fde9 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -703,12 +703,29 @@ a:focus { position: absolute; right: 0; margin-top:-2px; - color: #2ea56c !important; + color: @css-codehint-icon !important; .dark& { - color: #146a41 !important; + color: @dark-css-codehint-icon !important; } } +.emmet-css-code-hint { + visibility: hidden; +} + +.codehint-menu .dropdown-menu li .highlight .emmet-css-code-hint { + visibility: visible; + position: absolute; + right: 0; + margin-top: -2px; + font-size: 0.85em !important; + font-weight: @font-weight-semibold; + letter-spacing: 0.3px; + color: @css-codehint-icon !important; + .dark& { + color: @dark-css-codehint-icon !important; + } + .emmet-code-hint { position: absolute; font-size: 0.5em;