@adecore/editor-react 0.42.0 → 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/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 +19 -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/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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/* `vue` and `php` are HTML with interpolations or code blocks a markup feature leaves alone. */
|
|
2
|
+
export type MarkupSyntax = 'html' | 'xml' | 'vue' | 'php';
|
|
3
|
+
|
|
4
|
+
export interface OpenElement {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
/* The offset of the first character of the name, after the `<`. */
|
|
7
|
+
readonly nameFrom: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* The tag an offset is in, from its `<` up to and including its `>`. */
|
|
11
|
+
export interface TagAt {
|
|
12
|
+
readonly start: number;
|
|
13
|
+
readonly closing: boolean;
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly nameFrom: number;
|
|
16
|
+
readonly nameTo: number;
|
|
17
|
+
/* The offset is inside a quoted attribute value. */
|
|
18
|
+
readonly inValue: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type MarkupPlace =
|
|
22
|
+
| { readonly kind: 'text'; readonly open: readonly OpenElement[] }
|
|
23
|
+
| { readonly kind: 'tag'; readonly tag: TagAt }
|
|
24
|
+
| { readonly kind: 'other' };
|
|
25
|
+
|
|
26
|
+
/* The names of an element that are one start tag and its end tag, which change together. */
|
|
27
|
+
export interface TagPair {
|
|
28
|
+
readonly open: { readonly from: number; readonly to: number };
|
|
29
|
+
readonly close: { readonly from: number; readonly to: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Above this a document is not read on a keystroke; the features then do nothing in it. */
|
|
33
|
+
export const MAX_MARKUP_SCAN = 1_000_000;
|
|
34
|
+
|
|
35
|
+
const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
36
|
+
const RAW_ELEMENTS = new Set(['script', 'style', 'textarea', 'title']);
|
|
37
|
+
const NAME_START = /[A-Za-z]/;
|
|
38
|
+
const NAME = /[\w:.-]/;
|
|
39
|
+
|
|
40
|
+
export function markupSyntaxOf(languageId: string): MarkupSyntax | null {
|
|
41
|
+
switch (languageId.toLowerCase()) {
|
|
42
|
+
case 'html':
|
|
43
|
+
case 'xhtml':
|
|
44
|
+
return 'html';
|
|
45
|
+
case 'xml':
|
|
46
|
+
case 'svg':
|
|
47
|
+
case 'xsl':
|
|
48
|
+
return 'xml';
|
|
49
|
+
case 'vue':
|
|
50
|
+
return 'vue';
|
|
51
|
+
case 'php':
|
|
52
|
+
return 'php';
|
|
53
|
+
default:
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Whether an element of this name has no end tag, which only HTML has. */
|
|
59
|
+
export function isVoidElement(name: string, syntax: MarkupSyntax): boolean {
|
|
60
|
+
return syntax !== 'xml' && VOID_ELEMENTS.has(name.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class Stop {
|
|
64
|
+
readonly place: MarkupPlace;
|
|
65
|
+
|
|
66
|
+
constructor(place: MarkupPlace) {
|
|
67
|
+
this.place = place;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/*
|
|
72
|
+
* A reader of markup that is no parser: it follows tags, comments, raw elements and the code blocks of
|
|
73
|
+
* PHP and Vue far enough to tell what an offset is in, and pairs a start tag with the end tag that closes it.
|
|
74
|
+
*/
|
|
75
|
+
class MarkupScanner {
|
|
76
|
+
readonly pairs: TagPair[] = [];
|
|
77
|
+
private readonly text: string;
|
|
78
|
+
private readonly syntax: MarkupSyntax;
|
|
79
|
+
private readonly until: number;
|
|
80
|
+
private readonly open: (OpenElement & { readonly nameTo: number })[] = [];
|
|
81
|
+
|
|
82
|
+
constructor(text: string, syntax: MarkupSyntax, until: number) {
|
|
83
|
+
this.text = text;
|
|
84
|
+
this.syntax = syntax;
|
|
85
|
+
this.until = until;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
run(): MarkupPlace {
|
|
89
|
+
try {
|
|
90
|
+
this.content();
|
|
91
|
+
return this.textPlace();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof Stop) {
|
|
94
|
+
return error.place;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private textPlace(): MarkupPlace {
|
|
101
|
+
return { kind: 'text', open: this.open.map(({ name, nameFrom }) => ({ name, nameFrom })) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* Leaves the scan with what the offset is in when it lies in `from` to `to`, `to` included where the construct owns its last character. */
|
|
105
|
+
private reached(from: number, to: number, place: () => MarkupPlace): void {
|
|
106
|
+
if (from < this.until && this.until <= to) {
|
|
107
|
+
throw new Stop(place());
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private content(): void {
|
|
112
|
+
const { text } = this;
|
|
113
|
+
let at = 0;
|
|
114
|
+
while (at < text.length) {
|
|
115
|
+
if (at >= this.until) {
|
|
116
|
+
throw new Stop(this.textPlace());
|
|
117
|
+
}
|
|
118
|
+
const char = text[at]!;
|
|
119
|
+
if (char === '<') {
|
|
120
|
+
at = this.markup(at);
|
|
121
|
+
} else if (this.syntax === 'vue' && char === '{' && text[at + 1] === '{') {
|
|
122
|
+
at = this.skipTo(at, '}}');
|
|
123
|
+
} else {
|
|
124
|
+
at++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Skips from `from` past `close`, the whole construct counting as something other than text. */
|
|
130
|
+
private skipTo(from: number, close: string): number {
|
|
131
|
+
const end = this.text.indexOf(close, from + 1);
|
|
132
|
+
const after = end === -1 ? this.text.length : end + close.length;
|
|
133
|
+
this.reached(from, end === -1 ? after : after - 1, () => ({ kind: 'other' }));
|
|
134
|
+
return after;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private markup(at: number): number {
|
|
138
|
+
const { text } = this;
|
|
139
|
+
if (text.startsWith('<!--', at)) {
|
|
140
|
+
return this.skipTo(at, '-->');
|
|
141
|
+
}
|
|
142
|
+
if (text.startsWith('<![CDATA[', at)) {
|
|
143
|
+
return this.skipTo(at, ']]>');
|
|
144
|
+
}
|
|
145
|
+
if (text[at + 1] === '?') {
|
|
146
|
+
return this.skipTo(at, this.syntax === 'php' ? '?>' : '>');
|
|
147
|
+
}
|
|
148
|
+
if (text[at + 1] === '!') {
|
|
149
|
+
return this.skipTo(at, '>');
|
|
150
|
+
}
|
|
151
|
+
const closing = text[at + 1] === '/';
|
|
152
|
+
const nameFrom = at + (closing ? 2 : 1);
|
|
153
|
+
if (!NAME_START.test(text[nameFrom] ?? '')) {
|
|
154
|
+
return at + 1;
|
|
155
|
+
}
|
|
156
|
+
let nameTo = nameFrom;
|
|
157
|
+
while (nameTo < text.length && NAME.test(text[nameTo]!)) {
|
|
158
|
+
nameTo++;
|
|
159
|
+
}
|
|
160
|
+
const name = text.slice(nameFrom, nameTo);
|
|
161
|
+
const end = this.tagEnd(at, closing, name, nameFrom, nameTo);
|
|
162
|
+
if (closing) {
|
|
163
|
+
this.close(name, nameFrom, nameTo);
|
|
164
|
+
return end;
|
|
165
|
+
}
|
|
166
|
+
const selfClosing = text[end - 2] === '/' && text[end - 1] === '>';
|
|
167
|
+
if (selfClosing || isVoidElement(name, this.syntax)) {
|
|
168
|
+
return end;
|
|
169
|
+
}
|
|
170
|
+
if (this.syntax !== 'xml' && RAW_ELEMENTS.has(name.toLowerCase())) {
|
|
171
|
+
return this.rawContent(end, name, nameFrom, nameTo);
|
|
172
|
+
}
|
|
173
|
+
this.open.push({ name, nameFrom, nameTo });
|
|
174
|
+
return end;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Reads the attributes of a tag up to the offset after its `>`, quotes and PHP blocks included. */
|
|
178
|
+
private tagEnd(start: number, closing: boolean, name: string, nameFrom: number, nameTo: number): number {
|
|
179
|
+
const { text } = this;
|
|
180
|
+
let at = nameTo;
|
|
181
|
+
let quote: string | null = null;
|
|
182
|
+
let valueFrom = -1;
|
|
183
|
+
while (at < text.length) {
|
|
184
|
+
const char = text[at]!;
|
|
185
|
+
if (this.syntax === 'php' && char === '<' && text[at + 1] === '?') {
|
|
186
|
+
at = this.skipTo(at, '?>');
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (quote !== null) {
|
|
190
|
+
if (char === quote) {
|
|
191
|
+
this.reached(valueFrom, at, () => ({ kind: 'tag', tag: { start, closing, name, nameFrom, nameTo, inValue: true } }));
|
|
192
|
+
quote = null;
|
|
193
|
+
}
|
|
194
|
+
} else if (char === '"' || char === "'") {
|
|
195
|
+
quote = char;
|
|
196
|
+
valueFrom = at;
|
|
197
|
+
} else if (char === '>') {
|
|
198
|
+
this.reached(start, at, () => ({ kind: 'tag', tag: { start, closing, name, nameFrom, nameTo, inValue: false } }));
|
|
199
|
+
return at + 1;
|
|
200
|
+
} else if (char === '<') {
|
|
201
|
+
// A tag that never closed ends where the next one starts.
|
|
202
|
+
this.reached(start, at, () => ({ kind: 'tag', tag: { start, closing, name, nameFrom, nameTo, inValue: false } }));
|
|
203
|
+
return at;
|
|
204
|
+
}
|
|
205
|
+
at++;
|
|
206
|
+
}
|
|
207
|
+
this.reached(start, text.length, () => ({
|
|
208
|
+
kind: 'tag',
|
|
209
|
+
tag: { start, closing, name, nameFrom, nameTo, inValue: quote !== null && this.until > valueFrom }
|
|
210
|
+
}));
|
|
211
|
+
return text.length;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* 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 {
|
|
216
|
+
const pattern = new RegExp(`</${name}(?=[\\s/>]|$)`, 'gi');
|
|
217
|
+
pattern.lastIndex = from;
|
|
218
|
+
const match = pattern.exec(this.text);
|
|
219
|
+
const end = match === null ? this.text.length : match.index;
|
|
220
|
+
this.reached(from - 1, end, () => ({ kind: 'other' }));
|
|
221
|
+
if (match === null) {
|
|
222
|
+
return end;
|
|
223
|
+
}
|
|
224
|
+
const closeFrom = end + 2;
|
|
225
|
+
const closeTo = closeFrom + name.length;
|
|
226
|
+
const after = this.tagEnd(end, true, this.text.slice(closeFrom, closeTo), closeFrom, closeTo);
|
|
227
|
+
this.pairs.push({ open: { from: nameFrom, to: nameTo }, close: { from: closeFrom, to: closeTo } });
|
|
228
|
+
return after;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* 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 {
|
|
233
|
+
const caseless = this.syntax !== 'xml';
|
|
234
|
+
for (let index = this.open.length - 1; index >= 0; index--) {
|
|
235
|
+
const element = this.open[index]!;
|
|
236
|
+
if (element.name === name || (caseless && element.name.toLowerCase() === name.toLowerCase())) {
|
|
237
|
+
this.pairs.push({ open: { from: element.nameFrom, to: element.nameTo }, close: { from: nameFrom, to: nameTo } });
|
|
238
|
+
this.open.length = index;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* What `offset` is in; null for a document too large to read on a keystroke. */
|
|
246
|
+
export function markupPlaceAt(text: string, offset: number, syntax: MarkupSyntax): MarkupPlace | null {
|
|
247
|
+
if (text.length > MAX_MARKUP_SCAN) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return new MarkupScanner(text, syntax, offset).run();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* The start and end tag names of the element whose name holds `offset`, either one, its edges included. */
|
|
254
|
+
export function tagPairAt(text: string, offset: number, syntax: MarkupSyntax): TagPair | null {
|
|
255
|
+
if (text.length > MAX_MARKUP_SCAN) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const scanner = new MarkupScanner(text, syntax, Infinity);
|
|
259
|
+
scanner.run();
|
|
260
|
+
const holds = (range: { from: number; to: number }): boolean => range.from <= offset && offset <= range.to;
|
|
261
|
+
return scanner.pairs.find((pair) => holds(pair.open) || holds(pair.close)) ?? null;
|
|
262
|
+
}
|
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]!);
|