From 9253416651c88e124fc85488b0a21d4e0b97c581 Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 6 Feb 2025 23:00:41 +0530 Subject: [PATCH 01/24] feat: emmet integration --- src/extensionsIntegrated/Emmet/main.js | 652 +++++++++++++++++++++++++ src/extensionsIntegrated/loader.js | 1 + 2 files changed, 653 insertions(+) create mode 100644 src/extensionsIntegrated/Emmet/main.js diff --git a/src/extensionsIntegrated/Emmet/main.js b/src/extensionsIntegrated/Emmet/main.js new file mode 100644 index 0000000000..3f108aad9d --- /dev/null +++ b/src/extensionsIntegrated/Emmet/main.js @@ -0,0 +1,652 @@ +define(function (require, exports, module) { + const AppInit = require("utils/AppInit"); + const EditorManager = require("editor/EditorManager"); + const PreferencesManager = require("preferences/PreferencesManager"); + const Strings = require("strings"); + + const EXPAND_ABBR = Phoenix.libs.Emmet.expand; + // const EMMET = Phoenix.libs.Emmet.module; + + /** + * Object with all the markup snippets that can be expanded into something different than like normal tags + */ + var markupSnippets = { + "a": "a[href]", + "a:blank": "a[href='http://${0}' target='_blank' rel='noopener noreferrer']", + "a:link": "a[href='http://${0}']", + "a:mail": "a[href='mailto:${0}']", + "a:tel": "a[href='tel:+${0}']", + "abbr": "abbr[title]", + "acr|acronym": "acronym[title]", + "base": "base[href]/", + "basefont": "basefont/", + "br": "br/", + "frame": "frame/", + "hr": "hr/", + "bdo": "bdo[dir]", + "bdo:r": "bdo[dir=rtl]", + "bdo:l": "bdo[dir=ltr]", + "col": "col/", + "link": "link[rel=stylesheet href]/", + "link:css": "link[href='${1:style}.css']", + "link:print": "link[href='${1:print}.css' media=print]", + "link:favicon": "link[rel='shortcut icon' type=image/x-icon href='${1:favicon.ico}']", + "link:mf|link:manifest": "link[rel='manifest' href='${1:manifest.json}']", + "link:touch": "link[rel=apple-touch-icon href='${1:favicon.png}']", + "link:rss": "link[rel=alternate type=application/rss+xml title=RSS href='${1:rss.xml}']", + "link:atom": "link[rel=alternate type=application/atom+xml title=Atom href='${1:atom.xml}']", + "link:im|link:import": "link[rel=import href='${1:component}.html']", + "meta": "meta/", + "meta:utf": "meta[http-equiv=Content-Type content='text/html;charset=UTF-8']", + "meta:vp": "meta[name=viewport content='width=${1:device-width}, initial-scale=${2:1.0}']", + "meta:compat": "meta[http-equiv=X-UA-Compatible content='${1:IE=7}']", + "meta:edge": "meta:compat[content='${1:ie=edge}']", + "meta:redirect": "meta[http-equiv=refresh content='0; url=${1:http://example.com}']", + "meta:refresh": "meta[http-equiv=refresh content='${1:5}']", + "meta:kw": "meta[name=keywords content]", + "meta:desc": "meta[name=description content]", + "style": "style", + "script": "script", + "script:src": "script[src]", + "script:module": "script[type=module src]", + "img": "img[src alt]/", + "img:s|img:srcset": "img[srcset src alt]", + "img:z|img:sizes": "img[sizes srcset src alt]", + "picture": "picture", + "src|source": "source/", + "src:sc|source:src": "source[src type]", + "src:s|source:srcset": "source[srcset]", + "src:t|source:type": "source[srcset type='${1:image/}']", + "src:z|source:sizes": "source[sizes srcset]", + "src:m|source:media": "source[media='(${1:min-width: })' srcset]", + "src:mt|source:media:type": "source:media[type='${2:image/}']", + "src:mz|source:media:sizes": "source:media[sizes srcset]", + "src:zt|source:sizes:type": "source[sizes srcset type='${1:image/}']", + "iframe": "iframe[src frameborder=0]", + "embed": "embed[src type]/", + "object": "object[data type]", + "param": "param[name value]/", + "map": "map[name]", + "area": "area[shape coords href alt]/", + "area:d": "area[shape=default]", + "area:c": "area[shape=circle]", + "area:r": "area[shape=rect]", + "area:p": "area[shape=poly]", + "form": "form[action]", + "form:get": "form[method=get]", + "form:post": "form[method=post]", + "label": "label[for]", + "input": "input[type=${1:text}]/", + "inp": "input[name=${1} id=${1}]", + "input:h|input:hidden": "input[type=hidden name]", + "input:t|input:text": "inp[type=text]", + "input:search": "inp[type=search]", + "input:email": "inp[type=email]", + "input:url": "inp[type=url]", + "input:p|input:password": "inp[type=password]", + "input:datetime": "inp[type=datetime]", + "input:date": "inp[type=date]", + "input:datetime-local": "inp[type=datetime-local]", + "input:month": "inp[type=month]", + "input:week": "inp[type=week]", + "input:time": "inp[type=time]", + "input:tel": "inp[type=tel]", + "input:number": "inp[type=number]", + "input:color": "inp[type=color]", + "input:c|input:checkbox": "inp[type=checkbox]", + "input:r|input:radio": "inp[type=radio]", + "input:range": "inp[type=range]", + "input:f|input:file": "inp[type=file]", + "input:s|input:submit": "input[type=submit value]", + "input:i|input:image": "input[type=image src alt]", + "input:b|input:btn|input:button": "input[type=button value]", + "input:reset": "input:button[type=reset]", + "isindex": "isindex/", + "select": "select[name=${1} id=${1}]", + "select:d|select:disabled": "select[disabled.]", + "opt|option": "option[value]", + "textarea": "textarea[name=${1} id=${1}]", + "tarea:c|textarea:cols": "textarea[name=${1} id=${1} cols=${2:30}]", + "tarea:r|textarea:rows": "textarea[name=${1} id=${1} rows=${3:10}]", + "tarea:cr|textarea:cols:rows": "textarea[name=${1} id=${1} cols=${2:30} rows=${3:10}]", + "marquee": "marquee[behavior direction]", + "menu:c|menu:context": "menu[type=context]", + "menu:t|menu:toolbar": "menu[type=toolbar]", + "video": "video[src]", + "audio": "audio[src]", + "html:xml": "html[xmlns=http://www.w3.org/1999/xhtml]", + "keygen": "keygen/", + "command": "command/", + "btn:s|button:s|button:submit": "button[type=submit]", + "btn:r|button:r|button:reset": "button[type=reset]", + "btn:b|button:b|button:button": "button[type=button]", + "btn:d|button:d|button:disabled": "button[disabled.]", + "fst:d|fset:d|fieldset:d|fieldset:disabled": "fieldset[disabled.]", + + "bq": "blockquote", + "fig": "figure", + "figc": "figcaption", + "pic": "picture", + "ifr": "iframe", + "emb": "embed", + "obj": "object", + "cap": "caption", + "colg": "colgroup", + "fst": "fieldset", + "btn": "button", + "optg": "optgroup", + "tarea": "textarea", + "leg": "legend", + "sect": "section", + "art": "article", + "hdr": "header", + "ftr": "footer", + "adr": "address", + "dlg": "dialog", + "str": "strong", + "prog": "progress", + "mn": "main", + "tem": "template", + "fset": "fieldset", + "datal": "datalist", + "kg": "keygen", + "out": "output", + "det": "details", + "sum": "summary", + "cmd": "command", + "data": "data[value]", + "meter": "meter[value]", + "time": "time[datetime]", + + "ri:d|ri:dpr": "img:s", + "ri:v|ri:viewport": "img:z", + "ri:a|ri:art": "pic>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}}" + }; + + /** + * A list of all the markup snippets that can be expanded. + * For ex: 'link:css', 'iframe' + * They expand differently as compared to normal tags. + */ + const markupSnippetsList = Object.keys(markupSnippets); + + /** + * A list of all the HTML tags that expand like normal tags + */ + 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" + ]; + + /** + * A list of all those symbols which if present in a word, that word can be expanded + */ + const positiveSymbols = [ + '.', // classes + '#', // ids + '!', // document generator + '>', // Child Selector + '+', // Adjacent Sibling Selector + '^', // Parent Selector + '*', // Multiplication (Repeat Element) + '[', + ']', // Attributes + '{', + '}', // Text Content + '(', + ')', // Group + '&' // Current Element Reference + ]; + + /** + * A list of all those symbols which if present in a word, that word cannot be expanded + */ + const negativeSymbols = [ + ' 0) { + const char = line.charAt(start - 1); + + if (char === '}' || char === ']') { + insideBraces = true; + } else if (char === '{' || char === '[') { + insideBraces = false; + } + + if (/[a-zA-Z0-9:+*<>()/!$\-@#}{]/.test(char) || + specialChars.has(char) || + (insideBraces && char === ' ')) { + start--; + } else { + break; + } + } + + return { + word: line.substring(start, pos.ch), + start: { line: pos.line, ch: start }, + end: pos + }; + } + + + /** + * 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 + * @param {Object} config - the config object, to make sure it is a valid file type, + * refer to createConfig function for more info about config object. + * @returns {String | false} - returns the expanded abbr, and if cannot be expanded, returns false + */ + function isExpandable(editor, word, config) { + + // make sure that word doesn't contain any negativeSymbols + if (negativeSymbols.some(symbol => word.includes(symbol))) { + return false; + } + + // the word must be either in markupSnippetsList, htmlList or it must have a positive symbol + if (markupSnippetsList.includes(word) || + htmlTags.includes(word) || + positiveSymbols.some(symbol => word.includes(symbol))) { + + try { + const expanded = EXPAND_ABBR(word, config); + return expanded; + } catch (error) { + + // emmet api throws an error when abbr contains unclosed quotes, handling that case + const pos = editor.getCursorPos(); + const line = editor.document.getLine(pos.line); + 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, config); + 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; + } + + + /** + * Responsible to handle the flow of the program + * + * @param {Editor} editor - the editor instance + * @param {Object} keyboardEvent - the keyboard event object + */ + function driver(editor, keyboardEvent) { + const config = createConfig(editor); + + if (config) { + + // to make sure it is an html file + if (config.syntax === "html") { + const wordObj = getWordBeforeCursor(editor); + + // make sure we donot have empty spaces + if (wordObj.word.trim()) { + + const expandedAbbr = isExpandable(editor, wordObj.word, config); + if (expandedAbbr) { + updateAbbrInEditor(editor, wordObj, expandedAbbr); + + // prevent the default working of the 'tab' key + keyboardEvent.preventDefault(); + } + } + } + } + } + + /** + * Function that gets triggered when any key is pressed. + * We only want to look for 'tab' key events + * + * @param {Event} event - unused event detail + * @param {Editor} editor - the editor instance + * @param {Object} keyboardEvent - an object that has properties related to the keyboard, + * mainly the key that is pressed (keyboardEvent.key) + * @returns {Boolean} True if abbreviation is expanded else false + */ + function handleKeyEvent(event, editor, keyboardEvent) { + if (!enabled) { + return false; + } + + // if not a 'tab' key press, ignore + if (keyboardEvent.key !== "Tab") { + return false; + } + + // the function that drives the flow of the program + driver(editor, keyboardEvent); + } + + /** + * Register all the required handlers + */ + function registerHandlers() { + // Get the current active editor and attach the change listener + const activeEditor = EditorManager.getActiveEditor(); + if (activeEditor) { + activeEditor.on("keydown", handleKeyEvent); + } + + // Listen for active editor changes, to attach the handler to new editor + EditorManager.on("activeEditorChange", function (event, newEditor, oldEditor) { + if (oldEditor) { + // Remove listener from old editor + oldEditor.off("keydown", handleKeyEvent); + } + if (newEditor) { + // Add listener to new editor + newEditor.off("change", handleKeyEvent); + newEditor.on("keydown", handleKeyEvent); + } + }); + } + + /** + * Checks for preference changes, to enable/disable the feature + */ + function preferenceChanged() { + const value = PreferencesManager.get(PREFERENCES_EMMET); + enabled = value; + } + + AppInit.appReady(function () { + // Set up preferences + PreferencesManager.on("change", PREFERENCES_EMMET, preferenceChanged); + preferenceChanged(); + + registerHandlers(); + }); +}); + + + + + diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js index 337e3f82f9..6b64df3bdb 100644 --- a/src/extensionsIntegrated/loader.js +++ b/src/extensionsIntegrated/loader.js @@ -43,4 +43,5 @@ define(function (require, exports, module) { require("./HtmlTagSyncEdit/main"); require("./indentGuides/main"); require("./CSSColorPreview/main"); + require("./Emmet/main"); }); From d4d7bda04486ac53b65cb8dcc182393b64b2e005 Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 6 Feb 2025 23:43:08 +0530 Subject: [PATCH 02/24] feat: display hints for emmet abbr that can be expanded --- src/extensionsIntegrated/Emmet/main.js | 141 +++++++++++-------------- 1 file changed, 62 insertions(+), 79 deletions(-) diff --git a/src/extensionsIntegrated/Emmet/main.js b/src/extensionsIntegrated/Emmet/main.js index 3f108aad9d..84143c54db 100644 --- a/src/extensionsIntegrated/Emmet/main.js +++ b/src/extensionsIntegrated/Emmet/main.js @@ -3,6 +3,7 @@ define(function (require, exports, module) { const EditorManager = require("editor/EditorManager"); const PreferencesManager = require("preferences/PreferencesManager"); const Strings = require("strings"); + const CodeHintManager = require("editor/CodeHintManager"); const EXPAND_ABBR = Phoenix.libs.Emmet.expand; // const EMMET = Phoenix.libs.Emmet.module; @@ -234,6 +235,64 @@ define(function (require, exports, module) { description: Strings.DESCRIPTION_EMMET }); + + /** + * @constructor + */ + function EmmetMarkupHints() { + } + + EmmetMarkupHints.prototype.hasHints = function (editor, implicitChar) { + + this.editor = editor; + + const wordObj = getWordBeforeCursor(editor); + const config = createConfig(editor); + if (config && config.syntax === "html") { + + // make sure we donot have empty spaces + if (wordObj.word.trim()) { + + const expandedAbbr = isExpandable(editor, wordObj.word, config); + if (expandedAbbr) { + return true; + } + } + } + + return false; + }; + + + EmmetMarkupHints.prototype.getHints = function (implicitChar) { + const wordObj = getWordBeforeCursor(this.editor); + const config = createConfig(this.editor); + + const expandedAbbr = isExpandable(this.editor, wordObj.word, config); + if (!expandedAbbr) { + return null; + } + + const result = [wordObj.word]; + + return { + hints: result, + match: null, + selectInitial: true, + defaultDescriptionWidth: true, + handleWideResults: false + }; + }; + + EmmetMarkupHints.prototype.insertHint = function (completion) { + const wordObj = getWordBeforeCursor(this.editor); + const config = createConfig(this.editor); + const expandedAbbr = isExpandable(this.editor, wordObj.word, config); + updateAbbrInEditor(this.editor, wordObj, expandedAbbr); + return false; + }; + + /** * Responsible to create the configuration based on the file type. * Config is an object with two properties, type & snytax. @@ -551,84 +610,6 @@ define(function (require, exports, module) { } - /** - * Responsible to handle the flow of the program - * - * @param {Editor} editor - the editor instance - * @param {Object} keyboardEvent - the keyboard event object - */ - function driver(editor, keyboardEvent) { - const config = createConfig(editor); - - if (config) { - - // to make sure it is an html file - if (config.syntax === "html") { - const wordObj = getWordBeforeCursor(editor); - - // make sure we donot have empty spaces - if (wordObj.word.trim()) { - - const expandedAbbr = isExpandable(editor, wordObj.word, config); - if (expandedAbbr) { - updateAbbrInEditor(editor, wordObj, expandedAbbr); - - // prevent the default working of the 'tab' key - keyboardEvent.preventDefault(); - } - } - } - } - } - - /** - * Function that gets triggered when any key is pressed. - * We only want to look for 'tab' key events - * - * @param {Event} event - unused event detail - * @param {Editor} editor - the editor instance - * @param {Object} keyboardEvent - an object that has properties related to the keyboard, - * mainly the key that is pressed (keyboardEvent.key) - * @returns {Boolean} True if abbreviation is expanded else false - */ - function handleKeyEvent(event, editor, keyboardEvent) { - if (!enabled) { - return false; - } - - // if not a 'tab' key press, ignore - if (keyboardEvent.key !== "Tab") { - return false; - } - - // the function that drives the flow of the program - driver(editor, keyboardEvent); - } - - /** - * Register all the required handlers - */ - function registerHandlers() { - // Get the current active editor and attach the change listener - const activeEditor = EditorManager.getActiveEditor(); - if (activeEditor) { - activeEditor.on("keydown", handleKeyEvent); - } - - // Listen for active editor changes, to attach the handler to new editor - EditorManager.on("activeEditorChange", function (event, newEditor, oldEditor) { - if (oldEditor) { - // Remove listener from old editor - oldEditor.off("keydown", handleKeyEvent); - } - if (newEditor) { - // Add listener to new editor - newEditor.off("change", handleKeyEvent); - newEditor.on("keydown", handleKeyEvent); - } - }); - } - /** * Checks for preference changes, to enable/disable the feature */ @@ -642,7 +623,9 @@ define(function (require, exports, module) { PreferencesManager.on("change", PREFERENCES_EMMET, preferenceChanged); preferenceChanged(); - registerHandlers(); + var emmetMarkupHints = new EmmetMarkupHints(); + CodeHintManager.registerHintProvider(emmetMarkupHints, ["html"], 2); + }); }); From 58004f1fa5ed6fb0647627b89e4c4d06d85685c4 Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 7 Feb 2025 13:19:49 +0530 Subject: [PATCH 03/24] fix: effective handling of closing braces in emmet abbr --- src/extensionsIntegrated/Emmet/main.js | 91 ++++++++++++++++++-------- 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/src/extensionsIntegrated/Emmet/main.js b/src/extensionsIntegrated/Emmet/main.js index 84143c54db..a0b9342802 100644 --- a/src/extensionsIntegrated/Emmet/main.js +++ b/src/extensionsIntegrated/Emmet/main.js @@ -1,6 +1,6 @@ define(function (require, exports, module) { const AppInit = require("utils/AppInit"); - const EditorManager = require("editor/EditorManager"); + // const EditorManager = require("editor/EditorManager"); const PreferencesManager = require("preferences/PreferencesManager"); const Strings = require("strings"); const CodeHintManager = require("editor/CodeHintManager"); @@ -294,8 +294,8 @@ define(function (require, exports, module) { /** - * Responsible to create the configuration based on the file type. - * Config is an object with two properties, type & snytax. + * Responsible to create the configuration based on the file type + * Config is an object with two properties, type & snytax * This is required by the Emmet API to distinguish between HTML & Stylesheets * * @param {Editor} editor - The editor instance @@ -316,53 +316,92 @@ define(function (require, exports, module) { } /** - * Responsible to get the current word before cursor + * Determines whether a given character is allowed as part of an Emmet abbreviation * - * @param {Editor} editor - The editor instance - * @returns {Object} an object in the format : - * { - * word: "", // the word before the cursor - * start: {line: Number, ch: Number}, - * end: {line: Number, ch: Number} - * } + * @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 getWordBeforeCursor(editor) { - const pos = editor.getCursorPos(); - const line = editor.document.getLine(pos.line); - let start = pos.ch; + 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; - // special chars that may be in emmet abbr - const specialChars = new Set(['.', '#', '[', ']', '"', '=', '//', ':', '-', ',']); - // If the cursor is right before '}', move it inside - if (line.charAt(start) === '}') { + // 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; } - // Look backwards + // 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 (/[a-zA-Z0-9:+*<>()/!$\-@#}{]/.test(char) || - specialChars.has(char) || - (insideBraces && char === ' ')) { + // 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: line.substring(start, pos.ch), - start: { line: pos.line, ch: start }, - end: pos + word: word, + start: { line: pos.line, ch: abbreviationStart }, + end: { line: pos.line, ch: abbreviationEnd } }; } From 046c08c9b9c5d096cfcf489ada36b39dd70be2ee Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 7 Feb 2025 13:25:26 +0530 Subject: [PATCH 04/24] chore: also support tag names when in uppercase --- src/extensionsIntegrated/Emmet/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensionsIntegrated/Emmet/main.js b/src/extensionsIntegrated/Emmet/main.js index a0b9342802..20d8768f47 100644 --- a/src/extensionsIntegrated/Emmet/main.js +++ b/src/extensionsIntegrated/Emmet/main.js @@ -612,7 +612,7 @@ define(function (require, exports, module) { // the word must be either in markupSnippetsList, htmlList or it must have a positive symbol if (markupSnippetsList.includes(word) || - htmlTags.includes(word) || + htmlTags.includes(word.toLowerCase()) || positiveSymbols.some(symbol => word.includes(symbol))) { try { From 0a689951b5099d7895e1daa6ed820e51ccea8ee0 Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 7 Feb 2025 13:35:42 +0530 Subject: [PATCH 05/24] fix: emmet preferences enable/disable not working --- src/extensionsIntegrated/Emmet/main.js | 24 ++++++++++++++---------- src/nls/root/strings.js | 3 +++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/extensionsIntegrated/Emmet/main.js b/src/extensionsIntegrated/Emmet/main.js index 20d8768f47..32844fe15e 100644 --- a/src/extensionsIntegrated/Emmet/main.js +++ b/src/extensionsIntegrated/Emmet/main.js @@ -243,19 +243,20 @@ define(function (require, exports, module) { } EmmetMarkupHints.prototype.hasHints = function (editor, implicitChar) { + if (enabled) { + this.editor = editor; - this.editor = editor; + const wordObj = getWordBeforeCursor(editor); + const config = createConfig(editor); + if (config && config.syntax === "html") { - const wordObj = getWordBeforeCursor(editor); - const config = createConfig(editor); - if (config && config.syntax === "html") { + // make sure we donot have empty spaces + if (wordObj.word.trim()) { - // make sure we donot have empty spaces - if (wordObj.word.trim()) { - - const expandedAbbr = isExpandable(editor, wordObj.word, config); - if (expandedAbbr) { - return true; + const expandedAbbr = isExpandable(editor, wordObj.word, config); + if (expandedAbbr) { + return true; + } } } } @@ -611,6 +612,9 @@ define(function (require, exports, module) { } // 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))) { 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", From 17cb3881736ccb89d5c74241b13a171b3259b1a2 Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 7 Feb 2025 23:38:02 +0530 Subject: [PATCH 06/24] feat: display icon at the side of code hints. Helps in identifying if hint is coming from emmet --- .../Emmet/emmet-snippets.js | 245 ++++++++++++++++ src/extensionsIntegrated/Emmet/main.js | 275 ++++-------------- src/styles/brackets_patterns_override.less | 12 + 3 files changed, 318 insertions(+), 214 deletions(-) create mode 100644 src/extensionsIntegrated/Emmet/emmet-snippets.js diff --git a/src/extensionsIntegrated/Emmet/emmet-snippets.js b/src/extensionsIntegrated/Emmet/emmet-snippets.js new file mode 100644 index 0000000000..0fc8fba4db --- /dev/null +++ b/src/extensionsIntegrated/Emmet/emmet-snippets.js @@ -0,0 +1,245 @@ +/* + * 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. + * + * 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 = [ + '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 EXPAND_ABBR = Phoenix.libs.Emmet.expand; + // const EMMET = Phoenix.libs.Emmet.module; /** * A list of all the markup snippets that can be expanded. @@ -180,52 +44,6 @@ define(function (require, exports, module) { */ const markupSnippetsList = Object.keys(markupSnippets); - /** - * A list of all the HTML tags that expand like normal tags - */ - 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" - ]; - - /** - * A list of all those symbols which if present in a word, that word can be expanded - */ - const positiveSymbols = [ - '.', // classes - '#', // ids - '!', // document generator - '>', // Child Selector - '+', // Adjacent Sibling Selector - '^', // Parent Selector - '*', // Multiplication (Repeat Element) - '[', - ']', // Attributes - '{', - '}', // Text Content - '(', - ')', // Group - '&' // Current Element Reference - ]; - - /** - * A list of all those symbols which if present in a word, that word cannot be expanded - */ - 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; + } + + EmmetMarkupHints.prototype.insertHint = function (completion) { const wordObj = getWordBeforeCursor(this.editor); const config = createConfig(this.editor); diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index eda14f4ef5..166cd46d8b 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -709,6 +709,18 @@ a:focus { } } +.emmet-code-hint { + position: absolute; + font-size: 0.5em; + font-weight: bold; + right: 4px; + bottom: 0px; + color: #2ea56c !important; + .dark& { + color: #146a41 !important; + } +} + #codehint-desc { background: @bc-codehint-desc; position: absolute; From 63d2662325b5a105676a0b8e68466dfed22f2918 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sat, 8 Feb 2025 00:13:09 +0530 Subject: [PATCH 07/24] chore: improve readability and add support for php and jsp --- src/extensionsIntegrated/Emmet/main.js | 27 ++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/extensionsIntegrated/Emmet/main.js b/src/extensionsIntegrated/Emmet/main.js index cec148ca92..f975ede1cf 100644 --- a/src/extensionsIntegrated/Emmet/main.js +++ b/src/extensionsIntegrated/Emmet/main.js @@ -31,16 +31,19 @@ define(function (require, exports, module) { negativeSymbols } = require('./emmet-snippets'); + /** * The Emmet api's */ const EXPAND_ABBR = Phoenix.libs.Emmet.expand; // const EMMET = Phoenix.libs.Emmet.module; + /** * A list of all the markup snippets that can be expanded. * For ex: 'link:css', 'iframe' * They expand differently as compared to normal tags. + * Refer to `./emmet-snippets.js` file for more info. */ const markupSnippetsList = Object.keys(markupSnippets); @@ -60,6 +63,14 @@ define(function (require, exports, module) { function EmmetMarkupHints() { } + + /** + * Checks whether hints are available for the current word where cursor is present. + * + * @param {Editor} editor - the editor instance + * @param {String} implicitChar - unused param [didn't remove, as we might need it in future] + * @returns {Boolean} - true if the abbr can be expanded otherwise false. + */ EmmetMarkupHints.prototype.hasHints = function (editor, implicitChar) { if (enabled) { this.editor = editor; @@ -85,7 +96,9 @@ define(function (require, exports, module) { /** * Returns the Emmet hint for the current word before the cursor. - * The hint element will have an appended "E" icon to indicate it's an Emmet abbreviation. + * The hint element will have an appended "Emmet" icon at bottom-rigth to indicate it's an Emmet abbreviation. + * + * @param {String} implicitChar - unused param [didn't remove, as we might need it in future] */ EmmetMarkupHints.prototype.getHints = function (implicitChar) { const wordObj = getWordBeforeCursor(this.editor); @@ -132,7 +145,12 @@ define(function (require, exports, module) { } - EmmetMarkupHints.prototype.insertHint = function (completion) { + /** + * 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 config = createConfig(this.editor); const expandedAbbr = isExpandable(this.editor, wordObj.word, config); @@ -152,7 +170,7 @@ define(function (require, exports, module) { function createConfig(editor) { const fileType = editor.document.getLanguage().getId(); - if (fileType === "html") { + if (fileType === "html" || fileType === "php" || fileType === "jsp") { return { syntax: "html", type: "markup" }; } @@ -163,6 +181,7 @@ define(function (require, exports, module) { return false; } + /** * Determines whether a given character is allowed as part of an Emmet abbreviation * @@ -514,7 +533,7 @@ define(function (require, exports, module) { preferenceChanged(); var emmetMarkupHints = new EmmetMarkupHints(); - CodeHintManager.registerHintProvider(emmetMarkupHints, ["html"], 2); + CodeHintManager.registerHintProvider(emmetMarkupHints, ["html", "php", "jsp"], 2); }); }); From 9f14cb34e63545230c173ba23e9a22651ba9cb66 Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 13 Feb 2025 03:18:40 +0530 Subject: [PATCH 08/24] feat: emmet integration for stylesheets --- src/extensions/default/CSSCodeHints/main.js | 78 ++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/src/extensions/default/CSSCodeHints/main.js b/src/extensions/default/CSSCodeHints/main.js index 9d7e0c13ba..db719b9138 100644 --- a/src/extensions/default/CSSCodeHints/main.js +++ b/src/extensions/default/CSSCodeHints/main.js @@ -38,6 +38,12 @@ define(function (require, exports, module) { 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; + require("./css-lint"); const BOOSTED_PROPERTIES = [ @@ -248,7 +254,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 @@ -377,8 +383,48 @@ define(function (require, exports, module) { } } + // pushedHints stores all the hints that will be displayed to the user + // up until this much is the normal working of css code hints + let pushedHints = formatHints(result); + + // 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('-')) { + 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]; + } + + 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(expandedAbbr); + } else { + pushedHints = expandedAbbr; + } + } + } + + + return { - hints: formatHints(result), + hints: pushedHints, match: null, // the CodeHintManager should not format the results selectInitial: selectInitial, handleWideResults: false @@ -387,6 +433,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; From c168331719bc5a7ef3d202b629d870e03ddc0c23 Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 13 Feb 2025 17:42:52 +0530 Subject: [PATCH 09/24] chore: add emmet icon at the side of emmet hint --- src/extensions/default/CSSCodeHints/main.js | 58 +++++++++++++-------- src/styles/brackets_patterns_override.less | 18 +++++++ 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/extensions/default/CSSCodeHints/main.js b/src/extensions/default/CSSCodeHints/main.js index db719b9138..7d438d703f 100644 --- a/src/extensions/default/CSSCodeHints/main.js +++ b/src/extensions/default/CSSCodeHints/main.js @@ -384,7 +384,6 @@ define(function (require, exports, module) { } // pushedHints stores all the hints that will be displayed to the user - // up until this much is the normal working of css code hints let pushedHints = formatHints(result); // needle gives the current word before cursor, make sure that it exists @@ -392,32 +391,47 @@ define(function (require, exports, module) { // `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('-')) { - 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]; - } - if(pushedHints) { + // 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]; + } + + // 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`); - // 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; + const $emmetHintObj = $(``).addClass("brackets-css-hints brackets-hints"); + $emmetHintObj.text(expandedAbbr); + $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; } - pushedHints.unshift(expandedAbbr); - } else { - pushedHints = expandedAbbr; } + } catch (e) { + // pass } } diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index eda14f4ef5..ac5af93c2e 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -709,6 +709,24 @@ a:focus { } } +.emmet-css-code-hint { + visibility: hidden; +} + +.highlight .emmet-css-code-hint { + visibility: visible; + position: absolute; + right: 8px; + font-size: 0.7em; + font-weight: bold; + margin-top: 2px; + letter-spacing: 0.3px; + color: #2ea56c !important; + .dark& { + color: #146a41 !important; + } +} + #codehint-desc { background: @bc-codehint-desc; position: absolute; From 9e7efdc0649e70126ff1c387ea4505a7a5fbdaf3 Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 13 Feb 2025 19:16:53 +0530 Subject: [PATCH 10/24] fix: add highlight to already typed characters for emmet hints --- src/extensions/default/CSSCodeHints/main.js | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/extensions/default/CSSCodeHints/main.js b/src/extensions/default/CSSCodeHints/main.js index 7d438d703f..61af2d435d 100644 --- a/src/extensions/default/CSSCodeHints/main.js +++ b/src/extensions/default/CSSCodeHints/main.js @@ -407,12 +407,35 @@ define(function (require, exports, module) { 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`); - const $emmetHintObj = $(``).addClass("brackets-css-hints brackets-hints"); - $emmetHintObj.text(expandedAbbr); + 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) { @@ -435,8 +458,6 @@ define(function (require, exports, module) { } } - - return { hints: pushedHints, match: null, // the CodeHintManager should not format the results From 10eaeb86dd6d046175b78c15e7ea9be36fba92de Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 13 Feb 2025 19:35:38 +0530 Subject: [PATCH 11/24] fix: css hints getting displayed even when the css property is fully specified --- src/extensions/default/CSSCodeHints/main.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/extensions/default/CSSCodeHints/main.js b/src/extensions/default/CSSCodeHints/main.js index 61af2d435d..431a332016 100644 --- a/src/extensions/default/CSSCodeHints/main.js +++ b/src/extensions/default/CSSCodeHints/main.js @@ -687,6 +687,12 @@ define(function (require, exports, module) { this.editor.setCursorPos(newCursor); } + // If the current line ends with a semicolon, the CSS property is fully specified, + // so we don't need to continue showing hints for its value. + if(this.editor.getLine(start.line).trim().endsWith(';')) { + keepHints = false; + } + return keepHints; }; From 5375e7cd6346435af2a870e0b60963d1c87dc347 Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 13 Feb 2025 20:06:46 +0530 Subject: [PATCH 12/24] fix: integ tests failing because emmet hints are now given max priority --- src/extensions/default/CSSCodeHints/unittests.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extensions/default/CSSCodeHints/unittests.js b/src/extensions/default/CSSCodeHints/unittests.js index 683440247c..a2d8ffd984 100644 --- a/src/extensions/default/CSSCodeHints/unittests.js +++ b/src/extensions/default/CSSCodeHints/unittests.js @@ -170,8 +170,8 @@ 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 all prop-name hints starting with 'bord' ", function () { @@ -459,7 +459,7 @@ 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 all prop-name hints starting with 'bord' for style value context", function () { From 3d159b5967ea9d8eeb9472e21bbe0611e44aab7b Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 14 Feb 2025 14:10:59 +0530 Subject: [PATCH 13/24] fix: check cursor position instead of line ending for keepHints --- src/extensions/default/CSSCodeHints/main.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/extensions/default/CSSCodeHints/main.js b/src/extensions/default/CSSCodeHints/main.js index 431a332016..2a54c3ce86 100644 --- a/src/extensions/default/CSSCodeHints/main.js +++ b/src/extensions/default/CSSCodeHints/main.js @@ -687,9 +687,11 @@ define(function (require, exports, module) { this.editor.setCursorPos(newCursor); } - // If the current line ends with a semicolon, the CSS property is fully specified, + // 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. - if(this.editor.getLine(start.line).trim().endsWith(';')) { + const cursorPos = this.editor.getCursorPos(); + if(this.editor.getCharacterAtPosition({line: cursorPos.line, ch: cursorPos.ch - 1}) === ';') { keepHints = false; } From 061d0cb1eec186e950a564121de9f147277f0a71 Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 14 Feb 2025 15:22:55 +0530 Subject: [PATCH 14/24] refactor: emmet icon is now a clickable link that redirects to MDN page for that property --- src/extensions/default/CSSCodeHints/main.js | 18 ++++++++++++++++-- src/styles/brackets_patterns_override.less | 12 +++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/extensions/default/CSSCodeHints/main.js b/src/extensions/default/CSSCodeHints/main.js index 2a54c3ce86..96b25c7732 100644 --- a/src/extensions/default/CSSCodeHints/main.js +++ b/src/extensions/default/CSSCodeHints/main.js @@ -66,6 +66,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 }); @@ -380,6 +387,7 @@ 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; } } @@ -413,7 +421,13 @@ define(function (require, exports, module) { // 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`); + 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") @@ -486,7 +500,7 @@ define(function (require, exports, module) { * 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. + * `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. diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index ac5af93c2e..9e0c1b320a 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -709,17 +709,19 @@ a:focus { } } + + .emmet-css-code-hint { visibility: hidden; } -.highlight .emmet-css-code-hint { +.codehint-menu .dropdown-menu li .highlight .emmet-css-code-hint { visibility: visible; position: absolute; - right: 8px; - font-size: 0.7em; - font-weight: bold; - margin-top: 2px; + right: 0; + margin-top: -2px; + font-size: 0.85em !important; + font-weight: @font-weight-semibold; letter-spacing: 0.3px; color: #2ea56c !important; .dark& { From 08947867a8ab59f622b08e2835b94ccdc5e63813 Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 14 Feb 2025 15:28:22 +0530 Subject: [PATCH 15/24] refactor: moved codehints icon colors to core_ui_variables file --- src/styles/brackets_core_ui_variables.less | 4 ++++ src/styles/brackets_patterns_override.less | 10 ++++------ 2 files changed, 8 insertions(+), 6 deletions(-) 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 9e0c1b320a..61ca971249 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -703,14 +703,12 @@ 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; } @@ -723,9 +721,9 @@ a:focus { font-size: 0.85em !important; font-weight: @font-weight-semibold; letter-spacing: 0.3px; - color: #2ea56c !important; + color: @css-codehint-icon !important; .dark& { - color: #146a41 !important; + color: @dark-css-codehint-icon !important; } } From 84bcacebff14c08d812f32eec8d264344eb5b358 Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 14 Feb 2025 21:02:58 +0530 Subject: [PATCH 16/24] feat: add allPreferences module and enable Emmet preference --- src/brackets.js | 1 + src/extensions/default/CSSCodeHints/main.js | 145 +++++++++++--------- src/nls/root/strings.js | 3 + src/preferences/AllPreferences.js | 50 +++++++ 4 files changed, 135 insertions(+), 64 deletions(-) create mode 100644 src/preferences/AllPreferences.js 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 96b25c7732..44a3d0d9a0 100644 --- a/src/extensions/default/CSSCodeHints/main.js +++ b/src/extensions/default/CSSCodeHints/main.js @@ -35,6 +35,7 @@ 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); @@ -43,6 +44,7 @@ define(function (require, exports, module) { * 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"); @@ -394,81 +396,85 @@ define(function (require, exports, module) { // pushedHints stores all the hints that will be displayed to the user let pushedHints = formatHints(result); - // 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]; - } + // 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]); + // 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`); + // 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); - } + // 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); - } + 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); + // add the emmet icon to the final hint object + $emmetHintObj.append($icon); - if(pushedHints) { + 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; + // 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; } - pushedHints.unshift($emmetHintObj); - } else { - pushedHints = $emmetHintObj; } + } catch (e) { + // pass } - } catch (e) { - // pass } } @@ -712,10 +718,21 @@ define(function (require, exports, module) { 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/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; +}); From 582dee6cff07c89b8649d30c1c7e031ebfbd89a4 Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 14 Feb 2025 22:09:25 +0530 Subject: [PATCH 17/24] chore: add unit-tests for emmet-stylesheets --- .../default/CSSCodeHints/unittests.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/extensions/default/CSSCodeHints/unittests.js b/src/extensions/default/CSSCodeHints/unittests.js index a2d8ffd984..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); @@ -174,6 +178,14 @@ define(function (require, exports, module) { // 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 () { // insert semicolon after previous rule to avoid incorrect tokenizing testDocument.replaceRange(";", { line: 6, ch: 2 }); @@ -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 }); @@ -462,6 +483,13 @@ define(function (require, exports, module) { 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 () { // insert semicolon after previous rule to avoid incorrect tokenizing testDocument.replaceRange(";", { line: 6, ch: 2 }); From 5ac6a145e044e17b919bfecbf4ded32b5f517494 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sat, 15 Feb 2025 16:58:56 +0530 Subject: [PATCH 18/24] chore: emmet-markup now use same emmet preference used by emmet-css --- src/extensionsIntegrated/Emmet/main.js | 24 +++++++++------------- src/styles/brackets_patterns_override.less | 1 + 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/extensionsIntegrated/Emmet/main.js b/src/extensionsIntegrated/Emmet/main.js index f975ede1cf..103a42b9b7 100644 --- a/src/extensionsIntegrated/Emmet/main.js +++ b/src/extensionsIntegrated/Emmet/main.js @@ -18,12 +18,14 @@ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. */ +/* Emmet for stylesheet is present inside `src/extensions/default/CSSCodeHints/main.js` */ define(function (require, exports, module) { const AppInit = require("utils/AppInit"); const PreferencesManager = require("preferences/PreferencesManager"); - const Strings = require("strings"); const CodeHintManager = require("editor/CodeHintManager"); + const AllPreferences = require("preferences/AllPreferences"); + const { markupSnippets, htmlTags, @@ -36,6 +38,8 @@ define(function (require, exports, module) { * The Emmet api's */ const EXPAND_ABBR = Phoenix.libs.Emmet.expand; + + // (leaving this for future, as we might need this when we extend the functionality of Emmet) // const EMMET = Phoenix.libs.Emmet.module; @@ -48,14 +52,7 @@ define(function (require, exports, module) { const markupSnippetsList = Object.keys(markupSnippets); - // For preferences settings, to toggle this feature on/off - const PREFERENCES_EMMET = "emmet"; - let enabled = true; // by default:- on - - PreferencesManager.definePreference(PREFERENCES_EMMET, "boolean", enabled, { - description: Strings.DESCRIPTION_EMMET - }); - + let enabled = true; // whether Emmet is enabled or not in preferences /** * @constructor @@ -520,16 +517,15 @@ define(function (require, exports, module) { /** - * Checks for preference changes, to enable/disable the feature + * Checks for preference changes, to enable/disable Emmet */ function preferenceChanged() { - const value = PreferencesManager.get(PREFERENCES_EMMET); - enabled = value; + enabled = PreferencesManager.get(AllPreferences.EMMET); } AppInit.appReady(function () { - // Set up preferences - PreferencesManager.on("change", PREFERENCES_EMMET, preferenceChanged); + + PreferencesManager.on("change", AllPreferences.EMMET, preferenceChanged); preferenceChanged(); var emmetMarkupHints = new EmmetMarkupHints(); diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index bc8203fde9..f6740d9dc1 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -725,6 +725,7 @@ a:focus { .dark& { color: @dark-css-codehint-icon !important; } +} .emmet-code-hint { position: absolute; From 28fd8a976ffa02a07986fdd7c4326fe32ed40b91 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sat, 15 Feb 2025 17:05:54 +0530 Subject: [PATCH 19/24] fix: made emmet-html icon look similar to emmet-css --- src/styles/brackets_patterns_override.less | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index f6740d9dc1..23e42dc6b9 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -729,13 +729,13 @@ a:focus { .emmet-code-hint { position: absolute; - font-size: 0.5em; - font-weight: bold; + font-size: 0.85em; + font-weight: @font-weight-semibold; right: 4px; bottom: 0px; - color: #2ea56c !important; + color: @css-codehint-icon !important; .dark& { - color: #146a41 !important; + color: @dark-css-codehint-icon !important; } } From f9c739fd9b7bfa4ca3cc115baab18497877d47f1 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sat, 15 Feb 2025 19:20:34 +0530 Subject: [PATCH 20/24] chore: add sourcing info for markupSnippets --- src/extensions/default/CSSCodeHints/unittests.js | 9 --------- src/extensionsIntegrated/Emmet/emmet-snippets.js | 2 ++ 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/extensions/default/CSSCodeHints/unittests.js b/src/extensions/default/CSSCodeHints/unittests.js index b6e411b7d2..82102d35d6 100644 --- a/src/extensions/default/CSSCodeHints/unittests.js +++ b/src/extensions/default/CSSCodeHints/unittests.js @@ -256,15 +256,6 @@ 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 }); diff --git a/src/extensionsIntegrated/Emmet/emmet-snippets.js b/src/extensionsIntegrated/Emmet/emmet-snippets.js index 0fc8fba4db..336f69d1a7 100644 --- a/src/extensionsIntegrated/Emmet/emmet-snippets.js +++ b/src/extensionsIntegrated/Emmet/emmet-snippets.js @@ -28,6 +28,8 @@ * 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. From 288f04d4a9756770874e28ade446ac2f01f88d40 Mon Sep 17 00:00:00 2001 From: Pluto Date: Sun, 16 Feb 2025 17:01:07 +0530 Subject: [PATCH 21/24] chore: moved emmet to core html codehints file --- .../default/HTMLCodeHints}/emmet-snippets.js | 0 src/extensions/default/HTMLCodeHints/main.js | 472 +++++++++++++++ src/extensionsIntegrated/Emmet/main.js | 540 ------------------ src/extensionsIntegrated/loader.js | 1 - 4 files changed, 472 insertions(+), 541 deletions(-) rename src/{extensionsIntegrated/Emmet => extensions/default/HTMLCodeHints}/emmet-snippets.js (100%) delete mode 100644 src/extensionsIntegrated/Emmet/main.js diff --git a/src/extensionsIntegrated/Emmet/emmet-snippets.js b/src/extensions/default/HTMLCodeHints/emmet-snippets.js similarity index 100% rename from src/extensionsIntegrated/Emmet/emmet-snippets.js rename to src/extensions/default/HTMLCodeHints/emmet-snippets.js diff --git a/src/extensions/default/HTMLCodeHints/main.js b/src/extensions/default/HTMLCodeHints/main.js index 2dc852f033..0a824dcc77 100644 --- a/src/extensions/default/HTMLCodeHints/main.js +++ b/src/extensions/default/HTMLCodeHints/main.js @@ -33,6 +33,7 @@ define(function (require, exports, module) { CSSUtils = brackets.getModule("language/CSSUtils"), StringMatch = brackets.getModule("utils/StringMatch"), LiveDevelopment = brackets.getModule("LiveDevelopment/main"), + AllPreferences = brackets.getModule("preferences/AllPreferences"), KeyEvent = brackets.getModule("utils/KeyEvent"), Metrics = brackets.getModule("utils/Metrics"), HTMLTags = require("text!HtmlTags.json"), @@ -42,6 +43,28 @@ define(function (require, exports, module) { require("./html-lint"); + const { + markupSnippets, + htmlTags, + positiveSymbols, + negativeSymbols + } = require('./emmet-snippets'); + + /** + * The Emmet api's + */ + const EXPAND_ABBR = Phoenix.libs.Emmet.expand; + + /** + * A list of all the markup snippets that can be expanded. + * For ex: 'link:css', 'iframe' + * They expand differently as compared to normal tags. + * Refer to `./emmet-snippets.js` file for more info. + */ + const markupSnippetsList = Object.keys(markupSnippets); + let enabled = true; // whether Emmet is enabled or not in preferences + + let tags, attributes; @@ -53,6 +76,441 @@ define(function (require, exports, module) { description: Strings.DESCRIPTION_ATTR_HINTS }); + /** + * @constructor + */ + function EmmetMarkupHints() { + } + + + /** + * Checks whether hints are available for the current word where cursor is present. + * + * @param {Editor} editor - the editor instance + * @param {String} implicitChar - unused param [didn't remove, as we might need it in future] + * @returns {Boolean} - true if the abbr can be expanded otherwise false. + */ + EmmetMarkupHints.prototype.hasHints = function (editor, implicitChar) { + if (enabled) { + this.editor = editor; + + const wordObj = getWordBeforeCursor(editor); + // make sure we donot have empty spaces + if (wordObj.word.trim()) { + + const expandedAbbr = isExpandable(editor, wordObj.word); + if (expandedAbbr) { + return true; + } + } + } + + return false; + }; + + + /** + * Returns the Emmet hint for the current word before the cursor. + * The hint element will have an appended "Emmet" icon at bottom-rigth to indicate it's an Emmet abbreviation. + * + * @param {String} implicitChar - unused param [didn't remove, as we might need it in future] + */ + EmmetMarkupHints.prototype.getHints = function (implicitChar) { + const wordObj = getWordBeforeCursor(this.editor); + + // Check if the abbreviation is expandable + const expandedAbbr = isExpandable(this.editor, wordObj.word); + if (!expandedAbbr) { + return null; + } + + // Create the formatted hint element with an appended Emmet icon + const formattedHint = formatEmmetHint(wordObj.word); + + return { + hints: [formattedHint], + match: null, + selectInitial: true, + defaultDescriptionWidth: true, + handleWideResults: false + }; + }; + + + /** + * Formats an Emmet abbreviation hint by appending an icon. + * + * @param {string} abbr - The Emmet abbreviation. + * @returns {jQuery} - A jQuery element representing the formatted hint. + */ + function formatEmmetHint(abbr) { + // Create the main container for the hint text. + var $hint = $("") + .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) { + // to prevent hints from appearing in line. Also to prevent hints from appearing in comments + if(editor.getLine(editor.getCursorPos().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 pos = editor.getCursorPos(); + const line = editor.document.getLine(pos.line); + 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 +1191,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,6 +1212,12 @@ 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.tagHintProvider = tagHints; exports.attrHintProvider = attrHints; diff --git a/src/extensionsIntegrated/Emmet/main.js b/src/extensionsIntegrated/Emmet/main.js deleted file mode 100644 index 103a42b9b7..0000000000 --- a/src/extensionsIntegrated/Emmet/main.js +++ /dev/null @@ -1,540 +0,0 @@ -/* - * 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 for stylesheet is present inside `src/extensions/default/CSSCodeHints/main.js` */ - -define(function (require, exports, module) { - const AppInit = require("utils/AppInit"); - const PreferencesManager = require("preferences/PreferencesManager"); - const CodeHintManager = require("editor/CodeHintManager"); - const AllPreferences = require("preferences/AllPreferences"); - - const { - markupSnippets, - htmlTags, - positiveSymbols, - negativeSymbols - } = require('./emmet-snippets'); - - - /** - * The Emmet api's - */ - const EXPAND_ABBR = Phoenix.libs.Emmet.expand; - - // (leaving this for future, as we might need this when we extend the functionality of Emmet) - // const EMMET = Phoenix.libs.Emmet.module; - - - /** - * A list of all the markup snippets that can be expanded. - * For ex: 'link:css', 'iframe' - * They expand differently as compared to normal tags. - * Refer to `./emmet-snippets.js` file for more info. - */ - const markupSnippetsList = Object.keys(markupSnippets); - - - let enabled = true; // whether Emmet is enabled or not in preferences - - /** - * @constructor - */ - function EmmetMarkupHints() { - } - - - /** - * Checks whether hints are available for the current word where cursor is present. - * - * @param {Editor} editor - the editor instance - * @param {String} implicitChar - unused param [didn't remove, as we might need it in future] - * @returns {Boolean} - true if the abbr can be expanded otherwise false. - */ - EmmetMarkupHints.prototype.hasHints = function (editor, implicitChar) { - if (enabled) { - this.editor = editor; - - const wordObj = getWordBeforeCursor(editor); - const config = createConfig(editor); - if (config && config.syntax === "html") { - - // make sure we donot have empty spaces - if (wordObj.word.trim()) { - - const expandedAbbr = isExpandable(editor, wordObj.word, config); - if (expandedAbbr) { - return true; - } - } - } - } - - return false; - }; - - - /** - * Returns the Emmet hint for the current word before the cursor. - * The hint element will have an appended "Emmet" icon at bottom-rigth to indicate it's an Emmet abbreviation. - * - * @param {String} implicitChar - unused param [didn't remove, as we might need it in future] - */ - EmmetMarkupHints.prototype.getHints = function (implicitChar) { - const wordObj = getWordBeforeCursor(this.editor); - const config = createConfig(this.editor); - - // Check if the abbreviation is expandable - const expandedAbbr = isExpandable(this.editor, wordObj.word, config); - if (!expandedAbbr) { - return null; - } - - // Create the formatted hint element with an appended Emmet icon - const formattedHint = formatEmmetHint(wordObj.word); - - return { - hints: [formattedHint], - match: null, - selectInitial: true, - defaultDescriptionWidth: true, - handleWideResults: false - }; - }; - - - /** - * Formats an Emmet abbreviation hint by appending an icon. - * - * @param {string} abbr - The Emmet abbreviation. - * @returns {jQuery} - A jQuery element representing the formatted hint. - */ - function formatEmmetHint(abbr) { - // Create the main container for the hint text. - var $hint = $("") - .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 config = createConfig(this.editor); - const expandedAbbr = isExpandable(this.editor, wordObj.word, config); - updateAbbrInEditor(this.editor, wordObj, expandedAbbr); - return false; - }; - - - /** - * Responsible to create the configuration based on the file type - * Config is an object with two properties, type & snytax - * This is required by the Emmet API to distinguish between HTML & Stylesheets - * - * @param {Editor} editor - The editor instance - * @returns {Object | False} Object with two properties 'syntax' and 'type' - */ - function createConfig(editor) { - const fileType = editor.document.getLanguage().getId(); - - if (fileType === "html" || fileType === "php" || fileType === "jsp") { - return { syntax: "html", type: "markup" }; - } - - if (fileType === "css" || fileType === "scss" || fileType === "less") { - return { syntax: "css", type: "stylesheet" }; - } - - 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 - * @param {Object} config - the config object, to make sure it is a valid file type, - * refer to createConfig function for more info about config object. - * @returns {String | false} - returns the expanded abbr, and if cannot be expanded, returns false - */ - function isExpandable(editor, word, config) { - - // make sure that word doesn't contain any negativeSymbols - if (negativeSymbols.some(symbol => 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, config); - return expanded; - } catch (error) { - - // emmet api throws an error when abbr contains unclosed quotes, handling that case - const pos = editor.getCursorPos(); - const line = editor.document.getLine(pos.line); - 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, config); - 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; - } - - - /** - * Checks for preference changes, to enable/disable Emmet - */ - function preferenceChanged() { - enabled = PreferencesManager.get(AllPreferences.EMMET); - } - - AppInit.appReady(function () { - - PreferencesManager.on("change", AllPreferences.EMMET, preferenceChanged); - preferenceChanged(); - - var emmetMarkupHints = new EmmetMarkupHints(); - CodeHintManager.registerHintProvider(emmetMarkupHints, ["html", "php", "jsp"], 2); - - }); -}); - - - - - diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js index 6b64df3bdb..337e3f82f9 100644 --- a/src/extensionsIntegrated/loader.js +++ b/src/extensionsIntegrated/loader.js @@ -43,5 +43,4 @@ define(function (require, exports, module) { require("./HtmlTagSyncEdit/main"); require("./indentGuides/main"); require("./CSSColorPreview/main"); - require("./Emmet/main"); }); From edbadb2d32cfb77498350bc59b78f78c557379ab Mon Sep 17 00:00:00 2001 From: Pluto Date: Mon, 17 Feb 2025 00:55:40 +0530 Subject: [PATCH 22/24] feat: add unit tests for html emmet hints --- src/extensions/default/HTMLCodeHints/main.js | 1 + .../default/HTMLCodeHints/unittests.js | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/extensions/default/HTMLCodeHints/main.js b/src/extensions/default/HTMLCodeHints/main.js index 0a824dcc77..5066bbfaae 100644 --- a/src/extensions/default/HTMLCodeHints/main.js +++ b/src/extensions/default/HTMLCodeHints/main.js @@ -1219,6 +1219,7 @@ define(function (require, exports, module) { 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..a94ef98de3 100644 --- a/src/extensions/default/HTMLCodeHints/unittests.js +++ b/src/extensions/default/HTMLCodeHints/unittests.js @@ -94,6 +94,74 @@ 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("Emmet hint provider", function () { + + it("should display boiler plate code on ! press", function () { + + let emmetBoilerPlate = "\n" + + "\n" + + "\n" + + " \n" + + " \n" + + " Document\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n"; + + testDocument.setText("!"); + testEditor.setCursorPos({ line: 0, ch: 1 }); + let emmetHintList = expectHints(HTMLCodeHints.emmetHintProvider); + verifyTagHints(emmetHintList, emmetBoilerPlate); + }); + + it("should display doctype html initial line on !!! press", function () { + + let emmetBoilerPlate = ""; + + testDocument.setText("!!!"); + testEditor.setCursorPos({ line: 0, ch: 3 }); + let emmetHintList = expectHints(HTMLCodeHints.emmetHintProvider); + verifyTagHints(emmetHintList, emmetBoilerPlate); + }); + + 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 () { + + console.log('--------------------------'); + console.log("reached here"); + console.log('--------------------------'); + + testDocument.setText("div.hello#world"); + testEditor.setCursorPos({ line: 0, ch: 15 }); + let emmetHintList = expectHints(HTMLCodeHints.emmetHintProvider); + verifyTagHints(emmetHintList, "
"); + }); + + it(". should expand to a div with empty class name and set cursor in between quotes", function() { + testDocument.setText("."); + testEditor.setCursorPos({ line: 0, ch: 0 }); + var hints = expectHints(HTMLCodeHints.emmetHintProvider); + HTMLCodeHints.emmetHintProvider.insertHint(hints[0]); + expect(fixPos(testEditor.getCursorPos())).toEql(fixPos({line: 0, ch: 12})); + }); + }); + describe("Tag hint provider", function () { From 2affd39d2523d9a359ac39256889056e8a183094 Mon Sep 17 00:00:00 2001 From: Pluto Date: Mon, 17 Feb 2025 23:21:09 +0530 Subject: [PATCH 23/24] feat: unit tests for html-emmet --- src/extensions/default/HTMLCodeHints/main.js | 17 +- .../default/HTMLCodeHints/unittests.js | 182 ++++++++++++------ test/SpecRunner.js | 1 + 3 files changed, 137 insertions(+), 63 deletions(-) diff --git a/src/extensions/default/HTMLCodeHints/main.js b/src/extensions/default/HTMLCodeHints/main.js index 5066bbfaae..4fe821e5e1 100644 --- a/src/extensions/default/HTMLCodeHints/main.js +++ b/src/extensions/default/HTMLCodeHints/main.js @@ -459,8 +459,21 @@ define(function (require, exports, module) { * @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(editor.getLine(editor.getCursorPos().line).includes('\n" + - "\n" + - "\n" + - " \n" + - " \n" + - " Document\n" + - "\n" + - "\n" + - "\n" + - "\n" + - "\n"; - - testDocument.setText("!"); - testEditor.setCursorPos({ line: 0, ch: 1 }); - let emmetHintList = expectHints(HTMLCodeHints.emmetHintProvider); - verifyTagHints(emmetHintList, emmetBoilerPlate); - }); - - it("should display doctype html initial line on !!! press", function () { - - let emmetBoilerPlate = ""; - - testDocument.setText("!!!"); - testEditor.setCursorPos({ line: 0, ch: 3 }); - let emmetHintList = expectHints(HTMLCodeHints.emmetHintProvider); - verifyTagHints(emmetHintList, emmetBoilerPlate); - }); - - 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 () { - - console.log('--------------------------'); - console.log("reached here"); - console.log('--------------------------'); - - testDocument.setText("div.hello#world"); - testEditor.setCursorPos({ line: 0, ch: 15 }); - let emmetHintList = expectHints(HTMLCodeHints.emmetHintProvider); - verifyTagHints(emmetHintList, "
"); - }); - - it(". should expand to a div with empty class name and set cursor in between quotes", function() { - testDocument.setText("."); - testEditor.setCursorPos({ line: 0, ch: 0 }); - var hints = expectHints(HTMLCodeHints.emmetHintProvider); - HTMLCodeHints.emmetHintProvider.insertHint(hints[0]); - expect(fixPos(testEditor.getCursorPos())).toEql(fixPos({line: 0, ch: 12})); - }); - }); - - describe("Tag hint provider", function () { it("should not hint within