@adecore/editor-react 0.41.1 → 0.43.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.
- package/dist/CallHierarchyPanel.js +177 -181
- package/dist/EditorContextMenu.js +118 -105
- package/dist/EditorView.js +7 -1
- package/dist/LanguagePopups.js +7 -0
- package/dist/PickPopup.js +190 -70
- package/dist/WrapCard.d.ts +8 -0
- package/dist/WrapCard.js +172 -0
- package/dist/call-hierarchy-model.d.ts +4 -2
- package/dist/call-hierarchy-model.js +20 -0
- package/dist/call-hierarchy.d.ts +3 -0
- package/dist/call-hierarchy.js +54 -22
- package/dist/clipboard-history.d.ts +31 -0
- package/dist/clipboard-history.js +70 -0
- package/dist/code-actions.d.ts +3 -1
- package/dist/code-actions.js +50 -10
- package/dist/code-vision.js +2 -3
- package/dist/completion.js +10 -15
- package/dist/context-menu-rows.d.ts +1 -1
- package/dist/context-menu-rows.js +4 -0
- package/dist/definition-link.js +15 -5
- package/dist/document-colors.d.ts +24 -0
- package/dist/document-colors.js +194 -0
- package/dist/document-links.d.ts +22 -0
- package/dist/document-links.js +133 -0
- package/dist/document-query.d.ts +15 -0
- package/dist/document-query.js +54 -0
- package/dist/edit-locations.d.ts +20 -0
- package/dist/edit-locations.js +136 -0
- package/dist/editor-language.d.ts +19 -1
- package/dist/editor-language.js +77 -2
- package/dist/emmet.d.ts +13 -0
- package/dist/emmet.js +62 -0
- package/dist/folding.js +2 -3
- package/dist/format-on-type.js +3 -5
- package/dist/highlights.js +2 -3
- package/dist/history.d.ts +7 -0
- package/dist/history.js +79 -1
- package/dist/host-types.d.ts +6 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +8 -0
- package/dist/inlay-hints.js +2 -2
- package/dist/inline-completion.d.ts +39 -0
- package/dist/inline-completion.js +221 -0
- package/dist/key-priority.d.ts +8 -5
- package/dist/key-priority.js +9 -6
- package/dist/linked-editing.d.ts +26 -0
- package/dist/linked-editing.js +215 -0
- package/dist/locales/en.json +31 -2
- package/dist/locales/nl.json +31 -2
- package/dist/markup-context.d.ts +37 -0
- package/dist/markup-context.js +291 -0
- package/dist/markup.d.ts +36 -0
- package/dist/markup.js +453 -0
- package/dist/navigation.js +4 -6
- package/dist/paste-history.d.ts +7 -0
- package/dist/paste-history.js +104 -0
- package/dist/peek.js +3 -4
- package/dist/pick.d.ts +20 -2
- package/dist/pick.js +141 -18
- package/dist/popups.d.ts +9 -0
- package/dist/popups.js +2 -1
- package/dist/project-language.d.ts +18 -1
- package/dist/project-language.js +129 -18
- package/dist/rename.js +8 -11
- package/dist/rendered-docs.js +1 -2
- package/dist/selection-ranges.js +2 -3
- package/dist/shortcut-keys.d.ts +27 -0
- package/dist/signature.js +3 -6
- package/dist/snippet-session.d.ts +2 -2
- package/dist/snippet-session.js +3 -3
- package/dist/symbol-picker.js +4 -6
- package/dist/symbols.js +22 -3
- package/dist/testing.d.ts +23 -1
- package/dist/testing.js +114 -0
- package/package.json +7 -6
- package/src/CallHierarchyPanel.tsx +26 -11
- package/src/EditorContextMenu.tsx +7 -0
- package/src/EditorView.tsx +9 -0
- package/src/LanguagePopups.tsx +4 -0
- package/src/PickPopup.tsx +40 -5
- package/src/WrapCard.tsx +64 -0
- package/src/call-hierarchy-model.ts +25 -3
- package/src/call-hierarchy.ts +62 -20
- package/src/clipboard-history.ts +102 -0
- package/src/code-actions.ts +48 -11
- package/src/code-vision.ts +3 -3
- package/src/completion.ts +15 -15
- package/src/context-menu-rows.ts +3 -2
- package/src/definition-link.ts +17 -5
- package/src/document-colors.ts +189 -0
- package/src/document-links.ts +129 -0
- package/src/document-query.ts +71 -0
- package/src/edit-locations.ts +162 -0
- package/src/editor-language.ts +80 -3
- package/src/emmet-module.d.ts +44 -0
- package/src/emmet.ts +73 -0
- package/src/folding.ts +3 -3
- package/src/format-on-type.ts +5 -5
- package/src/highlights.ts +3 -3
- package/src/history.ts +81 -1
- package/src/host-types.ts +10 -0
- package/src/index.ts +12 -3
- package/src/inlay-hints.ts +2 -2
- package/src/inline-completion.ts +221 -0
- package/src/key-priority.ts +9 -6
- package/src/linked-editing.ts +199 -0
- package/src/locales/en.json +31 -2
- package/src/locales/nl.json +31 -2
- package/src/markup-context.ts +262 -0
- package/src/markup.ts +367 -0
- package/src/navigation.ts +6 -6
- package/src/paste-history.ts +111 -0
- package/src/peek.ts +4 -4
- package/src/pick.ts +201 -19
- package/src/popups.ts +19 -1
- package/src/project-language.ts +136 -19
- package/src/rename.ts +11 -11
- package/src/rendered-docs.ts +2 -2
- package/src/selection-ranges.ts +3 -3
- package/src/signature.ts +6 -6
- package/src/snippet-session.ts +9 -4
- package/src/symbol-picker.ts +6 -6
- package/src/symbols.ts +30 -4
- package/src/testing.ts +126 -1
package/src/markup.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import type { EditorApplyEditsOptions, EditorContentChange, EditorRange, EditorTextChange, EditorTrackedRange } from '@adecore/editor';
|
|
2
|
+
import { abbreviationBefore, expandAbbreviation } from './emmet.ts';
|
|
3
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
4
|
+
import { KEY_PRIORITY } from './key-priority.ts';
|
|
5
|
+
import { lineTextOf } from './line-text.ts';
|
|
6
|
+
import { isVoidElement, markupPlaceAt, markupSyntaxOf, tagPairAt, type MarkupSyntax } from './markup-context.ts';
|
|
7
|
+
import { isShortcut } from './shortcut-keys.ts';
|
|
8
|
+
import { parseSnippet } from './snippet.ts';
|
|
9
|
+
import { indentSnippet } from './snippet-indent.ts';
|
|
10
|
+
import { positionInInsertion } from './snippet-session.ts';
|
|
11
|
+
|
|
12
|
+
/* Each is on unless false. */
|
|
13
|
+
export interface MarkupSettings {
|
|
14
|
+
/* Tab after an abbreviation expands it, as Emmet does. */
|
|
15
|
+
readonly emmet?: boolean;
|
|
16
|
+
/* The `>` of a start tag adds its end tag after the caret, and `</` completes the end tag of the open element. */
|
|
17
|
+
readonly closeTags?: boolean;
|
|
18
|
+
/* Changing the name of a start tag changes the name of its end tag with it, and the other way around. */
|
|
19
|
+
readonly linkedTags?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Span {
|
|
23
|
+
readonly from: number;
|
|
24
|
+
readonly to: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* The tag name the caret is in and the name of its other tag, as offsets of the current text. */
|
|
28
|
+
interface Link {
|
|
29
|
+
readonly own: Span;
|
|
30
|
+
readonly partner: Span;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const NAME = /^[\w:.-]*$/;
|
|
34
|
+
const NAME_BEFORE = /<\/?[\w:.-]*$/;
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* Writing HTML, XML, the template of a Vue file and the markup of PHP: Emmet abbreviations, end tags that
|
|
38
|
+
* write themselves and tag names that change in pairs. The edits it adds join the undo step of the key that
|
|
39
|
+
* caused them. It does nothing in other languages, in a document over `MAX_MARKUP_SCAN` characters, or with more
|
|
40
|
+
* than one caret.
|
|
41
|
+
*/
|
|
42
|
+
export class MarkupFeature {
|
|
43
|
+
private readonly language: EditorLanguage;
|
|
44
|
+
private readonly syntax: MarkupSyntax | null;
|
|
45
|
+
private settings: Required<MarkupSettings> = { emmet: true, closeTags: true, linkedTags: true };
|
|
46
|
+
private link: Link | null = null;
|
|
47
|
+
private wrapping: EditorTrackedRange | null = null;
|
|
48
|
+
/* Raised by every change of the text, so a follow-up edit for an older text is dropped. */
|
|
49
|
+
private revision = 0;
|
|
50
|
+
private mirroring = false;
|
|
51
|
+
|
|
52
|
+
constructor(language: EditorLanguage) {
|
|
53
|
+
this.language = language;
|
|
54
|
+
this.syntax = markupSyntaxOf(language.languageId);
|
|
55
|
+
if (this.syntax === null) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const { editor } = language;
|
|
59
|
+
const offs = [
|
|
60
|
+
language.onKey((event) => this.key(event), KEY_PRIORITY.markup),
|
|
61
|
+
editor.onTextChange((change) => this.edited(change)),
|
|
62
|
+
editor.onCaret(() => this.moved())
|
|
63
|
+
];
|
|
64
|
+
language.onDispose(() => {
|
|
65
|
+
this.cancelWrap();
|
|
66
|
+
for (const off of offs) {
|
|
67
|
+
off();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Whether the language of the document is markup this feature writes. */
|
|
73
|
+
get supported(): boolean {
|
|
74
|
+
return this.syntax !== null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
configure(settings: MarkupSettings): void {
|
|
78
|
+
this.settings = {
|
|
79
|
+
emmet: settings.emmet !== false,
|
|
80
|
+
closeTags: settings.closeTags !== false,
|
|
81
|
+
linkedTags: settings.linkedTags !== false
|
|
82
|
+
};
|
|
83
|
+
if (!this.settings.linkedTags) {
|
|
84
|
+
this.link = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
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
|
+
expand(): boolean {
|
|
90
|
+
const { syntax } = this;
|
|
91
|
+
const { editor } = this.language;
|
|
92
|
+
if (syntax === null || !this.settings.emmet || !this.editable()) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const caret = editor.getCaret();
|
|
96
|
+
const line = lineTextOf(editor, caret.line);
|
|
97
|
+
const found = abbreviationBefore(line, caret.character);
|
|
98
|
+
if (found === null) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
const range = { start: { line: caret.line, character: found.start }, end: { line: caret.line, character: found.end } };
|
|
102
|
+
if (markupPlaceAt(editor.getText(), editor.offsetAt(range.start), syntax)?.kind !== 'text') {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const expanded = expandAbbreviation(found.abbreviation, { syntax, indent: this.indent() });
|
|
106
|
+
return expanded !== null && this.insert(range, expanded, line);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* Opens the field for an abbreviation to wrap the selection in, or the text of the caret's line without one. */
|
|
110
|
+
startWrap(): boolean {
|
|
111
|
+
const { editor, popups } = this.language;
|
|
112
|
+
if (this.syntax === null || !this.editable(true)) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const range = this.wrapTarget();
|
|
116
|
+
if (range === null) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
this.cancelWrap();
|
|
120
|
+
this.wrapping = editor.trackRange(range);
|
|
121
|
+
popups.setState({ wrap: { anchor: range.start, error: null } });
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Wraps the text the field was opened over; false, with the reason in the field, when the abbreviation does not expand. */
|
|
126
|
+
submitWrap(abbreviation: string): boolean {
|
|
127
|
+
const { syntax } = this;
|
|
128
|
+
const { editor, popups, project } = this.language;
|
|
129
|
+
const range = this.wrapping?.get() ?? null;
|
|
130
|
+
if (syntax === null || range === null) {
|
|
131
|
+
this.cancelWrap();
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
const expanded = expandAbbreviation(abbreviation.trim(), { syntax, indent: this.indent(), wrap: this.wrapLines(range) });
|
|
135
|
+
if (expanded === null) {
|
|
136
|
+
popups.setState({ wrap: { anchor: range.start, error: project.i18n.t('editor:language.markup.invalid') } });
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
this.cancelWrap();
|
|
140
|
+
const inserted = this.insert(range, expanded, lineTextOf(editor, range.start.line));
|
|
141
|
+
editor.focus();
|
|
142
|
+
return inserted;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
cancelWrap(): void {
|
|
146
|
+
this.wrapping?.dispose();
|
|
147
|
+
this.wrapping = null;
|
|
148
|
+
if (this.language.popups.getState().wrap !== null) {
|
|
149
|
+
this.language.popups.setState({ wrap: null });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private key(event: KeyboardEvent): boolean {
|
|
154
|
+
if (isShortcut(this.language.shortcuts.wrapWithAbbreviation, event)) {
|
|
155
|
+
return this.startWrap();
|
|
156
|
+
}
|
|
157
|
+
if (event.key !== 'Tab' || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey || event.isComposing) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return this.expand();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private editable(selection = false): boolean {
|
|
164
|
+
const { editor } = this.language;
|
|
165
|
+
const selections = editor.getSelections();
|
|
166
|
+
if (editor.isReadOnly?.() === true || selections.length !== 1) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const { start, end } = selections[0]!;
|
|
170
|
+
return selection || (start.line === end.line && start.character === end.character);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private indent(): string {
|
|
174
|
+
const { tabSize, insertSpaces } = this.language.editor.getIndentation();
|
|
175
|
+
return insertSpaces ? ' '.repeat(tabSize) : '\t';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* The selection without the whitespace around it, or the text of the caret's line; null when that is blank. */
|
|
179
|
+
private wrapTarget(): EditorRange | null {
|
|
180
|
+
const { editor } = this.language;
|
|
181
|
+
const selection = editor.getSelection();
|
|
182
|
+
const text = editor.getText();
|
|
183
|
+
const empty = selection.start.line === selection.end.line && selection.start.character === selection.end.character;
|
|
184
|
+
let from = empty ? editor.offsetAt({ line: selection.start.line, character: 0 }) : editor.offsetAt(selection.start);
|
|
185
|
+
let to = empty ? from + lineTextOf(editor, selection.start.line).length : editor.offsetAt(selection.end);
|
|
186
|
+
while (from < to && /\s/.test(text[from]!)) {
|
|
187
|
+
from++;
|
|
188
|
+
}
|
|
189
|
+
while (to > from && /\s/.test(text[to - 1]!)) {
|
|
190
|
+
to--;
|
|
191
|
+
}
|
|
192
|
+
return from === to ? null : { start: editor.positionAt(from), end: editor.positionAt(to) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* The lines of the wrapped text, without the indentation they share, which the expansion gives them again. */
|
|
196
|
+
private wrapLines(range: EditorRange): string[] {
|
|
197
|
+
const lines = this.language.editor.textInRange(range).split(/\r\n|\r|\n/);
|
|
198
|
+
const indents = lines.slice(1).flatMap((line) => (line.trim() === '' ? [] : [/^[\t ]*/.exec(line)![0].length]));
|
|
199
|
+
const shared = indents.length === 0 ? 0 : Math.min(...indents);
|
|
200
|
+
return lines.map((line, index) => (index === 0 ? line : line.slice(Math.min(shared, /^[\t ]*/.exec(line)![0].length))));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private insert(range: EditorRange, snippet: string, line: string): boolean {
|
|
204
|
+
const { editor, snippets } = this.language;
|
|
205
|
+
const parsed = indentSnippet(parseSnippet(snippet), line);
|
|
206
|
+
if (parsed.limited === true) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (!this.apply([{ range, text: parsed.text }])) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
if (parsed.stops.length > 0) {
|
|
213
|
+
snippets.begin(range.start, parsed.text, parsed.stops, parsed.fields);
|
|
214
|
+
} else {
|
|
215
|
+
editor.setCaret(positionInInsertion(range.start, parsed.text, parsed.text.length));
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* Through the snippet session while one runs, so an edit inside its field keeps it going. */
|
|
221
|
+
private apply(changes: readonly EditorContentChange[], options?: EditorApplyEditsOptions): boolean {
|
|
222
|
+
const { editor, snippets } = this.language;
|
|
223
|
+
return snippets.isActive ? snippets.applyEdits(changes, snippets.token, [], options) : editor.applyEdits(changes, options);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private edited(change: EditorTextChange): void {
|
|
227
|
+
const revision = ++this.revision;
|
|
228
|
+
if (this.mirroring) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const edit = change.changes[0];
|
|
232
|
+
// Backspace and the word deletes are commands too; an undo of both names is two changes and lets go.
|
|
233
|
+
if (change.source === 'external' || change.changes.length !== 1 || edit === undefined || edit.range.start.line !== edit.range.end.line) {
|
|
234
|
+
this.link = null;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const from = this.language.editor.offsetAt(edit.range.start);
|
|
238
|
+
const removed = edit.range.end.character - edit.range.start.character;
|
|
239
|
+
this.follow(from, removed, edit.text, revision);
|
|
240
|
+
if (change.source !== 'input' || !this.settings.closeTags || removed !== 0 || this.language.editor.getSelections().length !== 1) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (edit.text === '>') {
|
|
244
|
+
queueMicrotask(() => this.closeTag(from, revision));
|
|
245
|
+
} else if (edit.text === '/') {
|
|
246
|
+
queueMicrotask(() => this.completeEndTag(from, revision));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* Keeps the link on the name being typed and copies the name to its other tag once the edit is in. */
|
|
251
|
+
private follow(from: number, removed: number, inserted: string, revision: number): void {
|
|
252
|
+
const link = this.link;
|
|
253
|
+
if (link === null) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const { own, partner } = link;
|
|
257
|
+
if (from < own.from || from + removed > own.to || !NAME.test(inserted)) {
|
|
258
|
+
this.link = null;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const delta = inserted.length - removed;
|
|
262
|
+
this.link = {
|
|
263
|
+
own: { from: own.from, to: own.to + delta },
|
|
264
|
+
partner: partner.from > own.from ? { from: partner.from + delta, to: partner.to + delta } : partner
|
|
265
|
+
};
|
|
266
|
+
queueMicrotask(() => this.mirror(revision));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private mirror(revision: number): void {
|
|
270
|
+
const link = this.link;
|
|
271
|
+
if (link === null || revision !== this.revision) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const { editor } = this.language;
|
|
275
|
+
const text = editor.getText();
|
|
276
|
+
const name = text.slice(link.own.from, link.own.to);
|
|
277
|
+
if (name === text.slice(link.partner.from, link.partner.to)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const range = { start: editor.positionAt(link.partner.from), end: editor.positionAt(link.partner.to) };
|
|
281
|
+
this.mirroring = true;
|
|
282
|
+
try {
|
|
283
|
+
if (!this.apply([{ range, text: name }], { mergeUndo: true })) {
|
|
284
|
+
this.link = null;
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
} finally {
|
|
288
|
+
this.mirroring = false;
|
|
289
|
+
}
|
|
290
|
+
const delta = name.length - (link.partner.to - link.partner.from);
|
|
291
|
+
this.link = {
|
|
292
|
+
own: link.partner.from < link.own.from ? { from: link.own.from + delta, to: link.own.to + delta } : link.own,
|
|
293
|
+
partner: { from: link.partner.from, to: link.partner.to + delta }
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* The `>` at `at` ended a start tag: its end tag goes after it, with the caret between the two. */
|
|
298
|
+
private closeTag(at: number, revision: number): void {
|
|
299
|
+
const { editor } = this.language;
|
|
300
|
+
const text = editor.getText();
|
|
301
|
+
if (revision !== this.revision || text[at] !== '>' || text[at - 1] === '/') {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const place = markupPlaceAt(text, at, this.syntax!);
|
|
305
|
+
if (place?.kind !== 'tag' || place.tag.closing || place.tag.inValue || place.tag.name === '' || isVoidElement(place.tag.name, this.syntax!)) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const end = `</${place.tag.name}>`;
|
|
309
|
+
if (text.startsWith(end, at + 1)) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const caret = editor.positionAt(at + 1);
|
|
313
|
+
this.apply([{ range: { start: caret, end: caret }, text: end }], { mergeUndo: true, mapSelections: () => ({ start: caret, end: caret }) });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* The `/` at `at` follows a `<` in text: the end tag of the innermost open element is written out. */
|
|
317
|
+
private completeEndTag(at: number, revision: number): void {
|
|
318
|
+
const { editor } = this.language;
|
|
319
|
+
const text = editor.getText();
|
|
320
|
+
if (revision !== this.revision || text[at] !== '/' || text[at - 1] !== '<') {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const place = markupPlaceAt(text, at - 1, this.syntax!);
|
|
324
|
+
const element = place?.kind === 'text' ? place.open.at(-1) : undefined;
|
|
325
|
+
if (element === undefined) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const position = editor.positionAt(at + 1);
|
|
329
|
+
const closed = text[at + 1] === '>';
|
|
330
|
+
const completion = closed ? element.name : `${element.name}>`;
|
|
331
|
+
const after = { line: position.line, character: position.character + element.name.length + 1 };
|
|
332
|
+
this.apply([{ range: { start: position, end: position }, text: completion }], { mergeUndo: true, mapSelections: () => ({ start: after, end: after }) });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* Links the tag name the caret moved into with its other tag, once the edit that moved it is in. */
|
|
336
|
+
private moved(): void {
|
|
337
|
+
if (!this.settings.linkedTags || this.mirroring) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
queueMicrotask(() => {
|
|
341
|
+
this.link = this.linkAtCaret();
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private linkAtCaret(): Link | null {
|
|
346
|
+
const { editor } = this.language;
|
|
347
|
+
if (!this.editable()) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const caret = editor.getCaret();
|
|
351
|
+
const offset = editor.offsetAt(caret);
|
|
352
|
+
const link = this.link;
|
|
353
|
+
if (link !== null && link.own.from <= offset && offset <= link.own.to) {
|
|
354
|
+
return link;
|
|
355
|
+
}
|
|
356
|
+
const line = lineTextOf(editor, caret.line);
|
|
357
|
+
if (!NAME_BEFORE.test(line.slice(0, caret.character))) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
const pair = tagPairAt(editor.getText(), offset, this.syntax!);
|
|
361
|
+
if (pair === null) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
const inOpen = pair.open.from <= offset && offset <= pair.open.to;
|
|
365
|
+
return inOpen ? { own: pair.open, partner: pair.close } : { own: pair.close, partner: pair.open };
|
|
366
|
+
}
|
|
367
|
+
}
|
package/src/navigation.ts
CHANGED
|
@@ -95,11 +95,11 @@ export class NavigationFeature {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
supports(kind: NavigationKind): boolean {
|
|
98
|
-
return this.language.
|
|
98
|
+
return this.language.service.supports(METHODS[kind], this.language.uri);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
async go(kind: NavigationKind, position: EditorPosition = this.language.editor.getCaret()): Promise<void> {
|
|
102
|
-
const {
|
|
102
|
+
const { uri, editor } = this.language;
|
|
103
103
|
|
|
104
104
|
if (!this.supports(kind)) {
|
|
105
105
|
this.tell(this.say('unavailable'));
|
|
@@ -110,7 +110,7 @@ export class NavigationFeature {
|
|
|
110
110
|
let result: NavigationResult;
|
|
111
111
|
|
|
112
112
|
try {
|
|
113
|
-
result = await REQUESTS[kind](
|
|
113
|
+
result = await REQUESTS[kind](this.language.service, uri, position);
|
|
114
114
|
} catch (error) {
|
|
115
115
|
if (!(error instanceof StaleResultError)) {
|
|
116
116
|
this.tell(this.say('failed', { message: messageOf(error) }));
|
|
@@ -141,7 +141,7 @@ export class NavigationFeature {
|
|
|
141
141
|
* up by name among the symbols of the project. One match goes there at once, several are listed, and none says nothing.
|
|
142
142
|
*/
|
|
143
143
|
async goToName(name: string, anchor: EditorPosition, own: Location | null): Promise<void> {
|
|
144
|
-
const {
|
|
144
|
+
const { uri, editor } = this.language;
|
|
145
145
|
|
|
146
146
|
if (own !== null) {
|
|
147
147
|
this.language.goTo(own);
|
|
@@ -149,12 +149,12 @@ export class NavigationFeature {
|
|
|
149
149
|
return;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
if (!
|
|
152
|
+
if (!this.language.service.supports('workspace/symbol', uri)) {
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
try {
|
|
157
|
-
const places = placesOfName(await
|
|
157
|
+
const places = placesOfName(await this.language.service.workspaceSymbols(uri, name), name, editor.getText());
|
|
158
158
|
|
|
159
159
|
if (places.length === 1) {
|
|
160
160
|
this.language.goTo(places[0]!);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { rememberCopy } from '@adecore/editor';
|
|
2
|
+
import type { ClipboardEntry } from './clipboard-history.ts';
|
|
3
|
+
import type { EditorLanguage } from './editor-language.ts';
|
|
4
|
+
import { KEY_PRIORITY } from './key-priority.ts';
|
|
5
|
+
import type { PickGroup } from './popups.ts';
|
|
6
|
+
import { isShortcut } from './shortcut-keys.ts';
|
|
7
|
+
|
|
8
|
+
const LABEL_LENGTH = 80;
|
|
9
|
+
|
|
10
|
+
/* The first characters of a copy on one line, with each line break shown as a return sign. */
|
|
11
|
+
function labelOf(text: string): string {
|
|
12
|
+
const flat = text.replace(/\r\n|\r|\n/g, '⏎');
|
|
13
|
+
|
|
14
|
+
return flat.length > LABEL_LENGTH ? `${flat.slice(0, LABEL_LENGTH)}…` : flat;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* Paste from History: a list of what was copied in the editors of the project, newest first. Typing narrows it,
|
|
19
|
+
* Shift with the arrows chooses several entries, which paste as one, and Delete forgets entries.
|
|
20
|
+
*/
|
|
21
|
+
export class PasteHistoryFeature {
|
|
22
|
+
private readonly language: EditorLanguage;
|
|
23
|
+
|
|
24
|
+
constructor(language: EditorLanguage) {
|
|
25
|
+
this.language = language;
|
|
26
|
+
|
|
27
|
+
const off = language.onKey((event) => {
|
|
28
|
+
if (!isShortcut(this.language.shortcuts.pasteFromHistory, event)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.open();
|
|
33
|
+
|
|
34
|
+
return true;
|
|
35
|
+
}, KEY_PRIORITY.pasteHistory);
|
|
36
|
+
|
|
37
|
+
language.onDispose(off);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
open(): void {
|
|
41
|
+
const { editor, project } = this.language;
|
|
42
|
+
const { clipboard } = project;
|
|
43
|
+
|
|
44
|
+
if (clipboard.entries.length === 0) {
|
|
45
|
+
editor.announce(project.i18n.t('editor:language.pasteHistory.empty'));
|
|
46
|
+
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ids = new Map<ClipboardEntry, string>();
|
|
51
|
+
const entries = new Map<string, ClipboardEntry>();
|
|
52
|
+
|
|
53
|
+
const groups = (): readonly PickGroup[] => [
|
|
54
|
+
{
|
|
55
|
+
title: null,
|
|
56
|
+
rows: clipboard.entries.map((entry) => {
|
|
57
|
+
const id = ids.get(entry) ?? String(ids.size);
|
|
58
|
+
|
|
59
|
+
ids.set(entry, id);
|
|
60
|
+
entries.set(id, entry);
|
|
61
|
+
|
|
62
|
+
return { id, label: labelOf(entry.text), detail: '', search: entry.text };
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const rows = groups();
|
|
68
|
+
|
|
69
|
+
this.language.pick.open({
|
|
70
|
+
anchor: editor.getCaret(),
|
|
71
|
+
title: project.i18n.t('editor:language.pasteHistory.title'),
|
|
72
|
+
groups: rows,
|
|
73
|
+
active: rows[0]!.rows[0]!.id,
|
|
74
|
+
filter: true,
|
|
75
|
+
multiple: true,
|
|
76
|
+
accept: (id) => this.paste([entries.get(id)!]),
|
|
77
|
+
acceptMany: (chosen) => this.paste(clipboard.entries.filter((entry) => chosen.includes(ids.get(entry) ?? ''))),
|
|
78
|
+
preview: async (id) => ({ removed: [], added: entries.get(id)?.text.split('\n') ?? [], note: null, kind: 'text' }),
|
|
79
|
+
remove: (chosen) => {
|
|
80
|
+
for (const id of chosen) {
|
|
81
|
+
clipboard.remove(entries.get(id)!);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return groups();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Pastes entries in list order, joined by line breaks, as a paste of that text from the clipboard would. */
|
|
90
|
+
private paste(chosen: readonly ClipboardEntry[]): void {
|
|
91
|
+
const { editor, project } = this.language;
|
|
92
|
+
const { clipboard } = project;
|
|
93
|
+
|
|
94
|
+
if (chosen.length === 0) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const text = chosen.map((entry) => entry.text).join('\n');
|
|
99
|
+
const wholeLines = chosen.length === 1 && chosen[0]!.wholeLines;
|
|
100
|
+
|
|
101
|
+
if (chosen.length === 1) {
|
|
102
|
+
clipboard.promote(chosen[0]!);
|
|
103
|
+
} else {
|
|
104
|
+
clipboard.add(text);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
globalThis.navigator?.clipboard?.writeText(text).catch(() => undefined);
|
|
108
|
+
rememberCopy(text, wholeLines);
|
|
109
|
+
editor.paste?.(text, { wholeLines });
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/peek.ts
CHANGED
|
@@ -82,7 +82,7 @@ export class PeekFeature {
|
|
|
82
82
|
const { editor, project, uri } = this.language;
|
|
83
83
|
const method = KINDS[kind].method;
|
|
84
84
|
|
|
85
|
-
if (!
|
|
85
|
+
if (!this.language.service.supports(method, uri)) {
|
|
86
86
|
this.tell(this.say(KINDS[kind].unavailable));
|
|
87
87
|
|
|
88
88
|
return;
|
|
@@ -160,14 +160,14 @@ export class PeekFeature {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
private async fetch(kind: PeekKind, position: EditorPosition): Promise<{ locations: Location[]; targets: Map<string, Range> }> {
|
|
163
|
-
const {
|
|
163
|
+
const { uri } = this.language;
|
|
164
164
|
|
|
165
165
|
if (kind === 'references') {
|
|
166
166
|
// Without the declaration, so the list holds what the usages count above a declaration counts.
|
|
167
|
-
return { locations: (await
|
|
167
|
+
return { locations: (await this.language.service.references(uri, position, false)) ?? [], targets: new Map() };
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
const places = uniquePlaces(await
|
|
170
|
+
const places = uniquePlaces(await this.language.service.definition(uri, position));
|
|
171
171
|
const targets = new Map(places.flatMap(({ location, target }) => (target === null ? [] : [[placeKey(location), target] as const])));
|
|
172
172
|
|
|
173
173
|
return { locations: places.map(({ location }) => location), targets };
|