@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.
@@ -169,7 +169,9 @@ export class InputController {
169
169
  });
170
170
  this.listen(input, 'keyup', (event) => this.gesture.keyup(event));
171
171
  this.listen(root, 'focusout', (event) => {
172
- if (!(event.relatedTarget instanceof Node) || !root.contains(event.relatedTarget)) {
172
+ // A node by its type, as `instanceof Node` fails for one of another window, such as a window the opener mounted the editor in.
173
+ const related = event.relatedTarget;
174
+ if (typeof related?.nodeType !== 'number' || !root.contains(related)) {
173
175
  this.options.blur();
174
176
  }
175
177
  });
@@ -723,7 +725,10 @@ export class InputController {
723
725
  pointerDown(event) {
724
726
  this.view.cancelScroll();
725
727
  const target = event.target;
726
- if (event.button !== 0 || target.closest('button, a, input, textarea, [contenteditable], .se-block, .se-gutter, .se-sticky')) {
728
+ // A press a part took already, such as a pinned header, which draws itself anew and so takes its target off the page before the press bubbles here.
729
+ if (event.defaultPrevented ||
730
+ event.button !== 0 ||
731
+ target.closest('button, a, input, textarea, [contenteditable], .se-block, .se-gutter, .se-sticky')) {
727
732
  return;
728
733
  }
729
734
  // Cancels the compatibility mouse events, whose focus change would close the keyboard a tap opened; scrolling is not affected.
package/dist/fake.d.ts CHANGED
@@ -18,6 +18,10 @@ export declare class FakeEditor implements Editor {
18
18
  findQuery: EditorFindQuery | null;
19
19
  findState: EditorFindState;
20
20
  private text;
21
+ private selections;
22
+ private caretOffset;
23
+ private undoStack;
24
+ private redoStack;
21
25
  private readonly changes;
22
26
  private readonly textChanges;
23
27
  private readonly saves;
@@ -29,7 +33,11 @@ export declare class FakeEditor implements Editor {
29
33
  blur(): void;
30
34
  getText(): string;
31
35
  setText(text: string): void;
32
- private replaceText;
36
+ private edit;
37
+ private rewrite;
38
+ private state;
39
+ private restore;
40
+ private announceCaret;
33
41
  private readonly tracked;
34
42
  private followTracked;
35
43
  trackRange(range: EditorRange): EditorTrackedRange;
@@ -136,7 +144,6 @@ export declare class FakeEditor implements Editor {
136
144
  setIndentation(indentation: EditorIndentation): void;
137
145
  markers: readonly EditorMarker[];
138
146
  highlights: readonly EditorHighlight[];
139
- caret: EditorPosition;
140
147
  origin: {
141
148
  left: number;
142
149
  top: number;
@@ -175,10 +182,13 @@ export declare class FakeEditor implements Editor {
175
182
  setLink(range: EditorRange | null): void;
176
183
  setHighlights(highlights: readonly EditorHighlight[]): void;
177
184
  getCaret(): EditorPosition;
185
+ get caret(): EditorPosition;
186
+ set caret(position: EditorPosition);
178
187
  folds: EditorFolds;
179
188
  getFolds(): EditorFolds;
180
189
  getScrollTop(): number;
181
- selection: EditorRange | null;
190
+ get selection(): EditorRange | null;
191
+ set selection(range: EditorRange | null);
182
192
  contentHeight: number;
183
193
  setScrollTop(top: number): void;
184
194
  getLineHeight(line: number): number;
@@ -186,7 +196,6 @@ export declare class FakeEditor implements Editor {
186
196
  onLineHeights(listener: Listener): () => void;
187
197
  getContentHeight(): number;
188
198
  getSelection(): EditorRange;
189
- private otherSelections;
190
199
  get otherCarets(): EditorPosition[];
191
200
  set otherCarets(positions: EditorPosition[]);
192
201
  getSelections(): EditorRange[];
@@ -194,6 +203,7 @@ export declare class FakeEditor implements Editor {
194
203
  getIndentation(): EditorIndentation;
195
204
  setCaret(position: EditorPosition, reveal?: EditorReveal): void;
196
205
  setSelection(range: EditorRange, reveal?: EditorReveal): void;
206
+ private select;
197
207
  moveCaret(position: EditorPosition): void;
198
208
  onCaret(listener: (position: EditorPosition) => void): () => void;
199
209
  onHover(listener: (hover: EditorHover | null) => void): () => void;
package/dist/fake.js CHANGED
@@ -1,10 +1,108 @@
1
1
  import { emit, subscribe } from './listeners.js';
2
- import { changedSpan } from '@adecore/editor-core';
2
+ import { changedSpan, changedSpans } from '@adecore/editor-core';
3
3
  import { mapTrackedRange } from './tracked-range.js';
4
+ const LINE_BREAK = /\r\n|\r|\n/g;
5
+ /* 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. */
6
+ function linesOf(text) {
7
+ const lines = [];
8
+ let start = 0;
9
+ for (const match of text.matchAll(LINE_BREAK)) {
10
+ lines.push({ start, end: match.index });
11
+ start = match.index + match[0].length;
12
+ }
13
+ lines.push({ start, end: text.length });
14
+ return lines;
15
+ }
16
+ function isHighSurrogate(code) {
17
+ return code >= 0xd800 && code <= 0xdbff;
18
+ }
19
+ function isLowSurrogate(code) {
20
+ return code >= 0xdc00 && code <= 0xdfff;
21
+ }
22
+ /* Clamps to the text and moves back off the middle of a CRLF or a surrogate pair, as the real document does. */
23
+ function clampOffset(text, value) {
24
+ const offset = Math.max(0, Math.min(text.length, Number.isFinite(value) ? Math.trunc(value) : 0));
25
+ const splitsBreak = text[offset - 1] === '\r' && text[offset] === '\n';
26
+ const splitsPair = isHighSurrogate(text.charCodeAt(offset - 1)) && isLowSurrogate(text.charCodeAt(offset));
27
+ return splitsBreak || splitsPair ? offset - 1 : offset;
28
+ }
4
29
  function positionIn(text, offset) {
5
- const before = text.slice(0, Math.max(0, offset));
6
- const lines = before.split('\n');
7
- return { line: lines.length - 1, character: lines.at(-1).length };
30
+ const at = clampOffset(text, offset);
31
+ const lines = linesOf(text);
32
+ let line = lines.length - 1;
33
+ while (line > 0 && lines[line].start > at) {
34
+ line--;
35
+ }
36
+ return { line, character: Math.min(at, lines[line].end) - lines[line].start };
37
+ }
38
+ function offsetIn(text, position) {
39
+ const lines = linesOf(text);
40
+ const line = lines[Math.max(0, Math.min(Number.isFinite(position.line) ? Math.trunc(position.line) : 0, lines.length - 1))];
41
+ const character = Math.max(0, Math.min(Number.isFinite(position.character) ? Math.trunc(position.character) : 0, line.end - line.start));
42
+ return clampOffset(text, line.start + character);
43
+ }
44
+ /* The line of a one-based line number, clamped to the text. */
45
+ function lineNumbered(text, line) {
46
+ const lines = linesOf(text);
47
+ return lines[Math.min(Math.max(1, Math.trunc(line) || 1), lines.length) - 1];
48
+ }
49
+ /* 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. */
50
+ function normalized(text, selections) {
51
+ const input = selections.length > 0 ? selections : [{ anchor: 0, head: 0 }];
52
+ const clamped = input.map((raw) => {
53
+ const anchor = clampOffset(text, raw.anchor);
54
+ return { anchor, head: raw.head === raw.anchor ? anchor : clampOffset(text, raw.head) };
55
+ });
56
+ const order = clamped
57
+ .map((selection, index) => ({ from: Math.min(selection.anchor, selection.head), to: Math.max(selection.anchor, selection.head), index }))
58
+ .sort((left, right) => left.from - right.from);
59
+ const groups = [];
60
+ for (const { from, to, index } of order) {
61
+ const group = groups.at(-1);
62
+ if (group !== undefined && from <= group.to) {
63
+ group.to = Math.max(group.to, to);
64
+ group.last = Math.max(group.last, index);
65
+ }
66
+ else {
67
+ groups.push({ from, to, last: index });
68
+ }
69
+ }
70
+ return groups
71
+ .sort((left, right) => left.last - right.last)
72
+ .map((group) => {
73
+ const selection = clamped[group.last];
74
+ return selection.anchor > selection.head ? { anchor: group.to, head: group.from } : { anchor: group.from, head: group.to };
75
+ });
76
+ }
77
+ /* The edits in order and without repeats, or null where two overlap, which the real document refuses. */
78
+ function orderedEdits(edits) {
79
+ const sorted = [...edits].sort((left, right) => left.from - right.from || left.to - right.to);
80
+ const unique = [];
81
+ for (const edit of sorted) {
82
+ const previous = unique.at(-1);
83
+ if (previous !== undefined && edit.from === previous.from && edit.to === previous.to && edit.text === previous.text) {
84
+ continue;
85
+ }
86
+ if (previous !== undefined && (edit.from < previous.to || (edit.from === previous.from && edit.to === previous.to))) {
87
+ return null;
88
+ }
89
+ unique.push(edit);
90
+ }
91
+ return unique;
92
+ }
93
+ /* Where an offset lands after ordered edits: one inside a replaced stretch, or at an insertion, ends up behind the new text. */
94
+ function mapOffset(offset, edits) {
95
+ let delta = 0;
96
+ for (const edit of edits) {
97
+ if (edit.to > offset) {
98
+ return edit.from <= offset ? edit.from + delta + edit.text.length : offset + delta;
99
+ }
100
+ delta += edit.text.length - (edit.to - edit.from);
101
+ }
102
+ return offset + delta;
103
+ }
104
+ function rangeIn(text, selection) {
105
+ return { start: positionIn(text, Math.min(selection.anchor, selection.head)), end: positionIn(text, Math.max(selection.anchor, selection.head)) };
8
106
  }
9
107
  /* 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. */
10
108
  export class FakeEditor {
@@ -27,6 +125,12 @@ export class FakeEditor {
27
125
  findQuery = null;
28
126
  findState = { count: 0, current: null };
29
127
  text;
128
+ /* The primary selection last, as in the real editor. */
129
+ selections = [{ anchor: 0, head: 0 }];
130
+ /* Where the caret listeners last heard the caret was. */
131
+ caretOffset = 0;
132
+ undoStack = [];
133
+ redoStack = [];
30
134
  changes = new Set();
31
135
  textChanges = new Set();
32
136
  saves = new Set();
@@ -47,15 +151,26 @@ export class FakeEditor {
47
151
  this.revealedLine = options.line ?? null;
48
152
  this.column = options.column ?? null;
49
153
  this.scrollTop = options.scrollTop ?? null;
154
+ if (options.line !== undefined) {
155
+ const line = lineNumbered(this.text, options.line);
156
+ const offset = Math.min(line.end, line.start + Math.max(0, (options.column ?? 1) - 1));
157
+ this.selections = [{ anchor: offset, head: offset }];
158
+ this.caretOffset = offset;
159
+ }
50
160
  }
51
- /* A person's edit: the text changes and every change listener hears it. A read-only editor refuses it, as the real editor does. */
161
+ /* 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. */
52
162
  type(text) {
53
163
  this.assertLive();
54
- if (this.readOnly || text === this.text) {
164
+ const span = changedSpan(this.text, text);
165
+ if (this.readOnly || span === null) {
55
166
  return;
56
167
  }
57
- this.replaceText(text, 'input');
58
- emit(this.changes);
168
+ const edit = { from: span.start, to: span.end, text: span.text };
169
+ const end = span.start + span.text.length;
170
+ const others = this.selections
171
+ .slice(0, -1)
172
+ .map((selection) => ({ anchor: mapOffset(selection.anchor, [edit]), head: mapOffset(selection.head, [edit]) }));
173
+ this.edit([edit], 'input', false, () => [...others, { anchor: end, head: end }]);
59
174
  }
60
175
  /* Mod+S inside the editor. */
61
176
  save() {
@@ -71,40 +186,96 @@ export class FakeEditor {
71
186
  getText() {
72
187
  return this.text;
73
188
  }
189
+ /* Split into the stretches the real editor splits it into, so the carets and tracked ranges follow it alike. */
74
190
  setText(text) {
75
191
  this.assertLive();
76
- this.replaceText(text);
192
+ const spans = changedSpans(this.text, text).map((span) => ({ from: span.start, to: span.end, text: span.text }));
193
+ this.edit(orderedEdits(spans) ?? [], 'external', false);
194
+ }
195
+ /*
196
+ * Ordered edits as one step of the history, and the listeners told in the real editor's order: the caret's,
197
+ * the text's, then the change listeners for anything but a change from outside. `place` puts the selections
198
+ * in the new text; without it they follow the edits. False when the text stays as it was.
199
+ */
200
+ edit(edits, source, mergeUndo, place) {
201
+ const before = this.state();
202
+ if (!this.rewrite(edits, source, place)) {
203
+ return false;
204
+ }
205
+ const last = this.undoStack.at(-1);
206
+ if (mergeUndo && last !== undefined && last.after.text === before.text) {
207
+ last.after = this.state();
208
+ }
209
+ else {
210
+ this.undoStack.push({ before, after: this.state() });
211
+ }
212
+ this.redoStack = [];
213
+ if (source !== 'external') {
214
+ emit(this.changes);
215
+ }
216
+ return true;
77
217
  }
78
- /* The text becomes `text`, and listeners of the text hear the one stretch that differs, as the real editor reports it. */
79
- replaceText(text, source = 'external', selections) {
80
- const span = changedSpan(this.text, text);
218
+ rewrite(edits, source, place) {
81
219
  const before = this.text;
82
- this.text = text;
83
- if (span === null) {
84
- return;
220
+ let text = before;
221
+ for (let index = edits.length - 1; index >= 0; index--) {
222
+ const edit = edits[index];
223
+ text = text.slice(0, edit.from) + edit.text + text.slice(edit.to);
85
224
  }
86
- // As in the real editor the caret lands behind what was typed, and listeners of the caret hear it before those of the text.
87
- if (source !== 'external') {
88
- if (selections === undefined) {
89
- this.moveCaret(positionIn(text, span.start + span.text.length));
90
- }
91
- else {
92
- this.setSelections(selections);
93
- }
225
+ if (text === before) {
226
+ return false;
94
227
  }
95
- this.followTracked(span.start, span.end, span.text.length);
228
+ const changed = edits.filter((edit) => before.slice(edit.from, edit.to) !== edit.text);
229
+ this.text = text;
230
+ this.selections = normalized(text, place?.(text) ?? this.selections.map((selection) => ({ anchor: mapOffset(selection.anchor, edits), head: mapOffset(selection.head, edits) })));
231
+ this.followTracked(changed);
96
232
  this.ghost = null;
233
+ this.announceCaret();
234
+ // 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.
97
235
  const change = {
98
236
  source,
99
- changes: [{ range: { start: positionIn(before, span.start), end: positionIn(before, span.end) }, text: span.text }]
237
+ changes: [...changed]
238
+ .reverse()
239
+ .map((edit) => ({ range: { start: positionIn(before, edit.from), end: positionIn(before, edit.to) }, text: edit.text }))
100
240
  };
101
241
  emit(this.textChanges, change);
242
+ return true;
243
+ }
244
+ state() {
245
+ return { text: this.text, selections: this.selections.map((selection) => ({ ...selection })) };
246
+ }
247
+ /* Undo or redo, which the real editor reports as changes of a command and refuses while the text is read only. */
248
+ restore(direction) {
249
+ const [from, to] = direction === 'undo' ? [this.undoStack, this.redoStack] : [this.redoStack, this.undoStack];
250
+ const entry = this.readOnly ? undefined : from.pop();
251
+ if (entry === undefined) {
252
+ return false;
253
+ }
254
+ to.push(entry);
255
+ const state = direction === 'undo' ? entry.before : entry.after;
256
+ const span = changedSpan(this.text, state.text);
257
+ if (span === null) {
258
+ this.select(state.selections);
259
+ }
260
+ else {
261
+ this.rewrite([{ from: span.start, to: span.end, text: span.text }], 'command', () => state.selections);
262
+ }
263
+ emit(this.changes);
264
+ return true;
265
+ }
266
+ /* Tells the caret listeners where the primary caret is, when it moved since they last heard. */
267
+ announceCaret() {
268
+ const head = this.selections.at(-1).head;
269
+ if (head !== this.caretOffset) {
270
+ this.caretOffset = head;
271
+ emit(this.carets, positionIn(this.text, head));
272
+ }
102
273
  }
103
274
  tracked = new Set();
104
- /* 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. */
105
- followTracked(from, to, insertedLength) {
275
+ followTracked(edits) {
276
+ const changes = edits.map((edit) => ({ from: edit.from, to: edit.to, insertedLength: edit.text.length }));
106
277
  for (const entry of this.tracked) {
107
- const mapped = mapTrackedRange(entry.from, entry.to, [{ from, to, insertedLength }]);
278
+ const mapped = mapTrackedRange(entry.from, entry.to, changes);
108
279
  if (mapped === null) {
109
280
  this.tracked.delete(entry);
110
281
  entry.lost = true;
@@ -135,13 +306,7 @@ export class FakeEditor {
135
306
  return this.text.slice(this.offsetAt(range.start), this.offsetAt(range.end));
136
307
  }
137
308
  offsetAt(position) {
138
- const lines = this.text.split('\n');
139
- let offset = 0;
140
- for (let line = 0; line < Math.min(position.line, lines.length - 1); line++) {
141
- offset += lines[line].length + 1;
142
- }
143
- const current = lines[Math.min(position.line, lines.length - 1)];
144
- return offset + Math.min(position.character, current.length);
309
+ return offsetIn(this.text, position);
145
310
  }
146
311
  onChange(listener) {
147
312
  return subscribe(this.changes, listener);
@@ -157,6 +322,9 @@ export class FakeEditor {
157
322
  revealLine(line, reveal = 'center') {
158
323
  this.revealedLine = line;
159
324
  this.lastReveal = reveal;
325
+ const { start } = lineNumbered(this.text, line);
326
+ this.selections = [{ anchor: start, head: start }];
327
+ this.announceCaret();
160
328
  }
161
329
  /* Counts plain occurrences only; the matcher itself is the editor's, and a test here is about the host's side. */
162
330
  find(query) {
@@ -416,7 +584,6 @@ export class FakeEditor {
416
584
  /* What the host marked and highlighted last. */
417
585
  markers = [];
418
586
  highlights = [];
419
- caret = { line: 0, character: 0 };
420
587
  /* Where `rectAt` puts a position, which a test can move to simulate scrolling. */
421
588
  origin = { left: 100, top: 100 };
422
589
  charSize = { width: 8, height: 20 };
@@ -474,7 +641,15 @@ export class FakeEditor {
474
641
  this.highlights = highlights;
475
642
  }
476
643
  getCaret() {
477
- return this.caret;
644
+ return positionIn(this.text, this.selections.at(-1).head);
645
+ }
646
+ /* The primary caret; setting it tells no listener. */
647
+ get caret() {
648
+ return this.getCaret();
649
+ }
650
+ set caret(position) {
651
+ const offset = this.offsetAt(position);
652
+ this.selections = [...this.selections.slice(0, -1), { anchor: offset, head: offset }];
478
653
  }
479
654
  /* The folds the host asked to keep. */
480
655
  folds = { collapsed: [], custom: [] };
@@ -484,8 +659,15 @@ export class FakeEditor {
484
659
  getScrollTop() {
485
660
  return this.scrollTop ?? 0;
486
661
  }
487
- /* The selection a person made; without one the caret. */
488
- selection = null;
662
+ /* The primary selection, or null when it is only a caret; setting it tells no listener. */
663
+ get selection() {
664
+ const primary = this.selections.at(-1);
665
+ return primary.anchor === primary.head ? null : rangeIn(this.text, primary);
666
+ }
667
+ set selection(range) {
668
+ const head = this.offsetAt(range?.end ?? this.getCaret());
669
+ this.selections = [...this.selections.slice(0, -1), { anchor: range === null ? head : this.offsetAt(range.start), head }];
670
+ }
489
671
  /* A test supplies the measured height; this fake has no layout. */
490
672
  contentHeight = 0;
491
673
  setScrollTop(top) {
@@ -495,7 +677,7 @@ export class FakeEditor {
495
677
  }
496
678
  }
497
679
  getLineHeight(line) {
498
- return Number.isInteger(line) && line >= 0 && line < this.text.split('\n').length ? this.charSize.height : 0;
680
+ return Number.isInteger(line) && line >= 0 && line < linesOf(this.text).length ? this.charSize.height : 0;
499
681
  }
500
682
  getBaseLineHeight() {
501
683
  return this.charSize.height;
@@ -507,48 +689,49 @@ export class FakeEditor {
507
689
  return this.contentHeight;
508
690
  }
509
691
  getSelection() {
510
- return this.selection ?? { start: this.caret, end: this.caret };
692
+ return rangeIn(this.text, this.selections.at(-1));
511
693
  }
512
- /* The other selections of the editor; the primary one is always last. */
513
- otherSelections = [];
514
694
  /* The other carets of the editor, which a test sets. */
515
695
  get otherCarets() {
516
- return this.otherSelections.map((range) => range.end);
696
+ return this.selections.slice(0, -1).map((selection) => positionIn(this.text, selection.head));
517
697
  }
518
698
  set otherCarets(positions) {
519
- this.otherSelections = positions.map((position) => ({ start: position, end: position }));
699
+ const others = positions.map((position) => this.offsetAt(position)).map((offset) => ({ anchor: offset, head: offset }));
700
+ this.selections = normalized(this.text, [...others, this.selections.at(-1)]);
520
701
  }
521
702
  getSelections() {
522
- return [...this.otherSelections, this.getSelection()];
703
+ return this.selections.map((selection) => rangeIn(this.text, selection));
523
704
  }
524
705
  setSelections(ranges, reveal = 'relative') {
525
- const last = ranges.at(-1);
526
- if (last === undefined) {
706
+ if (ranges.length === 0) {
527
707
  return;
528
708
  }
529
- this.otherSelections = ranges.slice(0, -1).map((range) => ({ ...range }));
530
- this.setSelection(last, reveal);
709
+ this.lastReveal = reveal;
710
+ this.select(ranges.map((range) => ({ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) })));
531
711
  }
532
712
  getIndentation() {
533
713
  return this.indentation;
534
714
  }
535
715
  setCaret(position, reveal = 'relative') {
716
+ const offset = this.offsetAt(position);
536
717
  this.lastReveal = reveal;
537
- this.caret = position;
538
- this.revealedLine = position.line + 1;
539
- this.moveCaret(position);
718
+ this.revealedLine = positionIn(this.text, offset).line + 1;
719
+ this.select([{ anchor: offset, head: offset }]);
540
720
  }
541
721
  setSelection(range, reveal = 'relative') {
542
722
  this.lastReveal = reveal;
543
- this.caret = range.end;
544
- this.selection = range;
545
- emit(this.carets, range.end);
723
+ this.select([{ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) }]);
546
724
  }
547
- /* The caret a person's click or key moved. */
725
+ select(selections) {
726
+ this.selections = normalized(this.text, selections);
727
+ this.announceCaret();
728
+ }
729
+ /* 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. */
548
730
  moveCaret(position) {
549
- this.caret = position;
550
- this.selection = null;
551
- emit(this.carets, position);
731
+ const offset = this.offsetAt(position);
732
+ this.selections = normalized(this.text, [...this.selections.slice(0, -1), { anchor: offset, head: offset }]);
733
+ this.caretOffset = this.selections.at(-1).head;
734
+ emit(this.carets, positionIn(this.text, this.caretOffset));
552
735
  }
553
736
  onCaret(listener) {
554
737
  return subscribe(this.carets, listener);
@@ -610,44 +793,22 @@ export class FakeEditor {
610
793
  const full = { ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, preventDefault: () => undefined, ...event };
611
794
  return [...this.keys].some((handler) => handler(full));
612
795
  }
613
- /* 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. */
614
796
  applyEdits(edits, options = {}) {
615
- if (this.readOnly || edits.length === 0) {
797
+ const ordered = orderedEdits(edits.map((edit) => ({ from: this.offsetAt(edit.range.start), to: this.offsetAt(edit.range.end), text: edit.text })));
798
+ if (this.readOnly || ordered === null || ordered.length === 0) {
616
799
  return false;
617
800
  }
618
- let text = this.text;
619
- const ordered = edits
620
- .map((edit) => ({ from: this.offsetAt(edit.range.start), to: this.offsetAt(edit.range.end), text: edit.text }))
621
- .sort((a, b) => b.from - a.from);
622
- for (const edit of ordered) {
623
- text = text.slice(0, edit.from) + edit.text + text.slice(edit.to);
624
- }
625
- // The other carets follow the text as the real editor's do: one inside a replaced stretch ends behind what replaced it.
626
- const others = this.otherSelections.map((range) => ({ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) }));
627
- const follow = (offset) => {
628
- let delta = 0;
629
- for (const edit of [...ordered].reverse()) {
630
- if (offset < edit.from) {
631
- break;
632
- }
633
- if (offset < edit.to || (offset === edit.from && edit.from === edit.to)) {
634
- return edit.from + delta + edit.text.length;
635
- }
636
- delta += edit.text.length - (edit.to - edit.from);
637
- }
638
- return offset + delta;
639
- };
640
- const moved = others.map((range) => ({ anchor: follow(range.anchor), head: follow(range.head) }));
641
- const mapped = options.mapSelections && this.getSelections().map((range) => options.mapSelections(range));
642
- this.replaceText(text, 'command', mapped);
643
- if (mapped === undefined) {
644
- this.otherSelections = moved.map((range) => ({
645
- start: positionIn(text, Math.min(range.anchor, range.head)),
646
- end: positionIn(text, Math.max(range.anchor, range.head))
801
+ const { mapSelections } = options;
802
+ const mapped = mapSelections &&
803
+ this.selections.map((selection) => ({ backward: selection.head < selection.anchor, range: mapSelections(rangeIn(this.text, selection)) }));
804
+ // A backward selection stays backward, as the real editor places it.
805
+ const place = mapped &&
806
+ ((text) => mapped.map(({ backward, range }) => {
807
+ const from = offsetIn(text, range.start);
808
+ const to = offsetIn(text, range.end);
809
+ return backward ? { anchor: to, head: from } : { anchor: from, head: to };
647
810
  }));
648
- }
649
- emit(this.changes);
650
- return true;
811
+ return this.edit(ordered, 'command', options.mergeUndo === true, place);
651
812
  }
652
813
  setTheme(theme) {
653
814
  this.theme = theme;
@@ -662,11 +823,11 @@ export class FakeEditor {
662
823
  isReadOnly() {
663
824
  return this.readOnly;
664
825
  }
665
- /* The commands run, in order; the fake has no commands of its own to carry out. */
826
+ /* The commands run, in order; of its own the fake carries out only undo and redo. */
666
827
  commands = [];
667
828
  runCommand(command) {
668
829
  this.commands.push(command);
669
- return false;
830
+ return command === 'undo' || command === 'redo' ? this.restore(command) : false;
670
831
  }
671
832
  focus() {
672
833
  this.focused = true;
package/dist/layout.d.ts CHANGED
@@ -159,6 +159,8 @@ export declare class EditorLayout {
159
159
  lineHeightsVersion: number;
160
160
  width: number;
161
161
  private lineRows;
162
+ private keyed;
163
+ private special;
162
164
  private geometryCache;
163
165
  private measuredRows;
164
166
  private measuredHeights;
@@ -173,11 +175,16 @@ export declare class EditorLayout {
173
175
  constructor(document: LayoutDocument, metrics: LayoutMetrics);
174
176
  get wrapping(): boolean;
175
177
  configure(config: LayoutConfig): void;
178
+ private lineEdit;
176
179
  private retainGeometry;
177
180
  private rowCountOf;
178
181
  private rebuild;
182
+ private structure;
183
+ private patch;
184
+ private buildRows;
179
185
  private clampToLine;
180
186
  private reflow;
187
+ rowByKey(key: string): LayoutRow | undefined;
181
188
  setMeasuredHeight(key: string, height: number): boolean;
182
189
  syncRows(rows: readonly LayoutRow[]): boolean;
183
190
  rowAt(y: number): LayoutRow;