@adecore/editor 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/controller.js +7 -2
- package/dist/engine.js +5 -0
- package/dist/fake.d.ts +16 -4
- package/dist/fake.js +261 -96
- package/dist/layout.d.ts +7 -0
- package/dist/layout.js +228 -40
- package/dist/tokens.js +2 -1
- package/dist/types.d.ts +2 -0
- package/dist/view.d.ts +3 -3
- package/dist/view.js +38 -26
- package/package.json +2 -2
- package/src/controller.ts +9 -2
- package/src/engine.ts +6 -0
- package/src/fake.ts +306 -97
- package/src/layout.ts +262 -40
- package/src/tokens.ts +2 -1
- package/src/types.ts +4 -0
- package/src/view.ts +41 -28
package/src/engine.ts
CHANGED
|
@@ -106,6 +106,7 @@ class SmartEditor implements Editor {
|
|
|
106
106
|
messages: options.messages ?? {},
|
|
107
107
|
label: options.label ?? DEFAULT_LABEL,
|
|
108
108
|
guides: options.guides !== false,
|
|
109
|
+
stickyScroll: options.stickyScroll !== false,
|
|
109
110
|
whitespace: options.whitespace === true,
|
|
110
111
|
rightMargin: options.rightMargin ?? null,
|
|
111
112
|
foldOutline: options.foldOutline ?? 'hover',
|
|
@@ -806,6 +807,11 @@ class SmartEditor implements Editor {
|
|
|
806
807
|
this.view.applySettings();
|
|
807
808
|
}
|
|
808
809
|
|
|
810
|
+
setStickyScroll(sticky: boolean): void {
|
|
811
|
+
this.settings.stickyScroll = sticky;
|
|
812
|
+
this.view.applySettings();
|
|
813
|
+
}
|
|
814
|
+
|
|
809
815
|
setWhitespace(whitespace: boolean): void {
|
|
810
816
|
this.settings.whitespace = whitespace;
|
|
811
817
|
this.view.applySettings();
|
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. */
|
|
@@ -509,6 +714,7 @@ export class FakeEditor implements Editor {
|
|
|
509
714
|
smartKeys: Partial<EditorSmartKeys>;
|
|
510
715
|
|
|
511
716
|
guides = true;
|
|
717
|
+
stickyScroll = true;
|
|
512
718
|
whitespace = false;
|
|
513
719
|
rightMargin: number | null = null;
|
|
514
720
|
|
|
@@ -516,6 +722,10 @@ export class FakeEditor implements Editor {
|
|
|
516
722
|
this.guides = guides;
|
|
517
723
|
}
|
|
518
724
|
|
|
725
|
+
setStickyScroll(sticky: boolean): void {
|
|
726
|
+
this.stickyScroll = sticky;
|
|
727
|
+
}
|
|
728
|
+
|
|
519
729
|
setWhitespace(whitespace: boolean): void {
|
|
520
730
|
this.whitespace = whitespace;
|
|
521
731
|
}
|
|
@@ -552,7 +762,6 @@ export class FakeEditor implements Editor {
|
|
|
552
762
|
/* What the host marked and highlighted last. */
|
|
553
763
|
markers: readonly EditorMarker[] = [];
|
|
554
764
|
highlights: readonly EditorHighlight[] = [];
|
|
555
|
-
caret: EditorPosition = { line: 0, character: 0 };
|
|
556
765
|
/* Where `rectAt` puts a position, which a test can move to simulate scrolling. */
|
|
557
766
|
origin = { left: 100, top: 100 };
|
|
558
767
|
charSize = { width: 8, height: 20 };
|
|
@@ -628,7 +837,17 @@ export class FakeEditor implements Editor {
|
|
|
628
837
|
}
|
|
629
838
|
|
|
630
839
|
getCaret(): EditorPosition {
|
|
631
|
-
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 }];
|
|
632
851
|
}
|
|
633
852
|
|
|
634
853
|
/* The folds the host asked to keep. */
|
|
@@ -642,8 +861,16 @@ export class FakeEditor implements Editor {
|
|
|
642
861
|
return this.scrollTop ?? 0;
|
|
643
862
|
}
|
|
644
863
|
|
|
645
|
-
/* The selection a
|
|
646
|
-
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
|
+
}
|
|
647
874
|
|
|
648
875
|
/* A test supplies the measured height; this fake has no layout. */
|
|
649
876
|
contentHeight = 0;
|
|
@@ -656,7 +883,7 @@ export class FakeEditor implements Editor {
|
|
|
656
883
|
}
|
|
657
884
|
|
|
658
885
|
getLineHeight(line: number): number {
|
|
659
|
-
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;
|
|
660
887
|
}
|
|
661
888
|
|
|
662
889
|
getBaseLineHeight(): number {
|
|
@@ -672,32 +899,29 @@ export class FakeEditor implements Editor {
|
|
|
672
899
|
}
|
|
673
900
|
|
|
674
901
|
getSelection(): EditorRange {
|
|
675
|
-
return this.
|
|
902
|
+
return rangeIn(this.text, this.selections.at(-1)!);
|
|
676
903
|
}
|
|
677
904
|
|
|
678
|
-
/* The other selections of the editor; the primary one is always last. */
|
|
679
|
-
private otherSelections: EditorRange[] = [];
|
|
680
|
-
|
|
681
905
|
/* The other carets of the editor, which a test sets. */
|
|
682
906
|
get otherCarets(): EditorPosition[] {
|
|
683
|
-
return this.
|
|
907
|
+
return this.selections.slice(0, -1).map((selection) => positionIn(this.text, selection.head));
|
|
684
908
|
}
|
|
685
909
|
|
|
686
910
|
set otherCarets(positions: EditorPosition[]) {
|
|
687
|
-
|
|
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)!]);
|
|
688
913
|
}
|
|
689
914
|
|
|
690
915
|
getSelections(): EditorRange[] {
|
|
691
|
-
return
|
|
916
|
+
return this.selections.map((selection) => rangeIn(this.text, selection));
|
|
692
917
|
}
|
|
693
918
|
|
|
694
919
|
setSelections(ranges: readonly EditorRange[], reveal: EditorReveal = 'relative'): void {
|
|
695
|
-
|
|
696
|
-
if (last === undefined) {
|
|
920
|
+
if (ranges.length === 0) {
|
|
697
921
|
return;
|
|
698
922
|
}
|
|
699
|
-
this.
|
|
700
|
-
this.
|
|
923
|
+
this.lastReveal = reveal;
|
|
924
|
+
this.select(ranges.map((range) => ({ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) })));
|
|
701
925
|
}
|
|
702
926
|
|
|
703
927
|
getIndentation(): EditorIndentation {
|
|
@@ -705,24 +929,28 @@ export class FakeEditor implements Editor {
|
|
|
705
929
|
}
|
|
706
930
|
|
|
707
931
|
setCaret(position: EditorPosition, reveal: EditorReveal = 'relative'): void {
|
|
932
|
+
const offset = this.offsetAt(position);
|
|
708
933
|
this.lastReveal = reveal;
|
|
709
|
-
this.
|
|
710
|
-
this.
|
|
711
|
-
this.moveCaret(position);
|
|
934
|
+
this.revealedLine = positionIn(this.text, offset).line + 1;
|
|
935
|
+
this.select([{ anchor: offset, head: offset }]);
|
|
712
936
|
}
|
|
713
937
|
|
|
714
938
|
setSelection(range: EditorRange, reveal: EditorReveal = 'relative'): void {
|
|
715
939
|
this.lastReveal = reveal;
|
|
716
|
-
this.
|
|
717
|
-
|
|
718
|
-
|
|
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();
|
|
719
946
|
}
|
|
720
947
|
|
|
721
|
-
/* 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. */
|
|
722
949
|
moveCaret(position: EditorPosition): void {
|
|
723
|
-
|
|
724
|
-
this.
|
|
725
|
-
|
|
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));
|
|
726
954
|
}
|
|
727
955
|
|
|
728
956
|
onCaret(listener: (position: EditorPosition) => void): () => void {
|
|
@@ -802,44 +1030,25 @@ export class FakeEditor implements Editor {
|
|
|
802
1030
|
return [...this.keys].some((handler) => handler(full));
|
|
803
1031
|
}
|
|
804
1032
|
|
|
805
|
-
/* 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. */
|
|
806
1033
|
applyEdits(edits: readonly EditorContentChange[], options: EditorApplyEditsOptions = {}): boolean {
|
|
807
|
-
|
|
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) {
|
|
808
1036
|
return false;
|
|
809
1037
|
}
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
.
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
}
|
|
825
|
-
if (offset < edit.to || (offset === edit.from && edit.from === edit.to)) {
|
|
826
|
-
return edit.from + delta + edit.text.length;
|
|
827
|
-
}
|
|
828
|
-
delta += edit.text.length - (edit.to - edit.from);
|
|
829
|
-
}
|
|
830
|
-
return offset + delta;
|
|
831
|
-
};
|
|
832
|
-
const moved = others.map((range) => ({ anchor: follow(range.anchor), head: follow(range.head) }));
|
|
833
|
-
const mapped = options.mapSelections && this.getSelections().map((range) => options.mapSelections!(range));
|
|
834
|
-
this.replaceText(text, 'command', mapped);
|
|
835
|
-
if (mapped === undefined) {
|
|
836
|
-
this.otherSelections = moved.map((range) => ({
|
|
837
|
-
start: positionIn(text, Math.min(range.anchor, range.head)),
|
|
838
|
-
end: positionIn(text, Math.max(range.anchor, range.head))
|
|
839
|
-
}));
|
|
840
|
-
}
|
|
841
|
-
emit(this.changes);
|
|
842
|
-
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);
|
|
843
1052
|
}
|
|
844
1053
|
|
|
845
1054
|
setTheme(theme: EditorTheme): void {
|
|
@@ -859,12 +1068,12 @@ export class FakeEditor implements Editor {
|
|
|
859
1068
|
return this.readOnly;
|
|
860
1069
|
}
|
|
861
1070
|
|
|
862
|
-
/* The commands run, in order;
|
|
1071
|
+
/* The commands run, in order; of its own the fake carries out only undo and redo. */
|
|
863
1072
|
readonly commands: EditorRunCommand[] = [];
|
|
864
1073
|
|
|
865
1074
|
runCommand(command: EditorRunCommand): boolean {
|
|
866
1075
|
this.commands.push(command);
|
|
867
|
-
return false;
|
|
1076
|
+
return command === 'undo' || command === 'redo' ? this.restore(command) : false;
|
|
868
1077
|
}
|
|
869
1078
|
|
|
870
1079
|
focus(): void {
|