@adecore/editor 0.23.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controller.js +7 -2
- package/dist/fake.d.ts +14 -4
- package/dist/fake.js +257 -96
- package/dist/layout.d.ts +7 -0
- package/dist/layout.js +228 -40
- package/dist/tokens.js +2 -1
- package/dist/view.d.ts +2 -3
- package/dist/view.js +35 -26
- package/package.json +2 -2
- package/src/controller.ts +9 -2
- package/src/fake.ts +301 -97
- package/src/layout.ts +262 -40
- package/src/tokens.ts +2 -1
- package/src/view.ts +36 -28
package/dist/controller.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
6
|
-
const lines =
|
|
7
|
-
|
|
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
|
-
|
|
164
|
+
const span = changedSpan(this.text, text);
|
|
165
|
+
if (this.readOnly || span === null) {
|
|
55
166
|
return;
|
|
56
167
|
}
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
105
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
|
488
|
-
selection
|
|
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
|
|
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.
|
|
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.
|
|
696
|
+
return this.selections.slice(0, -1).map((selection) => positionIn(this.text, selection.head));
|
|
517
697
|
}
|
|
518
698
|
set otherCarets(positions) {
|
|
519
|
-
|
|
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
|
|
703
|
+
return this.selections.map((selection) => rangeIn(this.text, selection));
|
|
523
704
|
}
|
|
524
705
|
setSelections(ranges, reveal = 'relative') {
|
|
525
|
-
|
|
526
|
-
if (last === undefined) {
|
|
706
|
+
if (ranges.length === 0) {
|
|
527
707
|
return;
|
|
528
708
|
}
|
|
529
|
-
this.
|
|
530
|
-
this.
|
|
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.
|
|
538
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
this.
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
const
|
|
620
|
-
.map((
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
text
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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;
|
|
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;
|