@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.
- package/dist/PickPopup.js +94 -73
- package/dist/completion.d.ts +28 -0
- package/dist/completion.js +76 -15
- package/dist/editor-language.d.ts +4 -0
- package/dist/editor-language.js +6 -0
- package/dist/host-types.d.ts +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/key-priority.d.ts +1 -0
- package/dist/key-priority.js +2 -0
- package/dist/live-templates.d.ts +16 -0
- package/dist/live-templates.js +289 -0
- package/dist/locales/en.json +30 -0
- package/dist/locales/nl.json +30 -0
- package/dist/markup-context.d.ts +18 -0
- package/dist/markup-context.js +122 -5
- package/dist/markup.d.ts +3 -0
- package/dist/markup.js +16 -4
- package/dist/pick.d.ts +1 -0
- package/dist/pick.js +11 -1
- package/dist/popups.d.ts +1 -0
- package/dist/postfix.d.ts +21 -0
- package/dist/postfix.js +212 -0
- package/dist/project-language.d.ts +2 -0
- package/dist/project-language.js +3 -0
- package/dist/shortcut-keys.d.ts +5 -0
- package/dist/snippet-insert.d.ts +10 -0
- package/dist/snippet-insert.js +217 -0
- package/dist/snippet.d.ts +7 -1
- package/dist/snippet.js +65 -8
- package/dist/surround-constructs.d.ts +10 -0
- package/dist/surround-constructs.js +100 -0
- package/dist/surround.d.ts +18 -0
- package/dist/surround.js +399 -0
- package/dist/template-builtins.d.ts +3 -0
- package/dist/template-builtins.js +46 -0
- package/dist/template-context.d.ts +12 -0
- package/dist/template-context.js +56 -0
- package/dist/templates.d.ts +59 -0
- package/dist/templates.js +103 -0
- package/package.json +6 -6
- package/src/PickPopup.tsx +7 -0
- package/src/completion.ts +111 -13
- package/src/editor-language.ts +6 -0
- package/src/host-types.ts +7 -0
- package/src/index.ts +16 -0
- package/src/key-priority.ts +2 -0
- package/src/live-templates.ts +315 -0
- package/src/locales/en.json +30 -0
- package/src/locales/nl.json +30 -0
- package/src/markup-context.ts +131 -6
- package/src/markup.ts +15 -5
- package/src/pick.ts +17 -1
- package/src/popups.ts +2 -0
- package/src/postfix.ts +224 -0
- package/src/project-language.ts +3 -0
- package/src/snippet-insert.ts +258 -0
- package/src/snippet.ts +82 -7
- package/src/surround-constructs.ts +85 -0
- package/src/surround.ts +430 -0
- package/src/template-builtins.ts +94 -0
- package/src/template-context.ts +65 -0
- 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
|
+
}
|
package/src/locales/en.json
CHANGED
|
@@ -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": {
|
package/src/locales/nl.json
CHANGED
|
@@ -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": {
|
package/src/markup-context.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
|
113
|
+
return null;
|
|
104
114
|
}
|
|
105
115
|
const expanded = expandAbbreviation(found.abbreviation, { syntax, indent: this.indent() });
|
|
106
|
-
return expanded
|
|
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
|
|
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 {
|