@adecore/editor 0.23.1 → 0.24.1

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/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. */
@@ -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.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 }];
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 person made; without one the caret. */
651
- 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
+ }
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.split('\n').length ? this.charSize.height : 0;
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.selection ?? { start: this.caret, end: this.caret };
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.otherSelections.map((range) => range.end);
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
- 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)!]);
693
913
  }
694
914
 
695
915
  getSelections(): EditorRange[] {
696
- return [...this.otherSelections, this.getSelection()];
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
- const last = ranges.at(-1);
701
- if (last === undefined) {
920
+ if (ranges.length === 0) {
702
921
  return;
703
922
  }
704
- this.otherSelections = ranges.slice(0, -1).map((range) => ({ ...range }));
705
- this.setSelection(last, reveal);
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.caret = position;
715
- this.revealedLine = position.line + 1;
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.caret = range.end;
722
- this.selection = range;
723
- 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();
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
- this.caret = position;
729
- this.selection = null;
730
- 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));
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
- 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) {
813
1036
  return false;
814
1037
  }
815
- let text = this.text;
816
- const ordered = edits
817
- .map((edit) => ({ from: this.offsetAt(edit.range.start), to: this.offsetAt(edit.range.end), text: edit.text }))
818
- .sort((a, b) => b.from - a.from);
819
- for (const edit of ordered) {
820
- text = text.slice(0, edit.from) + edit.text + text.slice(edit.to);
821
- }
822
- // The other carets follow the text as the real editor's do: one inside a replaced stretch ends behind what replaced it.
823
- const others = this.otherSelections.map((range) => ({ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) }));
824
- const follow = (offset: number): number => {
825
- let delta = 0;
826
- for (const edit of [...ordered].reverse()) {
827
- if (offset < edit.from) {
828
- break;
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; 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. */
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 {