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