Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/browser/observableCodeEditor.ts
5225 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { equalsIfDefinedC, arrayEqualsC } from '../../base/common/equals.js';
7
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js';
8
import { DebugLocation, IObservable, IObservableWithChange, IReader, ITransaction, TransactionImpl, autorun, autorunOpts, derived, derivedOpts, derivedWithSetter, observableFromEvent, observableFromEventOpts, observableSignal, observableSignalFromEvent, observableValue, observableValueOpts } from '../../base/common/observable.js';
9
import { EditorOption, FindComputedEditorOptionValueById } from '../common/config/editorOptions.js';
10
import { LineRange } from '../common/core/ranges/lineRange.js';
11
import { OffsetRange } from '../common/core/ranges/offsetRange.js';
12
import { Position } from '../common/core/position.js';
13
import { Selection } from '../common/core/selection.js';
14
import { ICursorSelectionChangedEvent } from '../common/cursorEvents.js';
15
import { IModelDeltaDecoration, ITextModel } from '../common/model.js';
16
import { IModelContentChangedEvent } from '../common/textModelEvents.js';
17
import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition, IEditorMouseEvent, IOverlayWidget, IOverlayWidgetPosition, IPasteEvent } from './editorBrowser.js';
18
import { Point } from '../common/core/2d/point.js';
19
20
/**
21
* Returns a facade for the code editor that provides observables for various states/events.
22
*/
23
export function observableCodeEditor(editor: ICodeEditor): ObservableCodeEditor {
24
return ObservableCodeEditor.get(editor);
25
}
26
27
export class ObservableCodeEditor extends Disposable {
28
private static readonly _map = new Map<ICodeEditor, ObservableCodeEditor>();
29
30
/**
31
* Make sure that editor is not disposed yet!
32
*/
33
public static get(editor: ICodeEditor): ObservableCodeEditor {
34
let result = ObservableCodeEditor._map.get(editor);
35
if (!result) {
36
result = new ObservableCodeEditor(editor);
37
ObservableCodeEditor._map.set(editor, result);
38
const d = editor.onDidDispose(() => {
39
const item = ObservableCodeEditor._map.get(editor);
40
if (item) {
41
ObservableCodeEditor._map.delete(editor);
42
item.dispose();
43
d.dispose();
44
}
45
});
46
}
47
return result;
48
}
49
50
private _updateCounter;
51
private _currentTransaction: TransactionImpl | undefined;
52
53
private _beginUpdate(): void {
54
this._updateCounter++;
55
if (this._updateCounter === 1) {
56
this._currentTransaction = new TransactionImpl(() => {
57
/** @description Update editor state */
58
});
59
}
60
}
61
62
private _endUpdate(): void {
63
this._updateCounter--;
64
if (this._updateCounter === 0) {
65
const t = this._currentTransaction!;
66
this._currentTransaction = undefined;
67
t.finish();
68
}
69
}
70
71
private constructor(public readonly editor: ICodeEditor) {
72
super();
73
this._updateCounter = 0;
74
this._currentTransaction = undefined;
75
this._model = observableValue(this, this.editor.getModel());
76
this.model = this._model;
77
this.isReadonly = observableFromEventOpts({ owner: this, getTransaction: () => this._currentTransaction }, this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.readOnly));
78
this._versionId = observableValueOpts<number | null, IModelContentChangedEvent | undefined>({ owner: this, lazy: true }, this.editor.getModel()?.getVersionId() ?? null);
79
this.versionId = this._versionId;
80
this._selections = observableValueOpts<Selection[] | null, ICursorSelectionChangedEvent | undefined>(
81
{ owner: this, equalsFn: equalsIfDefinedC(arrayEqualsC(Selection.selectionsEqual)), lazy: true },
82
this.editor.getSelections() ?? null
83
);
84
this.selections = this._selections;
85
this.positions = derivedOpts<readonly Position[] | null>(
86
{ owner: this, equalsFn: equalsIfDefinedC(arrayEqualsC(Position.equals)) },
87
reader => this.selections.read(reader)?.map(s => s.getStartPosition()) ?? null
88
);
89
this.isFocused = observableFromEventOpts({ owner: this, getTransaction: () => this._currentTransaction }, e => {
90
const d1 = this.editor.onDidFocusEditorWidget(e);
91
const d2 = this.editor.onDidBlurEditorWidget(e);
92
return {
93
dispose() {
94
d1.dispose();
95
d2.dispose();
96
}
97
};
98
}, () => this.editor.hasWidgetFocus());
99
this.isTextFocused = observableFromEventOpts({ owner: this, getTransaction: () => this._currentTransaction }, e => {
100
const d1 = this.editor.onDidFocusEditorText(e);
101
const d2 = this.editor.onDidBlurEditorText(e);
102
return {
103
dispose() {
104
d1.dispose();
105
d2.dispose();
106
}
107
};
108
}, () => this.editor.hasTextFocus());
109
this.inComposition = observableFromEventOpts({ owner: this, getTransaction: () => this._currentTransaction }, e => {
110
const d1 = this.editor.onDidCompositionStart(() => {
111
e(undefined);
112
});
113
const d2 = this.editor.onDidCompositionEnd(() => {
114
e(undefined);
115
});
116
return {
117
dispose() {
118
d1.dispose();
119
d2.dispose();
120
}
121
};
122
}, () => this.editor.inComposition);
123
this.value = derivedWithSetter(this,
124
reader => { this.versionId.read(reader); return this.model.read(reader)?.getValue() ?? ''; },
125
(value, tx) => {
126
const model = this.model.get();
127
if (model !== null) {
128
if (value !== model.getValue()) {
129
model.setValue(value);
130
}
131
}
132
}
133
);
134
this.valueIsEmpty = derived(this, reader => { this.versionId.read(reader); return this.editor.getModel()?.getValueLength() === 0; });
135
this.cursorSelection = derivedOpts({ owner: this, equalsFn: equalsIfDefinedC(Selection.selectionsEqual) }, reader => this.selections.read(reader)?.[0] ?? null);
136
this.cursorPosition = derivedOpts({ owner: this, equalsFn: Position.equals }, reader => this.selections.read(reader)?.[0]?.getPosition() ?? null);
137
this.cursorLineNumber = derived<number | null>(this, reader => this.cursorPosition.read(reader)?.lineNumber ?? null);
138
this.onDidType = observableSignal<string>(this);
139
this.onDidPaste = observableSignal<IPasteEvent>(this);
140
this.scrollTop = observableFromEventOpts({ owner: this, getTransaction: () => this._currentTransaction }, this.editor.onDidScrollChange, () => this.editor.getScrollTop());
141
this.scrollLeft = observableFromEventOpts({ owner: this, getTransaction: () => this._currentTransaction }, this.editor.onDidScrollChange, () => this.editor.getScrollLeft());
142
this.layoutInfo = observableFromEventOpts({ owner: this, getTransaction: () => this._currentTransaction }, this.editor.onDidLayoutChange, () => this.editor.getLayoutInfo());
143
this.layoutInfoContentLeft = this.layoutInfo.map(l => l.contentLeft);
144
this.layoutInfoDecorationsLeft = this.layoutInfo.map(l => l.decorationsLeft);
145
this.layoutInfoWidth = this.layoutInfo.map(l => l.width);
146
this.layoutInfoHeight = this.layoutInfo.map(l => l.height);
147
this.layoutInfoMinimap = this.layoutInfo.map(l => l.minimap);
148
this.layoutInfoVerticalScrollbarWidth = this.layoutInfo.map(l => l.verticalScrollbarWidth);
149
this.contentWidth = observableFromEventOpts({ owner: this, getTransaction: () => this._currentTransaction }, this.editor.onDidContentSizeChange, () => this.editor.getContentWidth());
150
this.contentHeight = observableFromEventOpts({ owner: this, getTransaction: () => this._currentTransaction }, this.editor.onDidContentSizeChange, () => this.editor.getContentHeight());
151
this._onDidChangeViewZones = observableSignalFromEvent(this, this.editor.onDidChangeViewZones);
152
this._onDidHiddenAreasChanged = observableSignalFromEvent(this, this.editor.onDidChangeHiddenAreas);
153
this._onDidLineHeightChanged = observableSignalFromEvent(this, this.editor.onDidChangeLineHeight);
154
155
this._widgetCounter = 0;
156
this.openedPeekWidgets = observableValue(this, 0);
157
158
this._register(this.editor.onBeginUpdate(() => this._beginUpdate()));
159
this._register(this.editor.onEndUpdate(() => this._endUpdate()));
160
161
this._register(this.editor.onDidChangeModel(() => {
162
this._beginUpdate();
163
try {
164
this._model.set(this.editor.getModel(), this._currentTransaction);
165
this._forceUpdate();
166
} finally {
167
this._endUpdate();
168
}
169
}));
170
171
this._register(this.editor.onDidType((e) => {
172
this._beginUpdate();
173
try {
174
this._forceUpdate();
175
this.onDidType.trigger(this._currentTransaction, e);
176
} finally {
177
this._endUpdate();
178
}
179
}));
180
181
this._register(this.editor.onDidPaste((e) => {
182
this._beginUpdate();
183
try {
184
this._forceUpdate();
185
this.onDidPaste.trigger(this._currentTransaction, e);
186
} finally {
187
this._endUpdate();
188
}
189
}));
190
191
this._register(this.editor.onDidChangeModelContent(e => {
192
this._beginUpdate();
193
try {
194
this._versionId.set(this.editor.getModel()?.getVersionId() ?? null, this._currentTransaction, e);
195
this._forceUpdate();
196
} finally {
197
this._endUpdate();
198
}
199
}));
200
201
this._register(this.editor.onDidChangeCursorSelection(e => {
202
this._beginUpdate();
203
try {
204
this._selections.set(this.editor.getSelections(), this._currentTransaction, e);
205
this._forceUpdate();
206
} finally {
207
this._endUpdate();
208
}
209
}));
210
211
this.domNode = derived(reader => {
212
this.model.read(reader);
213
return this.editor.getDomNode();
214
});
215
}
216
217
/**
218
* Batches the transactions started by observableFromEvent.
219
*
220
* If the callback causes the editor to fire an event that updates
221
* an observable value backed by observableFromEvent (such as scrollTop etc.),
222
* then all such updates will be part of the same transaction.
223
*/
224
public transaction<T>(cb: (tx: ITransaction) => T): T {
225
this._beginUpdate();
226
try {
227
return cb(this._currentTransaction!);
228
} finally {
229
this._endUpdate();
230
}
231
}
232
233
public forceUpdate(): void;
234
public forceUpdate<T>(cb: (tx: ITransaction) => T): T;
235
public forceUpdate<T>(cb?: (tx: ITransaction) => T): T {
236
this._beginUpdate();
237
try {
238
this._forceUpdate();
239
if (!cb) { return undefined as T; }
240
return cb(this._currentTransaction!);
241
} finally {
242
this._endUpdate();
243
}
244
}
245
246
private _forceUpdate(): void {
247
this._beginUpdate();
248
try {
249
this._model.set(this.editor.getModel(), this._currentTransaction);
250
this._versionId.set(this.editor.getModel()?.getVersionId() ?? null, this._currentTransaction, undefined);
251
this._selections.set(this.editor.getSelections(), this._currentTransaction, undefined);
252
} finally {
253
this._endUpdate();
254
}
255
}
256
257
private readonly _model;
258
public readonly model: IObservable<ITextModel | null>;
259
260
public readonly isReadonly;
261
262
private readonly _versionId;
263
public readonly versionId: IObservableWithChange<number | null, IModelContentChangedEvent | undefined>;
264
265
private readonly _selections;
266
public readonly selections: IObservableWithChange<Selection[] | null, ICursorSelectionChangedEvent | undefined>;
267
268
269
public readonly positions;
270
271
public readonly isFocused;
272
273
public readonly isTextFocused;
274
275
public readonly inComposition;
276
277
public readonly value;
278
public readonly valueIsEmpty;
279
public readonly cursorSelection;
280
public readonly cursorPosition;
281
public readonly cursorLineNumber;
282
283
public readonly onDidType;
284
public readonly onDidPaste;
285
286
public readonly scrollTop;
287
public readonly scrollLeft;
288
289
public readonly layoutInfo;
290
public readonly layoutInfoContentLeft;
291
public readonly layoutInfoDecorationsLeft;
292
public readonly layoutInfoWidth;
293
public readonly layoutInfoHeight;
294
public readonly layoutInfoMinimap;
295
public readonly layoutInfoVerticalScrollbarWidth;
296
297
public readonly contentWidth;
298
public readonly contentHeight;
299
300
public readonly domNode;
301
302
public getOption<T extends EditorOption>(id: T, debugLocation = DebugLocation.ofCaller()): IObservable<FindComputedEditorOptionValueById<T>> {
303
return observableFromEvent(this, cb => this.editor.onDidChangeConfiguration(e => {
304
if (e.hasChanged(id)) { cb(undefined); }
305
}), () => this.editor.getOption(id), debugLocation);
306
}
307
308
public setDecorations(decorations: IObservable<IModelDeltaDecoration[]>): IDisposable {
309
const d = new DisposableStore();
310
const decorationsCollection = this.editor.createDecorationsCollection();
311
d.add(autorunOpts({ owner: this, debugName: () => `Apply decorations from ${decorations.debugName}` }, reader => {
312
const d = decorations.read(reader);
313
decorationsCollection.set(d);
314
}));
315
d.add({
316
dispose: () => {
317
decorationsCollection.clear();
318
}
319
});
320
return d;
321
}
322
323
private _widgetCounter;
324
325
public createOverlayWidget(widget: IObservableOverlayWidget): IDisposable {
326
const overlayWidgetId = 'observableOverlayWidget' + (this._widgetCounter++);
327
const w: IOverlayWidget = {
328
getDomNode: () => widget.domNode,
329
getPosition: () => widget.position.get(),
330
getId: () => overlayWidgetId,
331
allowEditorOverflow: widget.allowEditorOverflow,
332
getMinContentWidthInPx: () => widget.minContentWidthInPx.get(),
333
};
334
this.editor.addOverlayWidget(w);
335
const d = autorun(reader => {
336
widget.position.read(reader);
337
widget.minContentWidthInPx.read(reader);
338
this.editor.layoutOverlayWidget(w);
339
});
340
return toDisposable(() => {
341
d.dispose();
342
this.editor.removeOverlayWidget(w);
343
});
344
}
345
346
public createContentWidget(widget: IObservableContentWidget): IDisposable {
347
const contentWidgetId = 'observableContentWidget' + (this._widgetCounter++);
348
const w: IContentWidget = {
349
getDomNode: () => widget.domNode,
350
getPosition: () => widget.position.get(),
351
getId: () => contentWidgetId,
352
allowEditorOverflow: widget.allowEditorOverflow,
353
};
354
this.editor.addContentWidget(w);
355
const d = autorun(reader => {
356
widget.position.read(reader);
357
this.editor.layoutContentWidget(w);
358
});
359
return toDisposable(() => {
360
d.dispose();
361
this.editor.removeContentWidget(w);
362
});
363
}
364
365
public observeLineOffsetRange(lineRange: IObservable<LineRange>, store: DisposableStore): IObservable<OffsetRange> {
366
const start = this.observePosition(lineRange.map(r => new Position(r.startLineNumber, 1)), store);
367
const end = this.observePosition(lineRange.map(r => new Position(r.endLineNumberExclusive + 1, 1)), store);
368
369
return derived(reader => {
370
start.read(reader);
371
end.read(reader);
372
const range = lineRange.read(reader);
373
const lineCount = this.model.read(reader)?.getLineCount();
374
const s = (
375
(typeof lineCount !== 'undefined' && range.startLineNumber > lineCount
376
? this.editor.getBottomForLineNumber(lineCount)
377
: this.editor.getTopForLineNumber(range.startLineNumber)
378
)
379
- this.scrollTop.read(reader)
380
);
381
const e = range.isEmpty ? s : (this.editor.getBottomForLineNumber(range.endLineNumberExclusive - 1) - this.scrollTop.read(reader));
382
return new OffsetRange(s, e);
383
});
384
}
385
386
/**
387
* Uses an approximation if the exact position cannot be determined.
388
*/
389
getLeftOfPosition(position: Position, reader: IReader | undefined): number {
390
this.layoutInfo.read(reader);
391
this.value.read(reader);
392
393
let offset = this.editor.getOffsetForColumn(position.lineNumber, position.column);
394
if (offset === -1) {
395
// approximation
396
const typicalHalfwidthCharacterWidth = this.editor.getOption(EditorOption.fontInfo).typicalHalfwidthCharacterWidth;
397
const approximation = position.column * typicalHalfwidthCharacterWidth;
398
offset = approximation;
399
}
400
return offset;
401
}
402
403
public observePosition(position: IObservable<Position | null>, store: DisposableStore): IObservable<Point | null> {
404
let pos = position.get();
405
const result = observableValueOpts<Point | null>({ owner: this, debugName: () => `topLeftOfPosition${pos?.toString()}`, equalsFn: equalsIfDefinedC(Point.equals) }, new Point(0, 0));
406
const contentWidgetId = `observablePositionWidget` + (this._widgetCounter++);
407
const domNode = document.createElement('div');
408
const w: IContentWidget = {
409
getDomNode: () => domNode,
410
getPosition: () => {
411
return pos ? { preference: [ContentWidgetPositionPreference.EXACT], position: position.get() } : null;
412
},
413
getId: () => contentWidgetId,
414
allowEditorOverflow: false,
415
useDisplayNone: true,
416
afterRender: (position, coordinate) => {
417
const model = this._model.get();
418
if (model && pos && pos.lineNumber > model.getLineCount()) {
419
// the position is after the last line
420
result.set(new Point(0, this.editor.getBottomForLineNumber(model.getLineCount()) - this.scrollTop.get()), undefined);
421
} else {
422
result.set(coordinate ? new Point(coordinate.left, coordinate.top) : null, undefined);
423
}
424
},
425
};
426
this.editor.addContentWidget(w);
427
store.add(autorun(reader => {
428
pos = position.read(reader);
429
this.editor.layoutContentWidget(w);
430
}));
431
store.add(toDisposable(() => {
432
this.editor.removeContentWidget(w);
433
}));
434
return result;
435
}
436
437
public readonly openedPeekWidgets;
438
439
isTargetHovered(predicate: (target: IEditorMouseEvent) => boolean, store: DisposableStore): IObservable<boolean> {
440
const isHovered = observableValue('isInjectedTextHovered', false);
441
store.add(this.editor.onMouseMove(e => {
442
const val = predicate(e);
443
isHovered.set(val, undefined);
444
}));
445
446
store.add(this.editor.onMouseLeave(E => {
447
isHovered.set(false, undefined);
448
}));
449
return isHovered;
450
}
451
452
observeLineHeightForPosition(position: IObservable<Position> | Position): IObservable<number>;
453
observeLineHeightForPosition(position: IObservable<null>): IObservable<null>;
454
observeLineHeightForPosition(position: IObservable<Position | null> | Position): IObservable<number | null> {
455
return derived(reader => {
456
const pos = position instanceof Position ? position : position.read(reader);
457
if (pos === null) {
458
return null;
459
}
460
461
this.getOption(EditorOption.lineHeight).read(reader);
462
463
return this.editor.getLineHeightForPosition(pos);
464
});
465
}
466
467
observeLineHeightForLine(lineNumber: IObservable<number> | number): IObservable<number>;
468
observeLineHeightForLine(lineNumber: IObservable<null>): IObservable<null>;
469
observeLineHeightForLine(lineNumber: IObservable<number | null> | number): IObservable<number | null> {
470
if (typeof lineNumber === 'number') {
471
return this.observeLineHeightForPosition(new Position(lineNumber, 1));
472
}
473
474
return derived(reader => {
475
const line = lineNumber.read(reader);
476
if (line === null) {
477
return null;
478
}
479
480
return this.observeLineHeightForPosition(new Position(line, 1)).read(reader);
481
});
482
}
483
484
observeLineHeightsForLineRange(lineNumber: IObservable<LineRange> | LineRange): IObservable<number[]> {
485
return derived(reader => {
486
const range = lineNumber instanceof LineRange ? lineNumber : lineNumber.read(reader);
487
488
const heights: number[] = [];
489
for (let i = range.startLineNumber; i < range.endLineNumberExclusive; i++) {
490
heights.push(this.observeLineHeightForLine(i).read(reader));
491
}
492
return heights;
493
});
494
}
495
496
private readonly _onDidChangeViewZones;
497
private readonly _onDidHiddenAreasChanged;
498
private readonly _onDidLineHeightChanged;
499
500
/**
501
* Tracks whether getWidthOfLine returned 0, indicating the editor may be hidden.
502
* When resize happens and this flag is set, we reset cached line widths.
503
*/
504
private _sawZeroLineWidth = false;
505
506
/**
507
* Fires when the editor container resizes.
508
* This is lazily created only when someone subscribes to it.
509
* Useful for detecting when a parent element's display changes from 'none' to 'block'.
510
*/
511
private readonly _onDidContainerResize = observableFromEventOpts(
512
{ owner: this, getTransaction: () => this._currentTransaction },
513
e => {
514
const container = this.editor.getContainerDomNode();
515
const resizeObserver = new ResizeObserver(() => {
516
// If we previously saw a 0 width, the editor was likely hidden.
517
// Now that it resized (became visible), flush the cached widths.
518
if (this._sawZeroLineWidth) {
519
this._sawZeroLineWidth = false;
520
this.editor.resetLineWidthCaches();
521
}
522
e(undefined);
523
});
524
resizeObserver.observe(container);
525
return { dispose: () => resizeObserver.disconnect() };
526
},
527
() => ({}) // Return new object each time to ensure change detection
528
);
529
530
/**
531
* Get the width of a line in pixels.
532
* Reading the returned value depends on layoutInfo, value, scrollTop, and container resize events.
533
* The container resize dependency ensures correct values when the editor becomes visible after being hidden.
534
*/
535
getWidthOfLine(lineNumber: number, reader: IReader | undefined): number {
536
this.layoutInfo.read(reader);
537
this.value.read(reader);
538
this.scrollTop.read(reader);
539
const width = this.editor.getWidthOfLine(lineNumber);
540
this._onDidContainerResize.read(reader);
541
if (width === 0) {
542
this._sawZeroLineWidth = true;
543
}
544
return width;
545
}
546
547
/**
548
* Get the vertical position (top offset) for the line's bottom w.r.t. to the first line.
549
*/
550
observeTopForLineNumber(lineNumber: number): IObservable<number> {
551
return derived(reader => {
552
this.layoutInfo.read(reader);
553
this._onDidChangeViewZones.read(reader);
554
this._onDidHiddenAreasChanged.read(reader);
555
this._onDidLineHeightChanged.read(reader);
556
this._versionId.read(reader);
557
return this.editor.getTopForLineNumber(lineNumber);
558
});
559
}
560
561
/**
562
* Get the vertical position (top offset) for the line's bottom w.r.t. to the first line.
563
*/
564
observeBottomForLineNumber(lineNumber: number): IObservable<number> {
565
return derived(reader => {
566
this.layoutInfo.read(reader);
567
this._onDidChangeViewZones.read(reader);
568
this._onDidHiddenAreasChanged.read(reader);
569
this._onDidLineHeightChanged.read(reader);
570
this._versionId.read(reader);
571
return this.editor.getBottomForLineNumber(lineNumber);
572
});
573
}
574
}
575
576
interface IObservableOverlayWidget {
577
get domNode(): HTMLElement;
578
readonly position: IObservable<IOverlayWidgetPosition | null>;
579
readonly minContentWidthInPx: IObservable<number>;
580
get allowEditorOverflow(): boolean;
581
}
582
583
interface IObservableContentWidget {
584
get domNode(): HTMLElement;
585
readonly position: IObservable<IContentWidgetPosition | null>;
586
get allowEditorOverflow(): boolean;
587
}
588
589