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..d353d943c1 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 () { @@ -459,7 +471,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 () { @@ -810,6 +829,69 @@ define(function (require, exports, module) { expectNoHints(CSSCodeHints.cssPropHintProvider, " "); }); }); + + + const emmetContent = "body {\n" + + " m\n" + + " bgc\n" + + " m0\n" + + " pt10\n" + + " ma\n" + + "}"; + + describe("Emmet hints for CSS", function () { + + beforeEach(function () { + setupTest(emmetContent, "css"); + }); + + afterEach(function () { + tearDownTest(); + }); + + it("should display emmet hint margin when m is pressed", function () { + testEditor.setCursorPos({ line: 1, ch: 3 }); + const hints = expectHints(CSSCodeHints.cssPropHintProvider); + verifyAttrHints(hints, "margin"); + expect(hints.indexOf("margin")).toBe(0); + }); + + it("should display emmet hint background-color when bgc is pressed", function () { + testEditor.setCursorPos({ line: 2, ch: 5 }); + const hints = expectHints(CSSCodeHints.cssPropHintProvider); + verifyAttrHints(hints, "background-color"); + expect(hints.indexOf("background-color")).toBe(0); + }); + + it("should complete margin property when m0 is pressed", function () { + testEditor.setCursorPos({ line: 3, ch: 4 }); + const hints = expectHints(CSSCodeHints.cssPropHintProvider); + verifyAttrHints(hints, "margin: 0;"); + expect(hints.indexOf("margin: 0;")).toBe(0); + + selectHint(CSSCodeHints.cssPropHintProvider, "margin: 0;"); + expect(testDocument.getLine(3)).toBe(" margin: 0;"); + expectCursorAt({ line: 3, ch: 12 }); + }); + + it("should complete padding-top property when pt10 is pressed", function () { + testEditor.setCursorPos({ line: 4, ch: 6 }); + const hints = expectHints(CSSCodeHints.cssPropHintProvider); + verifyAttrHints(hints, "padding-top: 10px;"); + expect(hints.indexOf("padding-top: 10px;")).toBe(0); + + selectHint(CSSCodeHints.cssPropHintProvider, "padding-top: 10px;"); + expect(testDocument.getLine(4)).toBe(" padding-top: 10px;"); + expectCursorAt({ line: 4, ch: 20 }); + }); + + it("should not hint margin when ma is pressed", function () { + testEditor.setCursorPos({ line: 5, ch: 4 }); + const hints = expectHints(CSSCodeHints.cssPropHintProvider); + expect(hints.indexOf("margin")).toBe(1); // this should not be 0, as max-width comes first + }); + + }); }); }); diff --git a/src/extensions/default/HTMLCodeHints/emmet-snippets.js b/src/extensions/default/HTMLCodeHints/emmet-snippets.js new file mode 100644 index 0000000000..336f69d1a7 --- /dev/null +++ b/src/extensions/default/HTMLCodeHints/emmet-snippets.js @@ -0,0 +1,247 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * Original work Copyright (c) 2024 [emmet.io](https://github.com/emmetio/brackets-emmet). + * + * 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. + */ + + +/** + * Emmet Snippets Configuration + * + * This file defines the configuration for Emmet snippet expansion. + * It contains four main exports: + * + * 1. **markupSnippets**: An object that maps abbreviation keys to their expanded HTML markup. + * These are all the abbreviations that can be expanded into something other than the usual tags. + * When an abbreviation matching one of the markup snippets is passed to Emmet, it knows how to expand it. + * These are sourced from `thirdparty/emmet.es.js`. To update this in future, refer to that file. + * + * + * 2. **htmlTags**: An array of standard HTML tags that are expanded by default. + * This list helps determine whether an abbreviation corresponds to a valid HTML element. + * Although Emmet can expand any text as an HTML tag, + * doing so would trigger code hints for every piece of text in the editor. + * So, we maintain a list of standard tags; + * only when an abbreviation matches one of these does Emmet display the code hints. + * + * 3. **positiveSymbols**: An array of symbols that, when present in an abbreviation, + * indicate that the abbreviation is eligible for expansion. + * Examples include `.`, `#`, `>`, `+`, etc., which are used for classes, IDs, nesting, + * sibling selectors, attributes, and more. + * + * 4. **negativeSymbols**: An array of sequences that indicate a word should NOT be expanded. + * For example, the sequence `src:m+img", + "ri:t|ri:type": "pic>src:t+img", + + "!!!": "{}", + "doc": "html[lang=${lang}]>(head>meta[charset=${charset}]+meta:vp+title{${1:Document}})+body", + "!|html:5": "!!!+doc", + + "c": "{}", + "cc:ie": "{}", + "cc:noie": "{${0}}" + }; + + + const htmlTags = [ + "a", "abbr", "address", "area", "article", "aside", "audio", "b", "base", + "bdi", "bdo", "blockquote", "body", "br", "button", "canvas", "caption", + "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", + "details", "dfn", "dialog", "div", "dl", "dt", "em", "embed", "fieldset", + "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", + "h6", "head", "header", "hgroup", "hr", "html", "i", "iframe", "img", + "input", "ins", "kbd", "label", "legend", "li", "link", "main", "map", + "mark", "meta", "meter", "nav", "noscript", "object", "ol", "optgroup", + "option", "output", "p", "param", "picture", "pre", "progress", "q", + "rp", "rt", "ruby", "s", "samp", "script", "section", "select", "small", + "source", "span", "strong", "style", "sub", "summary", "sup", "table", + "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", + "title", "tr", "track", "u", "ul", "var", "video", "wbr" + ]; + + + const positiveSymbols = [ + '.', '#', '!', '>', '+', '^', '*', '[', ']', '{', '}', '(', ')', '&' + ]; + + + const negativeSymbols = [ + '") + .addClass("emmet-hint") + .text(abbr); + + // style in brackets_patterns_override.less file + let $icon = $(`Emmet`); + + // Append the icon to the hint element + $hint.append($icon); + + return $hint; + } + + + /** + * Responsible for updating the abbr with the expanded text in the editor. + * This function calls helper functions for this as there are, + * lot of complex cases that should be taken care of. + */ + EmmetMarkupHints.prototype.insertHint = function () { + const wordObj = getWordBeforeCursor(this.editor); + const expandedAbbr = isExpandable(this.editor, wordObj.word); + updateAbbrInEditor(this.editor, wordObj, expandedAbbr); + return false; + }; + + + /** + * Determines whether a given character is allowed as part of an Emmet abbreviation + * + * @param {String} char - The character to test + * @param {Boolean} insideBraces - Flag indicating if we are inside braces (e.g. {} or []) + * @returns True if the character is valid for an abbreviation + */ + function isEmmetChar(char, insideBraces) { + // Valid abbreviation characters: letters, digits, and some punctuation + // Adjust this regex or the list as needed for your implementation + const validPattern = /[a-zA-Z0-9:+*<>()/!$\-@#}{]/; + const specialChars = new Set(['.', '#', '[', ']', '"', '=', ':', ',', '-']); + return validPattern.test(char) || specialChars.has(char) || (insideBraces && char === ' '); + } + + + /** + * Scans backwards from the given cursor position on a line to locate the start of the Emmet abbreviation + * + * @param {String} line - The full text of the current line + * @param {Number} cursorCh - The cursor's character (column) position on that line + * @returns The index (column) where the abbreviation starts + */ + function findAbbreviationStart(line, cursorCh) { + let start = cursorCh; + let insideBraces = false; + + // If the cursor is right before a closing brace, adjust it to be "inside" the braces + if (line.charAt(start) === '}' || line.charAt(start) === ']') { + start--; + insideBraces = true; + } + + // Walk backwards from the cursor to find the boundary of the abbreviation + while (start > 0) { + const char = line.charAt(start - 1); + + // Update our "inside braces" state based on the character + if (char === '}' || char === ']') { + insideBraces = true; + } else if (char === '{' || char === '[') { + insideBraces = false; + } + + // If the character is valid as part of an Emmet abbreviation, continue scanning backwards + if (isEmmetChar(char, insideBraces)) { + start--; + } else { + break; + } + } + return start; + } + + + /** + * Retrieves the Emmet abbreviation (i.e. the word before the cursor) from the current editor state + * + * @param {Editor} editor - The editor instance + * @returns An object with the abbreviation and its start/end positions + * + * Format: + * { + * word: string, // the extracted abbreviation + * start: { line: number, ch: number }, + * end: { line: number, ch: number } + * } + */ + function getWordBeforeCursor(editor) { + const pos = editor.getCursorPos(); + const lineText = editor.document.getLine(pos.line); + + // to determine where the abbreviation starts on the line + const abbreviationStart = findAbbreviationStart(lineText, pos.ch); + + // Optionally, adjust the end position if the cursor is immediately before a closing brace. + let abbreviationEnd = pos.ch; + if (lineText.charAt(abbreviationEnd) === '}' || lineText.charAt(abbreviationEnd) === ']') { + abbreviationEnd++; + } + + const word = lineText.substring(abbreviationStart, abbreviationEnd); + + return { + word: word, + start: { line: pos.line, ch: abbreviationStart }, + end: { line: pos.line, ch: abbreviationEnd } + }; + } + + + /** + * Calculate the indentation level for the current line + * + * @param {Editor} editor - the editor instance + * @param {Object} position - position object with line number + * @returns {String} - the indentation string + */ + function getLineIndentation(editor, position) { + const line = editor.document.getLine(position.line); + const match = line.match(/^\s*/); + return match ? match[0] : ''; + } + + + /** + * Adds proper indentation to multiline Emmet expansion + * + * @param {String} expandedText - the expanded Emmet abbreviation + * @param {String} baseIndent - the base indentation string + * @returns {String} - properly indented text + */ + function addIndentation(expandedText, baseIndent) { + // Split into lines, preserve empty lines + const lines = expandedText.split(/(\r\n|\n)/g); + + // Process each line + let result = ''; + let isFirstLine = true; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // If it's a newline character, just add it + if (line === '\n' || line === '\r\n') { + result += line; + continue; + } + + // Skip indenting empty lines + if (line.trim() === '') { + result += line; + continue; + } + + // Don't indent the first line as it inherits the current indent + if (isFirstLine) { + result += line; + isFirstLine = false; + } else { + // Add base indent plus the existing indent in the expanded text + result += baseIndent + line; + } + } + + return result; + } + + + + /** + * Find the position where cursor should be placed after expansion + * Looks for patterns like '><', '""', '' + * + * @param {Editor} editor - The editor instance + * @param {String} indentedAbbr - the indented abbreviation + * @param {Object} startPos - Starting position {line, ch} of the expansion + * @returns {Object | false} - Cursor position {line, ch} or false if no pattern found + */ + function findCursorPosition(editor, indentedAbbr, startPos) { + const totalLines = startPos.line + indentedAbbr.split('\n').length; + + for (let i = startPos.line; i < totalLines; i++) { + const line = editor.document.getLine(i); + + for (let j = 0; j < line.length - 1; j++) { + const pair = line[j] + line[j + 1]; + + if (pair === '""' || pair === "''") { + return { line: i, ch: j + 1 }; + } + } + for (let j = 0; j < line.length - 1; j++) { + const pair = line[j] + line[j + 1]; + + if (pair === '><') { + return { line: i, ch: j + 1 }; + } + } + } + + // Look for opening and closing tag pairs with empty line in between + // + // | + // + // here in such scenarios, we want the cursor to be placed in between + // Look for opening and closing tag pairs with empty line in between + for (let i = startPos.line; i < totalLines; i++) { + const line = editor.document.getLine(i).trim(); + if (line.endsWith('>') && line.includes('<') && !line.includes('li{Hello}` and the cursor is before the closing braces right after 'o', + // then when this is expanded it results in an extra closing braces at the end. + // so we remove the extra closing brace from the end + if (wordObj.word.includes('{') || wordObj.word.includes('[')) { + const pos = editor.getCursorPos(); + const line = editor.document.getLine(pos.line); + const char = line.charAt(wordObj.end.ch); + const charsNext = line.charAt(wordObj.end.ch + 1); + + if (char === '}' || char === ']') { + wordObj.end.ch += 1; + } + + // sometimes at the end we get `"]` as extra with some abbreviations. + if (char === '"' && charsNext && charsNext === ']') { + wordObj.end.ch += 2; + } + + } + + // Replace the abbreviation + editor.document.replaceRange( + indentedAbbr, + wordObj.start, + wordObj.end + ); + + // Calculate and set the new cursor position + const cursorPos = findCursorPosition(editor, indentedAbbr, wordObj.start); + if (cursorPos) { + editor.setCursorPos(cursorPos.line, cursorPos.ch); + } + } + + + /** + * This function checks whether the abbreviation can be expanded or not. + * There are a lot of cases to check: + * There should not be any negative symbols + * The abbr should be either in htmlTags or in markupSnippetsList + * For other cases such as 'ul>li', we will check if there is any, + * positive word. This is done to handle complex abbreviations such as, + * 'ul>li' or 'li*3{Hello}'. So we check if the word includes any positive symbols. + * + * @param {Editor} editor - the editor instance + * @param {String} word - the abbr + * @returns {String | false} - returns the expanded abbr, and if cannot be expanded, returns false + */ + function isExpandable(editor, word) { + const pos = editor.getCursorPos(); + const line = editor.document.getLine(pos.line); + + // to prevent hints from appearing in line. Also to prevent hints from appearing in comments + if(line.includes(' word.includes(symbol))) { + return false; + } + + // the word must be either in markupSnippetsList, htmlList or it must have a positive symbol + // convert to lowercase only for `htmlTags` because HTML tag names are case-insensitive, + // but `markupSnippetsList` expands abbreviations in a non-tag manner, + // where the expanded abbreviation is already in lowercase. + if (markupSnippetsList.includes(word) || + htmlTags.includes(word.toLowerCase()) || + positiveSymbols.some(symbol => word.includes(symbol))) { + + try { + const expanded = EXPAND_ABBR(word, { syntax: "html", type: "markup" }); + return expanded; + } catch (error) { + + // emmet api throws an error when abbr contains unclosed quotes, handling that case + const nextChar = line.charAt(pos.ch); + + if (nextChar) { + // If the next character is a quote, add quote to abbr + if (nextChar === '"' || nextChar === "'") { + const modifiedWord = word + nextChar; + + try { + const expandedModified = EXPAND_ABBR(modifiedWord, { syntax: "html", type: "markup" }); + return expandedModified; + } catch (innerError) { + // If it still fails, return false + return false; + } + } + } + + // If no quote is found or expansion fails, return false + return false; + } + } + + return false; + } + + /** * @constructor */ @@ -733,6 +1202,14 @@ define(function (require, exports, module) { }); }; + /** + * Checks for preference changes, to enable/disable Emmet + */ + function preferenceChanged() { + enabled = PreferencesManager.get(AllPreferences.EMMET); + } + + AppInit.appReady(function () { // Parse JSON files tags = JSON.parse(HTMLTags); @@ -746,7 +1223,14 @@ define(function (require, exports, module) { CodeHintManager.registerHintProvider(attrHints, ["html"], 0); NewFileContentManager.registerContentProvider(newDocContentProvider, ["html"], 0); + PreferencesManager.on("change", AllPreferences.EMMET, preferenceChanged); + preferenceChanged(); + + var emmetMarkupHints = new EmmetMarkupHints(); + CodeHintManager.registerHintProvider(emmetMarkupHints, ["html", "php", "jsp"], 0); + // For unit testing + exports.emmetHintProvider = emmetMarkupHints; exports.tagHintProvider = tagHints; exports.attrHintProvider = attrHints; }); diff --git a/src/extensions/default/HTMLCodeHints/unittests.js b/src/extensions/default/HTMLCodeHints/unittests.js index 16406741b2..d53fadf725 100644 --- a/src/extensions/default/HTMLCodeHints/unittests.js +++ b/src/extensions/default/HTMLCodeHints/unittests.js @@ -94,6 +94,14 @@ define(function (require, exports, module) { expect(hintList[0]).toBe(expectedFirstHint); } + // Helper function for testing cursor position + function fixPos(pos) { + if (!("sticky" in pos)) { + pos.sticky = null; + } + return pos; + } + describe("Tag hint provider", function () { @@ -685,5 +693,127 @@ define(function (require, exports, module) { }); + describe("Emmet hint provider", function () { + + it("should display emmet hint and expand to boilerplate code on ! press", function () { + + let emmetBoilerPlate = [ + "", + "", + "", + " ", + " ", + " Document", + "", + "", + " ", + "", + "" + ]; + + testDocument.setText("!"); + testEditor.setCursorPos({ line: 0, ch: 1 }); + const hints = expectHints(HTMLCodeHints.emmetHintProvider); + + // get the hintText from the code hint + const hintText = hints[0][0].textContent; + expect(hintText).toBe("!Emmet"); // this should be same as the setText plus the Emmet + + // also test after inserting the hint + HTMLCodeHints.emmetHintProvider.insertHint(hints[0]); + + for(let i = 0; i <= 10; i++) { + expect(testDocument.getLine(i)).toBe(emmetBoilerPlate[i]); + } + + // make sure the cursor is between the body tag + expect(fixPos(testEditor.getCursorPos())).toEql(fixPos({line: 8, ch: 1})); + }); + + + it("should display emmet hint and expand to doctype html initial line on !!! press", function () { + + let emmetBoilerPlate = ""; + + testDocument.setText("!!!"); + testEditor.setCursorPos({ line: 0, ch: 3 }); + const hints = expectHints(HTMLCodeHints.emmetHintProvider); + + const hintText = hints[0][0].textContent; + expect(hintText).toBe("!!!Emmet"); + + HTMLCodeHints.emmetHintProvider.insertHint(hints[0]); + expect(testDocument.getLine(0)).toBe(emmetBoilerPlate); + + expect(fixPos(testEditor.getCursorPos())).toEql(fixPos({line: 0, ch: 15})); + }); + + it("should not display hints when two or more than three exclamation marks are present", function () { + testDocument.setText("!!"); + testEditor.setCursorPos({ line: 0, ch: 2 }); + expectNoHints(HTMLCodeHints.emmetHintProvider); + + testDocument.setText("!!!!"); + testEditor.setCursorPos({ line: 0, ch: 4 }); + expectNoHints(HTMLCodeHints.emmetHintProvider); + }); + + it("should not display emmet hints on < key press", function () { + testDocument.setText("<"); + testEditor.setCursorPos({ line: 0, ch: 1 }); + expectNoHints(HTMLCodeHints.emmetHintProvider); + }); + + it("should add class name id name if abbr contains . and #", function () { + testDocument.setText("div.hello#world"); + testEditor.setCursorPos({ line: 0, ch: 15 }); + + const hints = expectHints(HTMLCodeHints.emmetHintProvider); + + const hintText = hints[0][0].textContent; + expect(hintText).toBe("div.hello#worldEmmet"); + + HTMLCodeHints.emmetHintProvider.insertHint(hints[0]); + expect(testDocument.getLine(0)).toBe("
"); + + expect(fixPos(testEditor.getCursorPos())).toEql(fixPos({line: 0, ch: 30})); + }); + + it("./# should expand to a div with empty class/id name and set cursor in between quotes", function() { + testDocument.setText("."); + testEditor.setCursorPos({ line: 0, ch: 1 }); + let hints = expectHints(HTMLCodeHints.emmetHintProvider); + + HTMLCodeHints.emmetHintProvider.insertHint(hints[0]); + expect(fixPos(testEditor.getCursorPos())).toEql(fixPos({line: 0, ch: 12})); + + testDocument.setText("#"); + testEditor.setCursorPos({ line: 0, ch: 1 }); + hints = expectHints(HTMLCodeHints.emmetHintProvider); + + HTMLCodeHints.emmetHintProvider.insertHint(hints[0]); + expect(fixPos(testEditor.getCursorPos())).toEql(fixPos({line: 0, ch: 9})); + }); + + it("should expand emmet snippet with * and {}", function() { + const emmetSnippetResult = ""; + testDocument.setText("ul>li*4{hello world}"); + testEditor.setCursorPos({ line: 0, ch: 19 }); + const hints = expectHints(HTMLCodeHints.emmetHintProvider); + + const hintText = hints[0][0].textContent; + expect(hintText).toBe("ul>li*4{hello world}Emmet"); + + HTMLCodeHints.emmetHintProvider.insertHint(hints[0]); + expect(testDocument.getText()).toBe(emmetSnippetResult); + }); + }); + + }); // describe("HTML Code Hinting" }); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 19df7702f1..aab28583dc 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1264,6 +1264,9 @@ define({ "DESCRIPTION_HIDE_FIRST": "true to show the first Indent Guide line else false.", "DESCRIPTION_CSS_COLOR_PREVIEW": "true to display color previews in the gutter, else false.", + // Emmet + "DESCRIPTION_EMMET": "true to enable Emmet, else false.", + // Git extension "ENABLE_GIT": "Enable Git", "ACTION": "Action", 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 eda14f4ef5..23e42dc6b9 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -703,9 +703,39 @@ 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.85em; + font-weight: @font-weight-semibold; + right: 4px; + bottom: 0px; + color: @css-codehint-icon !important; + .dark& { + color: @dark-css-codehint-icon !important; } } diff --git a/test/SpecRunner.js b/test/SpecRunner.js index 0e0bf45447..bcb7fcbf2e 100644 --- a/test/SpecRunner.js +++ b/test/SpecRunner.js @@ -248,6 +248,7 @@ define(function (require, exports, module) { require("worker/ExtensionsWorker"); require("thirdparty/tinycolor"); require("widgets/NotificationUI"); + require("preferences/AllPreferences"); // Load modules that self-register and just need to get included in the test-runner window require("document/ChangedDocumentTracker");