@adecore/editor 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controller.js +7 -2
- package/dist/engine.js +5 -0
- package/dist/fake.d.ts +16 -4
- package/dist/fake.js +261 -96
- package/dist/layout.d.ts +7 -0
- package/dist/layout.js +228 -40
- package/dist/tokens.js +2 -1
- package/dist/types.d.ts +2 -0
- package/dist/view.d.ts +3 -3
- package/dist/view.js +38 -26
- package/package.json +2 -2
- package/src/controller.ts +9 -2
- package/src/engine.ts +6 -0
- package/src/fake.ts +306 -97
- package/src/layout.ts +262 -40
- package/src/tokens.ts +2 -1
- package/src/types.ts +4 -0
- package/src/view.ts +41 -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/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
|
|
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
|
|
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) {
|
|
@@ -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.
|
|
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
|
|
484
|
-
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
|
+
}
|
|
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
|
|
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.
|
|
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.
|
|
696
|
+
return this.selections.slice(0, -1).map((selection) => positionIn(this.text, selection.head));
|
|
513
697
|
}
|
|
514
698
|
set otherCarets(positions) {
|
|
515
|
-
|
|
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
|
|
703
|
+
return this.selections.map((selection) => rangeIn(this.text, selection));
|
|
519
704
|
}
|
|
520
705
|
setSelections(ranges, reveal = 'relative') {
|
|
521
|
-
|
|
522
|
-
if (last === undefined) {
|
|
706
|
+
if (ranges.length === 0) {
|
|
523
707
|
return;
|
|
524
708
|
}
|
|
525
|
-
this.
|
|
526
|
-
this.
|
|
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.
|
|
534
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
this.
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
const
|
|
616
|
-
.map((
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
text
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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;
|
|
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;
|