@adecore/editor 0.0.1 → 0.17.0-beta.2
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/LICENSE +110 -0
- package/README.md +57 -0
- package/dist/attribution.d.ts +28 -0
- package/dist/attribution.js +55 -0
- package/dist/code-block.d.ts +2 -0
- package/dist/code-block.js +89 -0
- package/dist/column-selection.d.ts +9 -0
- package/dist/column-selection.js +52 -0
- package/dist/controller.d.ts +64 -0
- package/dist/controller.js +772 -0
- package/dist/editor.css +813 -0
- package/dist/engine.d.ts +2 -0
- package/dist/engine.js +610 -0
- package/dist/fake.d.ts +192 -0
- package/dist/fake.js +663 -0
- package/dist/find.d.ts +30 -0
- package/dist/find.js +108 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/input.d.ts +9 -0
- package/dist/input.js +57 -0
- package/dist/keymap-table.d.ts +102 -0
- package/dist/keymap-table.js +130 -0
- package/dist/keymap.d.ts +36 -0
- package/dist/keymap.js +163 -0
- package/dist/layout.d.ts +167 -0
- package/dist/layout.js +583 -0
- package/dist/lens.d.ts +3 -0
- package/dist/lens.js +29 -0
- package/dist/listeners.d.ts +3 -0
- package/dist/listeners.js +12 -0
- package/dist/metrics.d.ts +8 -0
- package/dist/metrics.js +54 -0
- package/dist/modifier-gesture.d.ts +24 -0
- package/dist/modifier-gesture.js +98 -0
- package/dist/native-input.d.ts +26 -0
- package/dist/native-input.js +147 -0
- package/dist/offsets.d.ts +2 -0
- package/dist/offsets.js +14 -0
- package/dist/outline.d.ts +55 -0
- package/dist/outline.js +181 -0
- package/dist/overview.d.ts +17 -0
- package/dist/overview.js +55 -0
- package/dist/paint.d.ts +86 -0
- package/dist/paint.js +481 -0
- package/dist/php-html.d.ts +9 -0
- package/dist/php-html.js +36 -0
- package/dist/pointer.d.ts +49 -0
- package/dist/pointer.js +243 -0
- package/dist/scroll-animation.d.ts +3 -0
- package/dist/scroll-animation.js +45 -0
- package/dist/scroll.d.ts +28 -0
- package/dist/scroll.js +76 -0
- package/dist/semantic.d.ts +8 -0
- package/dist/semantic.js +47 -0
- package/dist/shiki.d.ts +7 -0
- package/dist/shiki.js +106 -0
- package/dist/smart-keys.d.ts +3 -0
- package/dist/smart-keys.js +14 -0
- package/dist/testing.d.ts +34 -0
- package/dist/testing.js +117 -0
- package/dist/theme-scopes.d.ts +9 -0
- package/dist/theme-scopes.js +61 -0
- package/dist/tokens.d.ts +28 -0
- package/dist/tokens.js +152 -0
- package/dist/tracked-range.d.ts +5 -0
- package/dist/tracked-range.js +20 -0
- package/dist/types.d.ts +344 -0
- package/dist/types.js +1 -0
- package/dist/view.d.ts +383 -0
- package/dist/view.js +2134 -0
- package/package.json +63 -3
- package/src/attribution.ts +85 -0
- package/src/code-block.ts +100 -0
- package/src/column-selection.ts +63 -0
- package/src/controller.ts +838 -0
- package/src/editor.css +813 -0
- package/src/engine.ts +764 -0
- package/src/fake.ts +838 -0
- package/src/find.ts +128 -0
- package/src/index.ts +69 -0
- package/src/input.ts +63 -0
- package/src/keymap-table.ts +163 -0
- package/src/keymap.ts +204 -0
- package/src/layout.ts +754 -0
- package/src/lens.ts +32 -0
- package/src/listeners.ts +15 -0
- package/src/metrics.ts +65 -0
- package/src/modifier-gesture.ts +115 -0
- package/src/native-input.ts +179 -0
- package/src/offsets.ts +16 -0
- package/src/outline.ts +248 -0
- package/src/overview.ts +80 -0
- package/src/paint.ts +608 -0
- package/src/php-html.ts +43 -0
- package/src/pointer.ts +326 -0
- package/src/scroll-animation.ts +48 -0
- package/src/scroll.ts +102 -0
- package/src/semantic.ts +54 -0
- package/src/shiki.ts +118 -0
- package/src/smart-keys.ts +17 -0
- package/src/testing.ts +157 -0
- package/src/theme-scopes.ts +75 -0
- package/src/tokens.ts +175 -0
- package/src/tracked-range.ts +22 -0
- package/src/types.ts +659 -0
- package/src/view.ts +2437 -0
package/src/view.ts
ADDED
|
@@ -0,0 +1,2437 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DocumentChange,
|
|
3
|
+
type DocumentModel,
|
|
4
|
+
type EditorSnapshot,
|
|
5
|
+
type FoldHints,
|
|
6
|
+
type FoldRole,
|
|
7
|
+
type FoldingRange,
|
|
8
|
+
replacementText,
|
|
9
|
+
scanBrackets,
|
|
10
|
+
type BracketIndex,
|
|
11
|
+
type Selection as EditorSelection
|
|
12
|
+
} from '@adecore/editor-core';
|
|
13
|
+
import { FindController } from './find.ts';
|
|
14
|
+
import { type BlockWidget, EditorLayout, type FoldState, type Inlay, type LayoutRect, type LayoutRow, RIGHT_PADDING, type TextRow } from './layout.ts';
|
|
15
|
+
import { createMetrics, type EditorFont, readEditorFont } from './metrics.ts';
|
|
16
|
+
import { type AttributedLines, AttributionRuns, colorValue, tintValue } from './attribution.ts';
|
|
17
|
+
import { renderCodeBlock } from './code-block.ts';
|
|
18
|
+
import { mapOffset } from './offsets.ts';
|
|
19
|
+
import { mapTrackedRange } from './tracked-range.ts';
|
|
20
|
+
import { type ScrollKind, scrollPosition } from './scroll.ts';
|
|
21
|
+
import { easeOut, scrollDuration } from './scroll-animation.ts';
|
|
22
|
+
import { Outline, scopeChain, type StickyPlacement, stickyCover, stickyPlacements, structuralEntries } from './outline.ts';
|
|
23
|
+
import { type OverviewSpan, overviewTicks, paintOverview } from './overview.ts';
|
|
24
|
+
import {
|
|
25
|
+
paintCarets,
|
|
26
|
+
paintGutter,
|
|
27
|
+
paintOver,
|
|
28
|
+
paintOverlays,
|
|
29
|
+
paintRemoteCarets,
|
|
30
|
+
paintSigns,
|
|
31
|
+
paintSticky,
|
|
32
|
+
type RemoteCaret,
|
|
33
|
+
RowPainter,
|
|
34
|
+
type StickyEntry,
|
|
35
|
+
type WrapSign
|
|
36
|
+
} from './paint.ts';
|
|
37
|
+
import { overlayTokens, type SemanticSpan } from './semantic.ts';
|
|
38
|
+
import { indentationColumn } from '@adecore/editor-core';
|
|
39
|
+
import { TokenCache } from './tokens.ts';
|
|
40
|
+
import type {
|
|
41
|
+
EditorBlock,
|
|
42
|
+
EditorChangeKind,
|
|
43
|
+
EditorChangeMark,
|
|
44
|
+
EditorCodeBlockOptions,
|
|
45
|
+
EditorFindQuery,
|
|
46
|
+
EditorFindState,
|
|
47
|
+
EditorHighlightKind,
|
|
48
|
+
EditorMarkerSeverity,
|
|
49
|
+
EditorMessages,
|
|
50
|
+
EditorRect,
|
|
51
|
+
EditorSmartKeys,
|
|
52
|
+
LineToken,
|
|
53
|
+
LineTokenizer,
|
|
54
|
+
ScopeColors
|
|
55
|
+
} from './types.ts';
|
|
56
|
+
|
|
57
|
+
function sameRect(left: EditorRect, right: EditorRect): boolean {
|
|
58
|
+
return left.left === right.left && left.top === right.top && left.right === right.right && left.bottom === right.bottom;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ViewSettings {
|
|
62
|
+
language: string | undefined;
|
|
63
|
+
tabSize: number;
|
|
64
|
+
insertSpaces: boolean;
|
|
65
|
+
readOnly: boolean;
|
|
66
|
+
readOnlyReason: string | undefined;
|
|
67
|
+
wrap: boolean;
|
|
68
|
+
smartKeys: EditorSmartKeys;
|
|
69
|
+
messages: Partial<EditorMessages>;
|
|
70
|
+
label: string;
|
|
71
|
+
/* A line at each indentation level, and the one of the scope around the caret stronger. */
|
|
72
|
+
guides: boolean;
|
|
73
|
+
/* Spaces drawn as dots and tabs as arrows. */
|
|
74
|
+
whitespace: boolean;
|
|
75
|
+
/* The column a line is held to, drawn as a line; null for none. */
|
|
76
|
+
rightMargin: number | null;
|
|
77
|
+
/* When the arrow that folds a block shows in the gutter. */
|
|
78
|
+
foldOutline: 'off' | 'hover' | 'always';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* The lines past the visible ones that are colored ahead of the scroll, and how long one slice of that work may take. */
|
|
82
|
+
const COLOR_MARGIN = 80;
|
|
83
|
+
const MIN_OVERSCAN = 400;
|
|
84
|
+
const COLOR_SLICE_MS = 8;
|
|
85
|
+
const FIRST_PAINT_MS = 12;
|
|
86
|
+
const FOLD_DELAY_MS = 300;
|
|
87
|
+
|
|
88
|
+
/* The space between the last character of a line and what the host puts after it. */
|
|
89
|
+
const LINE_ACTION_GAP = 28;
|
|
90
|
+
const NOTICE_MS = 3000;
|
|
91
|
+
const DEFAULT_HEIGHT = 400;
|
|
92
|
+
const DEFAULT_WIDTH = 800;
|
|
93
|
+
const FOLD_LIMIT = 2_000_000;
|
|
94
|
+
const MIN_GUTTER_WIDTH = 64;
|
|
95
|
+
const ATTRIBUTION_WIDTH = 3;
|
|
96
|
+
const ATTRIBUTION_REACH = 3;
|
|
97
|
+
/* The height of an agent's name over its caret, which the label's style in `editor.css` has too. */
|
|
98
|
+
const REMOTE_LABEL_HEIGHT = 18;
|
|
99
|
+
/* The caret stays solid this long after it moved, so holding an arrow key does not blink it between steps. */
|
|
100
|
+
const CARET_SOLID_MS = 500;
|
|
101
|
+
/* A jump moves the view even to something already in view, as the platform does by default. */
|
|
102
|
+
const REFRAIN_FROM_SCROLLING = false;
|
|
103
|
+
/* Selecting text marks its other occurrences, unless there are more than this many, which would be noise. */
|
|
104
|
+
const OCCURRENCE_LIMIT = 50;
|
|
105
|
+
const OCCURRENCE_TEXT_LIMIT = 1000;
|
|
106
|
+
/* How far the guides look for the lines around a blank one, and how far a scope's guide is followed from the caret. */
|
|
107
|
+
const GUIDE_SCAN_LINES = 100;
|
|
108
|
+
const GUIDE_RUN_LINES = 5000;
|
|
109
|
+
const STICKY_MAX_LINES = 5;
|
|
110
|
+
/* A view shows at most this share of its rows as sticky headers, so a small node keeps most of its text. */
|
|
111
|
+
const STICKY_ROW_SHARE = 4;
|
|
112
|
+
|
|
113
|
+
/* A range of lines that folds: the document's own, or one a person made of a selection. */
|
|
114
|
+
export type ViewFold = Omit<FoldingRange, 'kind'> & { kind: FoldingRange['kind'] | 'custom' };
|
|
115
|
+
|
|
116
|
+
/* A mark the host asked for, such as every use of the name under the caret. */
|
|
117
|
+
export interface Occurrence {
|
|
118
|
+
from: number;
|
|
119
|
+
to: number;
|
|
120
|
+
kind?: EditorHighlightKind;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* A stretch of text the language servers classified, anchored to offsets that follow the text through edits. */
|
|
124
|
+
export interface ViewSemanticToken {
|
|
125
|
+
from: number;
|
|
126
|
+
to: number;
|
|
127
|
+
scopes: readonly string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* A problem the host marked, anchored to offsets that follow the text through edits. */
|
|
131
|
+
export interface ViewMarker {
|
|
132
|
+
severity: EditorMarkerSeverity;
|
|
133
|
+
from: number;
|
|
134
|
+
to: number;
|
|
135
|
+
unnecessary: boolean;
|
|
136
|
+
deprecated: boolean;
|
|
137
|
+
message?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/*
|
|
141
|
+
* What is drawn and where: the DOM of the editor, the layout under it, and the colors of its lines.
|
|
142
|
+
* It never decides what a key does; `InputController` does, and asks this for where things are.
|
|
143
|
+
*/
|
|
144
|
+
export class EditorView {
|
|
145
|
+
readonly root: HTMLElement;
|
|
146
|
+
readonly viewport: HTMLElement;
|
|
147
|
+
readonly input: HTMLTextAreaElement;
|
|
148
|
+
readonly layout: EditorLayout;
|
|
149
|
+
readonly find: FindController;
|
|
150
|
+
readonly tokens: TokenCache;
|
|
151
|
+
private readonly content: HTMLElement;
|
|
152
|
+
private readonly gutter: HTMLElement;
|
|
153
|
+
private readonly gutterLines: HTMLElement;
|
|
154
|
+
private readonly overlays: HTMLElement;
|
|
155
|
+
private readonly over: HTMLElement;
|
|
156
|
+
private readonly carets: HTMLElement;
|
|
157
|
+
private readonly remote: HTMLElement;
|
|
158
|
+
private readonly actions: HTMLElement;
|
|
159
|
+
private readonly preview: HTMLElement;
|
|
160
|
+
private readonly signs: HTMLElement;
|
|
161
|
+
private readonly notice: HTMLElement;
|
|
162
|
+
private readonly sticky: HTMLElement;
|
|
163
|
+
private readonly overview: HTMLElement;
|
|
164
|
+
private readonly tickTip: HTMLElement;
|
|
165
|
+
private readonly painter: RowPainter;
|
|
166
|
+
private readonly document: Document;
|
|
167
|
+
private readonly resizeObserver: ResizeObserver | undefined;
|
|
168
|
+
private readonly blockObserver: ResizeObserver | undefined;
|
|
169
|
+
private readonly subscription: { dispose(): void };
|
|
170
|
+
private readonly findListeners = new Set<(state: EditorFindState) => void>();
|
|
171
|
+
private readonly scopeListeners = new Set<(scope: readonly EditorBlock[]) => void>();
|
|
172
|
+
private readonly viewListeners = new Set<() => void>();
|
|
173
|
+
private readonly gutterActionListeners = new Set<(line: number) => void>();
|
|
174
|
+
private gutterAction: { line: number; label: string } | null = null;
|
|
175
|
+
private readonly gutterMarkerListeners = new Set<(id: string) => void>();
|
|
176
|
+
private readonly gutterMarkers = new Map<string, readonly { id: string; line: number; label: string }[]>();
|
|
177
|
+
private semantic: ViewSemanticToken[] = [];
|
|
178
|
+
private scopeColors: ScopeColors | null = null;
|
|
179
|
+
private semanticVersion = 0;
|
|
180
|
+
private semanticIndex: { key: string; lines: Map<number, SemanticSpan[]> } | undefined;
|
|
181
|
+
private readonly semanticLines = new Map<number, { base: readonly LineToken[]; signature: string; result: readonly LineToken[] }>();
|
|
182
|
+
private markers: ViewMarker[] = [];
|
|
183
|
+
private markerVersion = 0;
|
|
184
|
+
private viewKey = '';
|
|
185
|
+
private readonly hoverListeners = new Set<(offset: number | null) => void>();
|
|
186
|
+
private hoverOffset: number | null = null;
|
|
187
|
+
private readonly outline = new Outline();
|
|
188
|
+
private stickyEntries: StickyEntry[] = [];
|
|
189
|
+
private scopeKey = '';
|
|
190
|
+
private changeMarks: { kind: EditorChangeKind; from: number; to: number }[] = [];
|
|
191
|
+
private changeVersion = 0;
|
|
192
|
+
private readonly attribution = new AttributionRuns();
|
|
193
|
+
private readonly highlights = new AttributionRuns();
|
|
194
|
+
private remoteCarets: { id: string; name: string; color: string; at: number }[] = [];
|
|
195
|
+
private remoteKey: string | null = null;
|
|
196
|
+
/* The host's DOM after the last character of a line, by owner; `at` is the start of its line and follows the text through edits. */
|
|
197
|
+
private readonly lineActions = new Map<string, { id: string; at: number; render: (container: HTMLElement) => void }[]>();
|
|
198
|
+
private readonly actionElements = new Map<string, HTMLElement>();
|
|
199
|
+
private readonly attributionListeners = new Set<(hover: { id: string; rect: EditorRect } | null) => void>();
|
|
200
|
+
private attributionHover: { id: string; rect: EditorRect } | null = null;
|
|
201
|
+
/* Where the pointer is in the gutter, so the bar under it is found again after the view scrolled or the text moved. */
|
|
202
|
+
private attributionPointer: { x: number; y: number } | null = null;
|
|
203
|
+
private readonly tracked = new Set<{ from: number; to: number; lost: boolean }>();
|
|
204
|
+
private overviewKey = '';
|
|
205
|
+
private gutterWidth = MIN_GUTTER_WIDTH;
|
|
206
|
+
private frame: number | undefined;
|
|
207
|
+
private caretTimer: ReturnType<typeof setTimeout> | undefined;
|
|
208
|
+
private caretKey = '';
|
|
209
|
+
private renderedScroll = { top: -1, left: -1 };
|
|
210
|
+
private font: EditorFont;
|
|
211
|
+
private layoutVersion = 0;
|
|
212
|
+
private revision: number;
|
|
213
|
+
private rendering = false;
|
|
214
|
+
private frameGeometry: { top: number; left: number; width: number; height: number } | null = null;
|
|
215
|
+
private disposed = false;
|
|
216
|
+
private inlays: Inlay[] = [];
|
|
217
|
+
/* The host's widgets by owner, so one owner setting its rows never takes another's away. */
|
|
218
|
+
private readonly blocks = new Map<string, BlockWidget[]>();
|
|
219
|
+
private lenses: BlockWidget[] = [];
|
|
220
|
+
private occurrences: Occurrence[] = [];
|
|
221
|
+
private replacePreview: { text: string; preserveCase: boolean } | null = null;
|
|
222
|
+
private dropCaret: number | null = null;
|
|
223
|
+
private activeGuideCache: { key: string; guide: { column: number; first: number; last: number } | null } | undefined;
|
|
224
|
+
/* Column mode: a drag or Shift with the arrows selects a box of columns, as Alt does for a drag. */
|
|
225
|
+
columnMode = false;
|
|
226
|
+
/* A scroll on its way: where it started and ends, when, and the frame that moves it. */
|
|
227
|
+
private scrollRun: { frame: number; from: { x: number; y: number }; to: { x: number; y: number }; start: number | null; duration: number } | null = null;
|
|
228
|
+
private selectionOccurrenceCache: { key: string; ranges: readonly { from: number; to: number }[] } = { key: '', ranges: [] };
|
|
229
|
+
private link: { from: number; to: number } | null = null;
|
|
230
|
+
private composition: string | undefined;
|
|
231
|
+
private ghost: { at: number; text: string; accessory: ((container: HTMLElement) => void) | undefined } | null = null;
|
|
232
|
+
private ghostSerial = 0;
|
|
233
|
+
private foldRanges: ViewFold[] = [];
|
|
234
|
+
/* The folds made of a selection, which follow their text through edits. */
|
|
235
|
+
private customFolds: { from: number; to: number }[] = [];
|
|
236
|
+
private collapsed = new Set<number>();
|
|
237
|
+
/* What a language server said about the folds, in offsets that follow the text through edits. */
|
|
238
|
+
private foldHints: FoldHints | null = null;
|
|
239
|
+
/* The roles that fold by themselves while the file is as it was opened; null once a person edited or the file opened with folds of its own. */
|
|
240
|
+
private defaultRoles: ReadonlySet<FoldRole> | null = null;
|
|
241
|
+
/* The folds somebody already decided on, a person or the defaults, so a later answer of a server leaves them as they are. */
|
|
242
|
+
private settled = new Set<number>();
|
|
243
|
+
private foldTimer: ReturnType<typeof setTimeout> | undefined;
|
|
244
|
+
private colorTimer: ReturnType<typeof setTimeout> | undefined;
|
|
245
|
+
private noticeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
246
|
+
private bracketCache: { revision: number; language: string | undefined; index: BracketIndex } | undefined;
|
|
247
|
+
private wrapWidth: number | null = null;
|
|
248
|
+
/* The offset a click past the end of a wrapped visual line chose, which its caret is drawn at the end of that line for. */
|
|
249
|
+
private rowEndCaret: number | null = null;
|
|
250
|
+
/* The first line on screen as an offset and how far into its row the scroll is, so an edit above it does not move what is read. */
|
|
251
|
+
private topAnchor: { offset: number; delta: number } | undefined;
|
|
252
|
+
|
|
253
|
+
readonly model: DocumentModel;
|
|
254
|
+
readonly settings: ViewSettings;
|
|
255
|
+
|
|
256
|
+
constructor(container: HTMLElement, model: DocumentModel, settings: ViewSettings) {
|
|
257
|
+
this.model = model;
|
|
258
|
+
this.settings = settings;
|
|
259
|
+
const document = container.ownerDocument;
|
|
260
|
+
this.document = document;
|
|
261
|
+
const make = <Tag extends keyof HTMLElementTagNameMap>(tag: Tag, className: string): HTMLElementTagNameMap[Tag] => {
|
|
262
|
+
const element = document.createElement(tag);
|
|
263
|
+
element.className = className;
|
|
264
|
+
return element;
|
|
265
|
+
};
|
|
266
|
+
this.root = make('div', 'se-editor');
|
|
267
|
+
this.gutter = make('div', 'se-gutter');
|
|
268
|
+
this.gutterLines = make('div', 'se-gutter-lines');
|
|
269
|
+
this.gutter.append(this.gutterLines);
|
|
270
|
+
this.viewport = make('div', 'se-viewport');
|
|
271
|
+
this.content = make('div', 'se-content');
|
|
272
|
+
this.overlays = make('div', 'se-overlays');
|
|
273
|
+
const code = make('div', 'se-code');
|
|
274
|
+
this.over = make('div', 'se-over');
|
|
275
|
+
this.carets = make('div', 'se-carets');
|
|
276
|
+
this.remote = make('div', 'se-remote');
|
|
277
|
+
this.actions = make('div', 'se-actions');
|
|
278
|
+
this.signs = make('div', 'se-signs');
|
|
279
|
+
this.signs.setAttribute('aria-hidden', 'true');
|
|
280
|
+
this.preview = make('div', 'se-preview');
|
|
281
|
+
this.preview.setAttribute('aria-hidden', 'true');
|
|
282
|
+
this.preview.hidden = true;
|
|
283
|
+
for (const layer of [this.overlays, this.over, this.carets, this.remote]) {
|
|
284
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
285
|
+
}
|
|
286
|
+
code.setAttribute('aria-hidden', 'true');
|
|
287
|
+
this.content.append(this.overlays, this.signs, code, this.over, this.carets, this.remote, this.actions, this.preview);
|
|
288
|
+
// The gutter is in the scroller with the text and sticks to its left, so the browser moves both in the same frame.
|
|
289
|
+
const scroller = make('div', 'se-scroller');
|
|
290
|
+
// The pinned headers stick to the corner the same way, which keeps them still against the text while the browser scrolls.
|
|
291
|
+
const stickyAnchor = make('div', 'se-sticky-anchor');
|
|
292
|
+
this.sticky = make('div', 'se-sticky');
|
|
293
|
+
stickyAnchor.append(this.sticky);
|
|
294
|
+
scroller.append(stickyAnchor, this.gutter, this.content);
|
|
295
|
+
this.viewport.append(scroller);
|
|
296
|
+
this.input = make('textarea', 'se-input');
|
|
297
|
+
this.input.wrap = 'off';
|
|
298
|
+
this.input.spellcheck = false;
|
|
299
|
+
for (const name of ['autocapitalize', 'autocomplete', 'autocorrect']) {
|
|
300
|
+
this.input.setAttribute(name, 'off');
|
|
301
|
+
}
|
|
302
|
+
this.input.setAttribute('aria-multiline', 'true');
|
|
303
|
+
this.sticky.setAttribute('aria-hidden', 'true');
|
|
304
|
+
this.sticky.hidden = true;
|
|
305
|
+
this.overview = make('div', 'se-overview');
|
|
306
|
+
this.overview.setAttribute('aria-hidden', 'true');
|
|
307
|
+
this.tickTip = make('div', 'se-tick-tip');
|
|
308
|
+
this.tickTip.hidden = true;
|
|
309
|
+
this.notice = make('div', 'se-notice');
|
|
310
|
+
this.notice.setAttribute('role', 'status');
|
|
311
|
+
this.notice.hidden = true;
|
|
312
|
+
this.root.append(this.viewport, this.overview, this.tickTip, this.input, this.notice);
|
|
313
|
+
this.wireOverview();
|
|
314
|
+
this.wireAttribution();
|
|
315
|
+
container.append(this.root);
|
|
316
|
+
|
|
317
|
+
this.font = readEditorFont(this.root);
|
|
318
|
+
this.layout = new EditorLayout(model, createMetrics(this.font, settings.tabSize, document));
|
|
319
|
+
this.find = new FindController(model);
|
|
320
|
+
this.tokens = new TokenCache({ getLineCount: () => model.getLineCount(), getLineText: (line) => model.getLine(line).text });
|
|
321
|
+
const ResizeObserverClass = document.defaultView?.ResizeObserver;
|
|
322
|
+
this.blockObserver = ResizeObserverClass
|
|
323
|
+
? new ResizeObserverClass((entries) => {
|
|
324
|
+
let changed = false;
|
|
325
|
+
this.anchorScroll(() => {
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
const element = entry.target as HTMLElement;
|
|
328
|
+
changed = this.layout.setMeasuredHeight(element.dataset.rowKey!, element.getBoundingClientRect().height) || changed;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
if (changed) {
|
|
332
|
+
this.render();
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
: undefined;
|
|
336
|
+
this.resizeObserver = ResizeObserverClass ? new ResizeObserverClass(() => this.requestRender()) : undefined;
|
|
337
|
+
this.resizeObserver?.observe(this.viewport);
|
|
338
|
+
this.painter = new RowPainter(code, this.layout, this.blockObserver);
|
|
339
|
+
this.revision = model.getRevision();
|
|
340
|
+
this.subscription = model.subscribe((snapshot) => this.modelChanged(snapshot));
|
|
341
|
+
this.applySettings();
|
|
342
|
+
this.scheduleFolds();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* Settings that are read off the page or off `settings` are applied again: after a change to either. */
|
|
346
|
+
applySettings(): void {
|
|
347
|
+
this.input.readOnly = this.settings.readOnly;
|
|
348
|
+
this.input.setAttribute('aria-label', this.settings.label);
|
|
349
|
+
this.root.dataset.readonly = String(this.settings.readOnly);
|
|
350
|
+
this.root.dataset.wrap = String(this.settings.wrap);
|
|
351
|
+
this.root.dataset.foldOutline = this.settings.foldOutline;
|
|
352
|
+
this.layoutVersion++;
|
|
353
|
+
this.layout.configure({ metrics: createMetrics(this.font, this.settings.tabSize, this.document) });
|
|
354
|
+
this.render();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
refreshFont(): void {
|
|
358
|
+
this.font = readEditorFont(this.root);
|
|
359
|
+
this.applySettings();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
setTokenizer(tokenizer: LineTokenizer | null): void {
|
|
363
|
+
this.tokens.setTokenizer(tokenizer);
|
|
364
|
+
this.colorAhead();
|
|
365
|
+
this.render();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
setInlays(inlays: readonly Inlay[]): void {
|
|
369
|
+
this.inlays = inlays.map((inlay) => ({ ...inlay, text: inlay.text.replace(/[\r\n]/g, ' ') }));
|
|
370
|
+
this.configureText();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
setBlockWidgets(owner: string, blocks: readonly BlockWidget[]): void {
|
|
374
|
+
if (blocks.length === 0) {
|
|
375
|
+
this.blocks.delete(owner);
|
|
376
|
+
} else {
|
|
377
|
+
this.blocks.set(owner, [...blocks]);
|
|
378
|
+
}
|
|
379
|
+
this.anchorScroll(() => this.layout.configure({ blocks: this.allBlocks() }));
|
|
380
|
+
this.render();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* The quiet rows above declarations: kept apart from the host's widgets, so setting one kind never takes the other away. */
|
|
384
|
+
setLenses(lenses: readonly BlockWidget[]): void {
|
|
385
|
+
this.lenses = [...lenses];
|
|
386
|
+
this.anchorScroll(() => this.layout.configure({ blocks: this.allBlocks() }));
|
|
387
|
+
this.render();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* Rows under the same line go by owner name and then by the order the owner gave them, so they never swap places when another owner sets its own. */
|
|
391
|
+
private allBlocks(): BlockWidget[] {
|
|
392
|
+
const owners = [...this.blocks.keys()].sort();
|
|
393
|
+
return [...owners.flatMap((owner) => this.blocks.get(owner)!), ...this.lenses];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
setOccurrences(occurrences: readonly Occurrence[]): void {
|
|
397
|
+
if (occurrences.length === 0 && this.occurrences.length === 0) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
this.occurrences = [...occurrences];
|
|
401
|
+
this.render();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* A name underlined as a link a press follows, with the pointer to match; null takes it away. */
|
|
405
|
+
setLink(link: { from: number; to: number } | null): void {
|
|
406
|
+
this.link = link;
|
|
407
|
+
this.content.classList.toggle('se-content-link', link !== null);
|
|
408
|
+
this.render();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/* What the language servers classified, drawn over the grammar's colors; null takes it away. */
|
|
412
|
+
setSemanticTokens(tokens: readonly ViewSemanticToken[] | null): void {
|
|
413
|
+
this.semantic = tokens === null ? [] : [...tokens];
|
|
414
|
+
this.semanticVersion++;
|
|
415
|
+
this.render();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/* How the current theme draws a scope; without it the classification has no colors to draw in. */
|
|
419
|
+
setScopeColors(colors: ScopeColors | null): void {
|
|
420
|
+
this.scopeColors = colors;
|
|
421
|
+
this.semanticVersion++;
|
|
422
|
+
this.semanticLines.clear();
|
|
423
|
+
this.render();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* The grammar's colors of a line with the servers' laid over them, the same object for as long as neither changed. */
|
|
427
|
+
private styledTokensOf(line: number): readonly LineToken[] | null {
|
|
428
|
+
const base = this.tokens.tokensOf(line);
|
|
429
|
+
if (base === null || this.semantic.length === 0 || this.scopeColors === null) {
|
|
430
|
+
return base;
|
|
431
|
+
}
|
|
432
|
+
const spans = this.semanticSpans().get(line);
|
|
433
|
+
if (spans === undefined) {
|
|
434
|
+
return base;
|
|
435
|
+
}
|
|
436
|
+
const signature = spans.map((span) => `${span.from}:${span.to}:${span.color}:${span.fontStyle}`).join('|');
|
|
437
|
+
const cached = this.semanticLines.get(line);
|
|
438
|
+
if (cached?.base === base && cached.signature === signature) {
|
|
439
|
+
return cached.result;
|
|
440
|
+
}
|
|
441
|
+
if (this.semanticLines.size > 600) {
|
|
442
|
+
this.semanticLines.clear();
|
|
443
|
+
}
|
|
444
|
+
const result = overlayTokens(base, spans);
|
|
445
|
+
this.semanticLines.set(line, { base, signature, result });
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private semanticSpans(): Map<number, SemanticSpan[]> {
|
|
450
|
+
const key = `${this.revision}|${this.semanticVersion}`;
|
|
451
|
+
if (this.semanticIndex?.key === key) {
|
|
452
|
+
return this.semanticIndex.lines;
|
|
453
|
+
}
|
|
454
|
+
const lines = new Map<number, SemanticSpan[]>();
|
|
455
|
+
const colors = this.scopeColors;
|
|
456
|
+
for (const token of this.semantic) {
|
|
457
|
+
const style = colors?.(token.scopes);
|
|
458
|
+
if (style === undefined || token.to <= token.from) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const line = this.model.positionAt(token.from).line;
|
|
462
|
+
const start = this.model.getLine(line).start;
|
|
463
|
+
const end = this.model.getLine(line).end;
|
|
464
|
+
if (token.to > end) {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const list = lines.get(line) ?? [];
|
|
468
|
+
list.push({ from: token.from - start, to: token.to - start, color: style.color, fontStyle: style.fontStyle });
|
|
469
|
+
lines.set(line, list);
|
|
470
|
+
}
|
|
471
|
+
for (const list of lines.values()) {
|
|
472
|
+
list.sort((left, right) => left.from - right.from);
|
|
473
|
+
}
|
|
474
|
+
this.semanticIndex = { key, lines };
|
|
475
|
+
return lines;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
setMarkers(markers: readonly ViewMarker[]): void {
|
|
479
|
+
this.markers = [...markers];
|
|
480
|
+
this.markerVersion++;
|
|
481
|
+
this.render();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* What an input method has composed so far, drawn at the caret and not yet in the document. */
|
|
485
|
+
setComposition(text: string | undefined): void {
|
|
486
|
+
this.composition = text;
|
|
487
|
+
this.configureText();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
notify(message: string): void {
|
|
491
|
+
this.notice.textContent = message;
|
|
492
|
+
this.notice.hidden = false;
|
|
493
|
+
clearTimeout(this.noticeTimer);
|
|
494
|
+
this.noticeTimer = setTimeout(() => {
|
|
495
|
+
this.notice.hidden = true;
|
|
496
|
+
}, NOTICE_MS);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
get gutterElement(): HTMLElement {
|
|
500
|
+
return this.gutter;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
get stickyElement(): HTMLElement {
|
|
504
|
+
return this.sticky;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
focus(): void {
|
|
508
|
+
this.input.focus({ preventScroll: true });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
get focused(): boolean {
|
|
512
|
+
return this.document.activeElement === this.input;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
get viewportHeight(): number {
|
|
516
|
+
return this.clientHeight || DEFAULT_HEIGHT;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* Read once at the start of a render: a read after the first write of the frame makes the browser lay the page out again. */
|
|
520
|
+
private get scrollTop(): number {
|
|
521
|
+
return this.frameGeometry?.top ?? this.viewport.scrollTop;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private get scrollLeft(): number {
|
|
525
|
+
return this.frameGeometry?.left ?? this.viewport.scrollLeft;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private get clientWidth(): number {
|
|
529
|
+
return this.frameGeometry?.width ?? this.viewport.clientWidth;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private get clientHeight(): number {
|
|
533
|
+
return this.frameGeometry?.height ?? this.viewport.clientHeight;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* A viewport of rows on each side, so a fling the compositor runs ahead of this thread still lands on drawn rows. */
|
|
537
|
+
private get overscan(): number {
|
|
538
|
+
return Math.max(MIN_OVERSCAN, this.viewportHeight);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* The width the text has, which is what the viewport has left of the gutter. */
|
|
542
|
+
private get viewportWidth(): number {
|
|
543
|
+
return Math.max(1, (this.clientWidth || DEFAULT_WIDTH) - this.gutterWidth);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private modelChanged(snapshot: EditorSnapshot): void {
|
|
547
|
+
if (snapshot.revision !== this.revision) {
|
|
548
|
+
this.cancelScroll();
|
|
549
|
+
this.revision = snapshot.revision;
|
|
550
|
+
this.defaultRoles = null;
|
|
551
|
+
this.rowEndCaret = null;
|
|
552
|
+
this.hoverOffset = null;
|
|
553
|
+
const batches = snapshot.changes ?? [];
|
|
554
|
+
if (this.ghost !== null && batches.length > 0) {
|
|
555
|
+
this.ghost = null;
|
|
556
|
+
this.layoutGhostParts();
|
|
557
|
+
}
|
|
558
|
+
for (const changes of batches) {
|
|
559
|
+
this.inlays = this.inlays.map((inlay) => ({ ...inlay, at: mapOffset(inlay.at, changes) }));
|
|
560
|
+
for (const [owner, blocks] of this.blocks) {
|
|
561
|
+
this.blocks.set(
|
|
562
|
+
owner,
|
|
563
|
+
blocks.map((block) => ({ ...block, at: mapOffset(block.at, changes) }))
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
this.lenses = this.lenses.map((lens) => ({ ...lens, at: mapOffset(lens.at, changes) }));
|
|
567
|
+
this.collapsed = new Set([...this.collapsed].map((anchor) => mapOffset(anchor, changes)));
|
|
568
|
+
this.settled = new Set([...this.settled].map((anchor) => mapOffset(anchor, changes)));
|
|
569
|
+
if (this.foldHints !== null) {
|
|
570
|
+
this.foldHints = {
|
|
571
|
+
symbols:
|
|
572
|
+
this.foldHints.symbols?.map((hint) => ({ ...hint, from: mapOffset(hint.from, changes), to: mapOffset(hint.to, changes) })) ?? [],
|
|
573
|
+
ranges: this.foldHints.ranges?.map((hint) => ({ ...hint, from: mapOffset(hint.from, changes), to: mapOffset(hint.to, changes) })) ?? []
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
this.customFolds = this.customFolds.map((fold) => ({ from: mapOffset(fold.from, changes), to: mapOffset(fold.to, changes) }));
|
|
577
|
+
this.find.mapBounds(changes);
|
|
578
|
+
this.changeMarks = this.changeMarks.map((mark) => ({ ...mark, from: mapOffset(mark.from, changes), to: mapOffset(mark.to, changes) }));
|
|
579
|
+
this.markers = this.markers.map((marker) => ({ ...marker, from: mapOffset(marker.from, changes), to: mapOffset(marker.to, changes) }));
|
|
580
|
+
this.mapTracked(changes);
|
|
581
|
+
this.attribution.map(changes);
|
|
582
|
+
this.highlights.map(changes);
|
|
583
|
+
this.remoteCarets = this.remoteCarets.map((caret) => ({ ...caret, at: mapOffset(caret.at, changes) }));
|
|
584
|
+
for (const [owner, entries] of this.lineActions) {
|
|
585
|
+
this.lineActions.set(
|
|
586
|
+
owner,
|
|
587
|
+
entries.map((entry) => ({ ...entry, at: mapOffset(entry.at, changes) }))
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
this.semantic = this.semantic.map((token) => ({ ...token, from: mapOffset(token.from, changes), to: mapOffset(token.to, changes) }));
|
|
591
|
+
}
|
|
592
|
+
this.foldRanges = this.mappedFolds(batches);
|
|
593
|
+
this.semanticVersion++;
|
|
594
|
+
this.markerVersion++;
|
|
595
|
+
const anchor = this.topAnchor;
|
|
596
|
+
this.occurrences = [];
|
|
597
|
+
this.link = null;
|
|
598
|
+
this.content.classList.remove('se-content-link');
|
|
599
|
+
this.outline.edited(batches);
|
|
600
|
+
this.changeVersion++;
|
|
601
|
+
this.tokens.edited(batches, (offset) => this.model.positionAt(offset).line);
|
|
602
|
+
this.layout.configure({ textChanged: true, inlays: this.displayedInlays(), blocks: this.allBlocks(), folds: this.foldStates() });
|
|
603
|
+
if (anchor && this.viewport.scrollTop > 0) {
|
|
604
|
+
let offset = anchor.offset;
|
|
605
|
+
for (const changes of batches) {
|
|
606
|
+
offset = mapOffset(offset, changes);
|
|
607
|
+
}
|
|
608
|
+
this.viewport.scrollTop = Math.max(0, this.layout.rowForLine(this.model.positionAt(offset).line).top + anchor.delta);
|
|
609
|
+
}
|
|
610
|
+
this.find.refresh();
|
|
611
|
+
this.announceFind();
|
|
612
|
+
this.scheduleFolds();
|
|
613
|
+
this.colorAhead(true);
|
|
614
|
+
}
|
|
615
|
+
if (this.rowEndCaret !== null && this.model.getPrimary().head !== this.rowEndCaret) {
|
|
616
|
+
this.rowEndCaret = null;
|
|
617
|
+
}
|
|
618
|
+
this.requestRender();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private mapTracked(changes: readonly DocumentChange[]): void {
|
|
622
|
+
for (const entry of this.tracked) {
|
|
623
|
+
const mapped = mapTrackedRange(entry.from, entry.to, changes);
|
|
624
|
+
if (mapped === null) {
|
|
625
|
+
entry.lost = true;
|
|
626
|
+
this.tracked.delete(entry);
|
|
627
|
+
} else {
|
|
628
|
+
entry.from = mapped.from;
|
|
629
|
+
entry.to = mapped.to;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/* A range that follows the text through edits and is lost once one touches it; see `mapTrackedRange`. */
|
|
635
|
+
trackRange(from: number, to: number): { get(): { from: number; to: number } | null; dispose(): void } {
|
|
636
|
+
const entry = { from, to, lost: false };
|
|
637
|
+
this.tracked.add(entry);
|
|
638
|
+
return {
|
|
639
|
+
get: () => (entry.lost ? null : { from: entry.from, to: entry.to }),
|
|
640
|
+
dispose: () => {
|
|
641
|
+
entry.lost = true;
|
|
642
|
+
this.tracked.delete(entry);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/* The ranges follow the text until the next read of the document, so a fold above an edit stays closed meanwhile. */
|
|
648
|
+
private mappedFolds(batches: readonly (readonly DocumentChange[])[]): ViewFold[] {
|
|
649
|
+
const mapped: ViewFold[] = [];
|
|
650
|
+
for (const range of this.foldRanges) {
|
|
651
|
+
let from = range.from;
|
|
652
|
+
let to = range.to;
|
|
653
|
+
for (const changes of batches) {
|
|
654
|
+
from = mapOffset(from, changes);
|
|
655
|
+
to = mapOffset(to, changes);
|
|
656
|
+
}
|
|
657
|
+
const startLine = this.model.positionAt(from).line;
|
|
658
|
+
const endLine = this.model.positionAt(to).line;
|
|
659
|
+
if (endLine > startLine) {
|
|
660
|
+
mapped.push({ ...range, from, to, startLine, endLine });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return mapped;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private displayedInlays(): Inlay[] {
|
|
667
|
+
const inlays = this.composition
|
|
668
|
+
? [...this.inlays, { id: '__composition', at: this.model.getPrimary().head, text: this.composition, composition: true }]
|
|
669
|
+
: this.inlays;
|
|
670
|
+
const [first = ''] = this.ghost?.text.split(/\r?\n/) ?? [];
|
|
671
|
+
return this.ghost === null || first === '' ? inlays : [...inlays, { id: '__ghost', at: this.ghost.at, text: first, ghost: true }];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/* A suggestion after the caret: its first line in the text, the rest in rows under the line, and the host's accessory after the first line. */
|
|
675
|
+
setGhost(ghost: { at: number; text: string; accessory: ((container: HTMLElement) => void) | undefined } | null): void {
|
|
676
|
+
this.ghost = ghost === null || ghost.text === '' ? null : ghost;
|
|
677
|
+
this.layoutGhostParts();
|
|
678
|
+
this.configureText();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private layoutGhostParts(): void {
|
|
682
|
+
const ghost = this.ghost;
|
|
683
|
+
const [, ...rest] = ghost?.text.split(/\r?\n/) ?? [];
|
|
684
|
+
const owner = '__ghost';
|
|
685
|
+
this.setBlockWidgets(
|
|
686
|
+
owner,
|
|
687
|
+
ghost === null || rest.length === 0
|
|
688
|
+
? []
|
|
689
|
+
: [
|
|
690
|
+
{
|
|
691
|
+
id: owner,
|
|
692
|
+
at: ghost.at,
|
|
693
|
+
placement: 'below',
|
|
694
|
+
height: rest.length * this.layout.metrics.lineHeight,
|
|
695
|
+
render: (container) => {
|
|
696
|
+
container.classList.add('se-widget', 'se-ghost-rows');
|
|
697
|
+
renderCodeBlock(container, rest.join('\n'), null, this.settings.tabSize, {});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
]
|
|
701
|
+
);
|
|
702
|
+
this.setLineActions(
|
|
703
|
+
owner,
|
|
704
|
+
ghost?.accessory === undefined
|
|
705
|
+
? []
|
|
706
|
+
: [
|
|
707
|
+
{
|
|
708
|
+
id: `ghost-${++this.ghostSerial}`,
|
|
709
|
+
at: ghost.at,
|
|
710
|
+
render: (container) => {
|
|
711
|
+
container.classList.add('se-ghost-accessory');
|
|
712
|
+
ghost.accessory?.(container);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
]
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private configureText(): void {
|
|
720
|
+
this.layoutVersion++;
|
|
721
|
+
this.layout.configure({ inlays: this.displayedInlays() });
|
|
722
|
+
this.render();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private foldStates(): FoldState[] {
|
|
726
|
+
return this.foldRanges.map((range) => ({
|
|
727
|
+
startLine: range.startLine,
|
|
728
|
+
endLine: range.endLine,
|
|
729
|
+
collapsed: this.collapsed.has(range.from),
|
|
730
|
+
closer: this.collapsed.has(range.from) ? this.closerOf(range) : undefined
|
|
731
|
+
}));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/* The folds of the document, a moment after the last edit since they read all of it. */
|
|
735
|
+
private scheduleFolds(): void {
|
|
736
|
+
clearTimeout(this.foldTimer);
|
|
737
|
+
this.foldTimer = setTimeout(() => this.refreshFolds(), FOLD_DELAY_MS);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/* Reads the folds off the document now, which otherwise waits for a pause in editing. */
|
|
741
|
+
refreshFolds(): void {
|
|
742
|
+
if (this.disposed) {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const ranges =
|
|
746
|
+
this.model.getLength() > FOLD_LIMIT
|
|
747
|
+
? []
|
|
748
|
+
: this.model.getFoldingRanges({
|
|
749
|
+
indentation: /^(python|py|yaml|yml)$/.test(this.settings.language ?? ''),
|
|
750
|
+
tabSize: this.settings.tabSize,
|
|
751
|
+
...(this.settings.language === undefined ? {} : { language: this.settings.language }),
|
|
752
|
+
...(this.foldHints === null ? {} : { hints: this.foldHints })
|
|
753
|
+
});
|
|
754
|
+
this.foldRanges = this.withCustomFolds(ranges);
|
|
755
|
+
this.outline.setStructure(
|
|
756
|
+
structuralEntries(
|
|
757
|
+
ranges,
|
|
758
|
+
this.model,
|
|
759
|
+
(from, to) => this.model.slice(from, to),
|
|
760
|
+
(offset) => this.brackets().pairs.get(offset)
|
|
761
|
+
)
|
|
762
|
+
);
|
|
763
|
+
const anchors = new Set(this.foldRanges.map((range) => range.from));
|
|
764
|
+
this.collapsed = new Set([...this.collapsed].filter((anchor) => anchors.has(anchor)));
|
|
765
|
+
this.anchorScroll(() => this.layout.configure({ folds: this.foldStates() }));
|
|
766
|
+
this.moveHiddenCarets();
|
|
767
|
+
this.render();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/* The folds a person made of a selection, which the document's own structure does not know, among the ones it does. */
|
|
771
|
+
private withCustomFolds(ranges: readonly FoldingRange[]): ViewFold[] {
|
|
772
|
+
const custom = this.customFolds.flatMap((fold): ViewFold[] => {
|
|
773
|
+
const startLine = this.model.positionAt(fold.from).line;
|
|
774
|
+
const endLine = this.model.positionAt(fold.to).line;
|
|
775
|
+
return endLine > startLine ? [{ startLine, endLine, from: fold.from, to: fold.to, kind: 'custom' }] : [];
|
|
776
|
+
});
|
|
777
|
+
return [...ranges, ...custom].sort((left, right) => left.startLine - right.startLine || right.endLine - left.endLine);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/* Where what stays visible of a collapsed range starts on its last line: its closer, with any closers right before it, so `])` is kept whole. */
|
|
781
|
+
private closerOf(range: ViewFold): number | undefined {
|
|
782
|
+
if (range.kind === 'comment') {
|
|
783
|
+
return range.to - 2;
|
|
784
|
+
}
|
|
785
|
+
if (range.kind !== 'bracket') {
|
|
786
|
+
return undefined;
|
|
787
|
+
}
|
|
788
|
+
const lineStart = this.model.getLine(range.endLine).start;
|
|
789
|
+
let start = range.to - 1;
|
|
790
|
+
while (start > lineStart && /[)\]}]/.test(this.model.slice(start - 1, start))) {
|
|
791
|
+
start--;
|
|
792
|
+
}
|
|
793
|
+
return start;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/* The range a collapsed or collapsible line starts, outermost first. */
|
|
797
|
+
private foldAt(line: number): ViewFold | undefined {
|
|
798
|
+
return this.foldRanges.find((range) => range.startLine === line);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/* The range that starts on a line when it is the only one, as the platform's fold commands read a line. */
|
|
802
|
+
private soleFoldAt(line: number): ViewFold | undefined {
|
|
803
|
+
const starting = this.foldRanges.filter((range) => range.startLine === line);
|
|
804
|
+
return starting.length === 1 ? starting[0] : undefined;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/* The ranges an offset is in or at the edge of, the innermost first. */
|
|
808
|
+
private foldsAt(offset: number): ViewFold[] {
|
|
809
|
+
return this.foldRanges.filter((range) => range.from <= offset && offset <= range.to).sort((left, right) => right.from - left.from);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private isCollapsed(range: ViewFold): boolean {
|
|
813
|
+
return this.collapsed.has(range.from);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private setCollapsed(range: ViewFold, collapse: boolean): void {
|
|
817
|
+
this.settled.add(range.from);
|
|
818
|
+
if (collapse) {
|
|
819
|
+
this.collapsed.add(range.from);
|
|
820
|
+
} else {
|
|
821
|
+
this.collapsed.delete(range.from);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/* A click on a line's control: the outermost range there folds, and unfolding opens every range that starts there. */
|
|
826
|
+
toggleFold(line: number, collapse?: boolean): boolean {
|
|
827
|
+
const range = this.foldAt(line);
|
|
828
|
+
if (!range) {
|
|
829
|
+
return false;
|
|
830
|
+
}
|
|
831
|
+
const next = collapse ?? !this.foldRanges.some((candidate) => candidate.startLine === line && this.isCollapsed(candidate));
|
|
832
|
+
for (const candidate of this.foldRanges) {
|
|
833
|
+
if (candidate.startLine === line && (next ? candidate === range : true)) {
|
|
834
|
+
this.setCollapsed(candidate, next);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
this.refoldLayout();
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/* Folds, at every caret, the range that starts on its line, or else the innermost open range around it. */
|
|
842
|
+
collapseRegion(): void {
|
|
843
|
+
const carets = this.model.getSelections();
|
|
844
|
+
const primary = this.model.getPrimary().head;
|
|
845
|
+
for (const caret of carets) {
|
|
846
|
+
const range = this.soleFoldAt(this.model.positionAt(caret.head).line);
|
|
847
|
+
if (range !== undefined && !this.isCollapsed(range)) {
|
|
848
|
+
this.setCollapsed(range, true);
|
|
849
|
+
} else {
|
|
850
|
+
const open = this.foldsAt(primary).find((candidate) => !this.isCollapsed(candidate));
|
|
851
|
+
if (open !== undefined) {
|
|
852
|
+
this.setCollapsed(open, true);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
this.refoldLayout();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/* Opens, at every caret, the range that starts on its line when it is folded, or else the outermost folded range around it. */
|
|
860
|
+
expandRegion(): void {
|
|
861
|
+
for (const caret of this.model.getSelections()) {
|
|
862
|
+
const range = this.soleFoldAt(this.model.positionAt(caret.head).line);
|
|
863
|
+
if (range !== undefined && this.isCollapsed(range)) {
|
|
864
|
+
this.setCollapsed(range, false);
|
|
865
|
+
} else {
|
|
866
|
+
const closed = this.foldsAt(caret.head).findLast((candidate) => this.isCollapsed(candidate));
|
|
867
|
+
if (closed !== undefined) {
|
|
868
|
+
this.setCollapsed(closed, false);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
this.refoldLayout();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/* The range at the primary caret and every range inside it, to be folded or opened together. */
|
|
876
|
+
private foldTreeAtCaret(collapse: boolean): ViewFold[] {
|
|
877
|
+
const head = this.model.getPrimary().head;
|
|
878
|
+
let root = this.soleFoldAt(this.model.positionAt(head).line);
|
|
879
|
+
if (root === undefined || (collapse && this.isCollapsed(root))) {
|
|
880
|
+
root = this.foldsAt(head).find((candidate) => !this.isCollapsed(candidate) === collapse);
|
|
881
|
+
}
|
|
882
|
+
return root === undefined ? [] : this.foldRanges.filter((range) => root.from <= range.from && range.to <= root.to);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
collapseRecursively(): void {
|
|
886
|
+
for (const range of this.foldTreeAtCaret(true)) {
|
|
887
|
+
this.setCollapsed(range, true);
|
|
888
|
+
}
|
|
889
|
+
this.refoldLayout();
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
expandRecursively(): void {
|
|
893
|
+
for (const range of this.foldTreeAtCaret(false)) {
|
|
894
|
+
this.setCollapsed(range, false);
|
|
895
|
+
}
|
|
896
|
+
this.refoldLayout();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/* Folds or opens every range, or the ones inside the selection when it holds any. */
|
|
900
|
+
foldAll(collapse: boolean): void {
|
|
901
|
+
const { anchor, head } = this.model.getPrimary();
|
|
902
|
+
const from = Math.min(anchor, head);
|
|
903
|
+
const to = Math.max(anchor, head);
|
|
904
|
+
const inside = from === to ? [] : this.foldRanges.filter((range) => from <= range.from && range.to <= to);
|
|
905
|
+
for (const range of inside.length > 0 ? inside : this.foldRanges) {
|
|
906
|
+
this.setCollapsed(range, collapse);
|
|
907
|
+
}
|
|
908
|
+
this.refoldLayout();
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/*
|
|
912
|
+
* Folds the lines of the selection under its first line, with a chip, or takes a fold of the same lines
|
|
913
|
+
* away. With nothing selected it takes away the fold around the caret that a selection made, or opens
|
|
914
|
+
* and closes one of the document's own. A selection inside one line has no line to fold.
|
|
915
|
+
*/
|
|
916
|
+
foldSelection(): void {
|
|
917
|
+
const { anchor, head } = this.model.getPrimary();
|
|
918
|
+
let from = Math.min(anchor, head);
|
|
919
|
+
let to = Math.max(anchor, head);
|
|
920
|
+
if (from === to) {
|
|
921
|
+
const around = this.foldsAt(head)[0];
|
|
922
|
+
if (around?.kind === 'custom') {
|
|
923
|
+
this.customFolds = this.customFolds.filter((fold) => fold.from !== around.from);
|
|
924
|
+
this.collapsed.delete(around.from);
|
|
925
|
+
} else if (around !== undefined) {
|
|
926
|
+
this.setCollapsed(around, !this.isCollapsed(around));
|
|
927
|
+
}
|
|
928
|
+
this.refreshFolds();
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (this.model.slice(to - 1, to) === '\n') {
|
|
932
|
+
to--;
|
|
933
|
+
}
|
|
934
|
+
const startLine = this.model.positionAt(from).line;
|
|
935
|
+
if (this.model.positionAt(to).line <= startLine) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
from = this.model.getLine(startLine).start;
|
|
939
|
+
const existing = this.foldRanges.find((range) => range.kind === 'custom' && range.from === from && range.to === to);
|
|
940
|
+
if (existing !== undefined) {
|
|
941
|
+
this.customFolds = this.customFolds.filter((fold) => fold.from !== from);
|
|
942
|
+
this.collapsed.delete(from);
|
|
943
|
+
} else {
|
|
944
|
+
this.customFolds = [...this.customFolds, { from, to }];
|
|
945
|
+
this.collapsed.add(from);
|
|
946
|
+
}
|
|
947
|
+
this.refreshFolds();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/* The lines that are folded and the ones a selection folded, for a host that opens the file again where it left it. */
|
|
951
|
+
getFolds(): { collapsed: { startLine: number; endLine: number }[]; custom: { startLine: number; endLine: number }[] } {
|
|
952
|
+
const lines = (range: ViewFold): { startLine: number; endLine: number } => ({ startLine: range.startLine, endLine: range.endLine });
|
|
953
|
+
return {
|
|
954
|
+
collapsed: this.foldRanges.filter((range) => this.isCollapsed(range)).map(lines),
|
|
955
|
+
custom: this.foldRanges.filter((range) => range.kind === 'custom').map(lines)
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/*
|
|
960
|
+
* Folds the lines a host remembers, those that still start a range of the same lines or else any range
|
|
961
|
+
* at their first line, after the ranges are read. Without a memory the roles in `defaults` fold, which
|
|
962
|
+
* is what a file opens as the first time, and again as a language server names more of them.
|
|
963
|
+
*/
|
|
964
|
+
restoreFolds(
|
|
965
|
+
state: { collapsed: readonly { startLine: number; endLine: number }[]; custom: readonly { startLine: number; endLine: number }[] } | null,
|
|
966
|
+
defaults: readonly FoldRole[] = []
|
|
967
|
+
): void {
|
|
968
|
+
const last = this.model.getLineCount() - 1;
|
|
969
|
+
if (state !== null) {
|
|
970
|
+
this.customFolds = state.custom
|
|
971
|
+
.filter((fold) => fold.startLine < fold.endLine && fold.endLine <= last)
|
|
972
|
+
.map((fold) => ({ from: this.model.getLine(fold.startLine).start, to: this.model.getLine(fold.endLine).end }));
|
|
973
|
+
}
|
|
974
|
+
this.refreshFolds();
|
|
975
|
+
this.defaultRoles = state === null && defaults.length > 0 ? new Set(defaults) : null;
|
|
976
|
+
for (const remembered of state?.collapsed ?? []) {
|
|
977
|
+
const match =
|
|
978
|
+
this.foldRanges.find((range) => range.startLine === remembered.startLine && range.endLine === remembered.endLine) ??
|
|
979
|
+
this.foldRanges.find((range) => range.startLine === remembered.startLine);
|
|
980
|
+
if (match !== undefined) {
|
|
981
|
+
this.setCollapsed(match, true);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
this.applyFoldDefaults();
|
|
985
|
+
this.refoldLayout();
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/* Hands over what a language server knows about the folds; null forgets it. The defaults fold what the answer names, once. */
|
|
989
|
+
setFoldHints(hints: FoldHints | null): void {
|
|
990
|
+
this.foldHints = hints;
|
|
991
|
+
this.refreshFolds();
|
|
992
|
+
this.applyFoldDefaults();
|
|
993
|
+
this.refoldLayout();
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/* Folds the ranges whose role is a default, except a range somebody decided on or one that has the caret or a selection in it. */
|
|
997
|
+
private applyFoldDefaults(): void {
|
|
998
|
+
const roles = this.defaultRoles;
|
|
999
|
+
if (roles === null) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const lines = this.model
|
|
1003
|
+
.getSelections()
|
|
1004
|
+
.flatMap((selection) => [this.model.positionAt(selection.anchor).line, this.model.positionAt(selection.head).line]);
|
|
1005
|
+
for (const range of this.foldRanges) {
|
|
1006
|
+
const hidesCaret = lines.some((line) => line > range.startLine && line <= range.endLine);
|
|
1007
|
+
if (range.role !== undefined && roles.has(range.role) && !this.settled.has(range.from) && !hidesCaret) {
|
|
1008
|
+
this.setCollapsed(range, true);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/* Folds or opens every range of a role, such as the documentation comments. */
|
|
1014
|
+
foldRole(role: FoldRole, collapse: boolean): void {
|
|
1015
|
+
for (const range of this.foldRanges) {
|
|
1016
|
+
if (range.role === role) {
|
|
1017
|
+
this.setCollapsed(range, collapse);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
this.refoldLayout();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/*
|
|
1024
|
+
* Opens every range above a depth and folds the ones at it, which leaves what lies deeper as it was.
|
|
1025
|
+
* The outermost ranges are at depth 0, so level 1 shows them open with what they hold folded.
|
|
1026
|
+
*/
|
|
1027
|
+
expandAllToLevel(level: number): void {
|
|
1028
|
+
const open: ViewFold[] = [];
|
|
1029
|
+
for (const range of this.foldRanges) {
|
|
1030
|
+
while (open.length > 0 && !(open.at(-1)!.from <= range.from && range.to <= open.at(-1)!.to)) {
|
|
1031
|
+
open.pop();
|
|
1032
|
+
}
|
|
1033
|
+
if (open.length < level) {
|
|
1034
|
+
this.setCollapsed(range, false);
|
|
1035
|
+
} else if (open.length === level) {
|
|
1036
|
+
this.setCollapsed(range, true);
|
|
1037
|
+
}
|
|
1038
|
+
open.push(range);
|
|
1039
|
+
}
|
|
1040
|
+
this.refoldLayout();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
private refoldLayout(): void {
|
|
1044
|
+
this.anchorScroll(() => this.layout.configure({ folds: this.foldStates() }));
|
|
1045
|
+
this.moveHiddenCarets();
|
|
1046
|
+
this.render();
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/* A caret in a line a fold hides would be typing where nobody can see. */
|
|
1050
|
+
private moveHiddenCarets(): void {
|
|
1051
|
+
const visible = (offset: number): number => {
|
|
1052
|
+
const line = this.model.positionAt(offset).line;
|
|
1053
|
+
if (!this.layout.isHidden(line)) {
|
|
1054
|
+
return offset;
|
|
1055
|
+
}
|
|
1056
|
+
return this.model.getLine(this.layout.rowForLine(line).line).end;
|
|
1057
|
+
};
|
|
1058
|
+
const selections = this.model.getSelections();
|
|
1059
|
+
const moved = selections.map((selection) => ({ anchor: visible(selection.anchor), head: visible(selection.head) }));
|
|
1060
|
+
if (moved.some((selection, index) => selection.anchor !== selections[index]!.anchor || selection.head !== selections[index]!.head)) {
|
|
1061
|
+
this.model.setSelections(moved);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/* Opens the folds that hide an offset, so a caret or a search result is where it can be seen. */
|
|
1066
|
+
ensureVisible(offset: number): void {
|
|
1067
|
+
const line = this.model.positionAt(offset).line;
|
|
1068
|
+
if (!this.layout.isHidden(line)) {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
for (const range of this.foldRanges) {
|
|
1072
|
+
if (range.startLine < line && range.endLine >= line) {
|
|
1073
|
+
this.collapsed.delete(range.from);
|
|
1074
|
+
this.settled.add(range.from);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
this.anchorScroll(() => this.layout.configure({ folds: this.foldStates() }));
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/* Keeps the first row on screen where it is while the rows above it change height. */
|
|
1081
|
+
private anchorScroll(action: () => void): void {
|
|
1082
|
+
const top = this.viewport.scrollTop;
|
|
1083
|
+
if (top <= 0) {
|
|
1084
|
+
action();
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const anchor = this.layout.rowAt(top);
|
|
1088
|
+
const offset = top - anchor.top;
|
|
1089
|
+
action();
|
|
1090
|
+
const next = this.layout.rows.find((row) => row.key === anchor.key);
|
|
1091
|
+
if (next) {
|
|
1092
|
+
this.viewport.scrollTop = Math.max(0, next.top + offset);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
private syncWrap(): void {
|
|
1097
|
+
const width =
|
|
1098
|
+
this.settings.wrap && this.viewport.clientWidth > 0 ? Math.max(this.layout.metrics.charWidth * 8, this.viewportWidth - RIGHT_PADDING) : null;
|
|
1099
|
+
if (width !== this.wrapWidth) {
|
|
1100
|
+
this.wrapWidth = width;
|
|
1101
|
+
this.layoutVersion++;
|
|
1102
|
+
this.anchorScroll(() => this.layout.configure({ wrapWidth: width }));
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private bracketAt(from: number, to: number, head: number): number | undefined {
|
|
1107
|
+
if (to - from > 1) {
|
|
1108
|
+
return undefined;
|
|
1109
|
+
}
|
|
1110
|
+
const candidates = from !== to ? [from] : [head, head - 1];
|
|
1111
|
+
return candidates.find((at) => at >= 0 && /[()[\]{}]/.test(this.model.slice(at, at + 1)));
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private brackets(): BracketIndex {
|
|
1115
|
+
if (this.bracketCache?.revision !== this.revision || this.bracketCache.language !== this.settings.language) {
|
|
1116
|
+
this.bracketCache = {
|
|
1117
|
+
revision: this.revision,
|
|
1118
|
+
language: this.settings.language,
|
|
1119
|
+
index: scanBrackets(this.model.getText(), this.settings.language)
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
return this.bracketCache.index;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/* The offset a bracket at the caret pairs with, if it is one. */
|
|
1126
|
+
matchingBracket(selection: { anchor: number; head: number }): { at: number; mate: number | undefined } | null {
|
|
1127
|
+
const at = this.bracketAt(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head), selection.head);
|
|
1128
|
+
return at === undefined ? null : { at, mate: this.brackets().pairs.get(at) };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
unmatchedBracket(at: number): boolean {
|
|
1132
|
+
return this.brackets().unmatched.has(at);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/* A point of the screen in the coordinates of the content, undoing the scale a canvas puts on a node. */
|
|
1136
|
+
contentPoint(clientX: number, clientY: number): { x: number; y: number } {
|
|
1137
|
+
const rect = this.viewport.getBoundingClientRect?.();
|
|
1138
|
+
if (!rect) {
|
|
1139
|
+
return { x: clientX - this.gutterWidth + this.viewport.scrollLeft, y: clientY + this.viewport.scrollTop };
|
|
1140
|
+
}
|
|
1141
|
+
const scaleX = this.viewport.offsetWidth > 0 ? rect.width / this.viewport.offsetWidth : 1;
|
|
1142
|
+
const scaleY = this.viewport.offsetHeight > 0 ? rect.height / this.viewport.offsetHeight : 1;
|
|
1143
|
+
return { x: (clientX - rect.left) / scaleX - this.gutterWidth + this.viewport.scrollLeft, y: (clientY - rect.top) / scaleY + this.viewport.scrollTop };
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
offsetAtPoint(clientX: number, clientY: number): number {
|
|
1147
|
+
const point = this.contentPoint(clientX, clientY);
|
|
1148
|
+
const hit = this.layout.hitTestRow(point.x, point.y);
|
|
1149
|
+
this.rowEndCaret = hit.rowEnd ? hit.offset : null;
|
|
1150
|
+
return hit.offset;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/* The scale a canvas puts on the node the editor is in, which is 1 anywhere else. */
|
|
1154
|
+
private screenScale(rect: DOMRect): { x: number; y: number } {
|
|
1155
|
+
return {
|
|
1156
|
+
x: this.viewport.offsetWidth > 0 ? rect.width / this.viewport.offsetWidth : 1,
|
|
1157
|
+
y: this.viewport.offsetHeight > 0 ? rect.height / this.viewport.offsetHeight : 1
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/* The character cell at an offset in the page's pixels, undoing nothing: a popup placed by it is right at any zoom. */
|
|
1162
|
+
clientRectOf(offset: number): EditorRect | null {
|
|
1163
|
+
const rect = this.viewport.getBoundingClientRect?.();
|
|
1164
|
+
if (!rect) {
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
const scale = this.screenScale(rect);
|
|
1168
|
+
const caret = this.layout.caret(offset);
|
|
1169
|
+
const left = rect.left + (caret.x + this.gutterWidth - this.viewport.scrollLeft) * scale.x;
|
|
1170
|
+
const top = rect.top + (caret.y - this.viewport.scrollTop) * scale.y;
|
|
1171
|
+
return { left, top, right: left + this.layout.metrics.charWidth * scale.x, bottom: top + caret.height * scale.y };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/* The offset of the character under a point of the page, or null over anything that is not text. */
|
|
1175
|
+
characterAtPoint(clientX: number, clientY: number, target: EventTarget | null): number | null {
|
|
1176
|
+
if ((target as HTMLElement | null)?.closest?.('.se-inlay, .se-block, .se-line-action, .se-gutter, .se-sticky, .se-fold-chip, .se-overview')) {
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
const point = this.contentPoint(clientX, clientY);
|
|
1180
|
+
if (point.y < 0 || point.y > this.layout.height) {
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
1183
|
+
const row = this.layout.rowAt(point.y);
|
|
1184
|
+
if (row.kind !== 'text') {
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
const hit = this.layout.hitTestRow(point.x, point.y);
|
|
1188
|
+
const caret = this.layout.caret(hit.offset);
|
|
1189
|
+
const line = this.model.getLine(row.line);
|
|
1190
|
+
const offset = point.x >= caret.x ? hit.offset : hit.offset - 1;
|
|
1191
|
+
// Past the last character of a line the nearest stop is its end, which no character is under.
|
|
1192
|
+
if (offset < line.start || offset >= line.end || Math.abs(point.x - caret.x) > this.layout.metrics.charWidth * 1.5) {
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
return offset;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/* The visual line an offset is on, where a caret drawn at the end of a wrapped row counts as being on that row. */
|
|
1199
|
+
rowOf(offset: number): { start: number; end: number } {
|
|
1200
|
+
const { start, end } = this.layout.visualLine(offset === this.rowEndCaret ? offset - 1 : offset);
|
|
1201
|
+
return { start, end };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/* The lines that stand on one row of the screen: a collapsed fold is a row, from its first line to its last. */
|
|
1205
|
+
lineSpan(line: number): { first: number; last: number } {
|
|
1206
|
+
const row = this.layout.rowForLine(line);
|
|
1207
|
+
return row.kind === 'text' ? { first: row.line, last: row.lastLine } : { first: line, last: line };
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/* The folds that were closed before lines moved stay closed on the lines they went to, which the edit that moved them could not carry. */
|
|
1211
|
+
refoldMoved(folded: readonly { startLine: number; endLine: number }[], moves: readonly { from: number; to: number }[]): void {
|
|
1212
|
+
const moved = new Map(moves.map((move) => [move.from, move.to]));
|
|
1213
|
+
this.refreshFolds();
|
|
1214
|
+
for (const range of this.foldRanges) {
|
|
1215
|
+
if (moved.size > 0 && [...moved.values()].includes(range.startLine)) {
|
|
1216
|
+
this.collapsed.delete(range.from);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
for (const fold of folded) {
|
|
1220
|
+
const start = moved.get(fold.startLine);
|
|
1221
|
+
const target =
|
|
1222
|
+
start === undefined
|
|
1223
|
+
? undefined
|
|
1224
|
+
: this.foldRanges.find((range) => range.startLine === start && range.endLine === start + fold.endLine - fold.startLine);
|
|
1225
|
+
if (target !== undefined) {
|
|
1226
|
+
this.setCollapsed(target, true);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
this.refoldLayout();
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/* A caret that went to the end of a wrapped row stays drawn there, though its offset is where the next row starts. */
|
|
1233
|
+
markRowEnd(offset: number): void {
|
|
1234
|
+
const line = this.model.getLine(this.model.positionAt(offset).line);
|
|
1235
|
+
if (offset > line.start && offset < line.end && this.layout.visualLine(offset).start === offset) {
|
|
1236
|
+
this.rowEndCaret = offset;
|
|
1237
|
+
this.requestRender();
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
onHover(listener: (offset: number | null) => void): () => void {
|
|
1242
|
+
this.hoverListeners.add(listener);
|
|
1243
|
+
return () => {
|
|
1244
|
+
this.hoverListeners.delete(listener);
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/* The pointer moved over the editor; a drag in progress is not a hover. */
|
|
1249
|
+
/*
|
|
1250
|
+
* The other places the selected text is, found without a language server: only for one selection that
|
|
1251
|
+
* holds no line break and is not blank, and not at all when there are more than a few of them.
|
|
1252
|
+
*/
|
|
1253
|
+
private selectionOccurrences(): readonly { from: number; to: number }[] {
|
|
1254
|
+
const selections = this.model.getSelections();
|
|
1255
|
+
const only = selections.length === 1 ? selections[0]! : null;
|
|
1256
|
+
const from = only === null ? 0 : Math.min(only.anchor, only.head);
|
|
1257
|
+
const to = only === null ? 0 : Math.max(only.anchor, only.head);
|
|
1258
|
+
const key = `${this.revision}|${from}|${to}`;
|
|
1259
|
+
if (this.selectionOccurrenceCache.key === key) {
|
|
1260
|
+
return this.selectionOccurrenceCache.ranges;
|
|
1261
|
+
}
|
|
1262
|
+
let ranges: { from: number; to: number }[] = [];
|
|
1263
|
+
const text = to - from > 0 && to - from <= OCCURRENCE_TEXT_LIMIT ? this.model.slice(from, to) : '';
|
|
1264
|
+
if (text.trim() !== '' && !/[\r\n]/.test(text) && this.model.getLength() <= FOLD_LIMIT) {
|
|
1265
|
+
const found = this.model.find(text, { maxResults: OCCURRENCE_LIMIT + 1 });
|
|
1266
|
+
ranges =
|
|
1267
|
+
found.length > OCCURRENCE_LIMIT
|
|
1268
|
+
? []
|
|
1269
|
+
: found.filter((match) => match.from !== from || match.to !== to).map((match) => ({ from: match.from, to: match.to }));
|
|
1270
|
+
}
|
|
1271
|
+
this.selectionOccurrenceCache = { key, ranges };
|
|
1272
|
+
return ranges;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/* The arrows of the soft wraps in the rows on screen. */
|
|
1276
|
+
private wrapSigns(rows: readonly LayoutRow[]): WrapSign[] {
|
|
1277
|
+
if (!this.layout.wrapping) {
|
|
1278
|
+
return [];
|
|
1279
|
+
}
|
|
1280
|
+
const lineHeight = this.layout.metrics.lineHeight;
|
|
1281
|
+
const signs: WrapSign[] = [];
|
|
1282
|
+
for (const row of rows) {
|
|
1283
|
+
if (row.kind !== 'text' || row.subRows < 2) {
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
const geometry = this.layout.geometry(row);
|
|
1287
|
+
for (let subRow = 0; subRow < geometry.subRows; subRow++) {
|
|
1288
|
+
const y = row.top + subRow * lineHeight;
|
|
1289
|
+
if (subRow + 1 < geometry.subRows) {
|
|
1290
|
+
const x = geometry.rowEnds[subRow]!;
|
|
1291
|
+
signs.push({ x, y, width: this.viewportWidth - x });
|
|
1292
|
+
}
|
|
1293
|
+
if (subRow > 0) {
|
|
1294
|
+
signs.push({ x: 0, y, width: geometry.before[geometry.rowStarts[subRow]!]! });
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
return signs.filter((sign) => sign.width >= 3);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/* The indentation of a line in columns, or null when it is blank. */
|
|
1302
|
+
private indentOf(line: number): number | null {
|
|
1303
|
+
const text = this.model.getLine(line).text;
|
|
1304
|
+
const lead = /^[\t ]*/.exec(text)![0];
|
|
1305
|
+
return lead.length === text.length ? null : indentationColumn(lead, this.settings.tabSize);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/* The indentation a line is read at: a blank one takes the lesser of the lines around it, so a guide runs through it. */
|
|
1309
|
+
private effectiveIndent(line: number): number {
|
|
1310
|
+
const own = this.indentOf(line);
|
|
1311
|
+
if (own !== null) {
|
|
1312
|
+
return own;
|
|
1313
|
+
}
|
|
1314
|
+
const last = this.model.getLineCount() - 1;
|
|
1315
|
+
const nearest = (direction: -1 | 1): number => {
|
|
1316
|
+
for (let at = line + direction, steps = 0; at >= 0 && at <= last && steps < GUIDE_SCAN_LINES; at += direction, steps++) {
|
|
1317
|
+
const indent = this.indentOf(at);
|
|
1318
|
+
if (indent !== null) {
|
|
1319
|
+
return indent;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return 0;
|
|
1323
|
+
};
|
|
1324
|
+
return Math.min(nearest(-1), nearest(1));
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/* The scope guide of the caret, worked out again only when the text or the caret's line is not the one it was for. */
|
|
1328
|
+
private activeGuide(): { column: number; first: number; last: number } | null {
|
|
1329
|
+
const line = this.model.positionAt(this.model.getPrimary().head).line;
|
|
1330
|
+
const key = `${this.revision}|${line}|${this.settings.tabSize}`;
|
|
1331
|
+
if (this.activeGuideCache?.key !== key) {
|
|
1332
|
+
this.activeGuideCache = { key, guide: this.scopeGuide(line) };
|
|
1333
|
+
}
|
|
1334
|
+
return this.activeGuideCache.guide;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/* The run of lines around a line that the scope guide there goes along, and the column it is at. */
|
|
1338
|
+
private scopeGuide(line: number): { column: number; first: number; last: number } | null {
|
|
1339
|
+
const size = this.settings.tabSize;
|
|
1340
|
+
const indent = this.effectiveIndent(line);
|
|
1341
|
+
const count = this.model.getLineCount();
|
|
1342
|
+
const deeper = (at: number, column: number): boolean => at >= 0 && at < count && this.effectiveIndent(at) > column;
|
|
1343
|
+
const next = (() => {
|
|
1344
|
+
for (let at = line + 1; at < count && at < line + GUIDE_SCAN_LINES; at++) {
|
|
1345
|
+
if (this.indentOf(at) !== null) {
|
|
1346
|
+
return this.indentOf(at)!;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return 0;
|
|
1350
|
+
})();
|
|
1351
|
+
const previous = (() => {
|
|
1352
|
+
for (let at = line - 1; at >= 0 && at > line - GUIDE_SCAN_LINES; at--) {
|
|
1353
|
+
if (this.indentOf(at) !== null) {
|
|
1354
|
+
return this.indentOf(at)!;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
return 0;
|
|
1358
|
+
})();
|
|
1359
|
+
// A line that opens a block, or closes one, is at the edge of its guide: the one that sits at its own indentation.
|
|
1360
|
+
const opens = indent % size === 0 && (next > indent || previous > indent) && this.indentOf(line) !== null;
|
|
1361
|
+
const column = opens ? indent : indent > 0 ? Math.ceil(indent / size - 1) * size : -1;
|
|
1362
|
+
if (column < 0) {
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
const reaches = (from: number, direction: -1 | 1): number => {
|
|
1366
|
+
let at = from;
|
|
1367
|
+
while (deeper(at + direction, column) && Math.abs(at - line) < GUIDE_RUN_LINES) {
|
|
1368
|
+
at += direction;
|
|
1369
|
+
}
|
|
1370
|
+
return at;
|
|
1371
|
+
};
|
|
1372
|
+
const inside = deeper(line, column);
|
|
1373
|
+
const first = inside ? reaches(line, -1) : next > indent ? line + 1 : reaches(line - 1, -1);
|
|
1374
|
+
const last = inside ? reaches(line, 1) : next > indent ? reaches(line + 1, 1) : line - 1;
|
|
1375
|
+
return { column, first, last };
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/* A line at each indentation level of the rows on screen, joined where the lines go on, and the one of the scope around the caret apart. */
|
|
1379
|
+
private guideRects(rows: readonly LayoutRow[]): { plain: LayoutRect[]; active: LayoutRect[] } {
|
|
1380
|
+
const size = this.settings.tabSize;
|
|
1381
|
+
const charWidth = this.layout.metrics.charWidth;
|
|
1382
|
+
const active = this.activeGuide();
|
|
1383
|
+
const plain: LayoutRect[] = [];
|
|
1384
|
+
const marked: LayoutRect[] = [];
|
|
1385
|
+
interface Open {
|
|
1386
|
+
top: number;
|
|
1387
|
+
bottom: number;
|
|
1388
|
+
first: number;
|
|
1389
|
+
last: number;
|
|
1390
|
+
}
|
|
1391
|
+
const open = new Map<number, Open>();
|
|
1392
|
+
const close = (column: number): void => {
|
|
1393
|
+
const segment = open.get(column)!;
|
|
1394
|
+
open.delete(column);
|
|
1395
|
+
const rect = { x: column * charWidth, y: segment.top, width: 1, height: segment.bottom - segment.top };
|
|
1396
|
+
(active !== null && active.column === column && segment.last >= active.first && segment.first <= active.last ? marked : plain).push(rect);
|
|
1397
|
+
};
|
|
1398
|
+
for (const row of rows) {
|
|
1399
|
+
if (row.kind !== 'text') {
|
|
1400
|
+
for (const segment of open.values()) {
|
|
1401
|
+
segment.bottom = row.top + row.height;
|
|
1402
|
+
}
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
const indent = this.effectiveIndent(row.line);
|
|
1406
|
+
for (const column of [...open.keys()]) {
|
|
1407
|
+
if (column >= indent) {
|
|
1408
|
+
close(column);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
for (let column = 0; column < indent; column += size) {
|
|
1412
|
+
const segment = open.get(column);
|
|
1413
|
+
if (segment === undefined) {
|
|
1414
|
+
open.set(column, { top: row.top, bottom: row.top + row.height, first: row.line, last: row.lastLine });
|
|
1415
|
+
} else {
|
|
1416
|
+
segment.bottom = row.top + row.height;
|
|
1417
|
+
segment.last = row.lastLine;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
for (const column of [...open.keys()]) {
|
|
1422
|
+
close(column);
|
|
1423
|
+
}
|
|
1424
|
+
return { plain, active: marked };
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/* The selection under a point of the screen, which a press there may drag away. */
|
|
1428
|
+
selectionAtPoint(clientX: number, clientY: number): EditorSelection | null {
|
|
1429
|
+
const point = this.contentPoint(clientX, clientY);
|
|
1430
|
+
const rows = this.layout.visibleRows(this.viewport.scrollTop, this.viewportHeight, 0);
|
|
1431
|
+
return (
|
|
1432
|
+
this.model.getSelections().find((selection) => {
|
|
1433
|
+
const from = Math.min(selection.anchor, selection.head);
|
|
1434
|
+
const to = Math.max(selection.anchor, selection.head);
|
|
1435
|
+
return (
|
|
1436
|
+
from !== to &&
|
|
1437
|
+
this.layout
|
|
1438
|
+
.rectangles(from, to, rows)
|
|
1439
|
+
.some((rect) => point.x >= rect.x && point.x < rect.x + rect.width && point.y >= rect.y && point.y < rect.y + rect.height)
|
|
1440
|
+
);
|
|
1441
|
+
}) ?? null
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
setColumnMode(on: boolean): void {
|
|
1446
|
+
this.columnMode = on;
|
|
1447
|
+
this.root.dataset.column = String(on);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/* Where text being carried would land, drawn as a caret of its own. */
|
|
1451
|
+
setDropCaret(offset: number | null): void {
|
|
1452
|
+
this.dropCaret = offset;
|
|
1453
|
+
this.render();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
hoverMoved(event: PointerEvent): void {
|
|
1457
|
+
this.content.classList.toggle(
|
|
1458
|
+
'se-over-selection',
|
|
1459
|
+
!this.settings.readOnly && event.buttons === 0 && this.selectionAtPoint(event.clientX, event.clientY) !== null
|
|
1460
|
+
);
|
|
1461
|
+
this.setHover(event.buttons === 0 ? this.characterAtPoint(event.clientX, event.clientY, event.target) : null);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
setHover(offset: number | null): void {
|
|
1465
|
+
if (offset === this.hoverOffset) {
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
this.hoverOffset = offset;
|
|
1469
|
+
for (const listener of [...this.hoverListeners]) {
|
|
1470
|
+
listener(offset);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
onViewChange(listener: () => void): () => void {
|
|
1475
|
+
this.viewListeners.add(listener);
|
|
1476
|
+
return () => {
|
|
1477
|
+
this.viewListeners.delete(listener);
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
private announceView(): void {
|
|
1482
|
+
const key = `${this.scrollTop}|${this.scrollLeft}|${this.clientWidth}|${this.clientHeight}|${this.layout.height}`;
|
|
1483
|
+
if (key === this.viewKey) {
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
this.viewKey = key;
|
|
1487
|
+
for (const listener of [...this.viewListeners]) {
|
|
1488
|
+
listener();
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
private caretOf(head: number): LayoutRect {
|
|
1493
|
+
return this.layout.caret(head, head === this.ghost?.at ? 'before' : 'after', head === this.rowEndCaret);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/* Where the view is, or where a scroll on its way will leave it, which is what the next scroll goes by. */
|
|
1497
|
+
get scrollPlace(): { x: number; y: number } {
|
|
1498
|
+
return this.scrollRun?.to ?? { x: this.viewport.scrollLeft, y: this.viewport.scrollTop };
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/* Stops a scroll on its way where it is. */
|
|
1502
|
+
cancelScroll(): void {
|
|
1503
|
+
if (this.scrollRun !== null) {
|
|
1504
|
+
this.document.defaultView?.cancelAnimationFrame?.(this.scrollRun.frame);
|
|
1505
|
+
this.scrollRun = null;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/* Whether a person asked their system for less motion. */
|
|
1510
|
+
private reducedMotion(): boolean {
|
|
1511
|
+
return this.document.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/*
|
|
1515
|
+
* Takes the view to a place: at once when it is no more than a line away, there is no frame to move it
|
|
1516
|
+
* in or motion is reduced, and over a moment otherwise. Another scroll, the wheel or a touch ends it.
|
|
1517
|
+
*/
|
|
1518
|
+
scrollTo(x: number, y: number, animate = true): void {
|
|
1519
|
+
this.cancelScroll();
|
|
1520
|
+
const view = this.document.defaultView;
|
|
1521
|
+
const distance = Math.hypot(x - this.viewport.scrollLeft, y - this.viewport.scrollTop);
|
|
1522
|
+
const duration = scrollDuration(distance, this.layout.metrics.lineHeight);
|
|
1523
|
+
if (!animate || duration === 0 || view?.requestAnimationFrame === undefined || this.reducedMotion()) {
|
|
1524
|
+
this.viewport.scrollTop = y;
|
|
1525
|
+
this.viewport.scrollLeft = x;
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const run = { frame: 0, from: { x: this.viewport.scrollLeft, y: this.viewport.scrollTop }, to: { x, y }, start: null as number | null, duration };
|
|
1529
|
+
const step = (time: number): void => {
|
|
1530
|
+
if (this.scrollRun !== run || this.disposed) {
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
run.start ??= time;
|
|
1534
|
+
const fraction = Math.min(1, (time - run.start) / run.duration);
|
|
1535
|
+
const eased = easeOut(fraction);
|
|
1536
|
+
this.viewport.scrollTop = Math.round(run.from.y + (run.to.y - run.from.y) * eased);
|
|
1537
|
+
this.viewport.scrollLeft = Math.round(run.from.x + (run.to.x - run.from.x) * eased);
|
|
1538
|
+
if (fraction >= 1) {
|
|
1539
|
+
this.scrollRun = null;
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
run.frame = view.requestAnimationFrame(step);
|
|
1543
|
+
};
|
|
1544
|
+
this.scrollRun = run;
|
|
1545
|
+
run.frame = view.requestAnimationFrame(step);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/* Scrolls an offset into view the way `kind` asks; the pinned headers at the top count as not being view. */
|
|
1549
|
+
revealOffset(offset: number, kind: ScrollKind = 'relative', animate = true): void {
|
|
1550
|
+
this.ensureVisible(offset);
|
|
1551
|
+
this.syncWrap();
|
|
1552
|
+
const caret = this.layout.caret(offset);
|
|
1553
|
+
const row = this.layout.rowForLine(this.model.positionAt(offset).line);
|
|
1554
|
+
const above = this.layout.rows[Math.max(0, row.index - 1)]!;
|
|
1555
|
+
const width = this.viewportWidth;
|
|
1556
|
+
this.content.style.height = `${this.layout.height}px`;
|
|
1557
|
+
this.content.style.width = `${Math.max(this.layout.width, width)}px`;
|
|
1558
|
+
// The headers pinned at the top change with the scroll, so the position is worked out again where it lands.
|
|
1559
|
+
const place = this.scrollPlace;
|
|
1560
|
+
let cover = this.stickyHeightAt(place.y);
|
|
1561
|
+
let position = place;
|
|
1562
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1563
|
+
position = scrollPosition(
|
|
1564
|
+
{
|
|
1565
|
+
target: { x: caret.x, y: caret.y },
|
|
1566
|
+
view: { x: place.x, y: place.y + cover, width, height: this.viewportHeight - cover },
|
|
1567
|
+
content: { width: Math.max(this.layout.width, width), height: this.layout.height },
|
|
1568
|
+
lineHeight: this.layout.metrics.lineHeight,
|
|
1569
|
+
charWidth: this.layout.metrics.charWidth,
|
|
1570
|
+
topBound: above.top,
|
|
1571
|
+
bottomBound: row.top + row.height + this.layout.metrics.lineHeight,
|
|
1572
|
+
refrain: REFRAIN_FROM_SCROLLING,
|
|
1573
|
+
horizontal: !this.settings.wrap,
|
|
1574
|
+
inset: cover
|
|
1575
|
+
},
|
|
1576
|
+
kind
|
|
1577
|
+
);
|
|
1578
|
+
const next = this.stickyHeightAt(position.y);
|
|
1579
|
+
if (next === cover) {
|
|
1580
|
+
break;
|
|
1581
|
+
}
|
|
1582
|
+
cover = next;
|
|
1583
|
+
}
|
|
1584
|
+
this.scrollTo(position.x, position.y, animate);
|
|
1585
|
+
this.requestRender();
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
revealCaret(kind: ScrollKind = 'relative'): void {
|
|
1589
|
+
this.revealOffset(this.model.getPrimary().head, kind);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/* What a regular expression replacement writes for the match the find is on, drawn under that match; null takes it away. */
|
|
1593
|
+
setReplacePreview(replacement: string | null, preserveCase = false): void {
|
|
1594
|
+
this.replacePreview = replacement === null ? null : { text: replacement, preserveCase };
|
|
1595
|
+
this.paintPreview();
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/* Shown only when it says something the replacement text does not, as a regular expression's does. */
|
|
1599
|
+
private paintPreview(): void {
|
|
1600
|
+
const mark = this.find.currentMark;
|
|
1601
|
+
const query = this.find.activeQuery;
|
|
1602
|
+
const wanted = this.replacePreview;
|
|
1603
|
+
let shown: string | null = null;
|
|
1604
|
+
if (wanted !== null && mark !== null && query?.regex === true) {
|
|
1605
|
+
try {
|
|
1606
|
+
const match = this.model.find(query.text, {
|
|
1607
|
+
caseSensitive: query.caseSensitive,
|
|
1608
|
+
wholeWord: query.wholeWord,
|
|
1609
|
+
regex: true,
|
|
1610
|
+
from: mark.from,
|
|
1611
|
+
to: mark.to,
|
|
1612
|
+
maxResults: 1
|
|
1613
|
+
})[0];
|
|
1614
|
+
const text = match === undefined ? wanted.text : replacementText(this.model.getText(), match, wanted.text, false, wanted.preserveCase);
|
|
1615
|
+
shown = text === wanted.text ? null : text;
|
|
1616
|
+
} catch {
|
|
1617
|
+
shown = null;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
this.preview.hidden = shown === null;
|
|
1621
|
+
if (shown === null || mark === null) {
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
const start = this.layout.caret(mark.from);
|
|
1625
|
+
const end = this.layout.caret(mark.to);
|
|
1626
|
+
this.preview.textContent = shown;
|
|
1627
|
+
this.preview.style.left = `${(start.x + end.x) / 2}px`;
|
|
1628
|
+
this.preview.style.top = `${end.y + end.height}px`;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
setFind(query: EditorFindQuery | null, reveal: boolean): void {
|
|
1632
|
+
this.find.set(query);
|
|
1633
|
+
this.announceFind();
|
|
1634
|
+
if (reveal) {
|
|
1635
|
+
this.revealFind();
|
|
1636
|
+
}
|
|
1637
|
+
this.render();
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
stepFind(direction: 1 | -1): void {
|
|
1641
|
+
this.find.step(direction);
|
|
1642
|
+
this.announceFind();
|
|
1643
|
+
this.revealFind();
|
|
1644
|
+
this.render();
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
onFind(listener: (state: EditorFindState) => void): () => void {
|
|
1648
|
+
this.findListeners.add(listener);
|
|
1649
|
+
return () => {
|
|
1650
|
+
this.findListeners.delete(listener);
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
private announceFind(): void {
|
|
1655
|
+
for (const listener of [...this.findListeners]) {
|
|
1656
|
+
listener(this.find.state);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
revealFind(): void {
|
|
1661
|
+
const mark = this.find.currentMark;
|
|
1662
|
+
if (mark) {
|
|
1663
|
+
this.revealOffset(mark.from, 'center');
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/* Colors the lines on and just past the screen, a slice at a time so typing never waits on a grammar. */
|
|
1668
|
+
colorAhead(immediate = false): void {
|
|
1669
|
+
if (this.disposed || !this.tokens.ready) {
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
clearTimeout(this.colorTimer);
|
|
1673
|
+
const target = this.layout.rowAt(this.scrollTop + this.viewportHeight).line + COLOR_MARGIN;
|
|
1674
|
+
if (this.tokens.covers(target)) {
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
this.colorTimer = setTimeout(
|
|
1678
|
+
() => {
|
|
1679
|
+
const changed = this.tokens.advance(target, COLOR_SLICE_MS);
|
|
1680
|
+
if (changed) {
|
|
1681
|
+
this.render();
|
|
1682
|
+
}
|
|
1683
|
+
this.colorAhead();
|
|
1684
|
+
},
|
|
1685
|
+
immediate ? 0 : 1
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/* The gutter is as wide as its widest number needs, which only changes when the line count gains a digit. */
|
|
1690
|
+
private syncGutter(): void {
|
|
1691
|
+
const digits = String(this.model.getLineCount()).length;
|
|
1692
|
+
const width = Math.max(MIN_GUTTER_WIDTH, Math.ceil(digits * this.layout.metrics.charWidth) + 40);
|
|
1693
|
+
if (width !== this.gutterWidth) {
|
|
1694
|
+
this.gutterWidth = width;
|
|
1695
|
+
this.root.style.setProperty('--se-gutter-width', `${width}px`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/* A render in the next frame, so a key that moves the caret and scrolls, and the scroll event after it, paint once together. */
|
|
1700
|
+
requestRender(): void {
|
|
1701
|
+
if (this.disposed || this.frame !== undefined) {
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const view = this.document.defaultView;
|
|
1705
|
+
if (!view?.requestAnimationFrame) {
|
|
1706
|
+
this.render();
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
this.frame = view.requestAnimationFrame(() => {
|
|
1710
|
+
this.frame = undefined;
|
|
1711
|
+
this.render();
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/*
|
|
1716
|
+
* The viewport scrolled, by the wheel, a scroll bar or the browser. A scroll event comes before the
|
|
1717
|
+
* frame's paint, so drawing here puts the rows, the pinned headers and the overlays in the frame the
|
|
1718
|
+
* scroll shows in; a render already queued for this frame follows right after.
|
|
1719
|
+
*/
|
|
1720
|
+
scrolled(): void {
|
|
1721
|
+
if (this.frame === undefined && (this.viewport.scrollTop !== this.renderedScroll.top || this.viewport.scrollLeft !== this.renderedScroll.left)) {
|
|
1722
|
+
this.render();
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
render(): void {
|
|
1727
|
+
if (this.disposed || this.rendering) {
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
this.rendering = true;
|
|
1731
|
+
try {
|
|
1732
|
+
this.syncGutter();
|
|
1733
|
+
this.syncWrap();
|
|
1734
|
+
this.frameGeometry = {
|
|
1735
|
+
top: this.viewport.scrollTop,
|
|
1736
|
+
left: this.viewport.scrollLeft,
|
|
1737
|
+
width: this.viewport.clientWidth,
|
|
1738
|
+
height: this.viewport.clientHeight
|
|
1739
|
+
};
|
|
1740
|
+
let rows = this.layout.visibleRows(this.scrollTop, this.viewportHeight, this.overscan);
|
|
1741
|
+
if (this.layout.syncRows(rows)) {
|
|
1742
|
+
rows = this.layout.visibleRows(this.scrollTop, this.viewportHeight, this.overscan);
|
|
1743
|
+
}
|
|
1744
|
+
const lastLine = rows.at(-1)?.line ?? 0;
|
|
1745
|
+
const firstRow = this.layout.rowAt(this.scrollTop);
|
|
1746
|
+
this.topAnchor = { offset: this.model.getLine(firstRow.line).start, delta: this.scrollTop - firstRow.top };
|
|
1747
|
+
if (lastLine - this.tokens.colored <= COLOR_MARGIN * 4) {
|
|
1748
|
+
this.tokens.advance(lastLine, FIRST_PAINT_MS);
|
|
1749
|
+
}
|
|
1750
|
+
const paint = {
|
|
1751
|
+
layoutVersion: this.layoutVersion,
|
|
1752
|
+
whitespace: this.settings.whitespace,
|
|
1753
|
+
tokensOf: (line: number) => this.styledTokensOf(line),
|
|
1754
|
+
viewportWidth: this.viewportWidth - RIGHT_PADDING,
|
|
1755
|
+
hostLeft: this.scrollLeft - this.gutterWidth,
|
|
1756
|
+
hostWidth: this.viewportWidth + this.gutterWidth,
|
|
1757
|
+
onUnfold: (line: number) => this.toggleFold(line, false)
|
|
1758
|
+
};
|
|
1759
|
+
if (this.painter.paint(rows, paint)) {
|
|
1760
|
+
rows = this.layout.visibleRows(this.scrollTop, this.viewportHeight, this.overscan);
|
|
1761
|
+
this.painter.paint(rows, paint);
|
|
1762
|
+
}
|
|
1763
|
+
this.content.style.height = `${this.layout.height}px`;
|
|
1764
|
+
this.content.style.width = `${Math.max(this.layout.width, this.viewportWidth)}px`;
|
|
1765
|
+
this.renderedScroll = { top: this.scrollTop, left: this.scrollLeft };
|
|
1766
|
+
this.paintDecorations(rows);
|
|
1767
|
+
this.paintPreview();
|
|
1768
|
+
this.paintSticky();
|
|
1769
|
+
this.paintOverview();
|
|
1770
|
+
this.paintRemote();
|
|
1771
|
+
this.paintLineActions();
|
|
1772
|
+
if (this.attributionPointer !== null) {
|
|
1773
|
+
this.refreshAttributionHover();
|
|
1774
|
+
}
|
|
1775
|
+
this.announceScope();
|
|
1776
|
+
this.announceView();
|
|
1777
|
+
this.colorAhead();
|
|
1778
|
+
} finally {
|
|
1779
|
+
this.frameGeometry = null;
|
|
1780
|
+
this.rendering = false;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
private paintDecorations(rows: readonly LayoutRow[]): void {
|
|
1785
|
+
const selections = this.model.getSelections();
|
|
1786
|
+
const primary = this.model.getPrimary();
|
|
1787
|
+
const focused = this.focused;
|
|
1788
|
+
const foldable = new Map<number, boolean>();
|
|
1789
|
+
for (const range of this.foldRanges) {
|
|
1790
|
+
foldable.set(range.startLine, (foldable.get(range.startLine) ?? false) || this.collapsed.has(range.from));
|
|
1791
|
+
}
|
|
1792
|
+
const lastRow = rows.at(-1);
|
|
1793
|
+
const firstLine = rows[0]?.line ?? 0;
|
|
1794
|
+
const lastLine = lastRow?.kind === 'text' ? lastRow.lastLine : (lastRow?.line ?? 0);
|
|
1795
|
+
const highlighted = this.highlightedLines(firstLine, lastLine);
|
|
1796
|
+
paintGutter(this.gutterLines, this.layout, rows, {
|
|
1797
|
+
changes: this.changedLines(rows),
|
|
1798
|
+
attribution: this.attributedLines(firstLine, lastLine),
|
|
1799
|
+
highlights: highlighted,
|
|
1800
|
+
activeLines: new Set(selections.map((selection) => this.model.positionAt(selection.head).line)),
|
|
1801
|
+
foldable,
|
|
1802
|
+
action: this.gutterAction,
|
|
1803
|
+
markers: this.markersByLine()
|
|
1804
|
+
});
|
|
1805
|
+
const marks: { className: string; rects: LayoutRect[]; background?: string }[] = this.highlightMarks(rows, highlighted);
|
|
1806
|
+
const row = this.layout.rowForLine(this.model.positionAt(primary.head).line);
|
|
1807
|
+
const currentLine =
|
|
1808
|
+
primary.anchor === primary.head && row.kind === 'text'
|
|
1809
|
+
? { x: 0, y: row.top, width: Math.max(this.layout.width, this.viewportWidth), height: row.height }
|
|
1810
|
+
: null;
|
|
1811
|
+
for (const kind of ['text', 'read', 'write'] as const) {
|
|
1812
|
+
const group = this.occurrences.filter((mark) => (mark.kind ?? 'text') === kind);
|
|
1813
|
+
if (group.length > 0) {
|
|
1814
|
+
marks.push({
|
|
1815
|
+
className: kind === 'write' ? 'se-occurrence se-occurrence-write' : 'se-occurrence',
|
|
1816
|
+
rects: group.flatMap((mark) => this.layout.rectangles(mark.from, mark.to, rows))
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
const marginX = this.settings.rightMargin === null ? null : this.settings.rightMargin * this.layout.metrics.charWidth;
|
|
1821
|
+
// A margin past the content would stretch the scroll extent to itself, so it shows only where the text or the view reaches it.
|
|
1822
|
+
if (marginX !== null && marginX < Math.max(this.layout.width, this.viewportWidth)) {
|
|
1823
|
+
marks.push({ className: 'se-margin', rects: [{ x: marginX, y: 0, width: 1, height: this.layout.height }] });
|
|
1824
|
+
}
|
|
1825
|
+
if (this.settings.guides) {
|
|
1826
|
+
const guides = this.guideRects(rows);
|
|
1827
|
+
marks.push({ className: 'se-guide', rects: guides.plain }, { className: 'se-guide se-guide-active', rects: guides.active });
|
|
1828
|
+
}
|
|
1829
|
+
paintSigns(this.signs, this.wrapSigns(rows), this.layout.metrics.lineHeight);
|
|
1830
|
+
const alike = this.selectionOccurrences();
|
|
1831
|
+
if (alike.length > 0) {
|
|
1832
|
+
marks.push({ className: 'se-occurrence', rects: alike.flatMap((range) => this.layout.rectangles(range.from, range.to, rows)) });
|
|
1833
|
+
}
|
|
1834
|
+
const faded: LayoutRect[] = [];
|
|
1835
|
+
const struck: LayoutRect[] = [];
|
|
1836
|
+
for (const marker of this.markers) {
|
|
1837
|
+
if (marker.to <= marker.from) {
|
|
1838
|
+
continue;
|
|
1839
|
+
}
|
|
1840
|
+
const rects = this.layout.rectangles(marker.from, marker.to, rows);
|
|
1841
|
+
if (marker.severity !== 'hint') {
|
|
1842
|
+
marks.push({ className: `se-squiggle se-squiggle-${marker.severity}`, rects });
|
|
1843
|
+
}
|
|
1844
|
+
if (marker.unnecessary) {
|
|
1845
|
+
faded.push(...rects);
|
|
1846
|
+
}
|
|
1847
|
+
if (marker.deprecated) {
|
|
1848
|
+
struck.push(...rects);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
const current = this.find.currentMark;
|
|
1852
|
+
const textRows = rows.filter((candidate) => candidate.kind === 'text');
|
|
1853
|
+
const visibleFrom = textRows.length > 0 ? this.model.getLine(textRows[0]!.line).start : 0;
|
|
1854
|
+
const visibleTo = textRows.length > 0 ? this.model.getLine(textRows.at(-1)!.line).next : 0;
|
|
1855
|
+
for (const mark of this.find.matches) {
|
|
1856
|
+
if (mark.to > mark.from && mark.to >= visibleFrom && mark.from <= visibleTo) {
|
|
1857
|
+
marks.push({ className: mark === current ? 'se-find-current' : 'se-find-match', rects: this.layout.rectangles(mark.from, mark.to, rows) });
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
const seen = new Set<number>();
|
|
1861
|
+
for (const selection of selections) {
|
|
1862
|
+
const bracket = this.matchingBracket(selection);
|
|
1863
|
+
if (!bracket) {
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
if (bracket.mate === undefined) {
|
|
1867
|
+
if (this.unmatchedBracket(bracket.at)) {
|
|
1868
|
+
marks.push({ className: 'se-bracket-unmatched', rects: this.layout.rectangles(bracket.at, bracket.at + 1, rows) });
|
|
1869
|
+
}
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
for (const offset of [bracket.at, bracket.mate]) {
|
|
1873
|
+
if (!seen.has(offset)) {
|
|
1874
|
+
seen.add(offset);
|
|
1875
|
+
marks.push({ className: 'se-bracket-match', rects: this.layout.rectangles(offset, offset + 1, rows) });
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
const selected = selections.flatMap((selection) =>
|
|
1880
|
+
selection.anchor === selection.head
|
|
1881
|
+
? []
|
|
1882
|
+
: this.layout.rectangles(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head), rows)
|
|
1883
|
+
);
|
|
1884
|
+
paintOverlays(this.overlays, { currentLine, selections: selected, focused, marks });
|
|
1885
|
+
paintOver(this.over, [
|
|
1886
|
+
{ className: 'se-faded', rects: faded },
|
|
1887
|
+
{ className: 'se-struck', rects: struck },
|
|
1888
|
+
{ className: 'se-link', rects: this.link === null ? [] : this.layout.rectangles(this.link.from, this.link.to, rows) }
|
|
1889
|
+
]);
|
|
1890
|
+
const top = this.scrollTop;
|
|
1891
|
+
const caretRects = focused
|
|
1892
|
+
? selections
|
|
1893
|
+
.map((selection) => ({ ...this.caretOf(selection.head), primary: selection === selections.at(-1) }))
|
|
1894
|
+
.filter((rect) => rect.y + rect.height >= top - rect.height && rect.y <= top + this.viewportHeight)
|
|
1895
|
+
: [];
|
|
1896
|
+
const caretKey = caretRects.map((rect) => `${rect.x},${rect.y}`).join(' ');
|
|
1897
|
+
if (caretKey !== this.caretKey) {
|
|
1898
|
+
this.caretKey = caretKey;
|
|
1899
|
+
this.carets.dataset.moving = 'true';
|
|
1900
|
+
clearTimeout(this.caretTimer);
|
|
1901
|
+
this.caretTimer = setTimeout(() => delete this.carets.dataset.moving, CARET_SOLID_MS);
|
|
1902
|
+
}
|
|
1903
|
+
paintCarets(this.carets, [...caretRects, ...(this.dropCaret === null ? [] : [{ ...this.layout.caret(this.dropCaret), primary: false, drop: true }])]);
|
|
1904
|
+
const caret = this.caretOf(primary.head);
|
|
1905
|
+
this.input.style.left = `${this.gutterWidth + Math.max(0, Math.min(this.viewportWidth - 2, caret.x - this.scrollLeft))}px`;
|
|
1906
|
+
this.input.style.top = `${Math.max(0, Math.min(this.viewportHeight - this.layout.metrics.lineHeight, caret.y - top))}px`;
|
|
1907
|
+
this.input.style.height = `${this.layout.metrics.lineHeight}px`;
|
|
1908
|
+
this.root.dataset.carets = String(selections.length);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/* The lines the host marked, which stay on their lines through edits until it marks them again. */
|
|
1912
|
+
setChangeMarks(marks: readonly EditorChangeMark[]): void {
|
|
1913
|
+
const last = this.model.getLineCount();
|
|
1914
|
+
const clamp = (line: number): number => Math.min(last, Math.max(1, Math.trunc(line) || 1)) - 1;
|
|
1915
|
+
this.changeMarks = marks.map((mark) => {
|
|
1916
|
+
const from = this.model.getLine(clamp(mark.startLine)).start;
|
|
1917
|
+
return { kind: mark.kind, from, to: mark.kind === 'deleted' ? from : this.model.getLine(Math.max(clamp(mark.startLine), clamp(mark.endLine))).end };
|
|
1918
|
+
});
|
|
1919
|
+
this.changeVersion++;
|
|
1920
|
+
this.render();
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
private markedLines(mark: { kind: EditorChangeKind; from: number; to: number }): { start: number; end: number } {
|
|
1924
|
+
const start = this.model.positionAt(mark.from).line;
|
|
1925
|
+
return { start, end: mark.kind === 'deleted' ? start : this.model.positionAt(Math.max(mark.from, mark.to)).line };
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/* How each line on screen differs, a removal never covering a line that was added or changed. */
|
|
1929
|
+
private changedLines(rows: readonly LayoutRow[]): Map<number, EditorChangeKind> {
|
|
1930
|
+
const lines = new Map<number, EditorChangeKind>();
|
|
1931
|
+
const first = rows[0]?.line ?? 0;
|
|
1932
|
+
const lastRow = rows.at(-1);
|
|
1933
|
+
const last = lastRow?.kind === 'text' ? lastRow.lastLine : (lastRow?.line ?? 0);
|
|
1934
|
+
for (const mark of this.changeMarks) {
|
|
1935
|
+
const { start, end } = this.markedLines(mark);
|
|
1936
|
+
for (let line = Math.max(start, first); line <= Math.min(end, last); line++) {
|
|
1937
|
+
if (mark.kind !== 'deleted' || !lines.has(line)) {
|
|
1938
|
+
lines.set(line, mark.kind);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
return lines;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/* The carets of agents, each at an offset that follows its text through edits until the host sets them again. */
|
|
1946
|
+
setRemoteCarets(carets: readonly { id: string; name: string; color: string; at: number }[]): void {
|
|
1947
|
+
this.remoteCarets = carets.map((caret) => ({ ...caret }));
|
|
1948
|
+
this.remoteKey = null;
|
|
1949
|
+
this.render();
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
private paintRemote(): void {
|
|
1953
|
+
const lineHeight = this.layout.metrics.lineHeight;
|
|
1954
|
+
const top = this.scrollTop;
|
|
1955
|
+
const cover = this.stickyHeightAt(top);
|
|
1956
|
+
const carets: RemoteCaret[] = [];
|
|
1957
|
+
for (const remote of this.remoteCarets) {
|
|
1958
|
+
const line = this.model.positionAt(remote.at).line;
|
|
1959
|
+
const row = this.layout.rowForLine(line);
|
|
1960
|
+
if (row.kind !== 'text' || row.line !== line) {
|
|
1961
|
+
continue;
|
|
1962
|
+
}
|
|
1963
|
+
const place = this.layout.caret(remote.at);
|
|
1964
|
+
const x = Math.round(place.x);
|
|
1965
|
+
const y = Math.round(place.y);
|
|
1966
|
+
if (y + lineHeight < top - REMOTE_LABEL_HEIGHT || y > top + this.viewportHeight) {
|
|
1967
|
+
continue;
|
|
1968
|
+
}
|
|
1969
|
+
carets.push({ id: remote.id, name: remote.name, color: remote.color, x, y, height: lineHeight, below: y - top - cover < REMOTE_LABEL_HEIGHT });
|
|
1970
|
+
}
|
|
1971
|
+
const key = carets.map((caret) => `${caret.id}|${caret.name}|${caret.color}|${caret.x},${caret.y},${caret.below}`).join('\n');
|
|
1972
|
+
if (key !== this.remoteKey) {
|
|
1973
|
+
this.remoteKey = key;
|
|
1974
|
+
paintRemoteCarets(this.remote, carets);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
/* The host's own DOM after the end of a line, which stays on its line through edits until the owner sets its actions again. */
|
|
1979
|
+
setLineActions(owner: string, actions: readonly { id: string; at: number; render: (container: HTMLElement) => void }[]): void {
|
|
1980
|
+
if (actions.length === 0) {
|
|
1981
|
+
if (!this.lineActions.has(owner)) {
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
this.lineActions.delete(owner);
|
|
1985
|
+
} else {
|
|
1986
|
+
this.lineActions.set(owner, [...actions]);
|
|
1987
|
+
}
|
|
1988
|
+
const wanted = new Set([...this.lineActions].flatMap(([name, entries]) => entries.map((entry) => `${name}\0${entry.id}`)));
|
|
1989
|
+
for (const [key, element] of [...this.actionElements]) {
|
|
1990
|
+
if (!wanted.has(key)) {
|
|
1991
|
+
element.remove();
|
|
1992
|
+
this.actionElements.delete(key);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
this.render();
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
private paintLineActions(): void {
|
|
1999
|
+
const lineHeight = this.layout.metrics.lineHeight;
|
|
2000
|
+
const top = this.scrollTop;
|
|
2001
|
+
for (const [owner, entries] of this.lineActions) {
|
|
2002
|
+
for (const entry of entries) {
|
|
2003
|
+
const key = `${owner}\0${entry.id}`;
|
|
2004
|
+
let element = this.actionElements.get(key);
|
|
2005
|
+
if (element === undefined) {
|
|
2006
|
+
element = this.document.createElement('div');
|
|
2007
|
+
element.className = 'se-line-action';
|
|
2008
|
+
element.addEventListener('pointerdown', (event) => event.stopPropagation());
|
|
2009
|
+
element.addEventListener('mousedown', (event) => event.stopPropagation());
|
|
2010
|
+
this.actions.append(element);
|
|
2011
|
+
this.actionElements.set(key, element);
|
|
2012
|
+
entry.render(element);
|
|
2013
|
+
}
|
|
2014
|
+
const line = this.model.positionAt(entry.at).line;
|
|
2015
|
+
const row = this.layout.rowForLine(line);
|
|
2016
|
+
const place = row.kind === 'text' && row.line === line ? this.layout.caret(this.model.getLine(line).end) : null;
|
|
2017
|
+
if (place === null || place.y + lineHeight < top - this.viewportHeight || place.y > top + 2 * this.viewportHeight) {
|
|
2018
|
+
element.hidden = true;
|
|
2019
|
+
continue;
|
|
2020
|
+
}
|
|
2021
|
+
element.hidden = false;
|
|
2022
|
+
element.style.left = `${Math.round(place.x) + LINE_ACTION_GAP}px`;
|
|
2023
|
+
element.style.top = `${Math.round(place.y)}px`;
|
|
2024
|
+
element.style.height = `${lineHeight}px`;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
/* Bars in the gutter for runs of lines, which follow their text through edits until the host sets them again. */
|
|
2030
|
+
setAttributionMarks(marks: readonly { id: string; startLine: number; endLine: number; color: string }[]): void {
|
|
2031
|
+
this.attribution.set(marks, this.model.getLineCount(), (line) => this.model.getLine(line));
|
|
2032
|
+
this.render();
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
/* Lines the host tinted, which follow their text through edits until it sets them again. */
|
|
2036
|
+
setLineHighlights(highlights: readonly { startLine: number; endLine: number; color: string; sign?: string; fill?: string }[]): void {
|
|
2037
|
+
this.highlights.set(
|
|
2038
|
+
highlights.map((highlight) => ({ id: '', ...highlight })),
|
|
2039
|
+
this.model.getLineCount(),
|
|
2040
|
+
(line) => this.model.getLine(line)
|
|
2041
|
+
);
|
|
2042
|
+
this.render();
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
private highlightedLines(first: number, last: number): Map<number, AttributedLines> {
|
|
2046
|
+
return this.highlights.linesIn(
|
|
2047
|
+
first,
|
|
2048
|
+
last,
|
|
2049
|
+
this.model.getLine(first).start,
|
|
2050
|
+
this.model.getLine(last).end,
|
|
2051
|
+
(offset) => this.model.positionAt(offset).line
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/* One box per row, so a row of the host's between two tinted lines stays clear. */
|
|
2056
|
+
private highlightMarks(
|
|
2057
|
+
rows: readonly LayoutRow[],
|
|
2058
|
+
lines: ReadonlyMap<number, AttributedLines>
|
|
2059
|
+
): { className: string; rects: LayoutRect[]; background: string }[] {
|
|
2060
|
+
const byBackground = new Map<string, LayoutRect[]>();
|
|
2061
|
+
const width = Math.max(this.layout.width, this.viewportWidth);
|
|
2062
|
+
for (const row of rows) {
|
|
2063
|
+
const highlight = row.kind === 'text' ? lines.get(row.line) : undefined;
|
|
2064
|
+
if (highlight !== undefined) {
|
|
2065
|
+
const background = highlight.fill === undefined ? tintValue(highlight.color) : colorValue(highlight.fill);
|
|
2066
|
+
const rects = byBackground.get(background) ?? [];
|
|
2067
|
+
rects.push({ x: 0, y: row.top, width, height: row.height });
|
|
2068
|
+
byBackground.set(background, rects);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
return [...byBackground].map(([background, rects]) => ({ className: 'se-line-highlight', rects, background }));
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/* Code that is not in the document, drawn in the editor's own face and colors. */
|
|
2075
|
+
renderCode(container: HTMLElement, text: string, options: EditorCodeBlockOptions): void {
|
|
2076
|
+
renderCodeBlock(container, text, this.tokens.tokenizeText(text.split(/\r?\n/)), this.settings.tabSize, options);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
private attributedLines(first: number, last: number): Map<number, AttributedLines> {
|
|
2080
|
+
return this.attribution.linesIn(
|
|
2081
|
+
first,
|
|
2082
|
+
last,
|
|
2083
|
+
this.model.getLine(first).start,
|
|
2084
|
+
this.model.getLine(last).end,
|
|
2085
|
+
(offset) => this.model.positionAt(offset).line
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
onAttributionHover(listener: (hover: { id: string; rect: EditorRect } | null) => void): () => void {
|
|
2090
|
+
this.attributionListeners.add(listener);
|
|
2091
|
+
return () => {
|
|
2092
|
+
this.attributionListeners.delete(listener);
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
/* The bar is three pixels wide, so the pointer finds it a few pixels to either side. */
|
|
2097
|
+
private wireAttribution(): void {
|
|
2098
|
+
this.gutter.addEventListener('pointermove', (event) => {
|
|
2099
|
+
this.attributionPointer = { x: event.clientX, y: event.clientY };
|
|
2100
|
+
this.refreshAttributionHover();
|
|
2101
|
+
});
|
|
2102
|
+
this.gutter.addEventListener('pointerleave', () => {
|
|
2103
|
+
this.attributionPointer = null;
|
|
2104
|
+
this.refreshAttributionHover();
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
private refreshAttributionHover(): void {
|
|
2109
|
+
const next = this.attributionPointer === null ? null : this.attributionUnder(this.attributionPointer.x, this.attributionPointer.y);
|
|
2110
|
+
const before = this.attributionHover;
|
|
2111
|
+
if (before === next || (before !== null && next !== null && before.id === next.id && sameRect(before.rect, next.rect))) {
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
this.attributionHover = next;
|
|
2115
|
+
for (const listener of [...this.attributionListeners]) {
|
|
2116
|
+
listener(next);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
private attributionUnder(clientX: number, clientY: number): { id: string; rect: EditorRect } | null {
|
|
2121
|
+
if (this.attribution.size === 0) {
|
|
2122
|
+
return null;
|
|
2123
|
+
}
|
|
2124
|
+
const point = this.contentPoint(clientX, clientY);
|
|
2125
|
+
const fromEdge = this.gutterWidth - (point.x + this.gutterWidth - this.viewport.scrollLeft);
|
|
2126
|
+
if (fromEdge < -ATTRIBUTION_REACH || fromEdge > ATTRIBUTION_REACH + ATTRIBUTION_WIDTH || point.y < 0 || point.y > this.layout.height) {
|
|
2127
|
+
return null;
|
|
2128
|
+
}
|
|
2129
|
+
const row = this.layout.rowAt(point.y);
|
|
2130
|
+
if (row.kind !== 'text') {
|
|
2131
|
+
return null;
|
|
2132
|
+
}
|
|
2133
|
+
const bar = this.attributedLines(row.line, row.line).get(row.line);
|
|
2134
|
+
if (bar === undefined) {
|
|
2135
|
+
return null;
|
|
2136
|
+
}
|
|
2137
|
+
const origin = this.viewport.getBoundingClientRect?.();
|
|
2138
|
+
const scale = origin ? this.screenScale(origin) : { x: 1, y: 1 };
|
|
2139
|
+
const right = (origin?.left ?? 0) + this.gutterWidth * scale.x;
|
|
2140
|
+
const top = (origin?.top ?? 0) + (row.top - this.viewport.scrollTop) * scale.y;
|
|
2141
|
+
return { id: bar.id, rect: { left: right - ATTRIBUTION_WIDTH * scale.x, top, right, bottom: top + row.height * scale.y } };
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/* The first and last line in view, zero-based. */
|
|
2145
|
+
visibleLines(): { first: number; last: number } {
|
|
2146
|
+
const top = this.scrollTop;
|
|
2147
|
+
return { first: this.layout.rowAt(top).line, last: this.layout.rowAt(top + this.viewportHeight).line };
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/* A problem's tick says what the problem is when the pointer rests on it, and a press goes to it. The rest of the track lets the pointer through. */
|
|
2151
|
+
private wireOverview(): void {
|
|
2152
|
+
const tickOf = (event: Event): HTMLElement | null => (event.target as HTMLElement | null)?.closest?.<HTMLElement>('.se-tick[data-offset]') ?? null;
|
|
2153
|
+
this.overview.addEventListener('pointerover', (event) => {
|
|
2154
|
+
const tick = tickOf(event);
|
|
2155
|
+
const title = tick?.getAttribute('data-title');
|
|
2156
|
+
if (tick === null || !title) {
|
|
2157
|
+
this.tickTip.hidden = true;
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
this.tickTip.textContent = title;
|
|
2161
|
+
this.tickTip.style.top = tick.style.top;
|
|
2162
|
+
this.tickTip.hidden = false;
|
|
2163
|
+
});
|
|
2164
|
+
this.overview.addEventListener('pointerout', () => {
|
|
2165
|
+
this.tickTip.hidden = true;
|
|
2166
|
+
});
|
|
2167
|
+
this.overview.addEventListener('pointerdown', (event) => {
|
|
2168
|
+
const tick = tickOf(event);
|
|
2169
|
+
if (tick !== null) {
|
|
2170
|
+
event.preventDefault();
|
|
2171
|
+
this.jumpTo(Number(tick.getAttribute('data-offset')));
|
|
2172
|
+
}
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/* Puts the caret on an offset, in the middle of the view, and gives the editor the keyboard. */
|
|
2177
|
+
private jumpTo(offset: number): void {
|
|
2178
|
+
this.model.setSelections([{ anchor: offset, head: offset }]);
|
|
2179
|
+
this.revealOffset(offset, 'center');
|
|
2180
|
+
this.focus();
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/* The find matches and the changes as ticks in the scroll track, redrawn when what they stand on changes. */
|
|
2184
|
+
private paintOverview(): void {
|
|
2185
|
+
const trackHeight = this.viewportHeight;
|
|
2186
|
+
const matches = this.find.matches;
|
|
2187
|
+
const current = this.find.current;
|
|
2188
|
+
const alike = this.selectionOccurrences();
|
|
2189
|
+
const key = `${this.revision}|${this.changeVersion}|${this.markerVersion}|${this.layout.height}|${trackHeight}|${matches.length}|${current}|${matches[0]?.from}|${matches.at(-1)?.from}|${this.selectionOccurrenceCache.key}`;
|
|
2190
|
+
if (key === this.overviewKey) {
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
this.overviewKey = key;
|
|
2194
|
+
const spans: OverviewSpan[] = [];
|
|
2195
|
+
const rowTop = (line: number): number => this.layout.rowForLine(line).top;
|
|
2196
|
+
const rowBottom = (line: number): number => {
|
|
2197
|
+
const row = this.layout.rowForLine(line);
|
|
2198
|
+
return row.top + row.height;
|
|
2199
|
+
};
|
|
2200
|
+
for (const mark of this.changeMarks) {
|
|
2201
|
+
const { start, end } = this.markedLines(mark);
|
|
2202
|
+
spans.push({ kind: mark.kind, top: rowTop(start), bottom: mark.kind === 'deleted' ? rowTop(start) : rowBottom(end) });
|
|
2203
|
+
}
|
|
2204
|
+
for (const marker of this.markers) {
|
|
2205
|
+
if (marker.severity !== 'hint' && marker.to > marker.from) {
|
|
2206
|
+
const start = this.model.positionAt(marker.from).line;
|
|
2207
|
+
const end = this.model.positionAt(marker.to).line;
|
|
2208
|
+
spans.push({
|
|
2209
|
+
kind: marker.severity,
|
|
2210
|
+
top: rowTop(start),
|
|
2211
|
+
bottom: rowBottom(end),
|
|
2212
|
+
offset: marker.from,
|
|
2213
|
+
...(marker.message === undefined ? {} : { title: marker.message })
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
for (const range of alike) {
|
|
2218
|
+
const line = this.model.positionAt(range.from).line;
|
|
2219
|
+
spans.push({ kind: 'occurrence', top: rowTop(line), bottom: rowBottom(line) });
|
|
2220
|
+
}
|
|
2221
|
+
matches.forEach((match, index) => {
|
|
2222
|
+
const line = this.model.positionAt(match.from).line;
|
|
2223
|
+
spans.push({ kind: index === current ? 'find-current' : 'find', top: rowTop(line), bottom: rowBottom(line) });
|
|
2224
|
+
});
|
|
2225
|
+
paintOverview(this.overview, overviewTicks(spans, this.layout.height, trackHeight));
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/* The blocks sticky scroll and the breadcrumb go by; null goes back to the editor's own reading. */
|
|
2229
|
+
setBlocks(blocks: readonly EditorBlock[] | null): void {
|
|
2230
|
+
this.outline.setProvided(blocks, this.model);
|
|
2231
|
+
this.render();
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
onScope(listener: (scope: readonly EditorBlock[]) => void): () => void {
|
|
2235
|
+
this.scopeListeners.add(listener);
|
|
2236
|
+
return () => {
|
|
2237
|
+
this.scopeListeners.delete(listener);
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
private announceScope(): void {
|
|
2242
|
+
const line = this.model.positionAt(this.model.getPrimary().head).line;
|
|
2243
|
+
const scope = scopeChain(this.outline.blocks(this.model), line).map((block): EditorBlock => ({
|
|
2244
|
+
startLine: block.startLine + 1,
|
|
2245
|
+
endLine: block.endLine + 1,
|
|
2246
|
+
name: block.name!,
|
|
2247
|
+
...(block.kind === undefined ? {} : { kind: block.kind })
|
|
2248
|
+
}));
|
|
2249
|
+
const key = scope.map((block) => `${block.startLine}:${block.endLine}:${block.name}:${block.kind ?? ''}`).join('|');
|
|
2250
|
+
if (key === this.scopeKey) {
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
this.scopeKey = key;
|
|
2254
|
+
for (const listener of [...this.scopeListeners]) {
|
|
2255
|
+
listener(scope);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
private stickyLimit(): number {
|
|
2260
|
+
return Math.min(STICKY_MAX_LINES, Math.floor(this.viewportHeight / this.layout.metrics.lineHeight / STICKY_ROW_SHARE));
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
private stickyAt(scrollTop: number): StickyPlacement[] {
|
|
2264
|
+
const { layout } = this;
|
|
2265
|
+
const bottomOf = (line: number): number => {
|
|
2266
|
+
const row = layout.rowForLine(line);
|
|
2267
|
+
return row.top + row.height;
|
|
2268
|
+
};
|
|
2269
|
+
return stickyPlacements(this.outline.blocks(this.model), scrollTop, layout.metrics.lineHeight, this.stickyLimit(), {
|
|
2270
|
+
top: (line) => layout.rowForLine(line).top,
|
|
2271
|
+
bottom: bottomOf,
|
|
2272
|
+
startsRow: (line) => {
|
|
2273
|
+
const row = layout.rowForLine(line);
|
|
2274
|
+
return row.kind === 'text' && row.line === line;
|
|
2275
|
+
}
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
private stickyHeightAt(scrollTop: number): number {
|
|
2280
|
+
return scrollTop <= 0 ? 0 : stickyCover(this.stickyAt(scrollTop), this.layout.metrics.lineHeight);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
/* Pins the headers of the blocks that have scrolled out of sight above the first visible line, and pushes the last one out as its block ends. */
|
|
2284
|
+
private paintSticky(): void {
|
|
2285
|
+
const lineHeight = this.layout.metrics.lineHeight;
|
|
2286
|
+
const placements = this.scrollTop <= 0 ? [] : this.stickyAt(this.scrollTop);
|
|
2287
|
+
const entries = placements.map((placement): StickyEntry => {
|
|
2288
|
+
const row = this.layout.rowForLine(placement.block.startLine) as TextRow;
|
|
2289
|
+
return { line: placement.block.startLine, geometry: this.layout.geometry(row), tokens: this.styledTokensOf(placement.block.startLine) };
|
|
2290
|
+
});
|
|
2291
|
+
const changed =
|
|
2292
|
+
entries.length !== this.stickyEntries.length ||
|
|
2293
|
+
entries.some((entry, index) => {
|
|
2294
|
+
const before = this.stickyEntries[index]!;
|
|
2295
|
+
return entry.line !== before.line || entry.geometry !== before.geometry || entry.tokens !== before.tokens;
|
|
2296
|
+
});
|
|
2297
|
+
this.sticky.hidden = entries.length === 0;
|
|
2298
|
+
this.sticky.style.setProperty('--se-scroll-left', `${this.scrollLeft}px`);
|
|
2299
|
+
this.sticky.style.width = `${this.clientWidth}px`;
|
|
2300
|
+
if (changed) {
|
|
2301
|
+
this.stickyEntries = entries;
|
|
2302
|
+
paintSticky(this.sticky, entries, lineHeight);
|
|
2303
|
+
}
|
|
2304
|
+
this.sticky.style.height = `${stickyCover(placements, lineHeight)}px`;
|
|
2305
|
+
placements.forEach((placement, index) => {
|
|
2306
|
+
const row = this.sticky.children[index] as HTMLElement | undefined;
|
|
2307
|
+
if (row) {
|
|
2308
|
+
row.style.transform = placement.offset === 0 ? '' : `translateY(${placement.offset}px)`;
|
|
2309
|
+
row.style.zIndex = String(placements.length - index);
|
|
2310
|
+
}
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
/* A click on a pinned header: the caret goes to it and it scrolls into place under the headers of the blocks around it. */
|
|
2315
|
+
jumpToHeader(line: number, depth: number): void {
|
|
2316
|
+
const bounds = this.model.getLine(line);
|
|
2317
|
+
const indent = bounds.text.length - bounds.text.trimStart().length;
|
|
2318
|
+
this.model.setSelections([{ anchor: bounds.start + indent, head: bounds.start + indent }]);
|
|
2319
|
+
this.viewport.scrollTop = Math.max(0, this.layout.rowForLine(line).top - depth * this.layout.metrics.lineHeight);
|
|
2320
|
+
this.render();
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
setGutterAction(action: { line: number; label: string } | null): void {
|
|
2324
|
+
if (action?.line === this.gutterAction?.line && action?.label === this.gutterAction?.label) {
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
this.gutterAction = action;
|
|
2328
|
+
this.render();
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
onGutterAction(listener: (line: number) => void): () => void {
|
|
2332
|
+
this.gutterActionListeners.add(listener);
|
|
2333
|
+
return () => {
|
|
2334
|
+
this.gutterActionListeners.delete(listener);
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
setGutterMarkers(owner: string, markers: readonly { id: string; line: number; label: string }[]): void {
|
|
2339
|
+
const before = this.gutterMarkers.get(owner) ?? [];
|
|
2340
|
+
if (
|
|
2341
|
+
before.length === markers.length &&
|
|
2342
|
+
before.every((marker, index) => marker.id === markers[index]!.id && marker.line === markers[index]!.line && marker.label === markers[index]!.label)
|
|
2343
|
+
) {
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
if (markers.length === 0) {
|
|
2347
|
+
this.gutterMarkers.delete(owner);
|
|
2348
|
+
} else {
|
|
2349
|
+
this.gutterMarkers.set(owner, markers);
|
|
2350
|
+
}
|
|
2351
|
+
this.render();
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
onGutterMarker(listener: (id: string) => void): () => void {
|
|
2355
|
+
this.gutterMarkerListeners.add(listener);
|
|
2356
|
+
return () => {
|
|
2357
|
+
this.gutterMarkerListeners.delete(listener);
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
/* The id of the host's gutter marker when the target is it. */
|
|
2362
|
+
gutterMarkerIdOf(target: EventTarget | null): string | null {
|
|
2363
|
+
const button = (target as HTMLElement | null)?.closest?.<HTMLElement>('[data-gutter-marker]');
|
|
2364
|
+
return button?.dataset.gutterMarker ?? null;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
pressGutterMarker(id: string): void {
|
|
2368
|
+
for (const listener of [...this.gutterMarkerListeners]) {
|
|
2369
|
+
listener(id);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/* One marker per line: where two owners mark the same line, the owner named first stays. */
|
|
2374
|
+
private markersByLine(): ReadonlyMap<number, { id: string; label: string }> {
|
|
2375
|
+
const byLine = new Map<number, { id: string; label: string }>();
|
|
2376
|
+
for (const owner of [...this.gutterMarkers.keys()].sort()) {
|
|
2377
|
+
for (const marker of this.gutterMarkers.get(owner)!) {
|
|
2378
|
+
if (!byLine.has(marker.line)) {
|
|
2379
|
+
byLine.set(marker.line, { id: marker.id, label: marker.label });
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
return byLine;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/* The line of the host's gutter button when the target is it. */
|
|
2387
|
+
gutterActionLineOf(target: EventTarget | null): number | null {
|
|
2388
|
+
const button = (target as HTMLElement | null)?.closest?.('[data-gutter-action]');
|
|
2389
|
+
return button ? Number((button as HTMLElement).dataset.gutterAction) : null;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
pressGutterAction(line: number): void {
|
|
2393
|
+
for (const listener of [...this.gutterActionListeners]) {
|
|
2394
|
+
listener(line);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/* Whether a click at this point of the gutter is on a fold control, and which line it folds. */
|
|
2399
|
+
foldLineOf(target: EventTarget | null): number | null {
|
|
2400
|
+
const button = (target as HTMLElement | null)?.closest?.('[data-fold-line]');
|
|
2401
|
+
return button ? Number((button as HTMLElement).dataset.foldLine) : null;
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
dispose(): void {
|
|
2405
|
+
if (this.disposed) {
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
this.disposed = true;
|
|
2409
|
+
this.cancelScroll();
|
|
2410
|
+
this.subscription.dispose();
|
|
2411
|
+
this.resizeObserver?.disconnect();
|
|
2412
|
+
this.blockObserver?.disconnect();
|
|
2413
|
+
clearTimeout(this.foldTimer);
|
|
2414
|
+
clearTimeout(this.colorTimer);
|
|
2415
|
+
clearTimeout(this.noticeTimer);
|
|
2416
|
+
clearTimeout(this.caretTimer);
|
|
2417
|
+
if (this.frame !== undefined) {
|
|
2418
|
+
this.document.defaultView?.cancelAnimationFrame?.(this.frame);
|
|
2419
|
+
}
|
|
2420
|
+
this.findListeners.clear();
|
|
2421
|
+
this.scopeListeners.clear();
|
|
2422
|
+
this.viewListeners.clear();
|
|
2423
|
+
this.hoverListeners.clear();
|
|
2424
|
+
this.gutterActionListeners.clear();
|
|
2425
|
+
this.gutterMarkerListeners.clear();
|
|
2426
|
+
this.gutterMarkers.clear();
|
|
2427
|
+
this.tracked.clear();
|
|
2428
|
+
this.remoteCarets = [];
|
|
2429
|
+
this.lineActions.clear();
|
|
2430
|
+
this.actionElements.clear();
|
|
2431
|
+
this.attribution.clear();
|
|
2432
|
+
this.highlights.clear();
|
|
2433
|
+
this.attributionListeners.clear();
|
|
2434
|
+
this.painter.clear();
|
|
2435
|
+
this.root.remove();
|
|
2436
|
+
}
|
|
2437
|
+
}
|