@adecore/editor 0.23.1 → 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/controller.js +7 -2
- package/dist/fake.d.ts +14 -4
- package/dist/fake.js +257 -96
- package/dist/layout.d.ts +7 -0
- package/dist/layout.js +228 -40
- package/dist/tokens.js +2 -1
- package/dist/view.d.ts +2 -3
- package/dist/view.js +35 -26
- package/package.json +2 -2
- package/src/controller.ts +9 -2
- package/src/fake.ts +301 -97
- package/src/layout.ts +262 -40
- package/src/tokens.ts +2 -1
- package/src/view.ts +36 -28
package/src/fake.ts
CHANGED
|
@@ -52,13 +52,137 @@ import type {
|
|
|
52
52
|
EditorWidget
|
|
53
53
|
} from './types.ts';
|
|
54
54
|
import { emit, type Listener, subscribe } from './listeners.ts';
|
|
55
|
-
import { changedSpan } from '@adecore/editor-core';
|
|
55
|
+
import { changedSpan, changedSpans } from '@adecore/editor-core';
|
|
56
56
|
import { mapTrackedRange } from './tracked-range.ts';
|
|
57
57
|
|
|
58
|
+
interface OffsetSelection {
|
|
59
|
+
anchor: number;
|
|
60
|
+
head: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Edit {
|
|
64
|
+
from: number;
|
|
65
|
+
to: number;
|
|
66
|
+
text: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface HistoryState {
|
|
70
|
+
text: string;
|
|
71
|
+
selections: readonly OffsetSelection[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LINE_BREAK = /\r\n|\r|\n/g;
|
|
75
|
+
|
|
76
|
+
/* Where each line starts and where its text ends, before its line break: `\r\n`, a lone `\r` or `\n`, as the real document counts them. */
|
|
77
|
+
function linesOf(text: string): { start: number; end: number }[] {
|
|
78
|
+
const lines: { start: number; end: number }[] = [];
|
|
79
|
+
let start = 0;
|
|
80
|
+
for (const match of text.matchAll(LINE_BREAK)) {
|
|
81
|
+
lines.push({ start, end: match.index });
|
|
82
|
+
start = match.index + match[0].length;
|
|
83
|
+
}
|
|
84
|
+
lines.push({ start, end: text.length });
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isHighSurrogate(code: number): boolean {
|
|
89
|
+
return code >= 0xd800 && code <= 0xdbff;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isLowSurrogate(code: number): boolean {
|
|
93
|
+
return code >= 0xdc00 && code <= 0xdfff;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* Clamps to the text and moves back off the middle of a CRLF or a surrogate pair, as the real document does. */
|
|
97
|
+
function clampOffset(text: string, value: number): number {
|
|
98
|
+
const offset = Math.max(0, Math.min(text.length, Number.isFinite(value) ? Math.trunc(value) : 0));
|
|
99
|
+
const splitsBreak = text[offset - 1] === '\r' && text[offset] === '\n';
|
|
100
|
+
const splitsPair = isHighSurrogate(text.charCodeAt(offset - 1)) && isLowSurrogate(text.charCodeAt(offset));
|
|
101
|
+
return splitsBreak || splitsPair ? offset - 1 : offset;
|
|
102
|
+
}
|
|
103
|
+
|
|
58
104
|
function positionIn(text: string, offset: number): EditorPosition {
|
|
59
|
-
const
|
|
60
|
-
const lines =
|
|
61
|
-
|
|
105
|
+
const at = clampOffset(text, offset);
|
|
106
|
+
const lines = linesOf(text);
|
|
107
|
+
let line = lines.length - 1;
|
|
108
|
+
while (line > 0 && lines[line]!.start > at) {
|
|
109
|
+
line--;
|
|
110
|
+
}
|
|
111
|
+
return { line, character: Math.min(at, lines[line]!.end) - lines[line]!.start };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function offsetIn(text: string, position: EditorPosition): number {
|
|
115
|
+
const lines = linesOf(text);
|
|
116
|
+
const line = lines[Math.max(0, Math.min(Number.isFinite(position.line) ? Math.trunc(position.line) : 0, lines.length - 1))]!;
|
|
117
|
+
const character = Math.max(0, Math.min(Number.isFinite(position.character) ? Math.trunc(position.character) : 0, line.end - line.start));
|
|
118
|
+
return clampOffset(text, line.start + character);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* The line of a one-based line number, clamped to the text. */
|
|
122
|
+
function lineNumbered(text: string, line: number): { start: number; end: number } {
|
|
123
|
+
const lines = linesOf(text);
|
|
124
|
+
return lines[Math.min(Math.max(1, Math.trunc(line) || 1), lines.length) - 1]!;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* Clamps every selection and merges those that overlap or touch, each merge taking the place and direction of the last given of them, as the real document does. */
|
|
128
|
+
function normalized(text: string, selections: readonly OffsetSelection[]): OffsetSelection[] {
|
|
129
|
+
const input = selections.length > 0 ? selections : [{ anchor: 0, head: 0 }];
|
|
130
|
+
const clamped = input.map((raw) => {
|
|
131
|
+
const anchor = clampOffset(text, raw.anchor);
|
|
132
|
+
return { anchor, head: raw.head === raw.anchor ? anchor : clampOffset(text, raw.head) };
|
|
133
|
+
});
|
|
134
|
+
const order = clamped
|
|
135
|
+
.map((selection, index) => ({ from: Math.min(selection.anchor, selection.head), to: Math.max(selection.anchor, selection.head), index }))
|
|
136
|
+
.sort((left, right) => left.from - right.from);
|
|
137
|
+
const groups: { from: number; to: number; last: number }[] = [];
|
|
138
|
+
for (const { from, to, index } of order) {
|
|
139
|
+
const group = groups.at(-1);
|
|
140
|
+
if (group !== undefined && from <= group.to) {
|
|
141
|
+
group.to = Math.max(group.to, to);
|
|
142
|
+
group.last = Math.max(group.last, index);
|
|
143
|
+
} else {
|
|
144
|
+
groups.push({ from, to, last: index });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return groups
|
|
148
|
+
.sort((left, right) => left.last - right.last)
|
|
149
|
+
.map((group) => {
|
|
150
|
+
const selection = clamped[group.last]!;
|
|
151
|
+
return selection.anchor > selection.head ? { anchor: group.to, head: group.from } : { anchor: group.from, head: group.to };
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* The edits in order and without repeats, or null where two overlap, which the real document refuses. */
|
|
156
|
+
function orderedEdits(edits: readonly Edit[]): Edit[] | null {
|
|
157
|
+
const sorted = [...edits].sort((left, right) => left.from - right.from || left.to - right.to);
|
|
158
|
+
const unique: Edit[] = [];
|
|
159
|
+
for (const edit of sorted) {
|
|
160
|
+
const previous = unique.at(-1);
|
|
161
|
+
if (previous !== undefined && edit.from === previous.from && edit.to === previous.to && edit.text === previous.text) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (previous !== undefined && (edit.from < previous.to || (edit.from === previous.from && edit.to === previous.to))) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
unique.push(edit);
|
|
168
|
+
}
|
|
169
|
+
return unique;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* Where an offset lands after ordered edits: one inside a replaced stretch, or at an insertion, ends up behind the new text. */
|
|
173
|
+
function mapOffset(offset: number, edits: readonly Edit[]): number {
|
|
174
|
+
let delta = 0;
|
|
175
|
+
for (const edit of edits) {
|
|
176
|
+
if (edit.to > offset) {
|
|
177
|
+
return edit.from <= offset ? edit.from + delta + edit.text.length : offset + delta;
|
|
178
|
+
}
|
|
179
|
+
delta += edit.text.length - (edit.to - edit.from);
|
|
180
|
+
}
|
|
181
|
+
return offset + delta;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function rangeIn(text: string, selection: OffsetSelection): EditorRange {
|
|
185
|
+
return { start: positionIn(text, Math.min(selection.anchor, selection.head)), end: positionIn(text, Math.max(selection.anchor, selection.head)) };
|
|
62
186
|
}
|
|
63
187
|
|
|
64
188
|
/* An editor without a DOM, for tests: `type`, `save` and `blur` do what a person would, the rest says what the host asked of it. */
|
|
@@ -82,6 +206,12 @@ export class FakeEditor implements Editor {
|
|
|
82
206
|
findQuery: EditorFindQuery | null = null;
|
|
83
207
|
findState: EditorFindState = { count: 0, current: null };
|
|
84
208
|
private text: string;
|
|
209
|
+
/* The primary selection last, as in the real editor. */
|
|
210
|
+
private selections: OffsetSelection[] = [{ anchor: 0, head: 0 }];
|
|
211
|
+
/* Where the caret listeners last heard the caret was. */
|
|
212
|
+
private caretOffset = 0;
|
|
213
|
+
private undoStack: { before: HistoryState; after: HistoryState }[] = [];
|
|
214
|
+
private redoStack: { before: HistoryState; after: HistoryState }[] = [];
|
|
85
215
|
private readonly changes = new Set<Listener>();
|
|
86
216
|
private readonly textChanges = new Set<(change: EditorTextChange) => void>();
|
|
87
217
|
private readonly saves = new Set<Listener>();
|
|
@@ -103,16 +233,27 @@ export class FakeEditor implements Editor {
|
|
|
103
233
|
this.revealedLine = options.line ?? null;
|
|
104
234
|
this.column = options.column ?? null;
|
|
105
235
|
this.scrollTop = options.scrollTop ?? null;
|
|
236
|
+
if (options.line !== undefined) {
|
|
237
|
+
const line = lineNumbered(this.text, options.line);
|
|
238
|
+
const offset = Math.min(line.end, line.start + Math.max(0, (options.column ?? 1) - 1));
|
|
239
|
+
this.selections = [{ anchor: offset, head: offset }];
|
|
240
|
+
this.caretOffset = offset;
|
|
241
|
+
}
|
|
106
242
|
}
|
|
107
243
|
|
|
108
|
-
/* A person's edit: the text changes and every change listener hears it. A read-only editor refuses it, as the real editor does. */
|
|
244
|
+
/* A person's edit: the text changes, the caret lands behind what changed and every change listener hears it. A read-only editor refuses it, as the real editor does. */
|
|
109
245
|
type(text: string): void {
|
|
110
246
|
this.assertLive();
|
|
111
|
-
|
|
247
|
+
const span = changedSpan(this.text, text);
|
|
248
|
+
if (this.readOnly || span === null) {
|
|
112
249
|
return;
|
|
113
250
|
}
|
|
114
|
-
|
|
115
|
-
|
|
251
|
+
const edit = { from: span.start, to: span.end, text: span.text };
|
|
252
|
+
const end = span.start + span.text.length;
|
|
253
|
+
const others = this.selections
|
|
254
|
+
.slice(0, -1)
|
|
255
|
+
.map((selection) => ({ anchor: mapOffset(selection.anchor, [edit]), head: mapOffset(selection.head, [edit]) }));
|
|
256
|
+
this.edit([edit], 'input', false, () => [...others, { anchor: end, head: end }]);
|
|
116
257
|
}
|
|
117
258
|
|
|
118
259
|
/* Mod+S inside the editor. */
|
|
@@ -132,42 +273,109 @@ export class FakeEditor implements Editor {
|
|
|
132
273
|
return this.text;
|
|
133
274
|
}
|
|
134
275
|
|
|
276
|
+
/* Split into the stretches the real editor splits it into, so the carets and tracked ranges follow it alike. */
|
|
135
277
|
setText(text: string): void {
|
|
136
278
|
this.assertLive();
|
|
137
|
-
this.
|
|
279
|
+
const spans = changedSpans(this.text, text).map((span) => ({ from: span.start, to: span.end, text: span.text }));
|
|
280
|
+
this.edit(orderedEdits(spans) ?? [], 'external', false);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/*
|
|
284
|
+
* Ordered edits as one step of the history, and the listeners told in the real editor's order: the caret's,
|
|
285
|
+
* the text's, then the change listeners for anything but a change from outside. `place` puts the selections
|
|
286
|
+
* in the new text; without it they follow the edits. False when the text stays as it was.
|
|
287
|
+
*/
|
|
288
|
+
private edit(
|
|
289
|
+
edits: readonly Edit[],
|
|
290
|
+
source: EditorTextChange['source'],
|
|
291
|
+
mergeUndo: boolean,
|
|
292
|
+
place?: (text: string) => readonly OffsetSelection[]
|
|
293
|
+
): boolean {
|
|
294
|
+
const before = this.state();
|
|
295
|
+
if (!this.rewrite(edits, source, place)) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
const last = this.undoStack.at(-1);
|
|
299
|
+
if (mergeUndo && last !== undefined && last.after.text === before.text) {
|
|
300
|
+
last.after = this.state();
|
|
301
|
+
} else {
|
|
302
|
+
this.undoStack.push({ before, after: this.state() });
|
|
303
|
+
}
|
|
304
|
+
this.redoStack = [];
|
|
305
|
+
if (source !== 'external') {
|
|
306
|
+
emit(this.changes);
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
138
309
|
}
|
|
139
310
|
|
|
140
|
-
|
|
141
|
-
private replaceText(text: string, source: EditorTextChange['source'] = 'external', selections?: readonly EditorRange[]): void {
|
|
142
|
-
const span = changedSpan(this.text, text);
|
|
311
|
+
private rewrite(edits: readonly Edit[], source: EditorTextChange['source'], place?: (text: string) => readonly OffsetSelection[]): boolean {
|
|
143
312
|
const before = this.text;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
313
|
+
let text = before;
|
|
314
|
+
for (let index = edits.length - 1; index >= 0; index--) {
|
|
315
|
+
const edit = edits[index]!;
|
|
316
|
+
text = text.slice(0, edit.from) + edit.text + text.slice(edit.to);
|
|
147
317
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (selections === undefined) {
|
|
151
|
-
this.moveCaret(positionIn(text, span.start + span.text.length));
|
|
152
|
-
} else {
|
|
153
|
-
this.setSelections(selections);
|
|
154
|
-
}
|
|
318
|
+
if (text === before) {
|
|
319
|
+
return false;
|
|
155
320
|
}
|
|
156
|
-
|
|
321
|
+
const changed = edits.filter((edit) => before.slice(edit.from, edit.to) !== edit.text);
|
|
322
|
+
this.text = text;
|
|
323
|
+
this.selections = normalized(
|
|
324
|
+
text,
|
|
325
|
+
place?.(text) ?? this.selections.map((selection) => ({ anchor: mapOffset(selection.anchor, edits), head: mapOffset(selection.head, edits) }))
|
|
326
|
+
);
|
|
327
|
+
this.followTracked(changed);
|
|
157
328
|
this.ghost = null;
|
|
329
|
+
this.announceCaret();
|
|
330
|
+
// Last to first, so each change's range is also where it stands once the ones after it are applied, as the real editor reports them.
|
|
158
331
|
const change: EditorTextChange = {
|
|
159
332
|
source,
|
|
160
|
-
changes: [
|
|
333
|
+
changes: [...changed]
|
|
334
|
+
.reverse()
|
|
335
|
+
.map((edit) => ({ range: { start: positionIn(before, edit.from), end: positionIn(before, edit.to) }, text: edit.text }))
|
|
161
336
|
};
|
|
162
337
|
emit(this.textChanges, change);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private state(): HistoryState {
|
|
342
|
+
return { text: this.text, selections: this.selections.map((selection) => ({ ...selection })) };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* Undo or redo, which the real editor reports as changes of a command and refuses while the text is read only. */
|
|
346
|
+
private restore(direction: 'undo' | 'redo'): boolean {
|
|
347
|
+
const [from, to] = direction === 'undo' ? [this.undoStack, this.redoStack] : [this.redoStack, this.undoStack];
|
|
348
|
+
const entry = this.readOnly ? undefined : from.pop();
|
|
349
|
+
if (entry === undefined) {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
to.push(entry);
|
|
353
|
+
const state = direction === 'undo' ? entry.before : entry.after;
|
|
354
|
+
const span = changedSpan(this.text, state.text);
|
|
355
|
+
if (span === null) {
|
|
356
|
+
this.select(state.selections);
|
|
357
|
+
} else {
|
|
358
|
+
this.rewrite([{ from: span.start, to: span.end, text: span.text }], 'command', () => state.selections);
|
|
359
|
+
}
|
|
360
|
+
emit(this.changes);
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/* Tells the caret listeners where the primary caret is, when it moved since they last heard. */
|
|
365
|
+
private announceCaret(): void {
|
|
366
|
+
const head = this.selections.at(-1)!.head;
|
|
367
|
+
if (head !== this.caretOffset) {
|
|
368
|
+
this.caretOffset = head;
|
|
369
|
+
emit(this.carets, positionIn(this.text, head));
|
|
370
|
+
}
|
|
163
371
|
}
|
|
164
372
|
|
|
165
373
|
private readonly tracked = new Set<{ from: number; to: number; lost: boolean }>();
|
|
166
374
|
|
|
167
|
-
|
|
168
|
-
|
|
375
|
+
private followTracked(edits: readonly Edit[]): void {
|
|
376
|
+
const changes = edits.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
|
|
169
377
|
for (const entry of this.tracked) {
|
|
170
|
-
const mapped = mapTrackedRange(entry.from, entry.to,
|
|
378
|
+
const mapped = mapTrackedRange(entry.from, entry.to, changes);
|
|
171
379
|
if (mapped === null) {
|
|
172
380
|
this.tracked.delete(entry);
|
|
173
381
|
entry.lost = true;
|
|
@@ -202,13 +410,7 @@ export class FakeEditor implements Editor {
|
|
|
202
410
|
}
|
|
203
411
|
|
|
204
412
|
offsetAt(position: EditorPosition): number {
|
|
205
|
-
|
|
206
|
-
let offset = 0;
|
|
207
|
-
for (let line = 0; line < Math.min(position.line, lines.length - 1); line++) {
|
|
208
|
-
offset += lines[line]!.length + 1;
|
|
209
|
-
}
|
|
210
|
-
const current = lines[Math.min(position.line, lines.length - 1)]!;
|
|
211
|
-
return offset + Math.min(position.character, current.length);
|
|
413
|
+
return offsetIn(this.text, position);
|
|
212
414
|
}
|
|
213
415
|
|
|
214
416
|
onChange(listener: Listener): () => void {
|
|
@@ -229,6 +431,9 @@ export class FakeEditor implements Editor {
|
|
|
229
431
|
revealLine(line: number, reveal: EditorReveal = 'center'): void {
|
|
230
432
|
this.revealedLine = line;
|
|
231
433
|
this.lastReveal = reveal;
|
|
434
|
+
const { start } = lineNumbered(this.text, line);
|
|
435
|
+
this.selections = [{ anchor: start, head: start }];
|
|
436
|
+
this.announceCaret();
|
|
232
437
|
}
|
|
233
438
|
|
|
234
439
|
/* Counts plain occurrences only; the matcher itself is the editor's, and a test here is about the host's side. */
|
|
@@ -557,7 +762,6 @@ export class FakeEditor implements Editor {
|
|
|
557
762
|
/* What the host marked and highlighted last. */
|
|
558
763
|
markers: readonly EditorMarker[] = [];
|
|
559
764
|
highlights: readonly EditorHighlight[] = [];
|
|
560
|
-
caret: EditorPosition = { line: 0, character: 0 };
|
|
561
765
|
/* Where `rectAt` puts a position, which a test can move to simulate scrolling. */
|
|
562
766
|
origin = { left: 100, top: 100 };
|
|
563
767
|
charSize = { width: 8, height: 20 };
|
|
@@ -633,7 +837,17 @@ export class FakeEditor implements Editor {
|
|
|
633
837
|
}
|
|
634
838
|
|
|
635
839
|
getCaret(): EditorPosition {
|
|
636
|
-
return this.
|
|
840
|
+
return positionIn(this.text, this.selections.at(-1)!.head);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/* The primary caret; setting it tells no listener. */
|
|
844
|
+
get caret(): EditorPosition {
|
|
845
|
+
return this.getCaret();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
set caret(position: EditorPosition) {
|
|
849
|
+
const offset = this.offsetAt(position);
|
|
850
|
+
this.selections = [...this.selections.slice(0, -1), { anchor: offset, head: offset }];
|
|
637
851
|
}
|
|
638
852
|
|
|
639
853
|
/* The folds the host asked to keep. */
|
|
@@ -647,8 +861,16 @@ export class FakeEditor implements Editor {
|
|
|
647
861
|
return this.scrollTop ?? 0;
|
|
648
862
|
}
|
|
649
863
|
|
|
650
|
-
/* The selection a
|
|
651
|
-
selection: EditorRange | null
|
|
864
|
+
/* The primary selection, or null when it is only a caret; setting it tells no listener. */
|
|
865
|
+
get selection(): EditorRange | null {
|
|
866
|
+
const primary = this.selections.at(-1)!;
|
|
867
|
+
return primary.anchor === primary.head ? null : rangeIn(this.text, primary);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
set selection(range: EditorRange | null) {
|
|
871
|
+
const head = this.offsetAt(range?.end ?? this.getCaret());
|
|
872
|
+
this.selections = [...this.selections.slice(0, -1), { anchor: range === null ? head : this.offsetAt(range.start), head }];
|
|
873
|
+
}
|
|
652
874
|
|
|
653
875
|
/* A test supplies the measured height; this fake has no layout. */
|
|
654
876
|
contentHeight = 0;
|
|
@@ -661,7 +883,7 @@ export class FakeEditor implements Editor {
|
|
|
661
883
|
}
|
|
662
884
|
|
|
663
885
|
getLineHeight(line: number): number {
|
|
664
|
-
return Number.isInteger(line) && line >= 0 && line < this.text
|
|
886
|
+
return Number.isInteger(line) && line >= 0 && line < linesOf(this.text).length ? this.charSize.height : 0;
|
|
665
887
|
}
|
|
666
888
|
|
|
667
889
|
getBaseLineHeight(): number {
|
|
@@ -677,32 +899,29 @@ export class FakeEditor implements Editor {
|
|
|
677
899
|
}
|
|
678
900
|
|
|
679
901
|
getSelection(): EditorRange {
|
|
680
|
-
return this.
|
|
902
|
+
return rangeIn(this.text, this.selections.at(-1)!);
|
|
681
903
|
}
|
|
682
904
|
|
|
683
|
-
/* The other selections of the editor; the primary one is always last. */
|
|
684
|
-
private otherSelections: EditorRange[] = [];
|
|
685
|
-
|
|
686
905
|
/* The other carets of the editor, which a test sets. */
|
|
687
906
|
get otherCarets(): EditorPosition[] {
|
|
688
|
-
return this.
|
|
907
|
+
return this.selections.slice(0, -1).map((selection) => positionIn(this.text, selection.head));
|
|
689
908
|
}
|
|
690
909
|
|
|
691
910
|
set otherCarets(positions: EditorPosition[]) {
|
|
692
|
-
|
|
911
|
+
const others = positions.map((position) => this.offsetAt(position)).map((offset) => ({ anchor: offset, head: offset }));
|
|
912
|
+
this.selections = normalized(this.text, [...others, this.selections.at(-1)!]);
|
|
693
913
|
}
|
|
694
914
|
|
|
695
915
|
getSelections(): EditorRange[] {
|
|
696
|
-
return
|
|
916
|
+
return this.selections.map((selection) => rangeIn(this.text, selection));
|
|
697
917
|
}
|
|
698
918
|
|
|
699
919
|
setSelections(ranges: readonly EditorRange[], reveal: EditorReveal = 'relative'): void {
|
|
700
|
-
|
|
701
|
-
if (last === undefined) {
|
|
920
|
+
if (ranges.length === 0) {
|
|
702
921
|
return;
|
|
703
922
|
}
|
|
704
|
-
this.
|
|
705
|
-
this.
|
|
923
|
+
this.lastReveal = reveal;
|
|
924
|
+
this.select(ranges.map((range) => ({ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) })));
|
|
706
925
|
}
|
|
707
926
|
|
|
708
927
|
getIndentation(): EditorIndentation {
|
|
@@ -710,24 +929,28 @@ export class FakeEditor implements Editor {
|
|
|
710
929
|
}
|
|
711
930
|
|
|
712
931
|
setCaret(position: EditorPosition, reveal: EditorReveal = 'relative'): void {
|
|
932
|
+
const offset = this.offsetAt(position);
|
|
713
933
|
this.lastReveal = reveal;
|
|
714
|
-
this.
|
|
715
|
-
this.
|
|
716
|
-
this.moveCaret(position);
|
|
934
|
+
this.revealedLine = positionIn(this.text, offset).line + 1;
|
|
935
|
+
this.select([{ anchor: offset, head: offset }]);
|
|
717
936
|
}
|
|
718
937
|
|
|
719
938
|
setSelection(range: EditorRange, reveal: EditorReveal = 'relative'): void {
|
|
720
939
|
this.lastReveal = reveal;
|
|
721
|
-
this.
|
|
722
|
-
|
|
723
|
-
|
|
940
|
+
this.select([{ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) }]);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
private select(selections: readonly OffsetSelection[]): void {
|
|
944
|
+
this.selections = normalized(this.text, selections);
|
|
945
|
+
this.announceCaret();
|
|
724
946
|
}
|
|
725
947
|
|
|
726
|
-
/* The caret a person's click or key moved. */
|
|
948
|
+
/* The caret a person's click or key moved, which the caret listeners hear even where it already was, so a test can replay a move. The other carets stay. */
|
|
727
949
|
moveCaret(position: EditorPosition): void {
|
|
728
|
-
|
|
729
|
-
this.
|
|
730
|
-
|
|
950
|
+
const offset = this.offsetAt(position);
|
|
951
|
+
this.selections = normalized(this.text, [...this.selections.slice(0, -1), { anchor: offset, head: offset }]);
|
|
952
|
+
this.caretOffset = this.selections.at(-1)!.head;
|
|
953
|
+
emit(this.carets, positionIn(this.text, this.caretOffset));
|
|
731
954
|
}
|
|
732
955
|
|
|
733
956
|
onCaret(listener: (position: EditorPosition) => void): () => void {
|
|
@@ -807,44 +1030,25 @@ export class FakeEditor implements Editor {
|
|
|
807
1030
|
return [...this.keys].some((handler) => handler(full));
|
|
808
1031
|
}
|
|
809
1032
|
|
|
810
|
-
/* The fake keeps no undo history, so `mergeUndo` changes nothing here. It keeps no selection direction either, so a test of `mapSelections` keeping a backward selection needs the real editor. */
|
|
811
1033
|
applyEdits(edits: readonly EditorContentChange[], options: EditorApplyEditsOptions = {}): boolean {
|
|
812
|
-
|
|
1034
|
+
const ordered = orderedEdits(edits.map((edit) => ({ from: this.offsetAt(edit.range.start), to: this.offsetAt(edit.range.end), text: edit.text })));
|
|
1035
|
+
if (this.readOnly || ordered === null || ordered.length === 0) {
|
|
813
1036
|
return false;
|
|
814
1037
|
}
|
|
815
|
-
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
.
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
830
|
-
if (offset < edit.to || (offset === edit.from && edit.from === edit.to)) {
|
|
831
|
-
return edit.from + delta + edit.text.length;
|
|
832
|
-
}
|
|
833
|
-
delta += edit.text.length - (edit.to - edit.from);
|
|
834
|
-
}
|
|
835
|
-
return offset + delta;
|
|
836
|
-
};
|
|
837
|
-
const moved = others.map((range) => ({ anchor: follow(range.anchor), head: follow(range.head) }));
|
|
838
|
-
const mapped = options.mapSelections && this.getSelections().map((range) => options.mapSelections!(range));
|
|
839
|
-
this.replaceText(text, 'command', mapped);
|
|
840
|
-
if (mapped === undefined) {
|
|
841
|
-
this.otherSelections = moved.map((range) => ({
|
|
842
|
-
start: positionIn(text, Math.min(range.anchor, range.head)),
|
|
843
|
-
end: positionIn(text, Math.max(range.anchor, range.head))
|
|
844
|
-
}));
|
|
845
|
-
}
|
|
846
|
-
emit(this.changes);
|
|
847
|
-
return true;
|
|
1038
|
+
const { mapSelections } = options;
|
|
1039
|
+
const mapped =
|
|
1040
|
+
mapSelections &&
|
|
1041
|
+
this.selections.map((selection) => ({ backward: selection.head < selection.anchor, range: mapSelections(rangeIn(this.text, selection)) }));
|
|
1042
|
+
// A backward selection stays backward, as the real editor places it.
|
|
1043
|
+
const place =
|
|
1044
|
+
mapped &&
|
|
1045
|
+
((text: string): OffsetSelection[] =>
|
|
1046
|
+
mapped.map(({ backward, range }) => {
|
|
1047
|
+
const from = offsetIn(text, range.start);
|
|
1048
|
+
const to = offsetIn(text, range.end);
|
|
1049
|
+
return backward ? { anchor: to, head: from } : { anchor: from, head: to };
|
|
1050
|
+
}));
|
|
1051
|
+
return this.edit(ordered, 'command', options.mergeUndo === true, place);
|
|
848
1052
|
}
|
|
849
1053
|
|
|
850
1054
|
setTheme(theme: EditorTheme): void {
|
|
@@ -864,12 +1068,12 @@ export class FakeEditor implements Editor {
|
|
|
864
1068
|
return this.readOnly;
|
|
865
1069
|
}
|
|
866
1070
|
|
|
867
|
-
/* The commands run, in order;
|
|
1071
|
+
/* The commands run, in order; of its own the fake carries out only undo and redo. */
|
|
868
1072
|
readonly commands: EditorRunCommand[] = [];
|
|
869
1073
|
|
|
870
1074
|
runCommand(command: EditorRunCommand): boolean {
|
|
871
1075
|
this.commands.push(command);
|
|
872
|
-
return false;
|
|
1076
|
+
return command === 'undo' || command === 'redo' ? this.restore(command) : false;
|
|
873
1077
|
}
|
|
874
1078
|
|
|
875
1079
|
focus(): void {
|