@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/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 before = text.slice(0, Math.max(0, offset));
60
- const lines = before.split('\n');
61
- return { line: lines.length - 1, character: lines.at(-1)!.length };
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
- if (this.readOnly || text === this.text) {
247
+ const span = changedSpan(this.text, text);
248
+ if (this.readOnly || span === null) {
112
249
  return;
113
250
  }
114
- this.replaceText(text, 'input');
115
- emit(this.changes);
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.replaceText(text);
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
- /* The text becomes `text`, and listeners of the text hear the one stretch that differs, as the real editor reports it. */
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
- this.text = text;
145
- if (span === null) {
146
- return;
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
- // As in the real editor the caret lands behind what was typed, and listeners of the caret hear it before those of the text.
149
- if (source !== 'external') {
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
- this.followTracked(span.start, span.end, span.text.length);
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: [{ range: { start: positionIn(before, span.start), end: positionIn(before, span.end) }, text: span.text }]
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
- /* The fake sees one change per edit, the stretch that differs, so a range it tracks is lost a little sooner than the real editor's. */
168
- private followTracked(from: number, to: number, insertedLength: number): void {
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, [{ from, to, insertedLength }]);
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
- const lines = this.text.split('\n');
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.caret;
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 person made; without one the caret. */
646
- selection: EditorRange | null = 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.split('\n').length ? this.charSize.height : 0;
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.selection ?? { start: this.caret, end: this.caret };
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.otherSelections.map((range) => range.end);
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
- this.otherSelections = positions.map((position) => ({ start: position, end: position }));
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 [...this.otherSelections, this.getSelection()];
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
- const last = ranges.at(-1);
696
- if (last === undefined) {
920
+ if (ranges.length === 0) {
697
921
  return;
698
922
  }
699
- this.otherSelections = ranges.slice(0, -1).map((range) => ({ ...range }));
700
- this.setSelection(last, reveal);
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.caret = position;
710
- this.revealedLine = position.line + 1;
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.caret = range.end;
717
- this.selection = range;
718
- emit(this.carets, range.end);
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
- this.caret = position;
724
- this.selection = null;
725
- emit(this.carets, position);
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
- if (this.readOnly || edits.length === 0) {
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
- let text = this.text;
811
- const ordered = edits
812
- .map((edit) => ({ from: this.offsetAt(edit.range.start), to: this.offsetAt(edit.range.end), text: edit.text }))
813
- .sort((a, b) => b.from - a.from);
814
- for (const edit of ordered) {
815
- text = text.slice(0, edit.from) + edit.text + text.slice(edit.to);
816
- }
817
- // The other carets follow the text as the real editor's do: one inside a replaced stretch ends behind what replaced it.
818
- const others = this.otherSelections.map((range) => ({ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) }));
819
- const follow = (offset: number): number => {
820
- let delta = 0;
821
- for (const edit of [...ordered].reverse()) {
822
- if (offset < edit.from) {
823
- break;
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; the fake has no commands of its own to carry out. */
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 {