@adecore/editor-react 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/PickPopup.js +94 -73
  2. package/dist/completion.d.ts +28 -0
  3. package/dist/completion.js +76 -15
  4. package/dist/editor-language.d.ts +4 -0
  5. package/dist/editor-language.js +6 -0
  6. package/dist/host-types.d.ts +4 -0
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +2 -0
  9. package/dist/key-priority.d.ts +1 -0
  10. package/dist/key-priority.js +2 -0
  11. package/dist/live-templates.d.ts +16 -0
  12. package/dist/live-templates.js +289 -0
  13. package/dist/locales/en.json +30 -0
  14. package/dist/locales/nl.json +30 -0
  15. package/dist/markup-context.d.ts +18 -0
  16. package/dist/markup-context.js +122 -5
  17. package/dist/markup.d.ts +3 -0
  18. package/dist/markup.js +16 -4
  19. package/dist/pick.d.ts +1 -0
  20. package/dist/pick.js +11 -1
  21. package/dist/popups.d.ts +1 -0
  22. package/dist/postfix.d.ts +21 -0
  23. package/dist/postfix.js +212 -0
  24. package/dist/project-language.d.ts +2 -0
  25. package/dist/project-language.js +3 -0
  26. package/dist/shortcut-keys.d.ts +5 -0
  27. package/dist/snippet-insert.d.ts +10 -0
  28. package/dist/snippet-insert.js +217 -0
  29. package/dist/snippet.d.ts +7 -1
  30. package/dist/snippet.js +65 -8
  31. package/dist/surround-constructs.d.ts +10 -0
  32. package/dist/surround-constructs.js +100 -0
  33. package/dist/surround.d.ts +18 -0
  34. package/dist/surround.js +399 -0
  35. package/dist/template-builtins.d.ts +3 -0
  36. package/dist/template-builtins.js +46 -0
  37. package/dist/template-context.d.ts +12 -0
  38. package/dist/template-context.js +56 -0
  39. package/dist/templates.d.ts +59 -0
  40. package/dist/templates.js +103 -0
  41. package/package.json +6 -6
  42. package/src/PickPopup.tsx +7 -0
  43. package/src/completion.ts +111 -13
  44. package/src/editor-language.ts +6 -0
  45. package/src/host-types.ts +7 -0
  46. package/src/index.ts +16 -0
  47. package/src/key-priority.ts +2 -0
  48. package/src/live-templates.ts +315 -0
  49. package/src/locales/en.json +30 -0
  50. package/src/locales/nl.json +30 -0
  51. package/src/markup-context.ts +131 -6
  52. package/src/markup.ts +15 -5
  53. package/src/pick.ts +17 -1
  54. package/src/popups.ts +2 -0
  55. package/src/postfix.ts +224 -0
  56. package/src/project-language.ts +3 -0
  57. package/src/snippet-insert.ts +258 -0
  58. package/src/snippet.ts +82 -7
  59. package/src/surround-constructs.ts +85 -0
  60. package/src/surround.ts +430 -0
  61. package/src/template-builtins.ts +94 -0
  62. package/src/template-context.ts +65 -0
  63. package/src/templates.ts +197 -0
@@ -0,0 +1,289 @@
1
+ import { identifierPrefix, isIdentifierCharacter } from './completion-model.js';
2
+ import { comparePositions } from './diagnostics-model.js';
3
+ import { KEY_PRIORITY } from './key-priority.js';
4
+ import { textBeforeOf } from './line-text.js';
5
+ import { isShortcut } from './shortcut-keys.js';
6
+ import { PostfixFeature } from './postfix.js';
7
+ import { bodyWithIndentUnit, dedentLines, indentUnitOf, insertSnippet } from './snippet-insert.js';
8
+ import { escapeSnippetText } from './snippet.js';
9
+ import { SNIPPET_KIND, sortAfterServer, templateApplies, templateContextAt } from './template-context.js';
10
+ const TOAST_ID = 'language-templates';
11
+ const IDENTIFIER_START = /[\p{L}_$]/u;
12
+ /* Sorts the rows of a list of templates by group, then key. */
13
+ function grouped(template) {
14
+ return `${template.group ?? template.languages[0]}\u0000${template.key}`;
15
+ }
16
+ /* A key that starts in the middle of a word is no key, unless it starts with a character no word has. */
17
+ function startsInsideWord(before, index) {
18
+ return index > 0 && IDENTIFIER_START.test(before[index]) && isIdentifierCharacter(before[index - 1]);
19
+ }
20
+ /* The longest start of `key` that the text before the caret ends with, as a number of characters; 0 when none. */
21
+ function prefixLength(key, before) {
22
+ for (let length = Math.min(key.length, before.length); length >= 1; length--) {
23
+ if (before.endsWith(key.slice(0, length)) && !startsInsideWord(before, before.length - length)) {
24
+ return length;
25
+ }
26
+ }
27
+ return 0;
28
+ }
29
+ /*
30
+ * Live templates: a key typed before the caret and Tab expands a snippet, Insert Live Template lists the ones that
31
+ * apply, and Save as Live Template hands a selection to the host. The templates come from the registry of the
32
+ * project. Tab comes after a running snippet and before Emmet, and a key that starts before an Emmet abbreviation
33
+ * wins over it, as in IntelliJ.
34
+ */
35
+ export class TemplatesFeature {
36
+ language;
37
+ postfix;
38
+ constructor(language) {
39
+ this.language = language;
40
+ this.postfix = new PostfixFeature(language);
41
+ language.onDispose(language.onKey(event => {
42
+ if (event.key === 'Tab' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey && !event.isComposing) {
43
+ return this.expand();
44
+ }
45
+ if (isShortcut(language.shortcuts.insertLiveTemplate, event)) {
46
+ this.insert();
47
+ return true;
48
+ }
49
+ if (isShortcut(language.shortcuts.saveAsLiveTemplate, event)) {
50
+ this.saveSelection();
51
+ return true;
52
+ }
53
+ return false;
54
+ }, KEY_PRIORITY.templates));
55
+ language.onDispose(language.completion.addSource({
56
+ items: request => [...this.rows(request.position), ...this.postfix.rows(request.position)]
57
+ }));
58
+ }
59
+ /*
60
+ * Expands the template whose key ends at the caret, or lists them when several share the key. False where Tab has
61
+ * nothing to expand, and where the key is an Emmet abbreviation, which the markup feature expands on the same Tab.
62
+ */
63
+ expand() {
64
+ const {
65
+ editor,
66
+ completion
67
+ } = this.language;
68
+ const selections = editor.getSelections();
69
+ if (editor.isReadOnly?.() === true || selections.some(selection => comparePositions(selection.start, selection.end) !== 0)) {
70
+ return false;
71
+ }
72
+ const match = this.matchAtCarets(selections.map(selection => selection.start));
73
+ const custom = selections.length === 1 ? this.customKey() : null;
74
+ if (custom !== null && (match === null || comparePositions(match.start, custom.start) >= 0)) {
75
+ return custom.expand?.() ?? false;
76
+ }
77
+ if (match === null) {
78
+ return false;
79
+ }
80
+ if (match.templates.length === 1) {
81
+ this.expandAtCarets(match.templates[0], match.start);
82
+ return true;
83
+ }
84
+ completion.showItems(match.templates.map(template => this.row(template, template.key, grouped(template), {
85
+ start: match.start,
86
+ end: editor.getCaret()
87
+ }, () => this.expandAtCarets(template, match.start))), match.start);
88
+ return true;
89
+ }
90
+ /* Lists the templates that apply, only the ones whose key or description fits the word before the caret when some do. */
91
+ insert() {
92
+ const {
93
+ editor,
94
+ completion,
95
+ project
96
+ } = this.language;
97
+ if (editor.isReadOnly?.() === true) {
98
+ return;
99
+ }
100
+ const selection = editor.getSelection();
101
+ const selected = comparePositions(selection.start, selection.end) !== 0;
102
+ const caret = editor.getCaret();
103
+ const before = textBeforeOf(editor, caret);
104
+ const word = selected ? '' : identifierPrefix(before);
105
+ const wordStart = {
106
+ line: caret.line,
107
+ character: caret.character - word.length
108
+ };
109
+ const context = templateContextAt(this.language, selected ? selection.start : wordStart);
110
+ const applicable = project.templates.live.filter(template => templateApplies(template, context));
111
+ if (applicable.length === 0) {
112
+ const title = project.i18n.t('editor:language.templates.none');
113
+ project.host.notify?.({
114
+ id: TOAST_ID,
115
+ kind: 'error',
116
+ title
117
+ });
118
+ editor.announce(title);
119
+ return;
120
+ }
121
+ const keyed = template => word !== '' && prefixLength(template.key, before) > 0;
122
+ const described = template => word !== '' && (template.description?.toLowerCase().includes(word.toLowerCase()) ?? false);
123
+ const matching = applicable.filter(template => keyed(template) || described(template));
124
+ const start = matching.length === 0 ? caret : wordStart;
125
+ const text = selected ? editor.textInRange(selection) : '';
126
+ const rows = (matching.length === 0 ? applicable : matching).map(template => this.row(template, keyed(template) || matching.length === 0 ? template.key : word, grouped(template), {
127
+ start,
128
+ end: caret
129
+ }, () => {
130
+ const range = selected ? selection : {
131
+ start,
132
+ end: editor.getCaret()
133
+ };
134
+ void this.run(template, [{
135
+ range,
136
+ selectedText: selected ? dedentLines(text) : undefined
137
+ }]);
138
+ }));
139
+ completion.showItems(rows, start);
140
+ }
141
+ /* Hands the selection to the host as a draft of a template; false without a selection or without a host that saves. */
142
+ saveSelection() {
143
+ const {
144
+ editor,
145
+ project
146
+ } = this.language;
147
+ const selection = editor.getSelection();
148
+ if (comparePositions(selection.start, selection.end) === 0) {
149
+ editor.announce(project.i18n.t('editor:language.templates.selectToSave'));
150
+ return false;
151
+ }
152
+ if (project.host.saveAsLiveTemplate === undefined) {
153
+ return false;
154
+ }
155
+ const context = templateContextAt(this.language, selection.start);
156
+ project.host.saveAsLiveTemplate({
157
+ body: escapeSnippetText(dedentLines(editor.textInRange(selection))),
158
+ languages: [context.families[0]],
159
+ places: [context.place]
160
+ });
161
+ return true;
162
+ }
163
+ /* The keys that compete with a live template for Tab: an Emmet abbreviation, then a postfix key. */
164
+ customKey() {
165
+ const abbreviation = this.language.markup.abbreviationAt();
166
+ return abbreviation === null ? this.postfix.key() : {
167
+ start: abbreviation,
168
+ expand: null
169
+ };
170
+ }
171
+ /* The templates every caret has the same key of, ending at the caret, in a context they apply in. */
172
+ matchAtCarets(carets) {
173
+ const matches = carets.map(caret => this.matchAt(caret));
174
+ const primary = matches.at(-1);
175
+ if (primary === null || primary === undefined) {
176
+ return null;
177
+ }
178
+ const templates = primary.templates.filter(template => matches.every(match => match?.templates.some(other => other.id === template.id) === true));
179
+ return templates.length === 0 ? null : {
180
+ start: primary.start,
181
+ templates
182
+ };
183
+ }
184
+ /*
185
+ * The templates whose key is the longest one that ends at the caret, as IntelliJ looks them up: when the longest
186
+ * key found applies nowhere, no shorter key is tried.
187
+ */
188
+ matchAt(caret) {
189
+ const {
190
+ editor,
191
+ project
192
+ } = this.language;
193
+ const {
194
+ live
195
+ } = project.templates;
196
+ const before = textBeforeOf(editor, caret);
197
+ const longest = live.reduce((length, template) => Math.max(length, template.key.length), 0);
198
+ for (let length = Math.min(longest, before.length); length >= 1; length--) {
199
+ const index = before.length - length;
200
+ const key = before.slice(index);
201
+ const found = live.filter(template => template.key === key);
202
+ if (found.length === 0 || startsInsideWord(before, index)) {
203
+ continue;
204
+ }
205
+ const start = {
206
+ line: caret.line,
207
+ character: index
208
+ };
209
+ const context = templateContextAt(this.language, start);
210
+ const templates = found.filter(template => templateApplies(template, context));
211
+ return templates.length === 0 ? null : {
212
+ start,
213
+ templates
214
+ };
215
+ }
216
+ return null;
217
+ }
218
+ /* Replaces the text between `start` and the caret, as far as the primary caret is from it, at every caret. */
219
+ expandAtCarets(template, start) {
220
+ const {
221
+ editor
222
+ } = this.language;
223
+ const length = Math.max(0, editor.getCaret().character - start.character);
224
+ void this.run(template, editor.getSelections().map(selection => ({
225
+ range: {
226
+ start: {
227
+ line: selection.end.line,
228
+ character: Math.max(0, selection.end.character - length)
229
+ },
230
+ end: selection.end
231
+ }
232
+ })));
233
+ }
234
+ run(template, placements) {
235
+ return insertSnippet(this.language, bodyWithIndentUnit(template.body, indentUnitOf(this.language.editor)), placements);
236
+ }
237
+ /* The rows for the word before the caret, each a template whose key starts with it. */
238
+ rows(position) {
239
+ const {
240
+ editor,
241
+ project
242
+ } = this.language;
243
+ if (!project.templates.settings.liveInCompletion) {
244
+ return [];
245
+ }
246
+ const before = textBeforeOf(editor, position);
247
+ if (identifierPrefix(before) === '') {
248
+ return [];
249
+ }
250
+ const contexts = new Map();
251
+ return project.templates.live.flatMap(template => {
252
+ const length = prefixLength(template.key, before);
253
+ if (length === 0) {
254
+ return [];
255
+ }
256
+ const start = {
257
+ line: position.line,
258
+ character: position.character - length
259
+ };
260
+ const context = contexts.get(length) ?? templateContextAt(this.language, start);
261
+ contexts.set(length, context);
262
+ return templateApplies(template, context) ? [this.row(template, template.key, sortAfterServer(template.key), {
263
+ start,
264
+ end: position
265
+ }, () => this.expandAtCarets(template, start))] : [];
266
+ });
267
+ }
268
+ row(template, filterText, sortText, range, accept) {
269
+ const item = {
270
+ label: template.key,
271
+ kind: SNIPPET_KIND,
272
+ detail: template.description,
273
+ documentation: {
274
+ kind: 'markdown',
275
+ value: `\`\`\`\n${template.body}\n\`\`\``
276
+ },
277
+ filterText,
278
+ sortText,
279
+ textEdit: {
280
+ range,
281
+ newText: template.key
282
+ }
283
+ };
284
+ return {
285
+ item,
286
+ accept
287
+ };
288
+ }
289
+ }
@@ -219,6 +219,36 @@
219
219
  "title": "Choose Content to Paste",
220
220
  "empty": "Nothing was copied yet"
221
221
  },
222
+ "templates": {
223
+ "none": "No templates defined in this context",
224
+ "selectToSave": "Select the text to save as a live template"
225
+ },
226
+ "postfix": {
227
+ "expressions": "Expressions"
228
+ },
229
+ "surround": {
230
+ "title": "Surround With",
231
+ "selectTemplate": "Select Template",
232
+ "liveTemplates": "Live templates",
233
+ "configure": "Configure live templates…",
234
+ "comments": "{{construct}} Comments",
235
+ "none": "Couldn't find Surround With variants applicable to the current context",
236
+ "noTemplates": "No surround templates defined in this context",
237
+ "unwrap": {
238
+ "title": "Choose the statement to unwrap/remove",
239
+ "if": "Unwrap 'if...'",
240
+ "else": "Unwrap 'else...'",
241
+ "removeElse": "Remove 'else...'",
242
+ "for": "Unwrap 'for...'",
243
+ "while": "Unwrap 'while...'",
244
+ "do": "Unwrap 'do...'",
245
+ "try": "Unwrap 'try...'",
246
+ "removeCatch": "Remove 'catch...'",
247
+ "removeFinally": "Remove 'finally...'",
248
+ "braces": "Unwrap braces",
249
+ "tag": "Remove enclosing tag: {{name}}"
250
+ }
251
+ },
222
252
  "announce": {
223
253
  "problem": "{{severity}}: {{message}}",
224
254
  "severity": {
@@ -219,6 +219,36 @@
219
219
  "title": "Kies wat je plakt",
220
220
  "empty": "Er is nog niets gekopieerd"
221
221
  },
222
+ "templates": {
223
+ "none": "Geen sjablonen gedefinieerd in deze context",
224
+ "selectToSave": "Selecteer de tekst die je als live sjabloon wilt opslaan"
225
+ },
226
+ "postfix": {
227
+ "expressions": "Expressies"
228
+ },
229
+ "surround": {
230
+ "title": "Omring met",
231
+ "selectTemplate": "Kies een sjabloon",
232
+ "liveTemplates": "Live sjablonen",
233
+ "configure": "Live sjablonen instellen…",
234
+ "comments": "{{construct}}-commentaar",
235
+ "none": "Geen Omring met-varianten gevonden voor de huidige context",
236
+ "noTemplates": "Geen omringende sjablonen gedefinieerd in deze context",
237
+ "unwrap": {
238
+ "title": "Kies het statement om uit te pakken of te verwijderen",
239
+ "if": "'if...' uitpakken",
240
+ "else": "'else...' uitpakken",
241
+ "removeElse": "'else...' verwijderen",
242
+ "for": "'for...' uitpakken",
243
+ "while": "'while...' uitpakken",
244
+ "do": "'do...' uitpakken",
245
+ "try": "'try...' uitpakken",
246
+ "removeCatch": "'catch...' verwijderen",
247
+ "removeFinally": "'finally...' verwijderen",
248
+ "braces": "Accolades uitpakken",
249
+ "tag": "Omringende tag verwijderen: {{name}}"
250
+ }
251
+ },
222
252
  "announce": {
223
253
  "problem": "{{severity}}: {{message}}",
224
254
  "severity": {
@@ -30,8 +30,26 @@ export interface TagPair {
30
30
  readonly to: number;
31
31
  };
32
32
  }
33
+ export interface TagSpan {
34
+ readonly from: number;
35
+ readonly to: number;
36
+ }
37
+ export interface EnclosingElement {
38
+ readonly name: string;
39
+ readonly open: TagSpan;
40
+ readonly close: TagSpan | null;
41
+ }
33
42
  export declare const MAX_MARKUP_SCAN = 1000000;
34
43
  export declare function markupSyntaxOf(languageId: string): MarkupSyntax | null;
35
44
  export declare function isVoidElement(name: string, syntax: MarkupSyntax): boolean;
45
+ export declare function enclosingElements(text: string, offset: number, syntax: MarkupSyntax): EnclosingElement[];
46
+ export interface TextReplacement {
47
+ readonly from: number;
48
+ readonly to: number;
49
+ readonly text: string;
50
+ }
51
+ export declare function lineStartOf(text: string, offset: number): number;
52
+ export declare function lineEndOf(text: string, offset: number): number;
53
+ export declare function removeTagEdits(text: string, element: EnclosingElement, unit: string): TextReplacement[];
36
54
  export declare function markupPlaceAt(text: string, offset: number, syntax: MarkupSyntax): MarkupPlace | null;
37
55
  export declare function tagPairAt(text: string, offset: number, syntax: MarkupSyntax): TagPair | null;
@@ -37,6 +37,7 @@ class Stop {
37
37
  */
38
38
  class MarkupScanner {
39
39
  pairs = [];
40
+ elements = [];
40
41
  text;
41
42
  syntax;
42
43
  until;
@@ -131,7 +132,10 @@ class MarkupScanner {
131
132
  const name = text.slice(nameFrom, nameTo);
132
133
  const end = this.tagEnd(at, closing, name, nameFrom, nameTo);
133
134
  if (closing) {
134
- this.close(name, nameFrom, nameTo);
135
+ this.close(name, nameFrom, nameTo, {
136
+ from: at,
137
+ to: end
138
+ });
135
139
  return end;
136
140
  }
137
141
  const selfClosing = text[end - 2] === '/' && text[end - 1] === '>';
@@ -139,12 +143,19 @@ class MarkupScanner {
139
143
  return end;
140
144
  }
141
145
  if (this.syntax !== 'xml' && RAW_ELEMENTS.has(name.toLowerCase())) {
142
- return this.rawContent(end, name, nameFrom, nameTo);
146
+ return this.rawContent({
147
+ from: at,
148
+ to: end
149
+ }, name, nameFrom, nameTo);
143
150
  }
144
151
  this.open.push({
145
152
  name,
146
153
  nameFrom,
147
- nameTo
154
+ nameTo,
155
+ tag: {
156
+ from: at,
157
+ to: end
158
+ }
148
159
  });
149
160
  return end;
150
161
  }
@@ -224,7 +235,8 @@ class MarkupScanner {
224
235
  return text.length;
225
236
  }
226
237
  /* The content of `script`, `style` and the like is not markup, up to the end tag of the element. */
227
- rawContent(from, name, nameFrom, nameTo) {
238
+ rawContent(tag, name, nameFrom, nameTo) {
239
+ const from = tag.to;
228
240
  const pattern = new RegExp(`</${name}(?=[\\s/>]|$)`, 'gi');
229
241
  pattern.lastIndex = from;
230
242
  const match = pattern.exec(this.text);
@@ -248,10 +260,18 @@ class MarkupScanner {
248
260
  to: closeTo
249
261
  }
250
262
  });
263
+ this.elements.push({
264
+ name,
265
+ open: tag,
266
+ close: {
267
+ from: end,
268
+ to: after
269
+ }
270
+ });
251
271
  return after;
252
272
  }
253
273
  /* An end tag closes the nearest open element of its name and every element left open inside it. */
254
- close(name, nameFrom, nameTo) {
274
+ close(name, nameFrom, nameTo, tag) {
255
275
  const caseless = this.syntax !== 'xml';
256
276
  for (let index = this.open.length - 1; index >= 0; index--) {
257
277
  const element = this.open[index];
@@ -266,11 +286,108 @@ class MarkupScanner {
266
286
  to: nameTo
267
287
  }
268
288
  });
289
+ this.elements.push({
290
+ name: element.name,
291
+ open: element.tag,
292
+ close: tag
293
+ });
269
294
  this.open.length = index;
270
295
  return;
271
296
  }
272
297
  }
273
298
  }
299
+ /* The elements still open at the end of the text. */
300
+ unclosed() {
301
+ return this.open.map(element => ({
302
+ name: element.name,
303
+ open: element.tag,
304
+ close: null
305
+ }));
306
+ }
307
+ }
308
+ /*
309
+ * The elements whose tags hold `offset`, innermost first: each with its start tag and its end tag, which an element left
310
+ * open does not have. Empty for a document too large to read on a keystroke.
311
+ */
312
+ export function enclosingElements(text, offset, syntax) {
313
+ if (text.length > MAX_MARKUP_SCAN) {
314
+ return [];
315
+ }
316
+ const scanner = new MarkupScanner(text, syntax, Infinity);
317
+ scanner.run();
318
+ return [...scanner.elements, ...scanner.unclosed()].filter(element => element.open.from <= offset && offset <= (element.close?.to ?? text.length)).sort((left, right) => right.open.from - left.open.from);
319
+ }
320
+ export function lineStartOf(text, offset) {
321
+ let at = offset;
322
+ while (at > 0 && text[at - 1] !== '\n' && text[at - 1] !== '\r') {
323
+ at--;
324
+ }
325
+ return at;
326
+ }
327
+ export function lineEndOf(text, offset) {
328
+ let at = offset;
329
+ while (at < text.length && text[at] !== '\n' && text[at] !== '\r') {
330
+ at++;
331
+ }
332
+ return at;
333
+ }
334
+ function breakLengthAt(text, offset) {
335
+ if (text.startsWith('\r\n', offset)) {
336
+ return 2;
337
+ }
338
+ return text[offset] === '\n' || text[offset] === '\r' ? 1 : 0;
339
+ }
340
+ /* The tag alone on its line takes the line with it, and the break before it where no break follows. */
341
+ function tagReplacement(text, tag) {
342
+ const start = lineStartOf(text, tag.from);
343
+ const end = lineEndOf(text, tag.to);
344
+ if (text.slice(start, tag.from).trim() !== '' || text.slice(tag.to, end).trim() !== '') {
345
+ return {
346
+ from: tag.from,
347
+ to: tag.to,
348
+ text: ''
349
+ };
350
+ }
351
+ const after = breakLengthAt(text, end);
352
+ const before = start > 0 && after === 0 ? text.startsWith('\r\n', start - 2) ? 2 : 1 : 0;
353
+ return {
354
+ from: start - before,
355
+ to: end + after,
356
+ text: ''
357
+ };
358
+ }
359
+ /*
360
+ * The edits that take an element's tags away and keep what is between them: each tag that stands alone on its line
361
+ * goes with its line, and when the start tag does, the lines it enclosed lose one indentation unit. The offsets are
362
+ * those of `text`, ascending and apart.
363
+ */
364
+ export function removeTagEdits(text, element, unit) {
365
+ const open = tagReplacement(text, element.open);
366
+ const edits = [open];
367
+ if (element.close === null) {
368
+ return edits;
369
+ }
370
+ const close = tagReplacement(text, element.close);
371
+ const openAlone = open.from !== element.open.from || open.to !== element.open.to;
372
+ const closeAlone = close.from !== element.close.from || close.to !== element.close.to;
373
+ const indent = unit === '\t' ? /^\t/ : new RegExp(`^ {1,${unit.length}}`);
374
+ const lastLine = lineStartOf(text, element.close.from);
375
+ let line = lineEndOf(text, element.open.to);
376
+ line += breakLengthAt(text, line);
377
+ while (openAlone && line <= lastLine && line < text.length && !(closeAlone && line === lastLine)) {
378
+ const found = indent.exec(text.slice(line, lineEndOf(text, line)));
379
+ if (found !== null) {
380
+ edits.push({
381
+ from: line,
382
+ to: line + found[0].length,
383
+ text: ''
384
+ });
385
+ }
386
+ line = lineEndOf(text, line);
387
+ line += breakLengthAt(text, line);
388
+ }
389
+ edits.push(close);
390
+ return edits;
274
391
  }
275
392
  /* What `offset` is in; null for a document too large to read on a keystroke. */
276
393
  export function markupPlaceAt(text, offset, syntax) {
package/dist/markup.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { EditorPosition } from '@adecore/editor';
1
2
  import type { EditorLanguage } from './editor-language.ts';
2
3
  export interface MarkupSettings {
3
4
  readonly emmet?: boolean;
@@ -16,6 +17,8 @@ export declare class MarkupFeature {
16
17
  get supported(): boolean;
17
18
  configure(settings: MarkupSettings): void;
18
19
  expand(): boolean;
20
+ abbreviationAt(): EditorPosition | null;
21
+ private abbreviation;
19
22
  startWrap(): boolean;
20
23
  submitWrap(abbreviation: string): boolean;
21
24
  cancelWrap(): void;
package/dist/markup.js CHANGED
@@ -60,6 +60,14 @@ export class MarkupFeature {
60
60
  }
61
61
  /* Expands the abbreviation before the caret, its places to fill as a snippet; false when there is none, which leaves Tab its own meaning. */
62
62
  expand() {
63
+ const found = this.abbreviation();
64
+ return found !== null && this.insert(found.range, found.expanded, found.line);
65
+ }
66
+ /* Where the abbreviation that `expand` would take starts, without expanding it; null under the conditions where `expand` does nothing. */
67
+ abbreviationAt() {
68
+ return this.abbreviation()?.range.start ?? null;
69
+ }
70
+ abbreviation() {
63
71
  const {
64
72
  syntax
65
73
  } = this;
@@ -67,13 +75,13 @@ export class MarkupFeature {
67
75
  editor
68
76
  } = this.language;
69
77
  if (syntax === null || !this.settings.emmet || !this.editable()) {
70
- return false;
78
+ return null;
71
79
  }
72
80
  const caret = editor.getCaret();
73
81
  const line = lineTextOf(editor, caret.line);
74
82
  const found = abbreviationBefore(line, caret.character);
75
83
  if (found === null) {
76
- return false;
84
+ return null;
77
85
  }
78
86
  const range = {
79
87
  start: {
@@ -86,13 +94,17 @@ export class MarkupFeature {
86
94
  }
87
95
  };
88
96
  if (markupPlaceAt(editor.getText(), editor.offsetAt(range.start), syntax)?.kind !== 'text') {
89
- return false;
97
+ return null;
90
98
  }
91
99
  const expanded = expandAbbreviation(found.abbreviation, {
92
100
  syntax,
93
101
  indent: this.indent()
94
102
  });
95
- return expanded !== null && this.insert(range, expanded, line);
103
+ return expanded === null ? null : {
104
+ range,
105
+ expanded,
106
+ line
107
+ };
96
108
  }
97
109
  /* Opens the field for an abbreviation to wrap the selection in, or the text of the caret's line without one. */
98
110
  startWrap() {
package/dist/pick.d.ts CHANGED
@@ -48,6 +48,7 @@ export declare class PickFeature {
48
48
  private requery;
49
49
  private removeChosen;
50
50
  private key;
51
+ private takeMnemonic;
51
52
  private schedulePreview;
52
53
  private publish;
53
54
  }
package/dist/pick.js CHANGED
@@ -260,9 +260,19 @@ export class PickFeature {
260
260
  this.requery(this.query + event.key);
261
261
  return true;
262
262
  }
263
- return false;
263
+ return spec.filter !== true && event.key.length === 1 && this.takeMnemonic(event.key);
264
264
  }
265
265
  }
266
+ /* Takes the visible row whose mnemonic is the typed character, whatever the case. */
267
+ takeMnemonic(character) {
268
+ const wanted = character.toLowerCase();
269
+ const row = this.visibleGroups().flatMap(group => group.rows).find(candidate => candidate.mnemonic?.toLowerCase() === wanted);
270
+ if (row === undefined) {
271
+ return false;
272
+ }
273
+ this.choose(row.id);
274
+ return true;
275
+ }
266
276
  schedulePreview() {
267
277
  const spec = this.spec;
268
278
  this.timers.clear(this.previewTimer);
package/dist/popups.d.ts CHANGED
@@ -55,6 +55,7 @@ export interface PickRow {
55
55
  readonly detail: string;
56
56
  readonly path?: boolean;
57
57
  readonly search?: string;
58
+ readonly mnemonic?: string;
58
59
  }
59
60
  export interface PickGroup {
60
61
  readonly title: string | null;