@adecore/editor-core 0.23.0 → 0.24.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/bracket-tracker.d.ts +25 -0
- package/dist/bracket-tracker.js +272 -0
- package/dist/brackets.d.ts +35 -0
- package/dist/brackets.js +167 -99
- package/dist/document.d.ts +3 -0
- package/dist/document.js +84 -32
- package/dist/index.d.ts +1 -1
- package/dist/lexical.d.ts +1 -0
- package/dist/lexical.js +13 -3
- package/dist/paste.js +2 -1
- package/dist/rope.js +35 -8
- package/dist/structure.js +4 -1
- package/package.json +1 -1
- package/src/bracket-tracker.ts +321 -0
- package/src/brackets.ts +201 -98
- package/src/document.ts +82 -31
- package/src/index.ts +1 -1
- package/src/lexical.ts +14 -3
- package/src/paste.ts +2 -1
- package/src/rope.ts +43 -12
- package/src/structure.ts +4 -1
package/dist/document.js
CHANGED
|
@@ -12,6 +12,7 @@ import { lexicalFolds } from './lexical-folds.js';
|
|
|
12
12
|
import { rangeOf, whitespaceOf } from './line-text.js';
|
|
13
13
|
import { isQuote, replacesComparison, surround, swapQuotes } from './typing-handlers.js';
|
|
14
14
|
import { scanBrackets } from './brackets.js';
|
|
15
|
+
import { BracketTracker } from './bracket-tracker.js';
|
|
15
16
|
import { hasSmartSemicolon, isPhp } from './lexical.js';
|
|
16
17
|
import { clampInteger, TextRope, ROPE_CHUNK_SIZE } from './rope.js';
|
|
17
18
|
import { findMatches, findMatchFrom, replacementText, splitsSurrogate } from './search.js';
|
|
@@ -32,7 +33,7 @@ const identifier = /[\p{L}\p{N}\p{M}\p{Pc}$]/u;
|
|
|
32
33
|
/* What can follow `->`: the first letter of a member name, or the `{` of a dynamic one. */
|
|
33
34
|
const arrowContinues = /^[\p{L}_{]/u;
|
|
34
35
|
const wordCommand = /^(select|delete)?(word|camel)(Left|Right)$/i;
|
|
35
|
-
|
|
36
|
+
let graphemes;
|
|
36
37
|
function sameSelections(left, right) {
|
|
37
38
|
return left.length === right.length && left.every((selection, index) => selection.anchor === right[index].anchor && selection.head === right[index].head);
|
|
38
39
|
}
|
|
@@ -51,14 +52,35 @@ function selectionTransaction(plans) {
|
|
|
51
52
|
edits.push(plan);
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
// What the edits before each one add to the text, so a caret finds its shift by a binary search.
|
|
56
|
+
const shifts = [0];
|
|
57
|
+
for (const edit of edits) {
|
|
58
|
+
shifts.push(shifts.at(-1) + edit.text.length - (edit.to - edit.from));
|
|
59
|
+
}
|
|
54
60
|
const selections = plans.map((plan) => {
|
|
55
|
-
|
|
61
|
+
let low = 0;
|
|
62
|
+
let high = edits.length;
|
|
63
|
+
while (low < high) {
|
|
64
|
+
const middle = (low + high) >>> 1;
|
|
65
|
+
if (edits[middle].from < plan.from) {
|
|
66
|
+
low = middle + 1;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
high = middle;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const delta = shifts[low];
|
|
56
73
|
return { anchor: plan.from + delta + plan.anchor, head: plan.from + delta + plan.head };
|
|
57
74
|
});
|
|
58
75
|
return { edits, selections };
|
|
59
76
|
}
|
|
60
77
|
function splitsCluster(rope, offset) {
|
|
61
|
-
|
|
78
|
+
const before = rope.charAt(offset - 1);
|
|
79
|
+
// One read of the rope settles most offsets, which follow neither a `\r` nor the first half of a surrogate pair.
|
|
80
|
+
if (before === '\r') {
|
|
81
|
+
return rope.charAt(offset) === '\n';
|
|
82
|
+
}
|
|
83
|
+
return /[\ud800-\udbff]/.test(before) && splitsSurrogate(rope, offset);
|
|
62
84
|
}
|
|
63
85
|
/* Clamps to the document and moves back off the middle of a surrogate pair or a CRLF. */
|
|
64
86
|
function offsetIn(rope, value) {
|
|
@@ -85,6 +107,7 @@ function deletionStep(rope, offset, direction) {
|
|
|
85
107
|
}
|
|
86
108
|
const line = rope.getLine(rope.lineAt(offset));
|
|
87
109
|
let previous = line.start;
|
|
110
|
+
graphemes ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
88
111
|
for (const segment of graphemes.segment(line.text)) {
|
|
89
112
|
const at = line.start + segment.index;
|
|
90
113
|
if (direction < 0 && at >= offset) {
|
|
@@ -98,33 +121,33 @@ function deletionStep(rope, offset, direction) {
|
|
|
98
121
|
return direction < 0 ? previous : line.end;
|
|
99
122
|
}
|
|
100
123
|
/*
|
|
101
|
-
* Clamps every selection and merges the ones that overlap or touch, so the result is disjoint.
|
|
102
|
-
*
|
|
124
|
+
* Clamps every selection and merges the ones that overlap or touch, so the result is disjoint. A merged
|
|
125
|
+
* selection takes the place and the direction of the last added of the ones it holds, so the primary caret stays last.
|
|
103
126
|
*/
|
|
104
127
|
function normalizeSelections(rope, selections) {
|
|
105
128
|
const input = selections.length > 0 ? selections : [{ anchor: 0, head: 0 }];
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
const clamped = input.map((raw) => {
|
|
130
|
+
const anchor = offsetIn(rope, raw.anchor);
|
|
131
|
+
return { anchor, head: raw.head === raw.anchor ? anchor : offsetIn(rope, raw.head) };
|
|
132
|
+
});
|
|
133
|
+
const order = clamped.map((selection, index) => ({ ...rangeOf(selection), index })).sort((left, right) => left.from - right.from);
|
|
134
|
+
const groups = [];
|
|
135
|
+
for (const { from, to, index } of order) {
|
|
136
|
+
const group = groups.at(-1);
|
|
137
|
+
if (group !== undefined && from <= group.to) {
|
|
138
|
+
group.to = Math.max(group.to, to);
|
|
139
|
+
group.last = Math.max(group.last, index);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
groups.push({ from, to, last: index });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return groups
|
|
146
|
+
.sort((left, right) => left.last - right.last)
|
|
147
|
+
.map((group) => {
|
|
148
|
+
const selection = clamped[group.last];
|
|
149
|
+
return selection.anchor > selection.head ? { anchor: group.to, head: group.from } : { anchor: group.from, head: group.to };
|
|
150
|
+
});
|
|
128
151
|
}
|
|
129
152
|
function inverseChanges(changes) {
|
|
130
153
|
let delta = 0;
|
|
@@ -182,6 +205,16 @@ function editsOf(rope, edits) {
|
|
|
182
205
|
* either end, and is a little more than the change when two of them are far apart.
|
|
183
206
|
*/
|
|
184
207
|
function stretchEdits(before, after, transactions) {
|
|
208
|
+
const stretch = stretchOf(before, after, transactions);
|
|
209
|
+
if (stretch === undefined) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
return [
|
|
213
|
+
{ start: positionIn(before, stretch.from), end: positionIn(before, stretch.to), text: after.slice(stretch.from, stretch.from + stretch.insertedLength) }
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
/* The one change in the coordinates of `before` that holds every change of the transactions, or undefined when they change nothing. */
|
|
217
|
+
function stretchOf(before, after, transactions) {
|
|
185
218
|
let length = before.length;
|
|
186
219
|
let prefix = Number.POSITIVE_INFINITY;
|
|
187
220
|
let suffix = Number.POSITIVE_INFINITY;
|
|
@@ -195,15 +228,15 @@ function stretchEdits(before, after, transactions) {
|
|
|
195
228
|
length += delta;
|
|
196
229
|
}
|
|
197
230
|
if (!Number.isFinite(prefix)) {
|
|
198
|
-
return
|
|
231
|
+
return undefined;
|
|
199
232
|
}
|
|
200
233
|
const kept = Math.min(Math.max(0, suffix), before.length - prefix, after.length - prefix);
|
|
201
234
|
const oldEnd = before.length - kept;
|
|
202
235
|
const newEnd = after.length - kept;
|
|
203
236
|
if (oldEnd === prefix && newEnd === prefix) {
|
|
204
|
-
return
|
|
237
|
+
return undefined;
|
|
205
238
|
}
|
|
206
|
-
return
|
|
239
|
+
return { from: prefix, to: oldEnd, insertedLength: newEnd - prefix };
|
|
207
240
|
}
|
|
208
241
|
/* An occurrence selected with its caret `shift` characters into it, as the caret stood in the selection it repeats. */
|
|
209
242
|
function occurrenceSelection(match, shift) {
|
|
@@ -239,6 +272,7 @@ export class DocumentModel {
|
|
|
239
272
|
occurrencesExhausted = false;
|
|
240
273
|
closers = new CloserTracker();
|
|
241
274
|
bracketCache;
|
|
275
|
+
bracketTracker = new BracketTracker(() => this.rope);
|
|
242
276
|
scriptCache;
|
|
243
277
|
constructor(text = '') {
|
|
244
278
|
this.rope = TextRope.from(text);
|
|
@@ -266,6 +300,15 @@ export class DocumentModel {
|
|
|
266
300
|
slice(from, to) {
|
|
267
301
|
return this.rope.slice(from, to);
|
|
268
302
|
}
|
|
303
|
+
/*
|
|
304
|
+
* Where the bracket at `offset` pairs, as `scanBrackets` pairs it, with no mate for one it leaves unmatched.
|
|
305
|
+
* Undefined where no bracket of the code stands, such as in a string or a comment. An edit lexes only the few
|
|
306
|
+
* kilobytes around it again, so this stays cheap on every keystroke of a large document. With `limit` it lexes
|
|
307
|
+
* at most about that many characters and returns null when that was not enough; asking again goes on from there.
|
|
308
|
+
*/
|
|
309
|
+
matchBracket(offset, language = 'typescript', limit) {
|
|
310
|
+
return this.bracketTracker.match(offset, language, limit);
|
|
311
|
+
}
|
|
269
312
|
getSelections() {
|
|
270
313
|
return this.selections.map((selection) => ({ ...selection }));
|
|
271
314
|
}
|
|
@@ -356,15 +399,16 @@ export class DocumentModel {
|
|
|
356
399
|
const before = this.state();
|
|
357
400
|
const firstLine = this.rope.lineAt(changed[0].from);
|
|
358
401
|
const previous = this.rope;
|
|
402
|
+
const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
|
|
359
403
|
this.rope = next;
|
|
360
404
|
this.invalidate();
|
|
361
405
|
this.typingContexts.invalidate(firstLine);
|
|
406
|
+
this.bracketTracker.edited(previous, next, changes);
|
|
362
407
|
this.selections = normalizeSelections(this.rope, nextSelections);
|
|
363
408
|
this.closers.map(changed);
|
|
364
409
|
this.closers.prune(this.selections);
|
|
365
410
|
this.revision++;
|
|
366
411
|
const after = this.state();
|
|
367
|
-
const changes = changed.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
|
|
368
412
|
const last = this.undoStack.at(-1);
|
|
369
413
|
const joinsGroup = options.historyGroup &&
|
|
370
414
|
this.groupOpen &&
|
|
@@ -1892,6 +1936,8 @@ export class DocumentModel {
|
|
|
1892
1936
|
this.invalidate();
|
|
1893
1937
|
this.closers.clear();
|
|
1894
1938
|
this.typingContexts.invalidate();
|
|
1939
|
+
const stretch = stretchOf(previous, state.rope, changes);
|
|
1940
|
+
this.bracketTracker.edited(previous, state.rope, stretch === undefined ? [] : [stretch]);
|
|
1895
1941
|
this.selections = state.selections.map((selection) => ({ ...selection }));
|
|
1896
1942
|
this.revision++;
|
|
1897
1943
|
this.groupOpen = false;
|
|
@@ -1912,7 +1958,13 @@ export class DocumentModel {
|
|
|
1912
1958
|
}
|
|
1913
1959
|
}
|
|
1914
1960
|
for (const listener of this.listeners) {
|
|
1915
|
-
|
|
1961
|
+
try {
|
|
1962
|
+
listener(snapshot);
|
|
1963
|
+
}
|
|
1964
|
+
catch (error) {
|
|
1965
|
+
// The text has changed by now, so a failing listener must not throw at the caller or keep the others, such as a language server's sync, from hearing it.
|
|
1966
|
+
console.error('A document listener failed.', error);
|
|
1967
|
+
}
|
|
1916
1968
|
}
|
|
1917
1969
|
}
|
|
1918
1970
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { DocumentModel } from './document.ts';
|
|
2
2
|
export type { DocumentEditOptions, DocumentLine, OffsetRange, ReplaceAllOptions, ReplaceOptions, TypeTextOptions } from './document.ts';
|
|
3
|
-
export { scanBrackets, type BracketIndex } from './brackets.ts';
|
|
3
|
+
export { scanBrackets, type BracketIndex, type BracketMatch } from './brackets.ts';
|
|
4
4
|
export { replaceWithCaseRespect } from './preserve-case.ts';
|
|
5
5
|
export { findMatches, iterateMatches, findMatchFrom, replacementText, type FindMatch, type FindNextOptions, type FindOptions } from './search.ts';
|
|
6
6
|
export { indentationColumn } from './structure.ts';
|
package/dist/lexical.d.ts
CHANGED
|
@@ -13,4 +13,5 @@ export declare function indentsByBrackets(language: string): boolean;
|
|
|
13
13
|
export declare function hasSmartSemicolon(language: string): boolean;
|
|
14
14
|
export declare function isWordCharacter(character: string): boolean;
|
|
15
15
|
export declare function startsExpression(word: string): boolean;
|
|
16
|
+
export declare function startsExpressionAt(text: string, from: number, to: number): boolean;
|
|
16
17
|
export declare function startsExpressionAfter(character: string): boolean;
|
package/dist/lexical.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const openers = { '(': ')', '[': ']', '{': '}' };
|
|
2
2
|
/* Words after which a `/` starts a regex and a `{` an object or a block, not a division or a statement end. */
|
|
3
|
-
const expressionWords =
|
|
3
|
+
const expressionWords = new Set(['return', 'throw', 'case', 'yield', 'await', 'new', 'delete', 'typeof', 'void', 'in', 'of', 'instanceof']);
|
|
4
|
+
const expressionInitials = new Set([...expressionWords].map((word) => word[0]));
|
|
5
|
+
const expressionPunctuation = new Set('=,:;!?+-*%&|^~<>');
|
|
4
6
|
/* Whether a `#` at this index stands where a word would start, after whitespace, a separator or an opener. */
|
|
5
7
|
export function startsWord(text, at) {
|
|
6
8
|
return at === 0 || /[\s;|&(]/.test(text[at - 1]);
|
|
@@ -63,12 +65,20 @@ export function hasSmartSemicolon(language) {
|
|
|
63
65
|
return /^(typescript|javascript|typescriptreact|javascriptreact|tsx|jsx|ts|js|php)$/i.test(language);
|
|
64
66
|
}
|
|
65
67
|
export function isWordCharacter(character) {
|
|
68
|
+
const code = character.charCodeAt(0);
|
|
69
|
+
if (code < 128) {
|
|
70
|
+
return (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || (code >= 48 && code <= 57) || code === 95 || code === 36;
|
|
71
|
+
}
|
|
66
72
|
return /[\p{L}\p{N}_$]/u.test(character);
|
|
67
73
|
}
|
|
68
74
|
export function startsExpression(word) {
|
|
69
|
-
return expressionWords.
|
|
75
|
+
return expressionWords.has(word);
|
|
76
|
+
}
|
|
77
|
+
/* `startsExpression` for the word from `from` to `to` in `text`, which cuts the word out only when it could be one. */
|
|
78
|
+
export function startsExpressionAt(text, from, to) {
|
|
79
|
+
return to - from >= 2 && to - from <= 10 && expressionInitials.has(text[from]) && expressionWords.has(text.slice(from, to));
|
|
70
80
|
}
|
|
71
81
|
/* Whether what follows a punctuation character is an expression, so a `/` there is a regex. */
|
|
72
82
|
export function startsExpressionAfter(character) {
|
|
73
|
-
return
|
|
83
|
+
return expressionPunctuation.has(character);
|
|
74
84
|
}
|
package/dist/paste.js
CHANGED
|
@@ -37,7 +37,8 @@ function reindent(source, from, text, options) {
|
|
|
37
37
|
const measured = widths.filter((width, index) => width !== null && !(anchored && index === 0));
|
|
38
38
|
const trailingBreak = isBlank(lines.at(-1));
|
|
39
39
|
if (measured.length > 0) {
|
|
40
|
-
|
|
40
|
+
// A reduce, not a spread: a paste of a few hundred thousand lines is more arguments than V8 takes in one call.
|
|
41
|
+
const delta = target - measured.reduce((least, width) => Math.min(least, width));
|
|
41
42
|
for (const [index, entry] of lines.entries()) {
|
|
42
43
|
const width = widths[index];
|
|
43
44
|
if (width === null || width === undefined || (anchored && index === 0)) {
|
package/dist/rope.js
CHANGED
|
@@ -1,16 +1,41 @@
|
|
|
1
1
|
export const ROPE_CHUNK_SIZE = 2048;
|
|
2
|
+
const CR = 13;
|
|
3
|
+
const LF = 10;
|
|
4
|
+
/* A node counts a `\r` it ends with as a break; when the next node starts with `\n`, the two are one CRLF and only the `\n` is. */
|
|
5
|
+
function sharedBreak(left, right) {
|
|
6
|
+
return left.endsWithCr && right.startsWithLf ? 1 : 0;
|
|
7
|
+
}
|
|
2
8
|
function leaf(text) {
|
|
3
9
|
if (!text.length) {
|
|
4
10
|
return null;
|
|
5
11
|
}
|
|
6
12
|
const breaks = [];
|
|
7
|
-
for (let index =
|
|
8
|
-
|
|
13
|
+
for (let index = 0; index < text.length; index++) {
|
|
14
|
+
const code = text.charCodeAt(index);
|
|
15
|
+
if (code === LF || (code === CR && text.charCodeAt(index + 1) !== LF)) {
|
|
16
|
+
breaks.push(index);
|
|
17
|
+
}
|
|
9
18
|
}
|
|
10
|
-
return {
|
|
19
|
+
return {
|
|
20
|
+
text,
|
|
21
|
+
breaks,
|
|
22
|
+
length: text.length,
|
|
23
|
+
lines: breaks.length,
|
|
24
|
+
height: 1,
|
|
25
|
+
startsWithLf: text.charCodeAt(0) === LF,
|
|
26
|
+
endsWithCr: text.charCodeAt(text.length - 1) === CR
|
|
27
|
+
};
|
|
11
28
|
}
|
|
12
29
|
function branch(left, right) {
|
|
13
|
-
return {
|
|
30
|
+
return {
|
|
31
|
+
left,
|
|
32
|
+
right,
|
|
33
|
+
length: left.length + right.length,
|
|
34
|
+
lines: left.lines + right.lines - sharedBreak(left, right),
|
|
35
|
+
height: Math.max(left.height, right.height) + 1,
|
|
36
|
+
startsWithLf: left.startsWithLf,
|
|
37
|
+
endsWithCr: right.endsWithCr
|
|
38
|
+
};
|
|
14
39
|
}
|
|
15
40
|
function balance(left, right) {
|
|
16
41
|
if (left.height > right.height + 1 && 'left' in left) {
|
|
@@ -227,7 +252,7 @@ export class TextRope {
|
|
|
227
252
|
}
|
|
228
253
|
else {
|
|
229
254
|
offset -= node.left.length;
|
|
230
|
-
count += node.left.lines;
|
|
255
|
+
count += node.left.lines - sharedBreak(node.left, node.right);
|
|
231
256
|
node = node.right;
|
|
232
257
|
}
|
|
233
258
|
}
|
|
@@ -251,7 +276,8 @@ export class TextRope {
|
|
|
251
276
|
return { start, end: this.length, next: this.length };
|
|
252
277
|
}
|
|
253
278
|
const newline = this.breakAt(index);
|
|
254
|
-
|
|
279
|
+
const crlf = this.charAt(newline) === '\n' && this.charAt(newline - 1) === '\r';
|
|
280
|
+
return { start, end: newline - (crlf ? 1 : 0), next: newline + 1 };
|
|
255
281
|
}
|
|
256
282
|
getLine(index) {
|
|
257
283
|
const bounds = this.lineBounds(index);
|
|
@@ -261,11 +287,12 @@ export class TextRope {
|
|
|
261
287
|
let node = this.root;
|
|
262
288
|
let offset = 0;
|
|
263
289
|
while ('left' in node) {
|
|
264
|
-
|
|
290
|
+
const leftLines = node.left.lines - sharedBreak(node.left, node.right);
|
|
291
|
+
if (index < leftLines) {
|
|
265
292
|
node = node.left;
|
|
266
293
|
}
|
|
267
294
|
else {
|
|
268
|
-
index -=
|
|
295
|
+
index -= leftLines;
|
|
269
296
|
offset += node.left.length;
|
|
270
297
|
node = node.right;
|
|
271
298
|
}
|
package/dist/structure.js
CHANGED
|
@@ -103,7 +103,10 @@ export function deriveFoldingRanges(ranges, lineAt, lineCount, getLine, options
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
if (options.indentation) {
|
|
106
|
-
|
|
106
|
+
// One push per fold: a long file has more of them than V8 takes as arguments in one call.
|
|
107
|
+
for (const fold of indentationFolds(lineCount, getLine, tabWidth(options), minimum)) {
|
|
108
|
+
result.push(fold);
|
|
109
|
+
}
|
|
107
110
|
}
|
|
108
111
|
result.sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
|
|
109
112
|
return dedupeFoldingRanges(result);
|