@adecore/editor-react 0.43.0 → 0.44.1

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