├── .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 | 
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 |
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 = `${tag.name}>`;
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 "do what I mean"...
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 |
--------------------------------------------------------------------------------