├── .editorconfig ├── .gitignore ├── .npmignore ├── README.md ├── example └── emmet-expand.gif ├── favicon.svg ├── index.html ├── package-lock.json ├── package.json ├── src ├── commands │ ├── balance.ts │ ├── comment.ts │ ├── evaluate-math.ts │ ├── expand.ts │ ├── go-to-edit-point.ts │ ├── go-to-tag-pair.ts │ ├── inc-dec-number.ts │ ├── remove-tag.ts │ ├── select-item.ts │ ├── split-join-tag.ts │ └── wrap-with-abbreviation.ts ├── completion-icon.svg ├── lib │ ├── config.ts │ ├── context.ts │ ├── emmet.ts │ ├── output.ts │ ├── syntax.ts │ ├── types.ts │ └── utils.ts ├── main.ts ├── plugin.ts ├── tracker │ ├── AbbreviationPreviewWidget.ts │ └── index.ts └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{json,yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [{snippets/*.json,**.html}] 16 | indent_style = tab 17 | indent_size = 4 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | /dist 39 | .DS_Store 40 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | npm-debug.log* 2 | node_modules 3 | jspm_packages 4 | .npm 5 | /.* 6 | /*.* 7 | /test 8 | /lib 9 | !/example.html 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Emmet extension for CodeMirror 6 editor 2 | 3 | [CodeMirror 6](http://codemirror.net/) extension that adds [Emmet](https://emmet.io) support to text editor. 4 | 5 | > Extension development is sponsored by [CodePen](https://codepen.io) and [Replit](https://replit.com) 6 | --- 7 | 8 | ## How to use 9 | 10 | This extension can be installed as a regular npm module: 11 | 12 | ``` 13 | npm i @emmetio/codemirror6-plugin 14 | ``` 15 | 16 | The plugin API follows CodeMirror 6 design: it’s an ES6 module and provides a number of exported [extensions](https://codemirror.net/6/docs/guide/#extending-codemirror) which you should import and add into your `EditorState` instance. 17 | 18 | In most cases, this package exports [Emmet actions](https://docs.emmet.io/actions/) as [`StateCommand`](https://codemirror.net/6/docs/ref/#state.StateCommand), which should be used as follows: 19 | 20 | ```js 21 | import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'; 22 | import { html } from '@codemirror/lang-html'; 23 | import { keymap } from '@codemirror/view'; 24 | 25 | // Import Expand Abbreviation command 26 | import { expandAbbreviation } from '@emmetio/codemirror6-plugin'; 27 | 28 | new EditorView({ 29 | state: EditorState.create({ 30 | extensions: [ 31 | basicSetup, 32 | html(), 33 | 34 | // Bind Expand Abbreviation command to keyboard shortcut 35 | keymap.of([{ 36 | key: 'Cmd-e', 37 | run: expandAbbreviation 38 | }]), 39 | ] 40 | }), 41 | parent: document.body 42 | }); 43 | ``` 44 | 45 | ## Expanding abbreviations 46 | 47 | Emmet extension can _track abbreviations_ that user enters in some known syntaxes like HTML and CSS. When user enters something that looks like Emmet abbreviation, extension starts abbreviation tracking (adds `emmet-abbreviation` class to a text fragment). WHen abbreviation becomes _complex_ (expands to more that one element), it displays abbreviation preview: 48 | 49 | ![Emmet abbreviation example](./example/emmet-expand.gif) 50 | 51 | To enable abbreviation tracker, you should import `abbreviationTracker` function and add its result to editor extensions: 52 | 53 | ```js 54 | import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'; 55 | import { html } from '@codemirror/lang-html'; 56 | import { abbreviationTracker } from '@emmetio/codemirror6-plugin'; 57 | 58 | new EditorView({ 59 | state: EditorState.create({ 60 | extensions: [ 61 | basicSetup, 62 | html(), 63 | abbreviationTracker() 64 | ] 65 | }), 66 | parent: document.body 67 | }); 68 | ``` 69 | 70 | Abbreviation tracker is _context-aware_: it detect current syntax context and works only where abbreviation expected. For example, in HTML syntax it works in plain text context only and doesn’t work, for example, in attribute value or tag name. 71 | 72 | To expand tracked abbreviation, hit Tab key while caret is inside abbreviation, or hit Esc key to reset tracker. 73 | 74 | ### Abbreviation mode 75 | 76 | In case if abbreviation tracking is unavailable or you want to give user an opportunity to enter and expand abbreviation with interactive preview, a special _abbreviation mode_ is available. Run `enterAbbreviationMode` command to enter this mode: everything user types will be tracked as abbreviation with preview and validation. To expand tracked abbreviation, hit Tab key while caret is inside abbreviation, or hit Esc key to reset tracker. 77 | 78 | ### Notes on document syntax 79 | 80 | Currently, CodeMirror API doesn’t provide viable option to get document syntax to allow plugins to understand how to work with document. So you have to specify document syntax manually in Emmet plugin. You can do so via `emmetConfig` facet or as an option to `abbreviationTracker`: 81 | 82 | ```js 83 | import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'; 84 | import { html } from '@codemirror/lang-html'; 85 | import { keymap } from '@codemirror/view'; 86 | 87 | import { emmetConfig, abbreviationTracker } from '@emmetio/codemirror6-plugin'; 88 | 89 | const editor1 = new EditorView({ 90 | state: EditorState.create({ 91 | extensions: [ 92 | basicSetup, 93 | html(), 94 | // Option 1: specify document syntax as config facet 95 | emmetConfig.of({ 96 | syntax: 'css' 97 | }), 98 | // Option 2: pass syntax as config value of abbreviation tracker 99 | abbreviationTracker({ 100 | syntax: 'jsx' 101 | }) 102 | ... 103 | ] 104 | }), 105 | parent: document.body 106 | }); 107 | ``` 108 | 109 | Note that syntax is most important option Emmet: it controls how abbreviation is parsed in document (wether it’s markup, stylesheet or JSX syntax) and style of generated output (HTML or XML style, CSS or SASS dialect and so on). 110 | 111 | Some common syntaxes: 112 | * `html`, `xml`: HTML or XML document; in `html` also tries to detect inline CSS fragments. 113 | * `jsx` for JSX syntax. By default, requires abbreviation to start with `<` in order to skip false-positive abbreviation capturing for variables and functions, also modifies output to match JSX specs (e.g. rename `class` attribute to `className` etc.) 114 | * `css`, `scss`, `sass`, `stylus`: various options of stylesheet abbreviations and output. 115 | * `haml`, `jade`, `pug`, `slim`: supported markup preprocessors. 116 | 117 | Default syntax is `html`. 118 | 119 | ## Available commands 120 | 121 | The following commands are available in current package: 122 | 123 | * `expandAbbreviation` – expand abbreviation left to current caret position. Unlike abbreviation tracker, this command works anywhere and doesn’t respect current context. 124 | * `enterAbbreviationMode` – enters [abbreviation mode](#abbreviation_mode). 125 | * `wrapWithAbbreviation` — [Wrap with Abbreviation](https://docs.emmet.io/actions/wrap-with-abbreviation/). Note that this is not a StateCommand but a function that you should call and pass returned result as extension. You can optionally pass keyboard shortcut as argument (Ctrl-w by default). 126 | * `balanceOutward`/`balanceInward` — [Balance](https://docs.emmet.io/actions/match-pair/). 127 | * `toggleComment` — [Toggle Comment](https://docs.emmet.io/actions/toggle-comment/) 128 | * `evaluateMath` — [Evaluate Math Expression](https://docs.emmet.io/actions/evaluate-math/) 129 | * `goToNextEditPoint`/`goToPreviousEditPoint` — [Go to Edit Point](https://docs.emmet.io/actions/go-to-edit-point/) 130 | * `goToTagPair` — [Go to Matching Pair](https://docs.emmet.io/actions/go-to-pair/) 131 | * `incrementNumberN`/`decrementNumberN` — [Increment/Decrement Number](https://docs.emmet.io/actions/inc-dec-number/) where `N` is `1`, `01` or `10`. 132 | * `removeTag` — [Remove Tag](https://docs.emmet.io/actions/remove-tag/) 133 | * `splitJoinTag` — [Split/Join Tag](https://docs.emmet.io/actions/split-join-tag/) 134 | * `selectNextItem`/`selectPreviousItem` — [Select Item](https://docs.emmet.io/actions/select-item/) 135 | 136 | -------------------------------------------------------------------------------- /example/emmet-expand.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emmetio/codemirror6-plugin/8e94ed99d1fcb95b422d81f6a6b20b49e4bc2809/example/emmet-expand.gif -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Emmet for CodeMirror6 demo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@emmetio/codemirror6-plugin", 3 | "version": "0.4.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@emmetio/codemirror6-plugin", 9 | "version": "0.4.0", 10 | "dependencies": { 11 | "@emmetio/math-expression": "^1.0.5", 12 | "emmet": "^2.4.11" 13 | }, 14 | "devDependencies": { 15 | "@lezer/common": "^1.2.1", 16 | "codemirror": "^6.0.1", 17 | "typescript": "^5.5.4", 18 | "vite": "^5.3.5" 19 | }, 20 | "peerDependencies": { 21 | "@codemirror/autocomplete": "^6.17.0", 22 | "@codemirror/commands": "^6.6.0", 23 | "@codemirror/lang-css": "^6.2.1", 24 | "@codemirror/lang-html": "^6.4.9", 25 | "@codemirror/language": "^6.10.2", 26 | "@codemirror/state": "^6.4.1", 27 | "@codemirror/view": "^6.29.1" 28 | } 29 | }, 30 | "node_modules/@codemirror/autocomplete": { 31 | "version": "6.17.0", 32 | "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.17.0.tgz", 33 | "integrity": "sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==", 34 | "dependencies": { 35 | "@codemirror/language": "^6.0.0", 36 | "@codemirror/state": "^6.0.0", 37 | "@codemirror/view": "^6.17.0", 38 | "@lezer/common": "^1.0.0" 39 | }, 40 | "peerDependencies": { 41 | "@codemirror/language": "^6.0.0", 42 | "@codemirror/state": "^6.0.0", 43 | "@codemirror/view": "^6.0.0", 44 | "@lezer/common": "^1.0.0" 45 | } 46 | }, 47 | "node_modules/@codemirror/commands": { 48 | "version": "6.6.0", 49 | "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.0.tgz", 50 | "integrity": "sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==", 51 | "dependencies": { 52 | "@codemirror/language": "^6.0.0", 53 | "@codemirror/state": "^6.4.0", 54 | "@codemirror/view": "^6.27.0", 55 | "@lezer/common": "^1.1.0" 56 | } 57 | }, 58 | "node_modules/@codemirror/lang-css": { 59 | "version": "6.2.1", 60 | "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.2.1.tgz", 61 | "integrity": "sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==", 62 | "peer": true, 63 | "dependencies": { 64 | "@codemirror/autocomplete": "^6.0.0", 65 | "@codemirror/language": "^6.0.0", 66 | "@codemirror/state": "^6.0.0", 67 | "@lezer/common": "^1.0.2", 68 | "@lezer/css": "^1.0.0" 69 | } 70 | }, 71 | "node_modules/@codemirror/lang-html": { 72 | "version": "6.4.9", 73 | "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", 74 | "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", 75 | "peer": true, 76 | "dependencies": { 77 | "@codemirror/autocomplete": "^6.0.0", 78 | "@codemirror/lang-css": "^6.0.0", 79 | "@codemirror/lang-javascript": "^6.0.0", 80 | "@codemirror/language": "^6.4.0", 81 | "@codemirror/state": "^6.0.0", 82 | "@codemirror/view": "^6.17.0", 83 | "@lezer/common": "^1.0.0", 84 | "@lezer/css": "^1.1.0", 85 | "@lezer/html": "^1.3.0" 86 | } 87 | }, 88 | "node_modules/@codemirror/lang-javascript": { 89 | "version": "6.2.2", 90 | "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", 91 | "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", 92 | "peer": true, 93 | "dependencies": { 94 | "@codemirror/autocomplete": "^6.0.0", 95 | "@codemirror/language": "^6.6.0", 96 | "@codemirror/lint": "^6.0.0", 97 | "@codemirror/state": "^6.0.0", 98 | "@codemirror/view": "^6.17.0", 99 | "@lezer/common": "^1.0.0", 100 | "@lezer/javascript": "^1.0.0" 101 | } 102 | }, 103 | "node_modules/@codemirror/language": { 104 | "version": "6.10.2", 105 | "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.2.tgz", 106 | "integrity": "sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==", 107 | "dependencies": { 108 | "@codemirror/state": "^6.0.0", 109 | "@codemirror/view": "^6.23.0", 110 | "@lezer/common": "^1.1.0", 111 | "@lezer/highlight": "^1.0.0", 112 | "@lezer/lr": "^1.0.0", 113 | "style-mod": "^4.0.0" 114 | } 115 | }, 116 | "node_modules/@codemirror/lint": { 117 | "version": "6.8.1", 118 | "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.1.tgz", 119 | "integrity": "sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==", 120 | "dependencies": { 121 | "@codemirror/state": "^6.0.0", 122 | "@codemirror/view": "^6.0.0", 123 | "crelt": "^1.0.5" 124 | } 125 | }, 126 | "node_modules/@codemirror/search": { 127 | "version": "6.5.6", 128 | "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", 129 | "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", 130 | "dev": true, 131 | "dependencies": { 132 | "@codemirror/state": "^6.0.0", 133 | "@codemirror/view": "^6.0.0", 134 | "crelt": "^1.0.5" 135 | } 136 | }, 137 | "node_modules/@codemirror/state": { 138 | "version": "6.4.1", 139 | "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", 140 | "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" 141 | }, 142 | "node_modules/@codemirror/view": { 143 | "version": "6.29.1", 144 | "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.29.1.tgz", 145 | "integrity": "sha512-7r+DlO/QFwPqKp73uq5mmrS4TuLPUVotbNOKYzN3OLP5ScrOVXcm4g13/48b6ZXGhdmzMinzFYqH0vo+qihIkQ==", 146 | "dependencies": { 147 | "@codemirror/state": "^6.4.0", 148 | "style-mod": "^4.1.0", 149 | "w3c-keyname": "^2.2.4" 150 | } 151 | }, 152 | "node_modules/@emmetio/abbreviation": { 153 | "version": "2.3.3", 154 | "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", 155 | "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", 156 | "dependencies": { 157 | "@emmetio/scanner": "^1.0.4" 158 | } 159 | }, 160 | "node_modules/@emmetio/css-abbreviation": { 161 | "version": "2.1.8", 162 | "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", 163 | "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", 164 | "dependencies": { 165 | "@emmetio/scanner": "^1.0.4" 166 | } 167 | }, 168 | "node_modules/@emmetio/math-expression": { 169 | "version": "1.0.5", 170 | "resolved": "https://registry.npmjs.org/@emmetio/math-expression/-/math-expression-1.0.5.tgz", 171 | "integrity": "sha512-qf5SXD/ViS04rXSeDg9CRGM10xLC9dVaKIbMHrrwxYr5LNB/C0rOfokhGSBwnVQKcidLmdRJeNWH1V1tppZ84Q==", 172 | "dependencies": { 173 | "@emmetio/scanner": "^1.0.4" 174 | } 175 | }, 176 | "node_modules/@emmetio/scanner": { 177 | "version": "1.0.4", 178 | "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", 179 | "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==" 180 | }, 181 | "node_modules/@esbuild/aix-ppc64": { 182 | "version": "0.21.5", 183 | "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", 184 | "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", 185 | "cpu": [ 186 | "ppc64" 187 | ], 188 | "dev": true, 189 | "optional": true, 190 | "os": [ 191 | "aix" 192 | ], 193 | "engines": { 194 | "node": ">=12" 195 | } 196 | }, 197 | "node_modules/@esbuild/android-arm": { 198 | "version": "0.21.5", 199 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", 200 | "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", 201 | "cpu": [ 202 | "arm" 203 | ], 204 | "dev": true, 205 | "optional": true, 206 | "os": [ 207 | "android" 208 | ], 209 | "engines": { 210 | "node": ">=12" 211 | } 212 | }, 213 | "node_modules/@esbuild/android-arm64": { 214 | "version": "0.21.5", 215 | "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", 216 | "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", 217 | "cpu": [ 218 | "arm64" 219 | ], 220 | "dev": true, 221 | "optional": true, 222 | "os": [ 223 | "android" 224 | ], 225 | "engines": { 226 | "node": ">=12" 227 | } 228 | }, 229 | "node_modules/@esbuild/android-x64": { 230 | "version": "0.21.5", 231 | "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", 232 | "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", 233 | "cpu": [ 234 | "x64" 235 | ], 236 | "dev": true, 237 | "optional": true, 238 | "os": [ 239 | "android" 240 | ], 241 | "engines": { 242 | "node": ">=12" 243 | } 244 | }, 245 | "node_modules/@esbuild/darwin-arm64": { 246 | "version": "0.21.5", 247 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", 248 | "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", 249 | "cpu": [ 250 | "arm64" 251 | ], 252 | "dev": true, 253 | "optional": true, 254 | "os": [ 255 | "darwin" 256 | ], 257 | "engines": { 258 | "node": ">=12" 259 | } 260 | }, 261 | "node_modules/@esbuild/darwin-x64": { 262 | "version": "0.21.5", 263 | "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", 264 | "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", 265 | "cpu": [ 266 | "x64" 267 | ], 268 | "dev": true, 269 | "optional": true, 270 | "os": [ 271 | "darwin" 272 | ], 273 | "engines": { 274 | "node": ">=12" 275 | } 276 | }, 277 | "node_modules/@esbuild/freebsd-arm64": { 278 | "version": "0.21.5", 279 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", 280 | "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", 281 | "cpu": [ 282 | "arm64" 283 | ], 284 | "dev": true, 285 | "optional": true, 286 | "os": [ 287 | "freebsd" 288 | ], 289 | "engines": { 290 | "node": ">=12" 291 | } 292 | }, 293 | "node_modules/@esbuild/freebsd-x64": { 294 | "version": "0.21.5", 295 | "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", 296 | "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", 297 | "cpu": [ 298 | "x64" 299 | ], 300 | "dev": true, 301 | "optional": true, 302 | "os": [ 303 | "freebsd" 304 | ], 305 | "engines": { 306 | "node": ">=12" 307 | } 308 | }, 309 | "node_modules/@esbuild/linux-arm": { 310 | "version": "0.21.5", 311 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", 312 | "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", 313 | "cpu": [ 314 | "arm" 315 | ], 316 | "dev": true, 317 | "optional": true, 318 | "os": [ 319 | "linux" 320 | ], 321 | "engines": { 322 | "node": ">=12" 323 | } 324 | }, 325 | "node_modules/@esbuild/linux-arm64": { 326 | "version": "0.21.5", 327 | "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", 328 | "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", 329 | "cpu": [ 330 | "arm64" 331 | ], 332 | "dev": true, 333 | "optional": true, 334 | "os": [ 335 | "linux" 336 | ], 337 | "engines": { 338 | "node": ">=12" 339 | } 340 | }, 341 | "node_modules/@esbuild/linux-ia32": { 342 | "version": "0.21.5", 343 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", 344 | "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", 345 | "cpu": [ 346 | "ia32" 347 | ], 348 | "dev": true, 349 | "optional": true, 350 | "os": [ 351 | "linux" 352 | ], 353 | "engines": { 354 | "node": ">=12" 355 | } 356 | }, 357 | "node_modules/@esbuild/linux-loong64": { 358 | "version": "0.21.5", 359 | "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", 360 | "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", 361 | "cpu": [ 362 | "loong64" 363 | ], 364 | "dev": true, 365 | "optional": true, 366 | "os": [ 367 | "linux" 368 | ], 369 | "engines": { 370 | "node": ">=12" 371 | } 372 | }, 373 | "node_modules/@esbuild/linux-mips64el": { 374 | "version": "0.21.5", 375 | "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", 376 | "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", 377 | "cpu": [ 378 | "mips64el" 379 | ], 380 | "dev": true, 381 | "optional": true, 382 | "os": [ 383 | "linux" 384 | ], 385 | "engines": { 386 | "node": ">=12" 387 | } 388 | }, 389 | "node_modules/@esbuild/linux-ppc64": { 390 | "version": "0.21.5", 391 | "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", 392 | "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", 393 | "cpu": [ 394 | "ppc64" 395 | ], 396 | "dev": true, 397 | "optional": true, 398 | "os": [ 399 | "linux" 400 | ], 401 | "engines": { 402 | "node": ">=12" 403 | } 404 | }, 405 | "node_modules/@esbuild/linux-riscv64": { 406 | "version": "0.21.5", 407 | "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", 408 | "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", 409 | "cpu": [ 410 | "riscv64" 411 | ], 412 | "dev": true, 413 | "optional": true, 414 | "os": [ 415 | "linux" 416 | ], 417 | "engines": { 418 | "node": ">=12" 419 | } 420 | }, 421 | "node_modules/@esbuild/linux-s390x": { 422 | "version": "0.21.5", 423 | "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", 424 | "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", 425 | "cpu": [ 426 | "s390x" 427 | ], 428 | "dev": true, 429 | "optional": true, 430 | "os": [ 431 | "linux" 432 | ], 433 | "engines": { 434 | "node": ">=12" 435 | } 436 | }, 437 | "node_modules/@esbuild/linux-x64": { 438 | "version": "0.21.5", 439 | "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", 440 | "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", 441 | "cpu": [ 442 | "x64" 443 | ], 444 | "dev": true, 445 | "optional": true, 446 | "os": [ 447 | "linux" 448 | ], 449 | "engines": { 450 | "node": ">=12" 451 | } 452 | }, 453 | "node_modules/@esbuild/netbsd-x64": { 454 | "version": "0.21.5", 455 | "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", 456 | "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", 457 | "cpu": [ 458 | "x64" 459 | ], 460 | "dev": true, 461 | "optional": true, 462 | "os": [ 463 | "netbsd" 464 | ], 465 | "engines": { 466 | "node": ">=12" 467 | } 468 | }, 469 | "node_modules/@esbuild/openbsd-x64": { 470 | "version": "0.21.5", 471 | "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", 472 | "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", 473 | "cpu": [ 474 | "x64" 475 | ], 476 | "dev": true, 477 | "optional": true, 478 | "os": [ 479 | "openbsd" 480 | ], 481 | "engines": { 482 | "node": ">=12" 483 | } 484 | }, 485 | "node_modules/@esbuild/sunos-x64": { 486 | "version": "0.21.5", 487 | "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", 488 | "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", 489 | "cpu": [ 490 | "x64" 491 | ], 492 | "dev": true, 493 | "optional": true, 494 | "os": [ 495 | "sunos" 496 | ], 497 | "engines": { 498 | "node": ">=12" 499 | } 500 | }, 501 | "node_modules/@esbuild/win32-arm64": { 502 | "version": "0.21.5", 503 | "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", 504 | "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", 505 | "cpu": [ 506 | "arm64" 507 | ], 508 | "dev": true, 509 | "optional": true, 510 | "os": [ 511 | "win32" 512 | ], 513 | "engines": { 514 | "node": ">=12" 515 | } 516 | }, 517 | "node_modules/@esbuild/win32-ia32": { 518 | "version": "0.21.5", 519 | "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", 520 | "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", 521 | "cpu": [ 522 | "ia32" 523 | ], 524 | "dev": true, 525 | "optional": true, 526 | "os": [ 527 | "win32" 528 | ], 529 | "engines": { 530 | "node": ">=12" 531 | } 532 | }, 533 | "node_modules/@esbuild/win32-x64": { 534 | "version": "0.21.5", 535 | "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", 536 | "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", 537 | "cpu": [ 538 | "x64" 539 | ], 540 | "dev": true, 541 | "optional": true, 542 | "os": [ 543 | "win32" 544 | ], 545 | "engines": { 546 | "node": ">=12" 547 | } 548 | }, 549 | "node_modules/@lezer/common": { 550 | "version": "1.2.1", 551 | "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", 552 | "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" 553 | }, 554 | "node_modules/@lezer/css": { 555 | "version": "1.1.8", 556 | "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.8.tgz", 557 | "integrity": "sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==", 558 | "peer": true, 559 | "dependencies": { 560 | "@lezer/common": "^1.2.0", 561 | "@lezer/highlight": "^1.0.0", 562 | "@lezer/lr": "^1.0.0" 563 | } 564 | }, 565 | "node_modules/@lezer/highlight": { 566 | "version": "1.2.0", 567 | "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", 568 | "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", 569 | "dependencies": { 570 | "@lezer/common": "^1.0.0" 571 | } 572 | }, 573 | "node_modules/@lezer/html": { 574 | "version": "1.3.10", 575 | "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", 576 | "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", 577 | "peer": true, 578 | "dependencies": { 579 | "@lezer/common": "^1.2.0", 580 | "@lezer/highlight": "^1.0.0", 581 | "@lezer/lr": "^1.0.0" 582 | } 583 | }, 584 | "node_modules/@lezer/javascript": { 585 | "version": "1.4.17", 586 | "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.17.tgz", 587 | "integrity": "sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==", 588 | "peer": true, 589 | "dependencies": { 590 | "@lezer/common": "^1.2.0", 591 | "@lezer/highlight": "^1.1.3", 592 | "@lezer/lr": "^1.3.0" 593 | } 594 | }, 595 | "node_modules/@lezer/lr": { 596 | "version": "1.4.2", 597 | "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", 598 | "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", 599 | "dependencies": { 600 | "@lezer/common": "^1.0.0" 601 | } 602 | }, 603 | "node_modules/@rollup/rollup-android-arm-eabi": { 604 | "version": "4.20.0", 605 | "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.20.0.tgz", 606 | "integrity": "sha512-TSpWzflCc4VGAUJZlPpgAJE1+V60MePDQnBd7PPkpuEmOy8i87aL6tinFGKBFKuEDikYpig72QzdT3QPYIi+oA==", 607 | "cpu": [ 608 | "arm" 609 | ], 610 | "dev": true, 611 | "optional": true, 612 | "os": [ 613 | "android" 614 | ] 615 | }, 616 | "node_modules/@rollup/rollup-android-arm64": { 617 | "version": "4.20.0", 618 | "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.20.0.tgz", 619 | "integrity": "sha512-u00Ro/nok7oGzVuh/FMYfNoGqxU5CPWz1mxV85S2w9LxHR8OoMQBuSk+3BKVIDYgkpeOET5yXkx90OYFc+ytpQ==", 620 | "cpu": [ 621 | "arm64" 622 | ], 623 | "dev": true, 624 | "optional": true, 625 | "os": [ 626 | "android" 627 | ] 628 | }, 629 | "node_modules/@rollup/rollup-darwin-arm64": { 630 | "version": "4.20.0", 631 | "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.20.0.tgz", 632 | "integrity": "sha512-uFVfvzvsdGtlSLuL0ZlvPJvl6ZmrH4CBwLGEFPe7hUmf7htGAN+aXo43R/V6LATyxlKVC/m6UsLb7jbG+LG39Q==", 633 | "cpu": [ 634 | "arm64" 635 | ], 636 | "dev": true, 637 | "optional": true, 638 | "os": [ 639 | "darwin" 640 | ] 641 | }, 642 | "node_modules/@rollup/rollup-darwin-x64": { 643 | "version": "4.20.0", 644 | "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.20.0.tgz", 645 | "integrity": "sha512-xbrMDdlev53vNXexEa6l0LffojxhqDTBeL+VUxuuIXys4x6xyvbKq5XqTXBCEUA8ty8iEJblHvFaWRJTk/icAQ==", 646 | "cpu": [ 647 | "x64" 648 | ], 649 | "dev": true, 650 | "optional": true, 651 | "os": [ 652 | "darwin" 653 | ] 654 | }, 655 | "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 656 | "version": "4.20.0", 657 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.20.0.tgz", 658 | "integrity": "sha512-jMYvxZwGmoHFBTbr12Xc6wOdc2xA5tF5F2q6t7Rcfab68TT0n+r7dgawD4qhPEvasDsVpQi+MgDzj2faOLsZjA==", 659 | "cpu": [ 660 | "arm" 661 | ], 662 | "dev": true, 663 | "optional": true, 664 | "os": [ 665 | "linux" 666 | ] 667 | }, 668 | "node_modules/@rollup/rollup-linux-arm-musleabihf": { 669 | "version": "4.20.0", 670 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.20.0.tgz", 671 | "integrity": "sha512-1asSTl4HKuIHIB1GcdFHNNZhxAYEdqML/MW4QmPS4G0ivbEcBr1JKlFLKsIRqjSwOBkdItn3/ZDlyvZ/N6KPlw==", 672 | "cpu": [ 673 | "arm" 674 | ], 675 | "dev": true, 676 | "optional": true, 677 | "os": [ 678 | "linux" 679 | ] 680 | }, 681 | "node_modules/@rollup/rollup-linux-arm64-gnu": { 682 | "version": "4.20.0", 683 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.20.0.tgz", 684 | "integrity": "sha512-COBb8Bkx56KldOYJfMf6wKeYJrtJ9vEgBRAOkfw6Ens0tnmzPqvlpjZiLgkhg6cA3DGzCmLmmd319pmHvKWWlQ==", 685 | "cpu": [ 686 | "arm64" 687 | ], 688 | "dev": true, 689 | "optional": true, 690 | "os": [ 691 | "linux" 692 | ] 693 | }, 694 | "node_modules/@rollup/rollup-linux-arm64-musl": { 695 | "version": "4.20.0", 696 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.20.0.tgz", 697 | "integrity": "sha512-+it+mBSyMslVQa8wSPvBx53fYuZK/oLTu5RJoXogjk6x7Q7sz1GNRsXWjn6SwyJm8E/oMjNVwPhmNdIjwP135Q==", 698 | "cpu": [ 699 | "arm64" 700 | ], 701 | "dev": true, 702 | "optional": true, 703 | "os": [ 704 | "linux" 705 | ] 706 | }, 707 | "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { 708 | "version": "4.20.0", 709 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.20.0.tgz", 710 | "integrity": "sha512-yAMvqhPfGKsAxHN8I4+jE0CpLWD8cv4z7CK7BMmhjDuz606Q2tFKkWRY8bHR9JQXYcoLfopo5TTqzxgPUjUMfw==", 711 | "cpu": [ 712 | "ppc64" 713 | ], 714 | "dev": true, 715 | "optional": true, 716 | "os": [ 717 | "linux" 718 | ] 719 | }, 720 | "node_modules/@rollup/rollup-linux-riscv64-gnu": { 721 | "version": "4.20.0", 722 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.20.0.tgz", 723 | "integrity": "sha512-qmuxFpfmi/2SUkAw95TtNq/w/I7Gpjurx609OOOV7U4vhvUhBcftcmXwl3rqAek+ADBwSjIC4IVNLiszoj3dPA==", 724 | "cpu": [ 725 | "riscv64" 726 | ], 727 | "dev": true, 728 | "optional": true, 729 | "os": [ 730 | "linux" 731 | ] 732 | }, 733 | "node_modules/@rollup/rollup-linux-s390x-gnu": { 734 | "version": "4.20.0", 735 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.20.0.tgz", 736 | "integrity": "sha512-I0BtGXddHSHjV1mqTNkgUZLnS3WtsqebAXv11D5BZE/gfw5KoyXSAXVqyJximQXNvNzUo4GKlCK/dIwXlz+jlg==", 737 | "cpu": [ 738 | "s390x" 739 | ], 740 | "dev": true, 741 | "optional": true, 742 | "os": [ 743 | "linux" 744 | ] 745 | }, 746 | "node_modules/@rollup/rollup-linux-x64-gnu": { 747 | "version": "4.20.0", 748 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.20.0.tgz", 749 | "integrity": "sha512-y+eoL2I3iphUg9tN9GB6ku1FA8kOfmF4oUEWhztDJ4KXJy1agk/9+pejOuZkNFhRwHAOxMsBPLbXPd6mJiCwew==", 750 | "cpu": [ 751 | "x64" 752 | ], 753 | "dev": true, 754 | "optional": true, 755 | "os": [ 756 | "linux" 757 | ] 758 | }, 759 | "node_modules/@rollup/rollup-linux-x64-musl": { 760 | "version": "4.20.0", 761 | "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.20.0.tgz", 762 | "integrity": "sha512-hM3nhW40kBNYUkZb/r9k2FKK+/MnKglX7UYd4ZUy5DJs8/sMsIbqWK2piZtVGE3kcXVNj3B2IrUYROJMMCikNg==", 763 | "cpu": [ 764 | "x64" 765 | ], 766 | "dev": true, 767 | "optional": true, 768 | "os": [ 769 | "linux" 770 | ] 771 | }, 772 | "node_modules/@rollup/rollup-win32-arm64-msvc": { 773 | "version": "4.20.0", 774 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.20.0.tgz", 775 | "integrity": "sha512-psegMvP+Ik/Bg7QRJbv8w8PAytPA7Uo8fpFjXyCRHWm6Nt42L+JtoqH8eDQ5hRP7/XW2UiIriy1Z46jf0Oa1kA==", 776 | "cpu": [ 777 | "arm64" 778 | ], 779 | "dev": true, 780 | "optional": true, 781 | "os": [ 782 | "win32" 783 | ] 784 | }, 785 | "node_modules/@rollup/rollup-win32-ia32-msvc": { 786 | "version": "4.20.0", 787 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.20.0.tgz", 788 | "integrity": "sha512-GabekH3w4lgAJpVxkk7hUzUf2hICSQO0a/BLFA11/RMxQT92MabKAqyubzDZmMOC/hcJNlc+rrypzNzYl4Dx7A==", 789 | "cpu": [ 790 | "ia32" 791 | ], 792 | "dev": true, 793 | "optional": true, 794 | "os": [ 795 | "win32" 796 | ] 797 | }, 798 | "node_modules/@rollup/rollup-win32-x64-msvc": { 799 | "version": "4.20.0", 800 | "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.20.0.tgz", 801 | "integrity": "sha512-aJ1EJSuTdGnM6qbVC4B5DSmozPTqIag9fSzXRNNo+humQLG89XpPgdt16Ia56ORD7s+H8Pmyx44uczDQ0yDzpg==", 802 | "cpu": [ 803 | "x64" 804 | ], 805 | "dev": true, 806 | "optional": true, 807 | "os": [ 808 | "win32" 809 | ] 810 | }, 811 | "node_modules/@types/estree": { 812 | "version": "1.0.5", 813 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", 814 | "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", 815 | "dev": true 816 | }, 817 | "node_modules/codemirror": { 818 | "version": "6.0.1", 819 | "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", 820 | "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", 821 | "dev": true, 822 | "dependencies": { 823 | "@codemirror/autocomplete": "^6.0.0", 824 | "@codemirror/commands": "^6.0.0", 825 | "@codemirror/language": "^6.0.0", 826 | "@codemirror/lint": "^6.0.0", 827 | "@codemirror/search": "^6.0.0", 828 | "@codemirror/state": "^6.0.0", 829 | "@codemirror/view": "^6.0.0" 830 | } 831 | }, 832 | "node_modules/crelt": { 833 | "version": "1.0.6", 834 | "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", 835 | "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" 836 | }, 837 | "node_modules/emmet": { 838 | "version": "2.4.11", 839 | "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", 840 | "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", 841 | "dependencies": { 842 | "@emmetio/abbreviation": "^2.3.3", 843 | "@emmetio/css-abbreviation": "^2.1.8" 844 | } 845 | }, 846 | "node_modules/esbuild": { 847 | "version": "0.21.5", 848 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", 849 | "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", 850 | "dev": true, 851 | "hasInstallScript": true, 852 | "bin": { 853 | "esbuild": "bin/esbuild" 854 | }, 855 | "engines": { 856 | "node": ">=12" 857 | }, 858 | "optionalDependencies": { 859 | "@esbuild/aix-ppc64": "0.21.5", 860 | "@esbuild/android-arm": "0.21.5", 861 | "@esbuild/android-arm64": "0.21.5", 862 | "@esbuild/android-x64": "0.21.5", 863 | "@esbuild/darwin-arm64": "0.21.5", 864 | "@esbuild/darwin-x64": "0.21.5", 865 | "@esbuild/freebsd-arm64": "0.21.5", 866 | "@esbuild/freebsd-x64": "0.21.5", 867 | "@esbuild/linux-arm": "0.21.5", 868 | "@esbuild/linux-arm64": "0.21.5", 869 | "@esbuild/linux-ia32": "0.21.5", 870 | "@esbuild/linux-loong64": "0.21.5", 871 | "@esbuild/linux-mips64el": "0.21.5", 872 | "@esbuild/linux-ppc64": "0.21.5", 873 | "@esbuild/linux-riscv64": "0.21.5", 874 | "@esbuild/linux-s390x": "0.21.5", 875 | "@esbuild/linux-x64": "0.21.5", 876 | "@esbuild/netbsd-x64": "0.21.5", 877 | "@esbuild/openbsd-x64": "0.21.5", 878 | "@esbuild/sunos-x64": "0.21.5", 879 | "@esbuild/win32-arm64": "0.21.5", 880 | "@esbuild/win32-ia32": "0.21.5", 881 | "@esbuild/win32-x64": "0.21.5" 882 | } 883 | }, 884 | "node_modules/fsevents": { 885 | "version": "2.3.3", 886 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 887 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 888 | "dev": true, 889 | "hasInstallScript": true, 890 | "optional": true, 891 | "os": [ 892 | "darwin" 893 | ], 894 | "engines": { 895 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 896 | } 897 | }, 898 | "node_modules/nanoid": { 899 | "version": "3.3.7", 900 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", 901 | "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", 902 | "dev": true, 903 | "funding": [ 904 | { 905 | "type": "github", 906 | "url": "https://github.com/sponsors/ai" 907 | } 908 | ], 909 | "bin": { 910 | "nanoid": "bin/nanoid.cjs" 911 | }, 912 | "engines": { 913 | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 914 | } 915 | }, 916 | "node_modules/picocolors": { 917 | "version": "1.0.1", 918 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", 919 | "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", 920 | "dev": true 921 | }, 922 | "node_modules/postcss": { 923 | "version": "8.4.40", 924 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", 925 | "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", 926 | "dev": true, 927 | "funding": [ 928 | { 929 | "type": "opencollective", 930 | "url": "https://opencollective.com/postcss/" 931 | }, 932 | { 933 | "type": "tidelift", 934 | "url": "https://tidelift.com/funding/github/npm/postcss" 935 | }, 936 | { 937 | "type": "github", 938 | "url": "https://github.com/sponsors/ai" 939 | } 940 | ], 941 | "dependencies": { 942 | "nanoid": "^3.3.7", 943 | "picocolors": "^1.0.1", 944 | "source-map-js": "^1.2.0" 945 | }, 946 | "engines": { 947 | "node": "^10 || ^12 || >=14" 948 | } 949 | }, 950 | "node_modules/rollup": { 951 | "version": "4.20.0", 952 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.20.0.tgz", 953 | "integrity": "sha512-6rbWBChcnSGzIlXeIdNIZTopKYad8ZG8ajhl78lGRLsI2rX8IkaotQhVas2Ma+GPxJav19wrSzvRvuiv0YKzWw==", 954 | "dev": true, 955 | "dependencies": { 956 | "@types/estree": "1.0.5" 957 | }, 958 | "bin": { 959 | "rollup": "dist/bin/rollup" 960 | }, 961 | "engines": { 962 | "node": ">=18.0.0", 963 | "npm": ">=8.0.0" 964 | }, 965 | "optionalDependencies": { 966 | "@rollup/rollup-android-arm-eabi": "4.20.0", 967 | "@rollup/rollup-android-arm64": "4.20.0", 968 | "@rollup/rollup-darwin-arm64": "4.20.0", 969 | "@rollup/rollup-darwin-x64": "4.20.0", 970 | "@rollup/rollup-linux-arm-gnueabihf": "4.20.0", 971 | "@rollup/rollup-linux-arm-musleabihf": "4.20.0", 972 | "@rollup/rollup-linux-arm64-gnu": "4.20.0", 973 | "@rollup/rollup-linux-arm64-musl": "4.20.0", 974 | "@rollup/rollup-linux-powerpc64le-gnu": "4.20.0", 975 | "@rollup/rollup-linux-riscv64-gnu": "4.20.0", 976 | "@rollup/rollup-linux-s390x-gnu": "4.20.0", 977 | "@rollup/rollup-linux-x64-gnu": "4.20.0", 978 | "@rollup/rollup-linux-x64-musl": "4.20.0", 979 | "@rollup/rollup-win32-arm64-msvc": "4.20.0", 980 | "@rollup/rollup-win32-ia32-msvc": "4.20.0", 981 | "@rollup/rollup-win32-x64-msvc": "4.20.0", 982 | "fsevents": "~2.3.2" 983 | } 984 | }, 985 | "node_modules/source-map-js": { 986 | "version": "1.2.0", 987 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", 988 | "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", 989 | "dev": true, 990 | "engines": { 991 | "node": ">=0.10.0" 992 | } 993 | }, 994 | "node_modules/style-mod": { 995 | "version": "4.1.2", 996 | "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", 997 | "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" 998 | }, 999 | "node_modules/typescript": { 1000 | "version": "5.5.4", 1001 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", 1002 | "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", 1003 | "dev": true, 1004 | "bin": { 1005 | "tsc": "bin/tsc", 1006 | "tsserver": "bin/tsserver" 1007 | }, 1008 | "engines": { 1009 | "node": ">=14.17" 1010 | } 1011 | }, 1012 | "node_modules/vite": { 1013 | "version": "5.3.5", 1014 | "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", 1015 | "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", 1016 | "dev": true, 1017 | "dependencies": { 1018 | "esbuild": "^0.21.3", 1019 | "postcss": "^8.4.39", 1020 | "rollup": "^4.13.0" 1021 | }, 1022 | "bin": { 1023 | "vite": "bin/vite.js" 1024 | }, 1025 | "engines": { 1026 | "node": "^18.0.0 || >=20.0.0" 1027 | }, 1028 | "funding": { 1029 | "url": "https://github.com/vitejs/vite?sponsor=1" 1030 | }, 1031 | "optionalDependencies": { 1032 | "fsevents": "~2.3.3" 1033 | }, 1034 | "peerDependencies": { 1035 | "@types/node": "^18.0.0 || >=20.0.0", 1036 | "less": "*", 1037 | "lightningcss": "^1.21.0", 1038 | "sass": "*", 1039 | "stylus": "*", 1040 | "sugarss": "*", 1041 | "terser": "^5.4.0" 1042 | }, 1043 | "peerDependenciesMeta": { 1044 | "@types/node": { 1045 | "optional": true 1046 | }, 1047 | "less": { 1048 | "optional": true 1049 | }, 1050 | "lightningcss": { 1051 | "optional": true 1052 | }, 1053 | "sass": { 1054 | "optional": true 1055 | }, 1056 | "stylus": { 1057 | "optional": true 1058 | }, 1059 | "sugarss": { 1060 | "optional": true 1061 | }, 1062 | "terser": { 1063 | "optional": true 1064 | } 1065 | } 1066 | }, 1067 | "node_modules/w3c-keyname": { 1068 | "version": "2.2.8", 1069 | "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", 1070 | "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" 1071 | } 1072 | } 1073 | } 1074 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@emmetio/codemirror6-plugin", 3 | "version": "0.4.0", 4 | "main": "./dist/plugin.js", 5 | "types": "./dist/plugin.d.ts", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite --port=3009", 9 | "build": "vite build && tsc", 10 | "serve": "vite preview", 11 | "prepare": "npm run build" 12 | }, 13 | "devDependencies": { 14 | "@lezer/common": "^1.2.1", 15 | "codemirror": "^6.0.1", 16 | "typescript": "^5.5.4", 17 | "vite": "^5.3.5" 18 | }, 19 | "dependencies": { 20 | "@emmetio/math-expression": "^1.0.5", 21 | "emmet": "^2.4.11" 22 | }, 23 | "peerDependencies": { 24 | "@codemirror/autocomplete": "^6.17.0", 25 | "@codemirror/commands": "^6.6.0", 26 | "@codemirror/lang-css": "^6.2.1", 27 | "@codemirror/lang-html": "^6.4.9", 28 | "@codemirror/language": "^6.10.2", 29 | "@codemirror/state": "^6.4.1", 30 | "@codemirror/view": "^6.29.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/commands/balance.ts: -------------------------------------------------------------------------------- 1 | import { syntaxTree } from '@codemirror/language'; 2 | import { EditorSelection } from '@codemirror/state'; 3 | import type { EditorState, SelectionRange, StateCommand } from '@codemirror/state'; 4 | import { cssLanguage } from '@codemirror/lang-css'; 5 | import { htmlLanguage } from '@codemirror/lang-html'; 6 | import type { SyntaxNode } from '@lezer/common'; 7 | import type { RangeObject } from '../lib/types'; 8 | import { contains, fullCSSDeclarationRange, last, narrowToNonSpace, rangeContains, rangesEqual } from '../lib/utils'; 9 | import { getPropertyRanges } from '../lib/context'; 10 | 11 | // TODO use RangeObject instead of TextRange 12 | 13 | export const balanceOutward: StateCommand = ({ state, dispatch }) => { 14 | const nextSel: SelectionRange[] = []; 15 | let hasMatch = false; 16 | 17 | for (const sel of state.selection.ranges) { 18 | const ranges = getOutwardRanges(state, sel.from); 19 | if (ranges) { 20 | hasMatch = true; 21 | const targetRange = ranges.find(r => rangeContains(r, sel) && !rangesEqual(r, sel)) || sel; 22 | nextSel.push(EditorSelection.range(targetRange.from, targetRange.to)); 23 | } else { 24 | nextSel.push(sel); 25 | } 26 | } 27 | 28 | if (hasMatch) { 29 | const tr = state.update({ 30 | selection: EditorSelection.create(nextSel) 31 | }); 32 | 33 | dispatch(tr); 34 | return true; 35 | } 36 | 37 | return false; 38 | }; 39 | 40 | export const balanceInward: StateCommand = ({ state, dispatch }) => { 41 | const nextSel: SelectionRange[] = []; 42 | let hasMatch = false; 43 | for (const sel of state.selection.ranges) { 44 | const ranges = getInwardRanges(state, sel.from); 45 | if (ranges) { 46 | hasMatch = true; 47 | // Try to find range which equals to selection: we should pick leftmost 48 | let ix = ranges.findIndex(r => rangesEqual(sel, r)); 49 | let targetRange: RangeObject = sel; 50 | 51 | if (ix < ranges.length - 1) { 52 | targetRange = ranges[ix + 1]; 53 | } else if (ix !== -1) { 54 | // No match found, pick closest region 55 | targetRange = ranges.slice(ix).find(r => rangeContains(r, sel)) || sel; 56 | } 57 | 58 | nextSel.push(EditorSelection.range(targetRange.from, targetRange.to)); 59 | } else { 60 | nextSel.push(sel); 61 | } 62 | } 63 | 64 | if (hasMatch) { 65 | const tr = state.update({ 66 | selection: EditorSelection.create(nextSel) 67 | }); 68 | 69 | dispatch(tr); 70 | return true; 71 | } 72 | 73 | return false; 74 | }; 75 | 76 | function getOutwardRanges(state: EditorState, pos: number): RangeObject[] | undefined { 77 | if (cssLanguage.isActiveAt(state, pos)) { 78 | return getCSSOutwardRanges(state, pos); 79 | } 80 | 81 | if (htmlLanguage.isActiveAt(state, pos)) { 82 | return getHTMLOutwardRanges(state, pos); 83 | } 84 | 85 | return; 86 | } 87 | 88 | function getInwardRanges(state: EditorState, pos: number): RangeObject[] | undefined { 89 | if (cssLanguage.isActiveAt(state, pos)) { 90 | return getCSSInwardRanges(state, pos); 91 | } 92 | 93 | if (htmlLanguage.isActiveAt(state, pos)) { 94 | return getHTMLInwardRanges(state, pos); 95 | } 96 | 97 | return; 98 | } 99 | 100 | function getHTMLOutwardRanges(state: EditorState, pos: number): RangeObject[] { 101 | const result: RangeObject[] = []; 102 | const tree = syntaxTree(state).resolveInner(pos, -1); 103 | 104 | for (let node: SyntaxNode | null = tree; node; node = node.parent) { 105 | if (node.name === 'Element') { 106 | pushHTMLRanges(node, result); 107 | } 108 | } 109 | 110 | return compactRanges(result, false); 111 | } 112 | 113 | function getHTMLInwardRanges(state: EditorState, pos: number): RangeObject[] { 114 | const result: RangeObject[] = []; 115 | let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); 116 | 117 | // Find closest element 118 | while (node && node.name !== 'Element') { 119 | node = node.parent; 120 | } 121 | 122 | // Find all first child elements 123 | while (node) { 124 | pushHTMLRanges(node, result); 125 | node = node.getChild('Element'); 126 | } 127 | 128 | return compactRanges(result, true); 129 | } 130 | 131 | function getCSSOutwardRanges(state: EditorState, pos: number): RangeObject[] { 132 | const result: RangeObject[] = []; 133 | let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, -1); 134 | 135 | while (node) { 136 | pushCSSRanges(state, node, pos, result); 137 | node = node.parent; 138 | } 139 | 140 | return compactRanges(result, false); 141 | } 142 | 143 | function getCSSInwardRanges(state: EditorState, pos: number): RangeObject[] { 144 | const result: RangeObject[] = []; 145 | const knownNodes = ['Block', 'RuleSet', 'Declaration']; 146 | let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); 147 | 148 | while (node && !knownNodes.includes(node.name)) { 149 | node = node.parent; 150 | } 151 | 152 | while (node) { 153 | pushCSSRanges(state, node, pos, result); 154 | node = getChildOfType(node, knownNodes); 155 | } 156 | 157 | return result; 158 | } 159 | 160 | 161 | function pushHTMLRanges(node: SyntaxNode, ranges: RangeObject[]): void { 162 | const selfClose = node.getChild('SelfClosingTag'); 163 | if (selfClose) { 164 | ranges.push(selfClose); 165 | } else { 166 | const open = node.getChild('OpenTag'); 167 | if (open) { 168 | const close = node.getChild('CloseTag'); 169 | if (close) { 170 | // Inner range 171 | ranges.push({ from: open.to, to: close.from }); 172 | // Outer range 173 | ranges.push({ from: open.from, to: close.to }); 174 | } else { 175 | ranges.push(open); 176 | } 177 | } 178 | } 179 | } 180 | 181 | function pushCSSRanges(state: EditorState, node: SyntaxNode, pos: number, ranges: RangeObject[]): void { 182 | if (node.name === 'Block') { 183 | ranges.push(narrowToNonSpace(state, { 184 | from: node.from + 1, 185 | to: node.to - 1 186 | })); 187 | } else if (node.name === 'RuleSet') { 188 | ranges.push(node); 189 | } else if (node.name === 'Declaration') { 190 | const { name, value } = getPropertyRanges(node); 191 | if (value && contains(value, pos)) { 192 | ranges.push(value); 193 | } 194 | if (name && contains(name, pos)) { 195 | ranges.push(name); 196 | } 197 | 198 | ranges.push(fullCSSDeclarationRange(node)); 199 | } 200 | } 201 | 202 | function compactRanges(ranges: RangeObject[], inward: boolean): RangeObject[] { 203 | const result: RangeObject[] = []; 204 | ranges = [...ranges].sort(inward 205 | ? ((a, b) => a.from - b.from || b.to - a.to) 206 | : ((a, b) => b.from - a.from || a.to - b.to)); 207 | 208 | for (const range of ranges) { 209 | const prev = last(result); 210 | if (!prev || prev.from !== range.from || prev.to !== range.to) { 211 | result.push(range) 212 | } 213 | } 214 | 215 | return result; 216 | } 217 | 218 | function getChildOfType(node: SyntaxNode, types: string[]): SyntaxNode | null { 219 | const cur = node.cursor(); 220 | if (cur.firstChild()) { 221 | for (;;) { 222 | for (const t of types) { 223 | if (cur.node.name === t) { 224 | return cur.node; 225 | } 226 | } 227 | if (!cur.nextSibling()) { 228 | break; 229 | } 230 | } 231 | } 232 | 233 | return null; 234 | } 235 | -------------------------------------------------------------------------------- /src/commands/comment.ts: -------------------------------------------------------------------------------- 1 | import { syntaxTree } from '@codemirror/language'; 2 | import type { LRLanguage } from '@codemirror/language'; 3 | import { htmlLanguage } from '@codemirror/lang-html'; 4 | import { cssLanguage } from '@codemirror/lang-css'; 5 | import type { ChangeSpec, EditorState, StateCommand } from '@codemirror/state'; 6 | import type { SyntaxNode } from '@lezer/common'; 7 | import { narrowToNonSpace } from '../lib/utils'; 8 | 9 | type CommentTokens = [string, string]; 10 | 11 | const htmlComment: CommentTokens = ['']; 12 | const cssComment: CommentTokens = ['/*', '*/']; 13 | 14 | export const toggleComment: StateCommand = ({ state, dispatch }) => { 15 | let changes: ChangeSpec[] = []; 16 | 17 | for (const sel of state.selection.ranges) { 18 | if (cssLanguage.isActiveAt(state, sel.from)) { 19 | changes = changes.concat(toggleCSSComment(state, sel.from)); 20 | } else if (htmlLanguage.isActiveAt(state, sel.from)) { 21 | changes = changes.concat(toggleHTMLComment(state, sel.from)); 22 | } 23 | } 24 | 25 | if (!changes.length) { 26 | return false; 27 | } 28 | 29 | const tr = state.update({ changes }); 30 | dispatch(tr); 31 | 32 | return true; 33 | }; 34 | 35 | function toggleHTMLComment(state: EditorState, pos: number): ChangeSpec[] { 36 | let result: ChangeSpec[] = []; 37 | const ctx = getContextOfType(state, pos, ['Element', 'Comment']); 38 | if (ctx) { 39 | if (ctx.name === 'Comment') { 40 | result = result.concat(stripComment(state, ctx, htmlComment)) 41 | } else { 42 | result = result.concat(addComment(state, ctx, htmlComment, htmlLanguage)); 43 | } 44 | } 45 | 46 | return result; 47 | } 48 | 49 | function toggleCSSComment(state: EditorState, pos: number): ChangeSpec[] { 50 | let result: ChangeSpec[] = []; 51 | const ctx = getContextOfType(state, pos, ['RuleSet', 'Declaration', 'Comment']); 52 | if (ctx) { 53 | if (ctx.name === 'Comment') { 54 | result = result.concat(stripComment(state, ctx, cssComment)); 55 | } else { 56 | result = result.concat(addComment(state, ctx, cssComment, cssLanguage)); 57 | } 58 | } 59 | 60 | return result; 61 | } 62 | 63 | function getContextOfType(state: EditorState, pos: number, types: string[]): SyntaxNode | undefined { 64 | const names = new Set(types); 65 | let node: SyntaxNode | null = syntaxTree(state).resolve(pos, 1); 66 | while (node) { 67 | if (names.has(node.name)) { 68 | return node; 69 | } 70 | node = node.parent; 71 | } 72 | 73 | return; 74 | } 75 | 76 | function stripComment(state: EditorState, node: SyntaxNode, comment: CommentTokens): ChangeSpec[] { 77 | const innerRange = narrowToNonSpace(state, { 78 | from: node.from + comment[0].length, 79 | to: node.to - comment[1].length 80 | }); 81 | return [ 82 | { from: node.from, to: innerRange.from }, 83 | { from: innerRange.to, to: node.to }, 84 | ]; 85 | } 86 | 87 | function addComment(state: EditorState, node: SyntaxNode, comment: CommentTokens, lang: LRLanguage): ChangeSpec[] { 88 | // Add comment tokens around element 89 | let { to } = node; 90 | if (node.name === 'Declaration' && node.nextSibling?.name === ';') { 91 | // edge case for CSS property 92 | to = node.nextSibling.to; 93 | } 94 | 95 | let result: ChangeSpec[] = [ 96 | { from: node.from, insert: comment[0] + ' ' }, 97 | { from: to, insert: ' ' + comment[1] }, 98 | ]; 99 | 100 | // Remove nested comments 101 | result = result.concat(stripChildComments(state, node, comment, lang)); 102 | 103 | if (node.name === 'RuleSet') { 104 | // Edge case for CSS rule set: find nested block first 105 | const block = node.getChild('Block'); 106 | if (block) { 107 | result = result.concat(stripChildComments(state, block, comment, lang)); 108 | } 109 | } 110 | 111 | return result; 112 | } 113 | 114 | function stripChildComments(state: EditorState, node: SyntaxNode, comment: CommentTokens, lang: LRLanguage): ChangeSpec[] { 115 | let result: ChangeSpec[] = []; 116 | for (const child of node.getChildren('Comment')) { 117 | if (lang.isActiveAt(state, child.from)) { 118 | result = result.concat(stripComment(state, child, comment)); 119 | } 120 | } 121 | 122 | return result; 123 | } 124 | -------------------------------------------------------------------------------- /src/commands/evaluate-math.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from '@codemirror/state'; 2 | import type { ChangeSpec, SelectionRange, StateCommand } from '@codemirror/state'; 3 | import evaluate, { extract } from '@emmetio/math-expression'; 4 | 5 | export const evaluateMath: StateCommand = ({ state, dispatch }) => { 6 | const changes: ChangeSpec[] = []; 7 | const nextSel: SelectionRange[] = []; 8 | 9 | for (const sel of state.selection.ranges) { 10 | let { from, to } = sel; 11 | if (from === to) { 12 | const line = state.doc.lineAt(sel.from); 13 | const expr = extract(line.text, sel.from - line.from); 14 | if (expr) { 15 | from = expr[0] + line.from; 16 | to = expr[1] + line.from; 17 | } 18 | } 19 | 20 | if (from !== to) { 21 | try { 22 | const result = evaluate(state.doc.sliceString(from ,to)); 23 | if (result !== null) { 24 | const insert = result.toFixed(4).replace(/\.?0+$/, ''); 25 | changes.push({ from, to, insert }); 26 | nextSel.push(EditorSelection.range(from + insert.length, from + insert.length)); 27 | } 28 | } catch (err) { 29 | nextSel.push(sel); 30 | console.error(err); 31 | } 32 | } 33 | } 34 | 35 | if (changes.length) { 36 | const tr = state.update({ 37 | changes, 38 | selection: EditorSelection.create(nextSel) 39 | }); 40 | dispatch(tr); 41 | return true; 42 | } 43 | 44 | return false; 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/expand.ts: -------------------------------------------------------------------------------- 1 | import type { StateCommand } from '@codemirror/state'; 2 | import { expand, extract, getOptions } from '../lib/emmet'; 3 | import { getSyntaxType } from '../lib/syntax'; 4 | import { snippet } from '@codemirror/autocomplete'; 5 | import { getActivationContext } from '../tracker'; 6 | import type { EmmetKnownSyntax } from '../lib/types'; 7 | 8 | export const expandAbbreviation: StateCommand = ({ state, dispatch }) => { 9 | const sel = state.selection.main; 10 | const line = state.doc.lineAt(sel.anchor); 11 | const options = getOptions(state, sel.anchor); 12 | const abbr = extract(line.text, sel.anchor - line.from, getSyntaxType(options.syntax as EmmetKnownSyntax)); 13 | 14 | if (abbr) { 15 | const start = line.from + abbr.start; 16 | const expanded = expand(state, abbr.abbreviation, getActivationContext(state, start) || options); 17 | const fn = snippet(expanded); 18 | fn({ state, dispatch }, { label: 'expand' }, start, line.from + abbr.end); 19 | return true; 20 | } 21 | 22 | return false; 23 | }; 24 | 25 | -------------------------------------------------------------------------------- /src/commands/go-to-edit-point.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from '@codemirror/state'; 2 | import type { EditorState, SelectionRange, StateCommand } from '@codemirror/state'; 3 | import { isQuote, isSpace } from '../lib/utils'; 4 | 5 | export const goToNextEditPoint: StateCommand = ({ state, dispatch }) => { 6 | const tr = state.update({ 7 | selection: getNextSel(state, 1) 8 | }); 9 | dispatch(tr); 10 | return true; 11 | }; 12 | 13 | export const goToPreviousEditPoint: StateCommand = ({ state, dispatch }) => { 14 | const tr = state.update({ 15 | selection: getNextSel(state, -1) 16 | }); 17 | dispatch(tr); 18 | return true; 19 | }; 20 | 21 | function getNextSel(state: EditorState, inc: number): EditorSelection { 22 | const nextSel: SelectionRange[] = []; 23 | for (const sel of state.selection.ranges) { 24 | const nextPos = findNewEditPoint(state, sel.from + inc, inc); 25 | if (nextPos != null) { 26 | nextSel.push(EditorSelection.cursor(nextPos)); 27 | } else { 28 | nextSel.push(sel); 29 | } 30 | } 31 | 32 | return EditorSelection.create(nextSel); 33 | } 34 | 35 | function findNewEditPoint(state: EditorState, pos: number, inc: number): number | undefined { 36 | const doc = state.doc.toString(); 37 | const docSize = doc.length; 38 | let curPos = pos; 39 | 40 | while (curPos < docSize && curPos >= 0) { 41 | curPos += inc; 42 | const cur = doc[curPos]; 43 | const next = doc[curPos + 1]; 44 | const prev = doc[curPos - 1]; 45 | 46 | if (isQuote(cur) && next === cur && prev === '=') { 47 | // Empty attribute value 48 | return curPos + 1; 49 | } 50 | 51 | if (cur === '<' && prev === '>') { 52 | // Between tags 53 | return curPos; 54 | } 55 | 56 | if (isNewLine(cur)) { 57 | const line = state.doc.lineAt(curPos + inc); 58 | if (!line.length || isSpace(line.text)) { 59 | // Empty line 60 | return line.from + line.text.length; 61 | } 62 | } 63 | } 64 | 65 | return; 66 | } 67 | 68 | function isNewLine(ch: string) { 69 | return ch === '\r' || ch === '\n'; 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/go-to-tag-pair.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from '@codemirror/state'; 2 | import type { SelectionRange, StateCommand } from '@codemirror/state'; 3 | import { htmlLanguage } from '@codemirror/lang-html'; 4 | import { getTagContext } from '../lib/emmet'; 5 | 6 | export const goToTagPair: StateCommand = ({ state, dispatch }) => { 7 | const nextRanges: SelectionRange[] = []; 8 | let found = false; 9 | for (const sel of state.selection.ranges) { 10 | const pos = sel.from; 11 | let nextSel = sel; 12 | if (htmlLanguage.isActiveAt(state, pos)) { 13 | const ctx = getTagContext(state, pos); 14 | if (ctx && ctx.open && ctx.close) { 15 | found = true; 16 | const { open, close } = ctx; 17 | const nextPos = open.from <= pos && pos < open.to 18 | ? close.from 19 | : open.from; 20 | nextSel = EditorSelection.cursor(nextPos); 21 | } 22 | } 23 | 24 | nextRanges.push(nextSel); 25 | } 26 | 27 | if (found) { 28 | const tr = state.update({ 29 | selection: EditorSelection.create(nextRanges) 30 | }); 31 | dispatch(tr); 32 | return true; 33 | } 34 | 35 | return false; 36 | }; 37 | -------------------------------------------------------------------------------- /src/commands/inc-dec-number.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from '@codemirror/state'; 2 | import type { StateCommand, TransactionSpec } from '@codemirror/state'; 3 | import type { StateCommandTarget } from '../lib/types'; 4 | 5 | export const incrementNumber1: StateCommand = target => incDecNumber(target, 1); 6 | export const decrementNumber1: StateCommand = target => incDecNumber(target, -1); 7 | export const incrementNumber01: StateCommand = target => incDecNumber(target, .1); 8 | export const decrementNumber01: StateCommand = target => incDecNumber(target, -.1); 9 | export const incrementNumber10: StateCommand = target => incDecNumber(target, 10); 10 | export const decrementNumber10: StateCommand = target => incDecNumber(target, -10); 11 | 12 | function incDecNumber({ state, dispatch }: StateCommandTarget, delta: number): boolean { 13 | const specs: TransactionSpec[] = []; 14 | 15 | for (const sel of state.selection.ranges) { 16 | let { from, to } = sel; 17 | if (from === to) { 18 | // No selection, extract number 19 | const line = state.doc.lineAt(from); 20 | const numRange = extractNumber(line.text, from - line.from); 21 | if (numRange) { 22 | from = line.from + numRange[0]; 23 | to = line.from + numRange[1]; 24 | } 25 | } 26 | 27 | if (from !== to) { 28 | // Try to update value in given region 29 | let value = updateNumber(state.doc.sliceString(from, to), delta); 30 | specs.push({ 31 | changes: { from, to, insert: value }, 32 | selection: EditorSelection.range(from, from + value.length) 33 | }); 34 | } else { 35 | specs.push({ selection: sel }); 36 | } 37 | } 38 | 39 | if (specs.some(s => s.changes)) { 40 | const tr = state.update(...specs); 41 | dispatch(tr); 42 | return true; 43 | } 44 | 45 | return false; 46 | } 47 | 48 | /** 49 | * Extracts number from text at given location 50 | */ 51 | function extractNumber(text: string, pos: number): [number, number] | undefined { 52 | let hasDot = false; 53 | let end = pos; 54 | let start = pos; 55 | let ch: number; 56 | const len = text.length; 57 | 58 | // Read ahead for possible numbers 59 | while (end < len) { 60 | ch = text.charCodeAt(end); 61 | if (isDot(ch)) { 62 | if (hasDot) { 63 | break; 64 | } 65 | hasDot = true; 66 | } else if (!isNumber(ch)) { 67 | break; 68 | } 69 | end++; 70 | } 71 | 72 | // Read backward for possible numerics 73 | while (start >= 0) { 74 | ch = text.charCodeAt(start - 1); 75 | if (isDot(ch)) { 76 | if (hasDot) { 77 | break; 78 | } 79 | hasDot = true; 80 | } else if (!isNumber(ch)) { 81 | break; 82 | } 83 | start--; 84 | } 85 | 86 | // Negative number? 87 | if (start > 0 && text[start - 1] === '-') { 88 | start--; 89 | } 90 | 91 | if (start !== end) { 92 | return [start, end]; 93 | } 94 | 95 | return; 96 | } 97 | 98 | function updateNumber(num: string, delta: number, precision = 3): string { 99 | const value = parseFloat(num) + delta; 100 | 101 | if (isNaN(value)) { 102 | return num; 103 | } 104 | 105 | const neg = value < 0; 106 | let result = Math.abs(value).toFixed(precision); 107 | 108 | // Trim trailing zeroes and optionally decimal number 109 | result = result.replace(/\.?0+$/, ''); 110 | 111 | // Trim leading zero if input value doesn't have it 112 | if ((num[0] === '.' || num.startsWith('-.')) && result[0] === '0') { 113 | result = result.slice(1); 114 | } 115 | 116 | return (neg ? '-' : '') + result; 117 | } 118 | 119 | function isDot(ch: number) { 120 | return ch === 46; 121 | } 122 | 123 | /** 124 | * Check if given code is a number 125 | */ 126 | export function isNumber(code: number): boolean { 127 | return code > 47 && code < 58; 128 | } 129 | -------------------------------------------------------------------------------- /src/commands/remove-tag.ts: -------------------------------------------------------------------------------- 1 | import type { ChangeSpec, EditorState, StateCommand, TransactionSpec } from '@codemirror/state'; 2 | import { getTagContext } from '../lib/emmet'; 3 | import type { ContextTag } from '../lib/types'; 4 | import { lineIndent } from '../lib/output'; 5 | import { narrowToNonSpace, rangeEmpty, isSpace } from '../lib/utils'; 6 | 7 | export const removeTag: StateCommand = ({ state, dispatch }) => { 8 | const specs: TransactionSpec[] = []; 9 | for (const sel of state.selection.ranges) { 10 | const tag = getTagContext(state, sel.from); 11 | if (tag) { 12 | specs.push(removeTagSpec(state, tag)); 13 | } else { 14 | specs.push({ selection: sel }); 15 | } 16 | } 17 | 18 | if (specs.some(t => t.changes)) { 19 | const tr = state.update(...specs); 20 | dispatch(tr); 21 | return true; 22 | } 23 | 24 | return false; 25 | }; 26 | 27 | function removeTagSpec(state: EditorState, { open, close }: ContextTag): TransactionSpec { 28 | const changes: ChangeSpec[] = []; 29 | if (close) { 30 | // Remove open and close tag and dedent inner content 31 | const innerRange = narrowToNonSpace(state, { from: open.to, to: close.from }); 32 | if (!rangeEmpty(innerRange)) { 33 | // Gracefully remove open and close tags and tweak indentation on tag contents 34 | changes.push({ from: open.from, to: innerRange.from }); 35 | 36 | const lineStart = state.doc.lineAt(open.from); 37 | const lineEnd = state.doc.lineAt(close.to); 38 | if (lineStart.number !== lineEnd.number) { 39 | // Skip two lines: first one for open tag, on second one 40 | // indentation will be removed with open tag 41 | let lineNum = lineStart.number + 2; 42 | const baseIndent = getLineIndent(state, open.from); 43 | const innerIndent = getLineIndent(state, innerRange.from); 44 | 45 | while (lineNum <= lineEnd.number) { 46 | const line = state.doc.line(lineNum); 47 | if (isSpace(line.text.slice(0, innerIndent.length))) { 48 | changes.push({ 49 | from: line.from, 50 | to: line.from + innerIndent.length, 51 | insert: baseIndent 52 | }); 53 | } 54 | lineNum++; 55 | } 56 | } 57 | 58 | changes.push({ from: innerRange.to, to: close.to }); 59 | } else { 60 | changes.push({ from: open.from, to: close.to }); 61 | } 62 | } else { 63 | changes.push(open); 64 | } 65 | 66 | return { changes }; 67 | } 68 | 69 | /** 70 | * Returns indentation for line found from given character location 71 | */ 72 | function getLineIndent(state: EditorState, pos: number): string { 73 | return lineIndent(state.doc.lineAt(pos)); 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/select-item.ts: -------------------------------------------------------------------------------- 1 | import { syntaxTree } from '@codemirror/language'; 2 | import type { EditorState, SelectionRange, StateCommand } from '@codemirror/state'; 3 | import { EditorSelection } from '@codemirror/state'; 4 | import { cssLanguage } from '@codemirror/lang-css'; 5 | import type { SyntaxNode, TreeCursor } from '@lezer/common'; 6 | import type { RangeObject, StateCommandTarget } from '../lib/types'; 7 | import { fullCSSDeclarationRange, isQuote, isSpace, rangeContains, substr } from '../lib/utils'; 8 | import { getPropertyRanges, getSelectorRange } from '../lib/context'; 9 | 10 | export const selectNextItem: StateCommand = target => selectItemCommand(target, false); 11 | export const selectPreviousItem: StateCommand = target => selectItemCommand(target, true); 12 | 13 | const htmlParents = new Set(['OpenTag', 'CloseTag', 'SelfClosingTag']); 14 | const cssEnter = new Set(['Block', 'RuleSet', 'StyleSheet']); 15 | const cssParents = new Set(['RuleSet', 'Block', 'StyleSheet', 'Declaration']); 16 | 17 | function selectItemCommand({ state, dispatch }: StateCommandTarget, reverse: boolean): boolean { 18 | let handled = false; 19 | const selections: SelectionRange[] = []; 20 | for (const sel of state.selection.ranges) { 21 | const range = cssLanguage.isActiveAt(state, sel.from) 22 | ? getCSSRange(state, sel, reverse) 23 | : getHTMLRange(state, sel, reverse); 24 | if (range) { 25 | handled = true; 26 | selections.push(EditorSelection.range(range.from, range.to)); 27 | } else { 28 | selections.push(sel); 29 | } 30 | } 31 | 32 | if (handled) { 33 | const tr = state.update({ 34 | selection: EditorSelection.create(selections) 35 | }); 36 | dispatch(tr); 37 | return true; 38 | } 39 | 40 | return false; 41 | } 42 | 43 | function getHTMLRange(state: EditorState, sel: SelectionRange, reverse?: boolean): RangeObject | undefined { 44 | const node = getStartHTMLNode(state, sel); 45 | const cursor = node.cursor(); 46 | 47 | do { 48 | if (cursor.name === 'OpenTag' || cursor.name === 'SelfClosingTag') { 49 | const ranges = getHTMLCandidates(state, cursor.node); 50 | const range = findRange(sel, ranges, reverse); 51 | if (range) { 52 | return range; 53 | } 54 | } 55 | } while (moveHTMLCursor(cursor, reverse)); 56 | 57 | return; 58 | } 59 | 60 | function getCSSRange(state: EditorState, sel: SelectionRange, reverse?: boolean) { 61 | const node = getStartCSSNode(state, sel); 62 | const cursor = node.cursor(); 63 | 64 | do { 65 | const ranges = getCSSCandidates(state, cursor.node); 66 | const range = findRange(sel, ranges, reverse); 67 | if (range) { 68 | return range; 69 | } 70 | } while (moveCSSCursor(cursor, reverse)); 71 | 72 | return; 73 | } 74 | 75 | function moveHTMLCursor(cursor: TreeCursor, reverse?: boolean): boolean { 76 | const enter = cursor.name === 'Element'; 77 | return reverse ? cursor.prev(enter) : cursor.next(enter); 78 | } 79 | 80 | function moveCSSCursor(cursor: TreeCursor, reverse?: boolean): boolean { 81 | const enter = cssEnter.has(cursor.name); 82 | return reverse ? cursor.prev(enter) : cursor.next(enter); 83 | } 84 | 85 | function getStartHTMLNode(state: EditorState, sel: SelectionRange): SyntaxNode { 86 | let node: SyntaxNode = syntaxTree(state).resolveInner(sel.to, 1); 87 | 88 | // In case if we’re inside tag, find closest start node 89 | let ctx: SyntaxNode | null = node; 90 | while (ctx) { 91 | if (htmlParents.has(ctx.name)) { 92 | return ctx; 93 | } 94 | ctx = ctx.parent; 95 | } 96 | 97 | return node; 98 | } 99 | 100 | function getStartCSSNode(state: EditorState, sel: SelectionRange): SyntaxNode { 101 | let node: SyntaxNode = syntaxTree(state).resolveInner(sel.to, 1); 102 | 103 | // In case if we’re inside tag, find closest start node 104 | let ctx: SyntaxNode | null = node.parent; 105 | while (ctx) { 106 | if (cssParents.has(ctx.name)) { 107 | return ctx; 108 | } 109 | ctx = ctx.parent; 110 | } 111 | 112 | return node; 113 | } 114 | 115 | /** 116 | * Returns candidates for selection from given StartTag or SelfClosingTag 117 | */ 118 | function getHTMLCandidates(state: EditorState, node: SyntaxNode): RangeObject[] { 119 | let result: RangeObject[] = []; 120 | let child = node.firstChild; 121 | while (child) { 122 | if (child.name === 'TagName') { 123 | result.push(child); 124 | } else if (child.name === 'Attribute') { 125 | result.push(child); 126 | const attrName = child.getChild('AttributeName'); 127 | const attrValue = attrValueRange(state, child); 128 | if (attrName && attrValue) { 129 | result.push(attrName, attrValue); 130 | if (substr(state, attrName).toLowerCase() === 'class') { 131 | // For class names, split value into space-separated tokens 132 | result = result.concat(tokenList(substr(state, attrValue))); 133 | } 134 | } 135 | } 136 | child = child.nextSibling; 137 | } 138 | 139 | return result; 140 | } 141 | 142 | /** 143 | * Returns candidates for RuleSet node 144 | */ 145 | function getCSSCandidates(state: EditorState, node: SyntaxNode): RangeObject[] { 146 | let result: RangeObject[] = []; 147 | if (node.name === 'RuleSet') { 148 | const selector = getSelectorRange(node); 149 | result.push(selector); 150 | const block = node.getChild('Block'); 151 | if (block) { 152 | for (const child of block.getChildren('Declaration')) { 153 | result = result.concat(getCSSCandidates(state, child)); 154 | } 155 | } 156 | } else if (node.name === 'Declaration') { 157 | result.push(fullCSSDeclarationRange(node)); 158 | const { name, value } = getPropertyRanges(node); 159 | name && result.push(name); 160 | value && result.push(value); 161 | } 162 | 163 | return result; 164 | } 165 | 166 | function attrValueRange(state: EditorState, attr: SyntaxNode): RangeObject | undefined { 167 | const value = attr.getChild('AttributeValue'); 168 | if (value) { 169 | let { from, to } = value; 170 | const valueStr = substr(state, value); 171 | if (isQuote(valueStr[0])) { 172 | from++; 173 | if (valueStr[0] === valueStr[valueStr.length - 1]) { 174 | to--; 175 | } 176 | } 177 | 178 | if (from !== to) { 179 | return { from, to }; 180 | } 181 | } 182 | 183 | return; 184 | } 185 | 186 | /** 187 | * Returns ranges of tokens in given value. Tokens are space-separated words. 188 | */ 189 | function tokenList(value: string, offset = 0): RangeObject[] { 190 | const ranges: RangeObject[] = []; 191 | const len = value.length; 192 | let pos = 0; 193 | let start = 0; 194 | let end = len; 195 | 196 | while (pos < len) { 197 | end = pos; 198 | const ch = value.charAt(pos++); 199 | if (isSpace(ch)) { 200 | if (start !== end) { 201 | ranges.push({ 202 | from: offset + start, 203 | to: offset + end 204 | }); 205 | } 206 | 207 | while (isSpace(value.charAt(pos))) { 208 | pos++; 209 | } 210 | 211 | start = pos; 212 | } 213 | } 214 | 215 | if (start !== pos) { 216 | ranges.push({ 217 | from: offset + start, 218 | to: offset + pos 219 | }); 220 | } 221 | 222 | return ranges; 223 | } 224 | 225 | function findRange(sel: SelectionRange, ranges: RangeObject[], reverse = false): RangeObject | undefined { 226 | if (reverse) { 227 | ranges = ranges.slice().reverse(); 228 | } 229 | 230 | let needNext = false; 231 | let candidate: RangeObject | undefined; 232 | 233 | for (const r of ranges) { 234 | if (needNext) { 235 | return r; 236 | } 237 | if (r.from === sel.from && r.to === sel.to) { 238 | // This range is currently selected, request next 239 | needNext = true; 240 | } else if (!candidate && (rangeContains(r, sel) || (reverse && r.from <= sel.from) || (!reverse && r.from >= sel.from))) { 241 | candidate = r; 242 | } 243 | } 244 | 245 | return !needNext ? candidate : undefined; 246 | } 247 | -------------------------------------------------------------------------------- /src/commands/split-join-tag.ts: -------------------------------------------------------------------------------- 1 | import type { ChangeSpec, EditorState, StateCommand } from '@codemirror/state'; 2 | import { getTagContext } from '../lib/emmet'; 3 | import { isSpace } from '../lib/utils'; 4 | 5 | export const splitJoinTag: StateCommand = ({ state, dispatch }) => { 6 | const changes: ChangeSpec[] = []; 7 | for (const sel of state.selection.ranges) { 8 | const tag = getTagContext(state, sel.from); 9 | if (tag) { 10 | const { open, close } = tag; 11 | if (close) { 12 | // Join tag: remove tag contents, if any, and add closing slash 13 | let closing = isSpace(getChar(state, open.to - 2)) ? '/' : ' /'; 14 | changes.push({ 15 | from: open.to - 1, 16 | to: close.to, 17 | insert: `${closing}>` 18 | }); 19 | } else { 20 | // Split tag: add closing part and remove closing slash 21 | let insert = ``; 22 | let from = open.to; 23 | let to = open.to; 24 | 25 | if (getChar(state, open.to - 2) === '/') { 26 | from -= 2; 27 | if (isSpace(getChar(state, from - 1))) { 28 | from--; 29 | } 30 | insert = '>' + insert; 31 | } 32 | 33 | changes.push({ from, to, insert }); 34 | } 35 | } 36 | } 37 | 38 | if (changes.length) { 39 | const tr = state.update({ changes }); 40 | dispatch(tr); 41 | return true; 42 | } 43 | 44 | return false; 45 | }; 46 | 47 | function getChar(state: EditorState, pos: number): string { 48 | return state.doc.sliceString(pos, pos + 1); 49 | } 50 | -------------------------------------------------------------------------------- /src/commands/wrap-with-abbreviation.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from 'emmet'; 2 | import { EditorView, keymap, ViewPlugin } from '@codemirror/view'; 3 | import type { ViewUpdate } from '@codemirror/view'; 4 | import { EditorState, StateEffect, StateField } from '@codemirror/state'; 5 | import type { Extension, StateCommand, } from '@codemirror/state'; 6 | import { undo } from '@codemirror/commands'; 7 | import { expand, getOptions, getTagContext } from '../lib/emmet'; 8 | import { getSelectionsFromSnippet, narrowToNonSpace, rangeEmpty, substr } from '../lib/utils'; 9 | import type { RangeObject, ContextTag } from '../lib/types'; 10 | import { lineIndent } from '../lib/output'; 11 | 12 | interface WrapAbbreviation { 13 | abbreviation: string; 14 | range: RangeObject; 15 | options: UserConfig; 16 | context?: ContextTag; 17 | } 18 | 19 | const updateAbbreviation = StateEffect.define(); 20 | 21 | const wrapAbbreviationField = StateField.define({ 22 | create: () => null, 23 | update(value, tr) { 24 | for (const effect of tr.effects) { 25 | if (effect.is(updateAbbreviation)) { 26 | value = effect.value; 27 | } 28 | } 29 | return value; 30 | } 31 | }); 32 | 33 | const wrapTheme = EditorView.baseTheme({ 34 | '.emmet-wrap-with-abbreviation': { 35 | position: 'absolute', 36 | top: 0, 37 | zIndex: 2, 38 | width: '100%' 39 | }, 40 | '.emmet-wrap-with-abbreviation__content': { 41 | background: '#fff', 42 | margin: '0 auto', 43 | padding: '5px', 44 | boxSizing: 'border-box', 45 | width: '100%', 46 | maxWidth: '30em', 47 | borderBottomLeftRadius: '5px', 48 | borderBottomRightRadius: '5px', 49 | boxShadow: '0 3px 10px rgba(0, 0, 0, 0.3)', 50 | }, 51 | '.emmet-wrap-with-abbreviation__content input': { 52 | width: '100%', 53 | boxSizing: 'border-box' 54 | } 55 | }); 56 | 57 | const enterWrapWithAbbreviation: StateCommand = ({ state, dispatch }) => { 58 | const abbr = state.field(wrapAbbreviationField); 59 | if (abbr === null) { 60 | const sel = state.selection.main; 61 | const context = getTagContext(state, sel.from); 62 | const wrapRange = getWrapRange(state, sel, context); 63 | const options = getOptions(state, wrapRange.from); 64 | options.text = getContent(state, wrapRange); 65 | 66 | const tr = state.update({ 67 | effects: [updateAbbreviation.of({ 68 | abbreviation: '', 69 | range: wrapRange, 70 | options, 71 | context 72 | })] 73 | }); 74 | dispatch(tr); 75 | return true; 76 | } 77 | 78 | return false; 79 | } 80 | 81 | const wrapWithAbbreviationPlugin = ViewPlugin.fromClass(class WrapWithAbbreviationViewPlugin { 82 | private widget: HTMLElement | null = null; 83 | private input: HTMLInputElement | null = null; 84 | 85 | update(update: ViewUpdate) { 86 | const { state, view } = update; 87 | const abbr = state.field(wrapAbbreviationField); 88 | if (abbr) { 89 | if (!this.widget) { 90 | this.createInputPanel(view); 91 | } 92 | this.updateAbbreviation(abbr.abbreviation); 93 | } else if (this.widget) { 94 | this.disposeWidget(); 95 | view.focus(); 96 | } 97 | } 98 | 99 | // TODO use @codemirror/panel instead 100 | private createInputPanel(view: EditorView) { 101 | const widget = document.createElement('div'); 102 | widget.className = 'emmet-wrap-with-abbreviation'; 103 | 104 | const content = document.createElement('div'); 105 | content.className = 'emmet-wrap-with-abbreviation__content'; 106 | 107 | const input = document.createElement('input'); 108 | input.placeholder = 'Enter abbreviation'; 109 | 110 | let updated = false; 111 | 112 | const undoUpdate = () => { 113 | if (updated) { 114 | undo(view); 115 | updated = false; 116 | } 117 | }; 118 | 119 | input.addEventListener('input', () => { 120 | const abbr = view.state.field(wrapAbbreviationField); 121 | if (abbr) { 122 | const nextAbbreviation = input.value; 123 | undoUpdate(); 124 | 125 | const nextAbbr: WrapAbbreviation = { 126 | ...abbr, 127 | abbreviation: nextAbbreviation 128 | }; 129 | 130 | if (nextAbbr.abbreviation) { 131 | updated = true; 132 | const { from, to } = nextAbbr.range; 133 | const expanded = expand(view.state, nextAbbr.abbreviation, nextAbbr.options); 134 | const { ranges, snippet } = getSelectionsFromSnippet(expanded, from); 135 | const nextSel = ranges[0]; 136 | 137 | view.dispatch({ 138 | effects: [updateAbbreviation.of(nextAbbr)], 139 | changes: [{ 140 | from, 141 | to, 142 | insert: snippet 143 | }], 144 | selection: { 145 | head: nextSel.from, 146 | anchor: nextSel.to 147 | } 148 | }); 149 | } else { 150 | view.dispatch({ 151 | effects: [updateAbbreviation.of(nextAbbr)], 152 | }); 153 | } 154 | } 155 | }); 156 | 157 | input.addEventListener('keydown', evt => { 158 | if (evt.key === 'Escape' || evt.key === 'Enter') { 159 | if (evt.key === 'Escape') { 160 | undoUpdate(); 161 | } 162 | evt.preventDefault(); 163 | view.dispatch({ 164 | effects: [updateAbbreviation.of(null)] 165 | }); 166 | } 167 | }); 168 | 169 | content.append(input) 170 | widget.append(content); 171 | view.dom.append(widget); 172 | this.widget = widget; 173 | this.input = input; 174 | input.focus(); 175 | } 176 | 177 | private updateAbbreviation(value: string) { 178 | if (this.input && this.input.value !== value) { 179 | this.input.value = value; 180 | } 181 | } 182 | 183 | private disposeWidget() { 184 | if (this.widget) { 185 | this.widget.remove(); 186 | this.widget = this.input = null; 187 | } 188 | } 189 | }); 190 | 191 | export function wrapWithAbbreviation(key = 'Ctrl-w'): Extension[] { 192 | return [ 193 | wrapAbbreviationField, 194 | wrapWithAbbreviationPlugin, 195 | wrapTheme, 196 | keymap.of([{ 197 | key, 198 | run: enterWrapWithAbbreviation 199 | }]) 200 | ]; 201 | } 202 | 203 | function getWrapRange(editor: EditorState, range: RangeObject, context?: ContextTag): RangeObject { 204 | if (rangeEmpty(range) && context) { 205 | // No selection means user wants to wrap current tag container 206 | const { open, close } = context; 207 | const pos = range.from; 208 | 209 | // Check how given point relates to matched tag: 210 | // if it's in either open or close tag, we should wrap tag itself, 211 | // otherwise we should wrap its contents 212 | 213 | if (inRange(open, pos) || (close && inRange(close, pos))) { 214 | return { 215 | from: open.from, 216 | to: close ? close.to : open.to 217 | }; 218 | } 219 | 220 | if (close) { 221 | return narrowToNonSpace(editor, { from: open.to, to: close.from }); 222 | } 223 | } 224 | 225 | return range; 226 | } 227 | 228 | function inRange(range: RangeObject, pt: number): boolean { 229 | return range.from < pt && pt < range.to; 230 | } 231 | 232 | /** 233 | * Returns contents of given region, properly de-indented 234 | */ 235 | function getContent(state: EditorState, range: RangeObject): string | string[] { 236 | const baseIndent = lineIndent(state.doc.lineAt(range.from)); 237 | const srcLines = substr(state, range).split('\n'); 238 | const destLines = srcLines.map(line => { 239 | return line.startsWith(baseIndent) 240 | ? line.slice(baseIndent.length) 241 | : line; 242 | }); 243 | 244 | return destLines; 245 | } 246 | -------------------------------------------------------------------------------- /src/completion-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalConfig } from 'emmet'; 2 | import { EditorState, type Extension, Facet } from '@codemirror/state'; 3 | import { resetCache } from './emmet'; 4 | import { EmmetKnownSyntax } from './types'; 5 | 6 | export interface EmmetEditorOptions { 7 | emmet: EmmetConfig; 8 | } 9 | 10 | export type EnableForSyntax = boolean | string[]; 11 | export type PreviewExtensions = () => Extension; 12 | 13 | export interface EmmetPreviewConfig { 14 | /** Extensions factory for displaying HTML-like abbreviation preview */ 15 | html?: PreviewExtensions; 16 | /** Extensions factory for displaying CSS-like abbreviation preview */ 17 | css?: PreviewExtensions; 18 | 19 | /** Value for `root` options of EditorView: https://codemirror.net/docs/ref/#view.EditorViewConfig.root */ 20 | root?: Document | ShadowRoot; 21 | } 22 | 23 | export interface EmmetConfig { 24 | /** 25 | * A syntax of expanded abbreviations. In most cases, it must be the same syntax 26 | * as in your editor. Currently, CodeMirror doesn’t provide API to get syntax 27 | * name from host editor so you have to specify it manually. 28 | */ 29 | syntax: EmmetKnownSyntax; 30 | 31 | /** Enables abbreviation marking in editor. Works in known syntaxes only */ 32 | mark: EnableForSyntax; 33 | 34 | /** 35 | * Config for proview popup 36 | */ 37 | preview: EmmetPreviewConfig; 38 | 39 | /** 40 | * Enables preview of marked abbreviation. Pass `true` to enable preview for 41 | * all syntaxes or array of modes or Emmet syntax types (`markup` or `stylesheet`) 42 | * where preview should be displayed 43 | */ 44 | previewEnabled: EnableForSyntax; 45 | 46 | /** Mark HTML tag pairs in editor */ 47 | markTagPairs: boolean; 48 | 49 | /** 50 | * Displays open tag preview when caret is inside its matching closing tag. 51 | * Preview is displayed only if open tag has attributes. 52 | * Works only if `markTagPairs` is enabled 53 | */ 54 | previewOpenTag: boolean; 55 | 56 | /** Allow automatic tag pair rename, works only if `markTagPairs` is enabled */ 57 | autoRenameTags: boolean; 58 | 59 | /** 60 | * Force Tab key to apply Emmet autocomplete option 61 | */ 62 | autocompleteTab?: EnableForSyntax; 63 | 64 | /** Quotes to use in generated HTML attribute values */ 65 | attributeQuotes: 'single' | 'double'; 66 | 67 | /** Style for self-closing elements (like `
`) and boolean attributes */ 68 | markupStyle: 'html' | 'xhtml' | 'xml', 69 | 70 | /** 71 | * Enable automatic tag commenting. When enabled, elements generated from Emmet 72 | * abbreviation with `id` and/or `class` attributes will receive a comment 73 | * with these attribute values 74 | */ 75 | comments: boolean; 76 | 77 | /** 78 | * Commenting template. Default value is `\n` 79 | * Outputs everything between `[` and `]` only if specified attribute name 80 | * (written in UPPERCASE) exists in element. Attribute name is replaced with 81 | * actual value. Use `\n` to add a newline. 82 | */ 83 | commentsTemplate?: string; 84 | 85 | /** 86 | * Enable BEM support. When enabled, Emmet will treat class names starting 87 | * with `-` as _element_ and with `_` as _modifier_ in BEM notation. 88 | * These class names will inherit `block` name from current or ancestor element. 89 | * For example, the abbreviation `ul.nav.nav_secondary>li.nav__item` can be 90 | * shortened to `ul.nav._secondary>li.-item` with this option enabled. 91 | */ 92 | bem: boolean; 93 | 94 | /** 95 | * For stylesheet abbreviations, generate short HEX color values, if possible. 96 | * For example, `c#0` will be expanded to `color: #000;` instead of `color: #000000`. 97 | */ 98 | shortHex?: boolean; 99 | 100 | /** Advanced Emmet config */ 101 | config?: GlobalConfig; 102 | 103 | /** 104 | * A `boost` option for CodeMirror completions 105 | */ 106 | completionBoost?: number; 107 | 108 | /** 109 | * Function for attaching abbreviation preview 110 | */ 111 | // attachPreview?: (editor: CodeMirror.Editor, preview: HTMLElement, pos: CodeMirror.Position) => void; 112 | } 113 | 114 | export const defaultConfig: EmmetConfig = { 115 | syntax: EmmetKnownSyntax.html, 116 | mark: true, 117 | preview: { }, 118 | previewEnabled: true, 119 | autoRenameTags: true, 120 | markTagPairs: true, 121 | previewOpenTag: false, 122 | attributeQuotes: 'double', 123 | markupStyle: 'html', 124 | comments: false, 125 | commentsTemplate: '', 126 | bem: false, 127 | completionBoost: 99 128 | }; 129 | 130 | export const config = Facet.define, EmmetConfig>({ 131 | combine(value) { 132 | resetCache(); 133 | const baseConfig: EmmetConfig = { ...defaultConfig }; 134 | const { preview } = baseConfig; 135 | for (const item of value) { 136 | Object.assign(baseConfig, item); 137 | if (item.preview) { 138 | baseConfig.preview = { 139 | ...preview, 140 | ...item.preview 141 | }; 142 | } 143 | } 144 | 145 | return baseConfig; 146 | } 147 | }); 148 | 149 | export default function getEmmetConfig(state: EditorState, opt?: Partial): EmmetConfig { 150 | let conf = state.facet(config); 151 | if (opt) { 152 | conf = { ...conf, ...opt }; 153 | } 154 | 155 | return conf; 156 | } 157 | -------------------------------------------------------------------------------- /src/lib/context.ts: -------------------------------------------------------------------------------- 1 | import { syntaxTree } from '@codemirror/language'; 2 | import { cssLanguage } from '@codemirror/lang-css'; 3 | import { htmlLanguage } from '@codemirror/lang-html'; 4 | import type { EditorState } from '@codemirror/state'; 5 | import type { SyntaxNode } from '@lezer/common'; 6 | import type { CSSContext, CSSMatch, HTMLAncestor, HTMLContext, HTMLType, RangeObject } from './types'; 7 | import { contains, getAttributeValueRange, substr } from './utils'; 8 | 9 | // TODO use RangeObject instead of TextRange 10 | 11 | interface InlineProp { 12 | name: RangeObject; 13 | value?: RangeObject; 14 | } 15 | 16 | const nodeToHTMLType: Record = { 17 | OpenTag: 'open', 18 | CloseTag: 'close', 19 | SelfClosingTag: 'selfClose' 20 | }; 21 | 22 | export function getContext(state: EditorState, pos: number): HTMLContext | CSSContext | undefined { 23 | if (cssLanguage.isActiveAt(state, pos)) { 24 | return getCSSContext(state, pos); 25 | } 26 | 27 | if (htmlLanguage.isActiveAt(state, pos)) { 28 | return getHTMLContext(state, pos); 29 | } 30 | 31 | // const topLang = state.facet(language); 32 | // if (topLang === htmlLanguage) { 33 | // // HTML syntax may embed CSS 34 | // return cssLanguage.isActiveAt(state, pos) 35 | // ? getCSSContext(state, pos) 36 | // : getHTMLContext(state, pos); 37 | // } 38 | 39 | // if (topLang === cssLanguage) { 40 | // return getCSSContext(state, pos); 41 | // } 42 | 43 | return; 44 | } 45 | 46 | /** 47 | * Returns CSS context for given location in source code 48 | */ 49 | export function getCSSContext(state: EditorState, pos: number, embedded?: RangeObject) { 50 | const result: CSSContext = { 51 | type: 'css', 52 | ancestors: [], 53 | current: null, 54 | inline: false, 55 | embedded 56 | }; 57 | 58 | const tree = syntaxTree(state).resolveInner(pos, -1); 59 | const stack: CSSMatch[] = []; 60 | 61 | for (let node: SyntaxNode | null = tree; node; node = node.parent) { 62 | if (node.name === 'RuleSet') { 63 | const sel = getSelectorRange(node); 64 | stack.push({ 65 | name: substr(state, sel), 66 | type: 'selector', 67 | range: node 68 | }); 69 | } else if (node.name === 'Declaration') { 70 | const { name, value } = getPropertyRanges(node); 71 | if (value && contains(value, pos)) { 72 | // Direct hit on CSS value 73 | stack.push({ 74 | name: substr(state, value), 75 | type: 'propertyValue', 76 | range: value 77 | }); 78 | } 79 | 80 | if (name) { 81 | stack.push({ 82 | name: substr(state, name), 83 | type: 'propertyName', 84 | range: name 85 | }); 86 | } 87 | } 88 | } 89 | 90 | const tip = stack.shift(); 91 | 92 | // Check if stack tip contains current position: make it current 93 | // context item if so 94 | if (tip) { 95 | const range: RangeObject = tip.type === 'selector' 96 | ? { from: tip.range.from, to: tip.range.from + tip.name.length } 97 | : tip.range; 98 | if (contains(range, pos)) { 99 | result.current = tip; 100 | tip.range = range; 101 | } else { 102 | stack.unshift(tip); 103 | } 104 | } 105 | 106 | result.ancestors = stack.reverse() 107 | return result; 108 | } 109 | 110 | export function getHTMLContext(state: EditorState, pos: number): HTMLContext { 111 | const result: HTMLContext = { 112 | type: 'html', 113 | ancestors: [], 114 | current: null, 115 | }; 116 | 117 | const tree = syntaxTree(state).resolveInner(pos); 118 | 119 | for (let node: SyntaxNode | null = tree; node; node = node ? node.parent : null) { 120 | if (node.name in nodeToHTMLType) { 121 | const m = getContextMatchFromTag(state, node); 122 | if (m) { 123 | result.current = { 124 | ...m, 125 | type: nodeToHTMLType[node.name] 126 | }; 127 | 128 | // Skip `Element` parent from ancestors stack 129 | node = node.parent; 130 | } 131 | } else if (node.name === 'Element') { 132 | const child = node.getChild('OpenTag'); 133 | if (child) { 134 | const m = getContextMatchFromTag(state, child); 135 | if (m) { 136 | result.ancestors.push(m); 137 | } 138 | } 139 | } 140 | } 141 | 142 | result.ancestors.reverse(); 143 | detectCSSContextFromHTML(state, pos, result); 144 | return result; 145 | } 146 | 147 | function detectCSSContextFromHTML(state: EditorState, pos: number, ctx: HTMLContext) { 148 | if (ctx.current?.type === 'open') { 149 | // Maybe inline CSS? E.g. style="..." attribute 150 | let node: SyntaxNode | null = syntaxTree(state).resolve(ctx.current.range.from, 1); 151 | while (node && node.name !== 'OpenTag') { 152 | node = node.parent; 153 | } 154 | 155 | if (node) { 156 | for (const attr of node.getChildren('Attribute')) { 157 | if (attr.from > pos) { 158 | break; 159 | } 160 | 161 | if (contains(attr, pos) && getAttributeName(state, attr) === 'style') { 162 | const attrValue = attr.getChild('AttributeValue'); 163 | if (attrValue) { 164 | const cleanValueRange = getAttributeValueRange(state, attrValue); 165 | if (contains(cleanValueRange, pos)) { 166 | ctx.css = getInlineCSSContext(substr(state, cleanValueRange), pos - cleanValueRange.from, cleanValueRange.from); 167 | } 168 | } 169 | } 170 | } 171 | } 172 | } 173 | } 174 | 175 | function getContextMatchFromTag(state: EditorState, node: SyntaxNode): HTMLAncestor | void { 176 | const tagName = node.getChild('TagName'); 177 | if (tagName) { 178 | return { 179 | name: substr(state, tagName).toLowerCase(), 180 | range: node 181 | }; 182 | } 183 | } 184 | 185 | /** 186 | * Returns range of CSS selector from given rule block 187 | */ 188 | export function getSelectorRange(node: SyntaxNode): RangeObject { 189 | let from = node.from; 190 | let to = from; 191 | for (let child = node.firstChild; child && child.name !== 'Block'; child = child.nextSibling) { 192 | to = child.to; 193 | } 194 | 195 | return { from, to }; 196 | } 197 | 198 | /** 199 | * Returns CSS property name and value ranges. 200 | * @param node The `name: Declaration` node 201 | */ 202 | export function getPropertyRanges(node: SyntaxNode): { name: RangeObject | undefined, value: RangeObject | undefined } { 203 | let name: RangeObject | undefined; 204 | let value: RangeObject | undefined; 205 | let ptr = node.firstChild; 206 | if (ptr?.name === 'PropertyName') { 207 | name = ptr; 208 | ptr = ptr.nextSibling; 209 | if (ptr?.name === ':') { 210 | ptr = ptr.nextSibling; 211 | } 212 | 213 | if (ptr) { 214 | value = { 215 | from: ptr.from, 216 | to: node.lastChild!.to 217 | }; 218 | } 219 | } 220 | 221 | return { name, value }; 222 | } 223 | 224 | function getAttributeName(state: EditorState, node: SyntaxNode): string { 225 | const name = node.getChild('AttributeName'); 226 | return name ? substr(state, name).toLowerCase() : ''; 227 | } 228 | 229 | /** 230 | * Returns context for inline CSS 231 | */ 232 | export function getInlineCSSContext(code: string, pos: number, base = 0): CSSContext { 233 | // Currently, CodeMirror doesn’t provide syntax highlighting so we’ll perform 234 | // quick and naive persing of CSS properties 235 | const result: CSSContext = { 236 | type: 'css', 237 | ancestors: [], 238 | current: null, 239 | inline: true, 240 | embedded: { 241 | from: pos + base, 242 | to: pos + base + code.length 243 | } 244 | }; 245 | 246 | const props = parseInlineProps(code, pos); 247 | 248 | for (const prop of props) { 249 | if (prop.value && contains(prop.value, pos)) { 250 | result.current = { 251 | name: code.substring(prop.value.from, prop.value.to).trim(), 252 | type: 'propertyValue', 253 | range: { 254 | from: base + prop.value.from, 255 | to: base + prop.value.to 256 | } 257 | }; 258 | result.ancestors.push({ 259 | name: code.substring(prop.name.from, prop.name.to).trim(), 260 | type: 'propertyName', 261 | range: { 262 | from: base + prop.name.from, 263 | to: base + prop.value.to 264 | } 265 | }); 266 | break; 267 | } else if (contains(prop.name, pos)) { 268 | const end = prop.value ? prop.value.to : prop.name.to; 269 | result.current = { 270 | name: code.substring(prop.name.from, prop.name.to).trim(), 271 | type: 'propertyName', 272 | range: { 273 | from: base + prop.name.from, 274 | to: base + end 275 | } 276 | }; 277 | break; 278 | } 279 | } 280 | 281 | return result; 282 | } 283 | 284 | export function parseInlineProps(code: string, limit = code.length): InlineProp[] { 285 | const space = ' \t\n\r'; 286 | const propList: InlineProp[] = []; 287 | let prop: InlineProp | undefined; 288 | 289 | for (let i = 0; i < code.length; i++) { 290 | const ch = code[i]; 291 | if (prop) { 292 | if (prop.value) { 293 | if (prop.value.from !== -1) { 294 | prop.value.to = i; 295 | } 296 | } else { 297 | prop.name.to = i; 298 | } 299 | } 300 | 301 | if (ch === ';') { 302 | prop = undefined; 303 | if (i > limit) { 304 | break; 305 | } 306 | } else if (ch === ':') { 307 | if (prop && !prop.value) { 308 | prop.value = { from: -1, to: -1 }; 309 | } 310 | } else { 311 | if (prop) { 312 | if (prop.value?.from === -1 && !space.includes(ch)) { 313 | prop.value.from = prop.value.to = i; 314 | } 315 | } else if (!space.includes(ch)) { 316 | prop = { 317 | name: { from: i, to: i } 318 | }; 319 | propList.push(prop); 320 | } 321 | } 322 | } 323 | 324 | // Finalize state for trailing character 325 | if (prop) { 326 | if (prop.value) { 327 | prop.value.to++; 328 | } else { 329 | prop.name.to++; 330 | } 331 | } 332 | 333 | return propList; 334 | } 335 | -------------------------------------------------------------------------------- /src/lib/emmet.ts: -------------------------------------------------------------------------------- 1 | import type { EditorState } from '@codemirror/state'; 2 | import { syntaxTree } from '@codemirror/language'; 3 | import type { SyntaxNode } from '@lezer/common'; 4 | import expandAbbreviation, { extract as extractAbbreviation, resolveConfig } from 'emmet'; 5 | import type { UserConfig, AbbreviationContext, ExtractedAbbreviation, Options, ExtractOptions, MarkupAbbreviation, StylesheetAbbreviation, SyntaxType } from 'emmet'; 6 | import { syntaxInfo, getMarkupAbbreviationContext, getStylesheetAbbreviationContext } from './syntax'; 7 | import { getTagAttributes, substr } from './utils'; 8 | import getEmmetConfig from './config'; 9 | import getOutputOptions, { field } from './output'; 10 | import { EmmetKnownSyntax, type ContextTag } from './types'; 11 | 12 | export interface ExtractedAbbreviationWithContext extends ExtractedAbbreviation { 13 | context?: AbbreviationContext; 14 | inline?: boolean; 15 | } 16 | 17 | /** 18 | * Cache for storing internal Emmet data. 19 | * TODO reset whenever user settings are changed 20 | */ 21 | let cache = {}; 22 | 23 | export const JSX_PREFIX = '<'; 24 | 25 | /** 26 | * Expands given abbreviation into code snippet 27 | */ 28 | export function expand(state: EditorState, abbr: string | MarkupAbbreviation | StylesheetAbbreviation, config?: UserConfig) { 29 | let opt: UserConfig = { cache }; 30 | const outputOpt: Partial = { 31 | 'output.field': field, 32 | }; 33 | 34 | if (config) { 35 | Object.assign(opt, config); 36 | if (config.options) { 37 | Object.assign(outputOpt, config.options); 38 | } 39 | } 40 | 41 | opt.options = outputOpt; 42 | 43 | const pluginConfig = getEmmetConfig(state); 44 | if (pluginConfig.config) { 45 | opt = resolveConfig(opt, pluginConfig.config); 46 | } 47 | 48 | return expandAbbreviation(abbr as string, opt); 49 | } 50 | 51 | /** 52 | * Extracts abbreviation from given source code by detecting actual syntax context. 53 | * For example, if host syntax is HTML, it tries to detect if location is inside 54 | * embedded CSS. 55 | * 56 | * It also detects if abbreviation is allowed at given location: HTML tags, 57 | * CSS selectors may not contain abbreviations. 58 | * @param code Code from which abbreviation should be extracted 59 | * @param pos Location at which abbreviation should be expanded 60 | * @param type Syntax of abbreviation to expand 61 | */ 62 | export function extract(code: string, pos: number, type: SyntaxType = 'markup', options?: Partial): ExtractedAbbreviation | undefined { 63 | return extractAbbreviation(code, pos, { 64 | lookAhead: type !== 'stylesheet', 65 | type, 66 | ...options 67 | }); 68 | } 69 | 70 | /** 71 | * Returns matched HTML/XML tag for given point in view 72 | */ 73 | export function getTagContext(state: EditorState, pos: number): ContextTag | undefined { 74 | let element: SyntaxNode | null = syntaxTree(state).resolve(pos, 1); 75 | while (element && element.name !== 'Element') { 76 | element = element.parent; 77 | } 78 | 79 | if (element) { 80 | const selfClose = element.getChild('SelfClosingTag'); 81 | if (selfClose) { 82 | return { 83 | name: getTagName(state, selfClose), 84 | attributes: getTagAttributes(state, selfClose), 85 | open: selfClose 86 | } 87 | } 88 | 89 | const openTag = element.getChild('OpenTag'); 90 | if (openTag) { 91 | const closeTag = element.getChild('CloseTag'); 92 | const ctx: ContextTag = { 93 | name: getTagName(state, openTag), 94 | attributes: getTagAttributes(state, openTag), 95 | open: openTag, 96 | }; 97 | 98 | if (closeTag) { 99 | ctx.close = closeTag; 100 | } 101 | 102 | return ctx; 103 | } 104 | } 105 | 106 | return; 107 | } 108 | 109 | export function getTagName(state: EditorState, node: SyntaxNode): string { 110 | const tagName = node.getChild('TagName'); 111 | return tagName ? substr(state, tagName) : ''; 112 | } 113 | 114 | /** 115 | * Returns Emmet options for given character location in editor 116 | */ 117 | export function getOptions(state: EditorState, pos: number): UserConfig { 118 | const info = syntaxInfo(state, pos); 119 | const { context } = info; 120 | 121 | const config: UserConfig = { 122 | type: info.type, 123 | syntax: info.syntax || EmmetKnownSyntax.html, 124 | options: getOutputOptions(state, info.inline) 125 | }; 126 | 127 | if (context) { 128 | // Set context from syntax info 129 | if (context.type === 'html' && context.ancestors.length) { 130 | config.context = getMarkupAbbreviationContext(state, context); 131 | } else if (context.type === 'css') { 132 | config.context = getStylesheetAbbreviationContext(context); 133 | } 134 | } 135 | 136 | return config; 137 | } 138 | 139 | export function resetCache() { 140 | cache = {}; 141 | } 142 | -------------------------------------------------------------------------------- /src/lib/output.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'emmet'; 2 | import type { EditorState, Line } from '@codemirror/state'; 3 | import getEmmetConfig from './config'; 4 | import { isHTML, docSyntax } from './syntax'; 5 | import { EmmetKnownSyntax } from './types'; 6 | 7 | export default function getOutputOptions(state: EditorState, inline?: boolean): Partial { 8 | const syntax = docSyntax(state) || EmmetKnownSyntax.html; 9 | const config = getEmmetConfig(state); 10 | 11 | const opt: Partial = { 12 | // 'output.baseIndent': lineIndent(state.doc.lineAt(pos)), 13 | // 'output.indent': getIndentation(state), 14 | 'output.field': field, 15 | 'output.indent': '\t', 16 | 'output.format': !inline, 17 | 'output.attributeQuotes': config.attributeQuotes, 18 | 'stylesheet.shortHex': config.shortHex 19 | }; 20 | 21 | if (syntax === EmmetKnownSyntax.html) { 22 | opt['output.selfClosingStyle'] = config.markupStyle; 23 | opt['output.compactBoolean'] = config.markupStyle === 'html'; 24 | } 25 | 26 | if (isHTML(syntax)) { 27 | if (config.comments) { 28 | opt['comment.enabled'] = true; 29 | if (config.commentsTemplate) { 30 | opt['comment.after'] = config.commentsTemplate; 31 | } 32 | } 33 | 34 | opt['bem.enabled'] = config.bem; 35 | } 36 | 37 | return opt; 38 | } 39 | 40 | /** 41 | * Produces tabstop for CodeMirror editor 42 | */ 43 | export function field(index: number, placeholder?: string) { 44 | return placeholder ? `\${${index}:${placeholder}}` : `\${${index}}`; 45 | } 46 | 47 | /** 48 | * Returns indentation of given line 49 | */ 50 | export function lineIndent(line: Line): string { 51 | const indent = line.text.match(/^\s+/); 52 | return indent ? indent[0] : ''; 53 | } 54 | 55 | /** 56 | * Returns token used for single indentation in given editor 57 | */ 58 | export function getIndentation(state: EditorState): string { 59 | const { tabSize } = state; 60 | return tabSize ? ' '.repeat(tabSize) : '\t'; 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/syntax.ts: -------------------------------------------------------------------------------- 1 | import type { SyntaxType, AbbreviationContext } from 'emmet'; 2 | import type { EditorState } from '@codemirror/state'; 3 | import { syntaxTree } from '@codemirror/language'; 4 | import type { SyntaxNode } from '@lezer/common'; 5 | import { getContext } from './context'; 6 | import { HTMLContext, CSSContext, EmmetKnownSyntax } from './types'; 7 | import { last, getTagAttributes } from './utils'; 8 | import getEmmetConfig from './config'; 9 | 10 | const htmlSyntaxes: EmmetKnownSyntax[] = [EmmetKnownSyntax.html, EmmetKnownSyntax.vue]; 11 | const jsxSyntaxes: EmmetKnownSyntax[] = [EmmetKnownSyntax.jsx, EmmetKnownSyntax.tsx]; 12 | const xmlSyntaxes: EmmetKnownSyntax[] = [EmmetKnownSyntax.xml, EmmetKnownSyntax.xsl, ...jsxSyntaxes]; 13 | const cssSyntaxes: EmmetKnownSyntax[] = [EmmetKnownSyntax.css, EmmetKnownSyntax.scss, EmmetKnownSyntax.less]; 14 | const markupSyntaxes: EmmetKnownSyntax[] = [EmmetKnownSyntax.haml, EmmetKnownSyntax.jade, EmmetKnownSyntax.pug, EmmetKnownSyntax.slim, ...htmlSyntaxes, ...xmlSyntaxes, ...jsxSyntaxes]; 15 | const stylesheetSyntaxes: EmmetKnownSyntax[] = [EmmetKnownSyntax.sass, EmmetKnownSyntax.sss, EmmetKnownSyntax.stylus, EmmetKnownSyntax.postcss, ...cssSyntaxes]; 16 | 17 | export interface SyntaxInfo { 18 | type: SyntaxType; 19 | syntax?: string; 20 | inline?: boolean; 21 | context?: HTMLContext | CSSContext; 22 | } 23 | 24 | export interface StylesheetRegion { 25 | range: [number, number]; 26 | syntax: string; 27 | inline?: boolean; 28 | } 29 | 30 | export interface SyntaxCache { 31 | stylesheetRegions?: StylesheetRegion[]; 32 | } 33 | 34 | const enum TokenType { 35 | Selector = "selector", 36 | PropertyName = "propertyName", 37 | PropertyValue = "propertyValue", 38 | BlockEnd = "blockEnd" 39 | } 40 | 41 | const enum CSSAbbreviationScope { 42 | /** Include all possible snippets in match */ 43 | Global = "@@global", 44 | /** Include raw snippets only (e.g. no properties) in abbreviation match */ 45 | Section = "@@section", 46 | /** Include properties only in abbreviation match */ 47 | Property = "@@property", 48 | /** Resolve abbreviation in context of CSS property value */ 49 | Value = "@@value" 50 | } 51 | 52 | /** 53 | * Returns Emmet syntax info for given location in view. 54 | * Syntax info is an abbreviation type (either 'markup' or 'stylesheet') and syntax 55 | * name, which is used to apply syntax-specific options for output. 56 | * 57 | * By default, if given location doesn’t match any known context, this method 58 | * returns `null`, but if `fallback` argument is provided, it returns data for 59 | * given fallback syntax 60 | */ 61 | export function syntaxInfo(state: EditorState, ctx?: number | HTMLContext | CSSContext): SyntaxInfo { 62 | let syntax = docSyntax(state); 63 | let inline: boolean | undefined; 64 | let context = typeof ctx === 'number' ? getContext(state, ctx) : ctx; 65 | 66 | if (context?.type === 'html' && context.css) { 67 | inline = true; 68 | syntax = EmmetKnownSyntax.css; 69 | context = context.css; 70 | } else if (context?.type === 'css') { 71 | syntax = EmmetKnownSyntax.css; 72 | } 73 | 74 | return { 75 | type: getSyntaxType(syntax), 76 | syntax, 77 | inline, 78 | context 79 | }; 80 | } 81 | 82 | /** 83 | * Returns main editor syntax 84 | */ 85 | export function docSyntax(state: EditorState): EmmetKnownSyntax { 86 | return getEmmetConfig(state).syntax; 87 | } 88 | 89 | /** 90 | * Returns Emmet abbreviation type for given syntax 91 | */ 92 | export function getSyntaxType(syntax?: EmmetKnownSyntax): SyntaxType { 93 | return syntax && stylesheetSyntaxes.includes(syntax) ? 'stylesheet' : 'markup'; 94 | } 95 | 96 | /** 97 | * Check if given syntax is XML dialect 98 | */ 99 | export function isXML(syntax: string): syntax is EmmetKnownSyntax { 100 | return xmlSyntaxes.includes(syntax as EmmetKnownSyntax); 101 | } 102 | 103 | /** 104 | * Check if given syntax is HTML dialect (including XML) 105 | */ 106 | export function isHTML(syntax: string): syntax is EmmetKnownSyntax { 107 | return htmlSyntaxes.includes(syntax as EmmetKnownSyntax) || isXML(syntax); 108 | } 109 | 110 | /** 111 | * Check if given syntax name is supported by Emmet 112 | */ 113 | export function isSupported(syntax: string): syntax is EmmetKnownSyntax { 114 | return markupSyntaxes.includes(syntax as EmmetKnownSyntax) 115 | || stylesheetSyntaxes.includes(syntax as EmmetKnownSyntax); 116 | } 117 | 118 | /** 119 | * Check if given syntax is a CSS dialect. Note that it’s not the same as stylesheet 120 | * syntax: for example, SASS is a stylesheet but not CSS dialect (but SCSS is) 121 | */ 122 | export function isCSS(syntax: string): syntax is EmmetKnownSyntax { 123 | return cssSyntaxes.includes(syntax as EmmetKnownSyntax); 124 | } 125 | 126 | /** 127 | * Check if given syntax is JSX dialect 128 | */ 129 | export function isJSX(syntax: string): syntax is EmmetKnownSyntax { 130 | return jsxSyntaxes.includes(syntax as EmmetKnownSyntax); 131 | } 132 | 133 | /** 134 | * Returns context for Emmet abbreviation from given HTML context 135 | */ 136 | export function getMarkupAbbreviationContext(state: EditorState, ctx: HTMLContext): AbbreviationContext | undefined { 137 | const parent = last(ctx.ancestors); 138 | if (parent) { 139 | let node: SyntaxNode | null = syntaxTree(state).resolve(parent.range.from, 1); 140 | while (node && node.name !== 'OpenTag') { 141 | node = node.parent; 142 | } 143 | 144 | return { 145 | name: parent.name, 146 | attributes: node ? getTagAttributes(state, node) : {} 147 | }; 148 | } 149 | 150 | return; 151 | } 152 | 153 | /** 154 | * Returns context for Emmet abbreviation from given CSS context 155 | */ 156 | export function getStylesheetAbbreviationContext(ctx: CSSContext): AbbreviationContext { 157 | if (ctx.inline) { 158 | return { name: CSSAbbreviationScope.Property } 159 | } 160 | 161 | const parent = last(ctx.ancestors); 162 | let scope: string = CSSAbbreviationScope.Global; 163 | if (ctx.current) { 164 | if (ctx.current.type === TokenType.PropertyValue && parent) { 165 | scope = parent.name; 166 | } else if ((ctx.current.type === TokenType.Selector || ctx.current.type === TokenType.PropertyName) && !parent) { 167 | scope = CSSAbbreviationScope.Section; 168 | } 169 | } else if (!parent) { 170 | scope = CSSAbbreviationScope.Section; 171 | } 172 | 173 | return { 174 | name: scope 175 | }; 176 | } 177 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { AbbreviationContext, UserConfig } from 'emmet'; 2 | import type { EditorState, Transaction } from '@codemirror/state'; 3 | 4 | export enum EmmetKnownSyntax { 5 | html = 'html', 6 | xml = 'xml', 7 | xsl = 'xsl', 8 | jsx = 'jsx', 9 | tsx = 'tsx', 10 | vue = 'vue', 11 | haml = 'haml', 12 | jade = 'jade', 13 | pug = 'pug', 14 | slim = 'slim', 15 | css = 'css', 16 | scss = 'scss', 17 | less = 'less', 18 | sass = 'sass', 19 | sss = 'sss', 20 | stylus = 'stylus', 21 | postcss = 'postcss' 22 | } 23 | 24 | export type CSSTokenType = 'selector' | 'propertyName' | 'propertyValue'; 25 | 26 | export interface RangeObject { 27 | from: number; 28 | to: number; 29 | } 30 | 31 | export interface ContextTag extends AbbreviationContext { 32 | open: RangeObject; 33 | close?: RangeObject; 34 | } 35 | 36 | export interface CSSMatch { 37 | /** CSS selector, property or section name */ 38 | name: string; 39 | /** Type of ancestor element */ 40 | type: CSSTokenType; 41 | /** Range of selector or section (just name, not entire block) */ 42 | range: RangeObject; 43 | } 44 | 45 | export interface CSSContext { 46 | type: 'css', 47 | 48 | /** List of ancestor sections for current context */ 49 | ancestors: M[]; 50 | 51 | /** CSS match directly under given position */ 52 | current: M | null; 53 | 54 | /** Whether CSS context is inline, e.g. in `style=""` HTML attribute */ 55 | inline: boolean; 56 | 57 | /** 58 | * If current CSS context is embedded into HTML, this property contains 59 | * range of CSS source in original content 60 | */ 61 | embedded?: RangeObject; 62 | } 63 | 64 | export type HTMLType = 'open' | 'close' | 'selfClose'; 65 | 66 | export interface HTMLContext { 67 | type: 'html', 68 | /** List of ancestor elements for current context */ 69 | ancestors: HTMLAncestor[]; 70 | /** Tag match directly under given position */ 71 | current: HTMLMatch | null; 72 | /** CSS context, if any */ 73 | css?: CSSContext; 74 | } 75 | 76 | export interface HTMLAncestor { 77 | /** Element name */ 78 | name: string; 79 | /** Range of element’s open tag in source code */ 80 | range: RangeObject; 81 | } 82 | 83 | export interface HTMLMatch { 84 | /** Element name */ 85 | name: string; 86 | /** Element type */ 87 | type: HTMLType; 88 | /** Range of matched element in source code */ 89 | range: RangeObject; 90 | } 91 | 92 | export interface StateCommandTarget { 93 | state: EditorState; 94 | dispatch: (transaction: Transaction) => void; 95 | } 96 | 97 | export interface AbbreviationError { 98 | message: string; 99 | pos: number; 100 | } 101 | 102 | export interface StartTrackingParams { 103 | config: UserConfig; 104 | offset?: number; 105 | forced?: boolean; 106 | } 107 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { EditorState } from '@codemirror/state'; 2 | import type { SyntaxNode } from '@lezer/common'; 3 | import type { RangeObject } from './types'; 4 | 5 | /** Characters to indicate tab stop start and end in generated snippet */ 6 | export const tabStopStart = String.fromCodePoint(0xFFF0); 7 | export const tabStopEnd = String.fromCodePoint(0xFFF1); 8 | export const stateKey = '$$emmet'; 9 | 10 | export interface AbbrError { 11 | message: string, 12 | pos: number 13 | } 14 | 15 | export type DisposeFn = () => void; 16 | 17 | export interface EmmetState { 18 | id: string; 19 | tracker?: DisposeFn | null; 20 | tagMatch?: DisposeFn | null; 21 | } 22 | 23 | /** 24 | * Returns copy of region which starts and ends at non-space character 25 | */ 26 | export function narrowToNonSpace(state: EditorState, range: RangeObject): RangeObject { 27 | 28 | const text = substr(state, range); 29 | let startOffset = 0; 30 | let endOffset = text.length; 31 | 32 | while (startOffset < endOffset && isSpace(text[startOffset])) { 33 | startOffset++; 34 | } 35 | 36 | while (endOffset > startOffset && isSpace(text[endOffset - 1])) { 37 | endOffset--; 38 | } 39 | 40 | return { 41 | from: range.from + startOffset, 42 | to: range.from + endOffset 43 | }; 44 | } 45 | 46 | /** 47 | * Returns current caret position for single selection 48 | */ 49 | export function getCaret(state: EditorState): number { 50 | return state.selection.main.from; 51 | } 52 | 53 | /** 54 | * Returns contents of given range or node 55 | */ 56 | export function substr(state: EditorState, range: RangeObject): string { 57 | return state.doc.sliceString(range.from, range.to); 58 | } 59 | 60 | /** 61 | * Check if given range or syntax name contains given position 62 | */ 63 | export function contains(range: RangeObject, pos: number): boolean { 64 | return pos >= range.from && pos <= range.to; 65 | } 66 | 67 | /** 68 | * Returns range of full CSS declaration 69 | */ 70 | export function fullCSSDeclarationRange(node: SyntaxNode): RangeObject { 71 | return { 72 | from: node.from, 73 | to: node.nextSibling?.name === ';' ? node.nextSibling.to : node.to 74 | }; 75 | } 76 | 77 | export function isQuote(ch: string | undefined) { 78 | return ch === '"' || ch === "'"; 79 | } 80 | 81 | /** 82 | * Returns own (unquoted) attribute value range 83 | */ 84 | export function getAttributeValueRange(state: EditorState, node: RangeObject): RangeObject { 85 | let { from, to } = node; 86 | const value = substr(state, node); 87 | if (isQuote(value[0])) { 88 | from++; 89 | } 90 | 91 | if (isQuote(value[value.length - 1])) { 92 | to--; 93 | } 94 | 95 | return { from, to }; 96 | } 97 | 98 | /** 99 | * Returns given HTML element’s attributes as map 100 | */ 101 | export function getTagAttributes(state: EditorState, node: SyntaxNode): Record { 102 | const result: Record = {}; 103 | for (const attr of node.getChildren('Attribute')) { 104 | const attrNameNode = attr.getChild('AttributeName'); 105 | if (attrNameNode) { 106 | const attrName = substr(state, attrNameNode); 107 | const attrValueNode = attr.getChild('AttributeValue'); 108 | result[attrName] = attrValueNode ? substr(state, getAttributeValueRange(state, attrValueNode)) : null; 109 | } 110 | } 111 | 112 | return result; 113 | } 114 | export function isSpace(ch: string): boolean { 115 | return /^[\s\n\r]+$/.test(ch); 116 | } 117 | 118 | export function htmlEscape(str: string): string { 119 | const replaceMap: Record = { 120 | '<': '<', 121 | '>': '>', 122 | '&': '&', 123 | }; 124 | return str.replace(/[<>&]/g, ch => replaceMap[ch]); 125 | } 126 | 127 | /** 128 | * Check if `a` and `b` contains the same range 129 | */ 130 | export function rangesEqual(a: RangeObject, b: RangeObject): boolean { 131 | return a.from === b.from && a.to === b.to; 132 | } 133 | 134 | /** 135 | * Check if range `a` fully contains range `b` 136 | */ 137 | export function rangeContains(a: RangeObject, b: RangeObject): boolean { 138 | return a.from <= b.from && a.to >= b.to; 139 | } 140 | 141 | /** 142 | * Check if given range is empty 143 | */ 144 | export function rangeEmpty(r: RangeObject): boolean { 145 | return r.from === r.to; 146 | } 147 | 148 | /** 149 | * Returns last element in given array 150 | */ 151 | export function last(arr: T[]): T | undefined { 152 | return arr.length > 0 ? arr[arr.length - 1] : undefined; 153 | } 154 | 155 | /** 156 | * Finds and collects selections ranges from given snippet 157 | */ 158 | export function getSelectionsFromSnippet(snippet: string, base = 0): { ranges: RangeObject[], snippet: string } { 159 | // Find and collect selection ranges from snippet 160 | const ranges: RangeObject[] = []; 161 | let result = ''; 162 | let sel: RangeObject | null = null; 163 | let offset = 0; 164 | let i = 0; 165 | let ch: string; 166 | 167 | while (i < snippet.length) { 168 | ch = snippet.charAt(i++); 169 | if (ch === tabStopStart || ch === tabStopEnd) { 170 | result += snippet.slice(offset, i - 1); 171 | offset = i; 172 | 173 | if (ch === tabStopStart) { 174 | sel = { 175 | from: base + result.length, 176 | to: base + result.length 177 | }; 178 | ranges.push(sel); 179 | } else if (sel) { 180 | sel = null; 181 | } 182 | } 183 | } 184 | 185 | if (!ranges.length) { 186 | ranges.push({ 187 | from: snippet.length + base, 188 | to: snippet.length + base 189 | }); 190 | } 191 | 192 | return { 193 | ranges, 194 | snippet: result + snippet.slice(offset) 195 | }; 196 | } 197 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { EditorView, basicSetup } from 'codemirror'; 2 | import { html } from '@codemirror/lang-html'; 3 | import { keymap } from '@codemirror/view'; 4 | import { Prec } from '@codemirror/state'; 5 | 6 | import { 7 | abbreviationTracker, expandAbbreviation, 8 | enterAbbreviationMode, balanceOutward, toggleComment, evaluateMath, 9 | goToNextEditPoint, goToPreviousEditPoint, goToTagPair, incrementNumber1, decrementNumber1, 10 | removeTag, selectNextItem, selectPreviousItem, splitJoinTag, wrapWithAbbreviation 11 | } from './plugin'; 12 | 13 | const text = ` 14 | 15 | 16 | HTML Example 17 | 27 | 28 | 29 | 35 | line 1 36 | line 2 37 | line 3 38 | The indentation tries to be somewhat &quot;do what I mean&quot;... 39 | but might not match your style. 40 | 41 | `; 42 | 43 | new EditorView({ 44 | doc: text, 45 | extensions: [ 46 | basicSetup, 47 | html(), 48 | Prec.high(abbreviationTracker({ 49 | autocompleteTab: ['stylesheet'], 50 | config: { 51 | markup: { 52 | snippets: { 53 | 'foo': 'ul.foo>li.bar+li.baz' 54 | } 55 | }, 56 | stylesheet: { 57 | options: { 58 | 'stylesheet.strictMatch': true 59 | } 60 | }, 61 | } 62 | })), 63 | wrapWithAbbreviation(), 64 | keymap.of([{ 65 | key: 'Cmd-e', 66 | run: expandAbbreviation 67 | },{ 68 | key: 'Cmd-Shift-e', 69 | run: enterAbbreviationMode 70 | }, { 71 | key: 'Cmd-Shift-d', 72 | run: balanceOutward 73 | }, { 74 | key: 'Ctrl-/', 75 | run: toggleComment 76 | }, { 77 | key: 'Ctrl-y', 78 | run: evaluateMath 79 | }, { 80 | key: 'Ctrl-Alt-ArrowLeft', 81 | run: goToPreviousEditPoint 82 | }, { 83 | key: 'Ctrl-Alt-ArrowRight', 84 | run: goToNextEditPoint 85 | }, { 86 | key: 'Ctrl-g', 87 | run: goToTagPair 88 | }, { 89 | key: 'Ctrl-Alt-ArrowUp', 90 | run: incrementNumber1 91 | }, { 92 | key: 'Ctrl-Alt-ArrowDown', 93 | run: decrementNumber1 94 | }, { 95 | key: 'Ctrl-\'', 96 | run: removeTag 97 | }, { 98 | key: 'Ctrl-Shift-\'', 99 | run: splitJoinTag 100 | }, { 101 | key: 'Ctrl-.', 102 | run: selectNextItem 103 | }, { 104 | key: 'Ctrl-,', 105 | run: selectPreviousItem 106 | }]), 107 | ], 108 | parent: document.querySelector('#app')! 109 | }); 110 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | export { default as abbreviationTracker } from './tracker'; 2 | 3 | /* 4 | Emmet commands that should be used as standard CodeMirror commands. 5 | For example: 6 | 7 | ```js 8 | import { keymap } from '@codemirror/view'; 9 | import { html } from '@codemirror/lang-html'; 10 | import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'; 11 | import { balanceOutward } from '@emmetio/codemirror6-plugin'; 12 | 13 | new EditorView({ 14 | state: EditorState.create({ 15 | extensions: [ 16 | basicSetup, 17 | html(), 18 | keymap.of([{ 19 | key: 'Cmd-Shift-d', 20 | run: balanceOutward 21 | }]), 22 | ] 23 | }), 24 | parent: document.body 25 | }) 26 | ``` 27 | */ 28 | export { enterAbbreviationMode, emmetCompletionSource } from './tracker'; 29 | export { config as emmetConfig, type EmmetConfig } from './lib/config'; 30 | export { EmmetKnownSyntax } from './lib/types'; 31 | export { expandAbbreviation } from './commands/expand'; 32 | export { balanceOutward, balanceInward } from './commands/balance'; 33 | export { toggleComment } from './commands/comment'; 34 | export { evaluateMath } from './commands/evaluate-math'; 35 | export { goToNextEditPoint, goToPreviousEditPoint } from './commands/go-to-edit-point'; 36 | export { goToTagPair } from './commands/go-to-tag-pair'; 37 | export { 38 | incrementNumber1, decrementNumber1, 39 | incrementNumber01, decrementNumber01, 40 | incrementNumber10, decrementNumber10 41 | } from './commands/inc-dec-number'; 42 | export { removeTag } from './commands/remove-tag'; 43 | export { selectNextItem, selectPreviousItem } from './commands/select-item'; 44 | export { splitJoinTag } from './commands/split-join-tag'; 45 | export { wrapWithAbbreviation } from './commands/wrap-with-abbreviation'; 46 | -------------------------------------------------------------------------------- /src/tracker/AbbreviationPreviewWidget.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from 'codemirror'; 2 | import { EditorState } from '@codemirror/state'; 3 | import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'; 4 | import { html } from '@codemirror/lang-html'; 5 | import { css } from '@codemirror/lang-css'; 6 | import type { EmmetPreviewConfig, PreviewExtensions } from '../lib/config'; 7 | import { EmmetKnownSyntax } from '../plugin'; 8 | 9 | export interface HTMLElementPreview extends HTMLElement { 10 | update?: (value: string) => void; 11 | } 12 | 13 | export function createPreview(value: string, syntax: string, options?: EmmetPreviewConfig): HTMLElementPreview { 14 | const elem = document.createElement('div') as HTMLElementPreview; 15 | elem.className = 'emmet-preview'; 16 | if (syntax === 'error') { 17 | elem.classList.add('emmet-preview_error'); 18 | } 19 | 20 | let ext: PreviewExtensions = syntax === EmmetKnownSyntax.css ? css : html; 21 | if (options && syntax in options) { 22 | ext = options[syntax as keyof EmmetPreviewConfig] as PreviewExtensions; 23 | } 24 | 25 | const view = new EditorView({ 26 | doc: value, 27 | root: options?.root, 28 | extensions: [ 29 | EditorState.readOnly.of(true), 30 | syntaxHighlighting(defaultHighlightStyle, { fallback: true }), 31 | syntax === EmmetKnownSyntax.css ? css() : html(), 32 | ext() 33 | ], 34 | parent: elem 35 | }); 36 | 37 | elem.update = (nextValue) => { 38 | const tr = view.state.update({ 39 | changes: { 40 | from: 0, 41 | to: view.state.doc.length, 42 | insert: nextValue 43 | } 44 | }); 45 | view.dispatch(tr); 46 | }; 47 | 48 | return elem; 49 | } 50 | -------------------------------------------------------------------------------- /src/tracker/index.ts: -------------------------------------------------------------------------------- 1 | import type { MarkupAbbreviation, StylesheetAbbreviation, UserConfig } from 'emmet'; 2 | import { markupAbbreviation } from 'emmet'; 3 | import { ViewPlugin, Decoration, keymap, EditorView, showTooltip } from '@codemirror/view'; 4 | import type { DecorationSet, Command, Tooltip, ViewUpdate } from '@codemirror/view'; 5 | import { StateEffect, StateField } from '@codemirror/state'; 6 | import type { Range, EditorState, Extension, StateCommand, Transaction } from '@codemirror/state'; 7 | import { htmlLanguage } from '@codemirror/lang-html'; 8 | import { cssLanguage } from '@codemirror/lang-css'; 9 | import { snippet, pickedCompletion, completionStatus, selectedCompletion, acceptCompletion } from '@codemirror/autocomplete'; 10 | import type { CompletionResult, Completion, CompletionSource } from '@codemirror/autocomplete'; 11 | import { getCSSContext, getHTMLContext } from '../lib/context'; 12 | import { docSyntax, getMarkupAbbreviationContext, getStylesheetAbbreviationContext, getSyntaxType, isCSS, isHTML, isJSX, isSupported } from '../lib/syntax'; 13 | import getOutputOptions from '../lib/output'; 14 | import { type CSSContext, type AbbreviationError, type StartTrackingParams, type RangeObject, EmmetKnownSyntax } from '../lib/types'; 15 | import { contains, getCaret, rangeEmpty, substr } from '../lib/utils'; 16 | import { expand } from '../lib/emmet'; 17 | import { type HTMLElementPreview, createPreview } from './AbbreviationPreviewWidget'; 18 | import icon from '../completion-icon.svg'; 19 | import getEmmetConfig, { config, type EmmetPreviewConfig, type EmmetConfig } from '../lib/config'; 20 | 21 | interface EmmetCompletion extends Completion { 22 | tracker: AbbreviationTrackerValid; 23 | previewConfig: EmmetPreviewConfig; 24 | preview?: HTMLElementPreview; 25 | } 26 | 27 | interface EmmetTooltip extends Tooltip { 28 | tracker: AbbreviationTracker; 29 | } 30 | 31 | type AbbreviationTracker = AbbreviationTrackerValid | AbbreviationTrackerError; 32 | 33 | /// CSS property and value keyword completion source. 34 | // Проблема мигающего автокомплита в том, что он становится ActiveSource, 35 | // а не ActiveResult, из-за этого помечется как Pending и не обновляется на первый 36 | // проход. 37 | // Текущая реализация укладывается в нужную концепцию, 38 | // но проверка автокомплита обрабатывается раньше, чем обновляется трэкер. 39 | // Нужно найти способ обновить трэкер раньше, чем отработает код автокомплита 40 | export const emmetCompletionSource: CompletionSource = context => { 41 | const tracker = context.state.field(trackerField); 42 | if (tracker?.type === 'abbreviation' && tracker.preview && contains(tracker.range, context.pos)) { 43 | return { 44 | from: tracker.range.from, 45 | to: tracker.range.to, 46 | filter: false, 47 | update(current, _from, _to, context) { 48 | const tracker = context.state.field(trackerField); 49 | if (!tracker || tracker.type === 'error' || !contains(tracker.range, context.pos)) { 50 | return null; 51 | } 52 | 53 | return { 54 | ...current, 55 | from: tracker.range.from, 56 | to: tracker.range.to, 57 | options: completionOptionsFromTracker(context.state, tracker) 58 | }; 59 | }, 60 | options: completionOptionsFromTracker(context.state, tracker) 61 | } as CompletionResult; 62 | } 63 | 64 | return null; 65 | } 66 | 67 | const cssCompletion: Extension = cssLanguage.data.of({ autocomplete: emmetCompletionSource }); 68 | 69 | interface AbbreviationTrackerBase { 70 | /** Range in editor for abbreviation */ 71 | range: RangeObject; 72 | 73 | /** Actual abbreviation, tracked by current tracker */ 74 | abbreviation: string; 75 | 76 | /** 77 | * Abbreviation was forced, e.g. must remain in editor even if empty or contains 78 | * invalid abbreviation 79 | */ 80 | forced: boolean; 81 | 82 | /** Indicates that current tracker shouldn’t be displayed in editor */ 83 | inactive: boolean; 84 | 85 | /** 86 | * Relative offset from range start where actual abbreviation starts. 87 | * Used tp handle prefixes in abbreviation 88 | */ 89 | offset: number; 90 | 91 | config: UserConfig; 92 | } 93 | 94 | export interface AbbreviationTrackerValid extends AbbreviationTrackerBase { 95 | type: 'abbreviation'; 96 | 97 | /** 98 | * Abbreviation is simple, e.g. contains single element. 99 | * It’s suggested to not display preview for simple abbreviation 100 | */ 101 | simple: boolean; 102 | 103 | /** Preview of expanded abbreviation */ 104 | preview: string; 105 | } 106 | 107 | export interface AbbreviationTrackerError extends AbbreviationTrackerBase { 108 | type: 'error'; 109 | error: AbbreviationError; 110 | } 111 | 112 | export const JSX_PREFIX = '<'; 113 | 114 | const trackerMark = Decoration.mark({ class: 'emmet-tracker' }); 115 | 116 | const resetTracker = StateEffect.define(); 117 | const forceTracker = StateEffect.define(); 118 | 119 | export const enterAbbreviationMode: StateCommand = ({ state, dispatch }) => { 120 | const tr = state.update({ 121 | effects: [forceTracker.of(null)] 122 | }); 123 | dispatch(tr); 124 | return true; 125 | }; 126 | 127 | const trackerField = StateField.define({ 128 | create: () => null, 129 | update(value, tr) { 130 | const hasCompletion = tr.annotation(pickedCompletion); 131 | if (hasCompletion) { 132 | // When completion is applied, always reset tracker 133 | return null; 134 | } 135 | 136 | for (const effect of tr.effects) { 137 | if (effect.is(resetTracker)) { 138 | return null; 139 | } 140 | 141 | if (effect.is(forceTracker)) { 142 | const sel = tr.newSelection.main; 143 | const config = getActivationContext(tr.state, sel.from); 144 | if (config) { 145 | return createTracker(tr.state, sel, { 146 | forced: true, 147 | config 148 | }); 149 | } 150 | } 151 | } 152 | 153 | if (!tr.docChanged) { 154 | return value; 155 | } 156 | return handleUpdate(tr.state, value, tr); 157 | } 158 | }); 159 | 160 | const abbreviationPreview = StateField.define({ 161 | create: getAbbreviationPreview, 162 | update(tooltip, tr) { 163 | if (!tr.docChanged && !tr.selection) { 164 | const tracker = tr.state.field(trackerField); 165 | return tracker ? tooltip : null; 166 | } 167 | return getAbbreviationPreview(tr.state, tooltip); 168 | }, 169 | provide: f => showTooltip.from(f) 170 | }); 171 | 172 | function getAbbreviationPreview(state: EditorState, prevTooltip?: EmmetTooltip | null): EmmetTooltip | null { 173 | const tracker = state.field(trackerField); 174 | 175 | if (tracker && !tracker.inactive && completionStatus(state) !== 'active') { 176 | if (tracker.config.type === 'stylesheet') { 177 | // Do not display preview for CSS since completions are populated 178 | // automatically for this syntax and abbreviation will be a part of 179 | // completion list 180 | return null; 181 | } 182 | 183 | if (prevTooltip && prevTooltip.tracker.type !== tracker.type) { 184 | prevTooltip = null; 185 | } 186 | 187 | const { range } = tracker; 188 | 189 | if (canDisplayPreview(state, tracker)) { 190 | return prevTooltip || { 191 | pos: range.from, 192 | above: false, 193 | arrow: false, 194 | tracker, 195 | create() { 196 | const previewConfig = state.facet(config).preview; 197 | let preview = ''; 198 | let syntax = ''; 199 | 200 | if (tracker.type === 'error') { 201 | preview = tracker.error.message; 202 | syntax = 'error'; 203 | } else { 204 | preview = tracker.preview; 205 | syntax = tracker.config.syntax || EmmetKnownSyntax.html; 206 | } 207 | 208 | const dom = createPreview(preview, syntax, previewConfig); 209 | return { 210 | dom, 211 | update({ state }) { 212 | const tracker = state.field(trackerField); 213 | if (tracker && dom.update) { 214 | const value = tracker.type === 'error' 215 | ? tracker.error.message 216 | : tracker.preview; 217 | dom.update(value); 218 | } 219 | } 220 | } 221 | } 222 | } 223 | } 224 | } 225 | 226 | return null; 227 | } 228 | 229 | const abbreviationTracker = ViewPlugin.fromClass(class { 230 | decorations: DecorationSet; 231 | 232 | constructor() { 233 | this.decorations = Decoration.none; 234 | } 235 | 236 | update(update: ViewUpdate) { 237 | const { state } = update; 238 | 239 | const tracker = state.field(trackerField); 240 | const decors: Range[] = []; 241 | 242 | if (tracker && !tracker.inactive) { 243 | const { range } = tracker; 244 | 245 | if (!rangeEmpty(range) ) { 246 | decors.push(trackerMark.range(range.from, range.to)); 247 | } 248 | this.decorations = Decoration.set(decors, true); 249 | } else { 250 | this.decorations = Decoration.none; 251 | } 252 | } 253 | }, { 254 | decorations: v => v.decorations, 255 | }); 256 | 257 | export function expandTracker(view: EditorView, tracker: AbbreviationTracker): void { 258 | const { from, to } = tracker.range; 259 | const expanded = expand(view.state, tracker.abbreviation, tracker.config); 260 | const fn = snippet(expanded); 261 | 262 | view.dispatch(view.state.update({ 263 | effects: resetTracker.of(null) 264 | })); 265 | fn(view, { label: 'expand' }, from, to); 266 | } 267 | 268 | const tabKeyHandler: Command = (view) => { 269 | const { state } = view; 270 | const completion = selectedCompletion(state) 271 | const tracker = state.field(trackerField, false); 272 | 273 | if (completion && tracker) { 274 | // Check if we can use Tab key to apply Emmet completion 275 | if (completion.type === 'emmet') { 276 | const { autocompleteTab } = getEmmetConfig(state); 277 | if (!autocompleteTab) { 278 | return false; 279 | } 280 | 281 | if (Array.isArray(autocompleteTab)) { 282 | const { type, syntax } = tracker.config 283 | if (!autocompleteTab.includes(type!) && !autocompleteTab.includes(syntax!)) { 284 | return false; 285 | } 286 | } 287 | 288 | // Accept completion 289 | acceptCompletion(view); 290 | return true; 291 | } else { 292 | // Must be handled by `acceptCompletion` command 293 | return false; 294 | } 295 | } 296 | 297 | if (tracker && !tracker.inactive && contains(tracker.range, getCaret(state))) { 298 | expandTracker(view, tracker); 299 | return true; 300 | } 301 | return false; 302 | }; 303 | 304 | const escKeyHandler: Command = ({ state, dispatch }) => { 305 | const tracker = state.field(trackerField, false); 306 | if (tracker) { 307 | dispatch({ 308 | effects: resetTracker.of(null) 309 | }); 310 | return true; 311 | } 312 | 313 | return false; 314 | }; 315 | 316 | const trackerTheme = EditorView.baseTheme({ 317 | '.emmet-tracker': { 318 | textDecoration: 'underline 1px green', 319 | }, 320 | '.emmet-preview': { 321 | fontSize: '0.9em' 322 | }, 323 | '.emmet-preview_error': { 324 | color: 'red' 325 | }, 326 | '.cm-completionIcon-emmet::after': { 327 | content: '" "', 328 | background: `url("${icon}") center/contain no-repeat`, 329 | display: 'inline-block', 330 | width: '11px', 331 | height: '11px', 332 | verticalAlign: 'middle' 333 | } 334 | }); 335 | 336 | /** 337 | * A factory function that creates abbreviation tracker for known syntaxes. 338 | * When user starts typing, it detects whether user writes abbreviation and 339 | * if so, starts tracking by displaying an underline. Then if user hit Tab key 340 | * when cursor is inside tracked abbreviation, it will expand it. Or user can 341 | * press Escape key to reset tracker 342 | */ 343 | export default function tracker(options?: Partial): Extension[] { 344 | return [ 345 | trackerField, 346 | abbreviationTracker, 347 | abbreviationPreview, 348 | trackerTheme, 349 | cssCompletion, 350 | options ? config.of(options) : [], 351 | keymap.of([{ 352 | key: 'Tab', 353 | run: tabKeyHandler 354 | }, { 355 | key: 'Escape', 356 | run: escKeyHandler 357 | }]) 358 | ] 359 | } 360 | 361 | export { resetTracker as trackerResetAction } 362 | 363 | /** 364 | * Check if abbreviation tracking is allowed in editor at given location 365 | */ 366 | export function allowTracking(state: EditorState): boolean { 367 | return isSupported(docSyntax(state)); 368 | } 369 | 370 | /** 371 | * Detects if user is typing abbreviation at given location 372 | * @param pos Location where user started typing 373 | * @param input Text entered at `pos` location 374 | */ 375 | function typingAbbreviation(state: EditorState, pos: number, input: string): AbbreviationTracker | null { 376 | if (input.length !== 1) { 377 | // Expect single character enter to start abbreviation tracking 378 | return null; 379 | } 380 | 381 | // Start tracking only if user starts abbreviation typing: entered first 382 | // character at the word bound 383 | const line = state.doc.lineAt(pos); 384 | const prefix = line.text.substring(Math.max(0, pos - line.from - 1), pos - line.from); 385 | 386 | // Check if current syntax is supported for tracking 387 | if (!canStartTyping(prefix, input, getSyntaxFromPos(state, pos))) { 388 | return null; 389 | } 390 | 391 | const config = getActivationContext(state, pos); 392 | if (!config) { 393 | return null; 394 | } 395 | 396 | // Additional check for stylesheet abbreviation start: it’s slightly 397 | // differs from markup prefix, but we need activation context 398 | // to ensure that context under caret is CSS 399 | if (config.type === 'stylesheet') { 400 | if (!canStartTyping(prefix, input, EmmetKnownSyntax.css)) { 401 | return null; 402 | } 403 | 404 | // Do not trigger abbreviation tracking inside CSS property value. 405 | // Allow it for colors only 406 | const ctxName = config.context?.name; 407 | if (ctxName && !ctxName.startsWith('@@') && input !== '#') { 408 | return null; 409 | } 410 | } 411 | 412 | const syntax = config.syntax || EmmetKnownSyntax.html; 413 | let from = pos; 414 | let to = pos + input.length; 415 | let offset = 0; 416 | 417 | if (isJSX(syntax) && prefix === JSX_PREFIX) { 418 | offset = JSX_PREFIX.length; 419 | from -= offset; 420 | } 421 | 422 | return createTracker(state, { from, to }, { config }); 423 | } 424 | 425 | /** 426 | * Detects and returns valid abbreviation activation context for given location 427 | * in editor which can be used for abbreviation expanding. 428 | * For example, in given HTML code: 429 | * `
Hello world
` 430 | * it’s not allowed to expand abbreviations inside `
` or `
`, 431 | * yet it’s allowed inside `style` attribute and between tags. 432 | * 433 | * This method ensures that given `pos` is inside location allowed for expanding 434 | * abbreviations and returns context data about it. 435 | */ 436 | export function getActivationContext(state: EditorState, pos: number): UserConfig | undefined { 437 | if (cssLanguage.isActiveAt(state, pos)) { 438 | return getCSSActivationContext(state, pos, EmmetKnownSyntax.css, getCSSContext(state, pos)); 439 | } 440 | 441 | const syntax = docSyntax(state); 442 | 443 | if (isHTML(syntax)) { 444 | const ctx = getHTMLContext(state, pos); 445 | 446 | if (ctx.css) { 447 | return getCSSActivationContext(state, pos, EmmetKnownSyntax.css, ctx.css); 448 | } 449 | 450 | if (!ctx.current) { 451 | return { 452 | syntax, 453 | type: 'markup', 454 | context: getMarkupAbbreviationContext(state, ctx), 455 | options: getOutputOptions(state) 456 | }; 457 | } 458 | } else { 459 | return { 460 | syntax, 461 | type: getSyntaxType(syntax), 462 | options: getOutputOptions(state) 463 | }; 464 | } 465 | 466 | return undefined; 467 | } 468 | 469 | function getCSSActivationContext(state: EditorState, pos: number, syntax: EmmetKnownSyntax, ctx: CSSContext): UserConfig | undefined { 470 | const allowedContext = !ctx.current 471 | || ctx.current.type === 'propertyName' 472 | || ctx.current.type === 'propertyValue' 473 | || isTypingBeforeSelector(state, pos, ctx); 474 | 475 | if (allowedContext) { 476 | return { 477 | syntax, 478 | type: 'stylesheet', 479 | context: getStylesheetAbbreviationContext(ctx), 480 | options: getOutputOptions(state, ctx.inline) 481 | }; 482 | } 483 | 484 | return; 485 | } 486 | 487 | /** 488 | * Handle edge case: start typing abbreviation before selector. In this case, 489 | * entered character becomes part of selector 490 | * Activate only if it’s a nested section and it’s a first character of selector 491 | */ 492 | function isTypingBeforeSelector(state: EditorState, pos: number, { current }: CSSContext): boolean { 493 | if (current?.type === 'selector' && current.range.from === pos - 1) { 494 | // Typing abbreviation before selector is tricky one: 495 | // ensure it’s on its own line 496 | const line = state.doc.lineAt(current.range.from); 497 | return line.text.trim().length === 1; 498 | } 499 | 500 | return false; 501 | } 502 | 503 | function isValidPrefix(prefix: string, syntax: string): boolean { 504 | if (isJSX(syntax)) { 505 | return prefix === JSX_PREFIX; 506 | } 507 | 508 | if (isCSS(syntax)) { 509 | return prefix === '' || /^[\s>;"\']$/.test(prefix); 510 | } 511 | 512 | return prefix === '' || /^[\s>;"\']$/.test(prefix); 513 | } 514 | 515 | function isValidAbbreviationStart(input: string, syntax: string): boolean { 516 | if (isJSX(syntax)) { 517 | return /^[a-zA-Z.#\[\(]$/.test(input); 518 | } 519 | 520 | if (isCSS(syntax)) { 521 | return /^[a-zA-Z!@#]$/.test(input); 522 | } 523 | 524 | return /^[a-zA-Z.#!@\[\(]$/.test(input); 525 | } 526 | 527 | /** 528 | * Creates abbreviation tracker for given range in editor. Parses contents 529 | * of abbreviation in range and returns either valid abbreviation tracker, 530 | * error tracker or `null` if abbreviation cannot be created from given range 531 | */ 532 | function createTracker(state: EditorState, range: RangeObject, params: StartTrackingParams): AbbreviationTracker | null { 533 | if (range.from > range.to) { 534 | // Invalid range 535 | return null; 536 | } 537 | 538 | let abbreviation = substr(state, range); 539 | const { config, forced } = params; 540 | if (params.offset) { 541 | abbreviation = abbreviation.slice(params.offset); 542 | } 543 | 544 | // Basic validation: do not allow empty abbreviations 545 | // or newlines in abbreviations 546 | if ((!abbreviation && !forced) || hasInvalidChars(abbreviation)) { 547 | return null; 548 | } 549 | 550 | const base: AbbreviationTrackerBase = { 551 | abbreviation, 552 | range, 553 | config, 554 | forced: !!forced, 555 | inactive: false, 556 | offset: params.offset || 0, 557 | } 558 | 559 | try { 560 | let parsedAbbr: MarkupAbbreviation | StylesheetAbbreviation | undefined; 561 | let simple = false; 562 | 563 | if (config.type === 'markup') { 564 | parsedAbbr = markupAbbreviation(abbreviation, { 565 | jsx: config.syntax === 'jsx' 566 | }); 567 | simple = isSimpleMarkupAbbreviation(parsedAbbr); 568 | } 569 | 570 | const previewConfig = createPreviewConfig(config); 571 | const preview = expand(state, parsedAbbr || abbreviation, previewConfig); 572 | if (!preview) { 573 | // Handle edge case: abbreviation didn’t return any result for preview. 574 | // Most likely it means a CSS context where given abbreviation is not applicable 575 | return null; 576 | } 577 | 578 | return { 579 | ...base, 580 | type: 'abbreviation', 581 | simple, 582 | preview, 583 | }; 584 | } catch (error) { 585 | return base.forced ? { 586 | ...base, 587 | type: 'error', 588 | error: error as AbbreviationError, 589 | } : null; 590 | } 591 | } 592 | 593 | function hasInvalidChars(abbreviation: string): boolean { 594 | return /[\r\n]/.test(abbreviation); 595 | } 596 | 597 | /** 598 | * Check if given parsed markup abbreviation is simple.A simple abbreviation 599 | * may not be displayed to user as preview to reduce distraction 600 | */ 601 | function isSimpleMarkupAbbreviation(abbr: MarkupAbbreviation): boolean { 602 | if (abbr.children.length === 1 && !abbr.children[0].children.length) { 603 | // Single element: might be a HTML element or text snippet 604 | const first = abbr.children[0]; 605 | // XXX silly check for common snippets like `!`. Should read contents 606 | // of expanded abbreviation instead 607 | return !first.name || /^[a-z]/i.test(first.name); 608 | } 609 | return !abbr.children.length; 610 | } 611 | 612 | function createPreviewConfig(config: UserConfig) { 613 | return { 614 | ...config, 615 | options: { 616 | ...config.options, 617 | 'output.field': previewField, 618 | 'output.indent': ' ', 619 | 'output.baseIndent': '' 620 | } 621 | }; 622 | } 623 | 624 | function previewField(_: number, placeholder: string) { 625 | return placeholder; 626 | } 627 | 628 | function handleUpdate(state: EditorState, tracker: AbbreviationTracker | null, update: Transaction): AbbreviationTracker | null { 629 | if (hasSnippet(state)) { 630 | return null; 631 | } 632 | 633 | if (!tracker || tracker.inactive) { 634 | // Start abbreviation tracking 635 | update.changes.iterChanges((_fromA, _toA, fromB, _toB, text) => { 636 | if (text.length) { 637 | tracker = typingAbbreviation(state, fromB, text.toString()) || tracker; 638 | } 639 | }); 640 | 641 | if (!tracker || !tracker.inactive) { 642 | return tracker; 643 | } 644 | } 645 | 646 | // Continue abbreviation tracking 647 | update.changes.iterChanges((fromA, toA, fromB, toB, text) => { 648 | if (!tracker) { 649 | return; 650 | } 651 | 652 | const { range } = tracker; 653 | if (!contains(range, fromA)) { 654 | // Update is outside of abbreviation, reset it only if it’s not inactive 655 | if (!tracker.inactive) { 656 | tracker = null; 657 | } 658 | } else if (contains(range, fromB)) { 659 | const removed = toA - fromA; 660 | const inserted = toB - fromA; 661 | const to = range.to + inserted - removed; 662 | if (to <= range.from || hasInvalidChars(text.toString())) { 663 | tracker = null; 664 | } else { 665 | const abbrRange = tracker.inactive ? range : { from: range.from, to }; 666 | const nextTracker = createTracker(state, abbrRange, { 667 | config: tracker.config, 668 | forced: tracker.forced 669 | }); 670 | 671 | if (!nextTracker) { 672 | // Next tracker is empty mostly due to invalid abbreviation. 673 | // To allow users to fix error, keep previous tracker 674 | // instance as inactive 675 | tracker = { ...tracker, inactive: true }; 676 | } else { 677 | tracker = nextTracker; 678 | } 679 | } 680 | } 681 | }); 682 | 683 | return tracker; 684 | } 685 | 686 | function getSyntaxFromPos(state: EditorState, pos: number): EmmetKnownSyntax { 687 | if (cssLanguage.isActiveAt(state, pos)) { 688 | return EmmetKnownSyntax.css; 689 | } 690 | 691 | if (htmlLanguage.isActiveAt(state, pos)) { 692 | return EmmetKnownSyntax.html; 693 | } 694 | 695 | return '' as EmmetKnownSyntax; 696 | } 697 | 698 | function canStartTyping(prefix: string, input: string, syntax: EmmetKnownSyntax) { 699 | return isValidPrefix(prefix, syntax) && isValidAbbreviationStart(input, syntax); 700 | } 701 | 702 | /** 703 | * It’s a VERY hacky way to detect if snippet is currently active in given state. 704 | * Should ask package authors how to properly detect it 705 | */ 706 | function hasSnippet(state: any): boolean { 707 | if (Array.isArray(state.values)) { 708 | return state.values.some((item: any) => item && item.constructor?.name === 'ActiveSnippet'); 709 | } 710 | 711 | return false; 712 | } 713 | 714 | export function canDisplayPreview(state: EditorState, tracker: AbbreviationTracker): boolean { 715 | if (completionStatus(state) === 'active') { 716 | return false; 717 | } 718 | 719 | const config = getEmmetConfig(state); 720 | if (!config.previewEnabled) { 721 | return false; 722 | } 723 | 724 | if (Array.isArray(config.previewEnabled)) { 725 | const { type, syntax } = tracker.config; 726 | if (!config.previewEnabled.includes(type!) && !config.previewEnabled.includes(syntax!)) { 727 | return false; 728 | } 729 | } 730 | 731 | return tracker.type === 'error' || (!tracker.simple || tracker.forced) && !!tracker.abbreviation && contains(tracker.range, getCaret(state)); 732 | } 733 | 734 | function completionOptionsFromTracker(state: EditorState, tracker: AbbreviationTrackerValid, prev?: EmmetCompletion): EmmetCompletion[] { 735 | const opt = state.facet(config); 736 | return [{ 737 | label: 'Emmet abbreviation', 738 | type: 'emmet', 739 | boost: opt.completionBoost, 740 | tracker, 741 | previewConfig: opt.preview, 742 | preview: prev?.preview, 743 | info: completionInfo, 744 | apply: (view, completion) => { 745 | view.dispatch({ 746 | annotations: pickedCompletion.of(completion) 747 | }); 748 | expandTracker(view, tracker); 749 | } 750 | }]; 751 | } 752 | 753 | function completionInfo(completion: Completion): Node { 754 | let { tracker, previewConfig, preview } = completion as EmmetCompletion; 755 | if (preview?.update) { 756 | preview.update(tracker.preview); 757 | } else { 758 | (completion as EmmetCompletion).preview = preview = createPreview(tracker.preview, tracker.config.syntax || EmmetKnownSyntax.html, previewConfig); 759 | } 760 | 761 | return preview; 762 | } 763 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "sourceMap": true, 10 | "resolveJsonModule": true, 11 | "esModuleInterop": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "isolatedModules": true, 16 | "skipLibCheck": true, 17 | "declaration": true, 18 | "emitDeclarationOnly": true, 19 | "outDir": "./dist" 20 | }, 21 | "include": ["./src"] 22 | } 23 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | 3 | export default defineConfig(({ command }) => { 4 | if (command === 'build') { 5 | return { 6 | build: { 7 | target: 'es2017', 8 | sourcemap: true, 9 | minify: false, 10 | lib: { 11 | entry: './src/plugin.ts', 12 | formats: ['es'], 13 | fileName: () => 'plugin.js' 14 | }, 15 | rollupOptions: { 16 | external: /^@(codemirror|lezer)\//, 17 | } 18 | } 19 | }; 20 | } 21 | 22 | return {}; 23 | }); 24 | --------------------------------------------------------------------------------