@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.
@@ -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/engine.js CHANGED
@@ -50,6 +50,7 @@ class SmartEditor {
50
50
  messages: options.messages ?? {},
51
51
  label: options.label ?? DEFAULT_LABEL,
52
52
  guides: options.guides !== false,
53
+ stickyScroll: options.stickyScroll !== false,
53
54
  whitespace: options.whitespace === true,
54
55
  rightMargin: options.rightMargin ?? null,
55
56
  foldOutline: options.foldOutline ?? 'hover',
@@ -641,6 +642,10 @@ class SmartEditor {
641
642
  this.settings.guides = guides;
642
643
  this.view.applySettings();
643
644
  }
645
+ setStickyScroll(sticky) {
646
+ this.settings.stickyScroll = sticky;
647
+ this.view.applySettings();
648
+ }
644
649
  setWhitespace(whitespace) {
645
650
  this.settings.whitespace = whitespace;
646
651
  this.view.applySettings();
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;
@@ -119,9 +127,11 @@ export declare class FakeEditor implements Editor {
119
127
  enterScope(scope: readonly EditorBlock[]): void;
120
128
  smartKeys: Partial<EditorSmartKeys>;
121
129
  guides: boolean;
130
+ stickyScroll: boolean;
122
131
  whitespace: boolean;
123
132
  rightMargin: number | null;
124
133
  setGuides(guides: boolean): void;
134
+ setStickyScroll(sticky: boolean): void;
125
135
  setWhitespace(whitespace: boolean): void;
126
136
  setRightMargin(column: number | null): void;
127
137
  foldOutline: EditorFoldOutline;
@@ -134,7 +144,6 @@ export declare class FakeEditor implements Editor {
134
144
  setIndentation(indentation: EditorIndentation): void;
135
145
  markers: readonly EditorMarker[];
136
146
  highlights: readonly EditorHighlight[];
137
- caret: EditorPosition;
138
147
  origin: {
139
148
  left: number;
140
149
  top: number;
@@ -173,10 +182,13 @@ export declare class FakeEditor implements Editor {
173
182
  setLink(range: EditorRange | null): void;
174
183
  setHighlights(highlights: readonly EditorHighlight[]): void;
175
184
  getCaret(): EditorPosition;
185
+ get caret(): EditorPosition;
186
+ set caret(position: EditorPosition);
176
187
  folds: EditorFolds;
177
188
  getFolds(): EditorFolds;
178
189
  getScrollTop(): number;
179
- selection: EditorRange | null;
190
+ get selection(): EditorRange | null;
191
+ set selection(range: EditorRange | null);
180
192
  contentHeight: number;
181
193
  setScrollTop(top: number): void;
182
194
  getLineHeight(line: number): number;
@@ -184,7 +196,6 @@ export declare class FakeEditor implements Editor {
184
196
  onLineHeights(listener: Listener): () => void;
185
197
  getContentHeight(): number;
186
198
  getSelection(): EditorRange;
187
- private otherSelections;
188
199
  get otherCarets(): EditorPosition[];
189
200
  set otherCarets(positions: EditorPosition[]);
190
201
  getSelections(): EditorRange[];
@@ -192,6 +203,7 @@ export declare class FakeEditor implements Editor {
192
203
  getIndentation(): EditorIndentation;
193
204
  setCaret(position: EditorPosition, reveal?: EditorReveal): void;
194
205
  setSelection(range: EditorRange, reveal?: EditorReveal): void;
206
+ private select;
195
207
  moveCaret(position: EditorPosition): void;
196
208
  onCaret(listener: (position: EditorPosition) => void): () => void;
197
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) {
@@ -380,11 +548,15 @@ export class FakeEditor {
380
548
  }
381
549
  smartKeys;
382
550
  guides = true;
551
+ stickyScroll = true;
383
552
  whitespace = false;
384
553
  rightMargin = null;
385
554
  setGuides(guides) {
386
555
  this.guides = guides;
387
556
  }
557
+ setStickyScroll(sticky) {
558
+ this.stickyScroll = sticky;
559
+ }
388
560
  setWhitespace(whitespace) {
389
561
  this.whitespace = whitespace;
390
562
  }
@@ -412,7 +584,6 @@ export class FakeEditor {
412
584
  /* What the host marked and highlighted last. */
413
585
  markers = [];
414
586
  highlights = [];
415
- caret = { line: 0, character: 0 };
416
587
  /* Where `rectAt` puts a position, which a test can move to simulate scrolling. */
417
588
  origin = { left: 100, top: 100 };
418
589
  charSize = { width: 8, height: 20 };
@@ -470,7 +641,15 @@ export class FakeEditor {
470
641
  this.highlights = highlights;
471
642
  }
472
643
  getCaret() {
473
- 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 }];
474
653
  }
475
654
  /* The folds the host asked to keep. */
476
655
  folds = { collapsed: [], custom: [] };
@@ -480,8 +659,15 @@ export class FakeEditor {
480
659
  getScrollTop() {
481
660
  return this.scrollTop ?? 0;
482
661
  }
483
- /* The selection a person made; without one the caret. */
484
- 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
+ }
485
671
  /* A test supplies the measured height; this fake has no layout. */
486
672
  contentHeight = 0;
487
673
  setScrollTop(top) {
@@ -491,7 +677,7 @@ export class FakeEditor {
491
677
  }
492
678
  }
493
679
  getLineHeight(line) {
494
- 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;
495
681
  }
496
682
  getBaseLineHeight() {
497
683
  return this.charSize.height;
@@ -503,48 +689,49 @@ export class FakeEditor {
503
689
  return this.contentHeight;
504
690
  }
505
691
  getSelection() {
506
- return this.selection ?? { start: this.caret, end: this.caret };
692
+ return rangeIn(this.text, this.selections.at(-1));
507
693
  }
508
- /* The other selections of the editor; the primary one is always last. */
509
- otherSelections = [];
510
694
  /* The other carets of the editor, which a test sets. */
511
695
  get otherCarets() {
512
- return this.otherSelections.map((range) => range.end);
696
+ return this.selections.slice(0, -1).map((selection) => positionIn(this.text, selection.head));
513
697
  }
514
698
  set otherCarets(positions) {
515
- 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)]);
516
701
  }
517
702
  getSelections() {
518
- return [...this.otherSelections, this.getSelection()];
703
+ return this.selections.map((selection) => rangeIn(this.text, selection));
519
704
  }
520
705
  setSelections(ranges, reveal = 'relative') {
521
- const last = ranges.at(-1);
522
- if (last === undefined) {
706
+ if (ranges.length === 0) {
523
707
  return;
524
708
  }
525
- this.otherSelections = ranges.slice(0, -1).map((range) => ({ ...range }));
526
- this.setSelection(last, reveal);
709
+ this.lastReveal = reveal;
710
+ this.select(ranges.map((range) => ({ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) })));
527
711
  }
528
712
  getIndentation() {
529
713
  return this.indentation;
530
714
  }
531
715
  setCaret(position, reveal = 'relative') {
716
+ const offset = this.offsetAt(position);
532
717
  this.lastReveal = reveal;
533
- this.caret = position;
534
- this.revealedLine = position.line + 1;
535
- this.moveCaret(position);
718
+ this.revealedLine = positionIn(this.text, offset).line + 1;
719
+ this.select([{ anchor: offset, head: offset }]);
536
720
  }
537
721
  setSelection(range, reveal = 'relative') {
538
722
  this.lastReveal = reveal;
539
- this.caret = range.end;
540
- this.selection = range;
541
- emit(this.carets, range.end);
723
+ this.select([{ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) }]);
542
724
  }
543
- /* 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. */
544
730
  moveCaret(position) {
545
- this.caret = position;
546
- this.selection = null;
547
- 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));
548
735
  }
549
736
  onCaret(listener) {
550
737
  return subscribe(this.carets, listener);
@@ -606,44 +793,22 @@ export class FakeEditor {
606
793
  const full = { ctrlKey: false, metaKey: false, altKey: false, shiftKey: false, preventDefault: () => undefined, ...event };
607
794
  return [...this.keys].some((handler) => handler(full));
608
795
  }
609
- /* 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. */
610
796
  applyEdits(edits, options = {}) {
611
- 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) {
612
799
  return false;
613
800
  }
614
- let text = this.text;
615
- const ordered = edits
616
- .map((edit) => ({ from: this.offsetAt(edit.range.start), to: this.offsetAt(edit.range.end), text: edit.text }))
617
- .sort((a, b) => b.from - a.from);
618
- for (const edit of ordered) {
619
- text = text.slice(0, edit.from) + edit.text + text.slice(edit.to);
620
- }
621
- // The other carets follow the text as the real editor's do: one inside a replaced stretch ends behind what replaced it.
622
- const others = this.otherSelections.map((range) => ({ anchor: this.offsetAt(range.start), head: this.offsetAt(range.end) }));
623
- const follow = (offset) => {
624
- let delta = 0;
625
- for (const edit of [...ordered].reverse()) {
626
- if (offset < edit.from) {
627
- break;
628
- }
629
- if (offset < edit.to || (offset === edit.from && edit.from === edit.to)) {
630
- return edit.from + delta + edit.text.length;
631
- }
632
- delta += edit.text.length - (edit.to - edit.from);
633
- }
634
- return offset + delta;
635
- };
636
- const moved = others.map((range) => ({ anchor: follow(range.anchor), head: follow(range.head) }));
637
- const mapped = options.mapSelections && this.getSelections().map((range) => options.mapSelections(range));
638
- this.replaceText(text, 'command', mapped);
639
- if (mapped === undefined) {
640
- this.otherSelections = moved.map((range) => ({
641
- start: positionIn(text, Math.min(range.anchor, range.head)),
642
- 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 };
643
810
  }));
644
- }
645
- emit(this.changes);
646
- return true;
811
+ return this.edit(ordered, 'command', options.mergeUndo === true, place);
647
812
  }
648
813
  setTheme(theme) {
649
814
  this.theme = theme;
@@ -658,11 +823,11 @@ export class FakeEditor {
658
823
  isReadOnly() {
659
824
  return this.readOnly;
660
825
  }
661
- /* 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. */
662
827
  commands = [];
663
828
  runCommand(command) {
664
829
  this.commands.push(command);
665
- return false;
830
+ return command === 'undo' || command === 'redo' ? this.restore(command) : false;
666
831
  }
667
832
  focus() {
668
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;