Path: blob/main/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts
5263 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 * as dom from '../../../../base/browser/dom.js';6import { FastDomNode, createFastDomNode } from '../../../../base/browser/fastDomNode.js';7import { ContentWidgetPositionPreference, IContentWidget, IContentWidgetRenderedCoordinate } from '../../editorBrowser.js';8import { PartFingerprint, PartFingerprints, ViewPart } from '../../view/viewPart.js';9import { RenderingContext, RestrictedRenderingContext } from '../../view/renderingContext.js';10import { ViewContext } from '../../../common/viewModel/viewContext.js';11import * as viewEvents from '../../../common/viewEvents.js';12import { ViewportData } from '../../../common/viewLayout/viewLinesViewportData.js';13import { EditorOption } from '../../../common/config/editorOptions.js';14import { IDimension } from '../../../common/core/2d/dimension.js';15import { PositionAffinity } from '../../../common/model.js';16import { IPosition, Position } from '../../../common/core/position.js';17import { IViewModel } from '../../../common/viewModel.js';1819/**20* This view part is responsible for rendering the content widgets, which are21* used for rendering elements that are associated to an editor position,22* such as suggestions or the parameter hints.23*/24export class ViewContentWidgets extends ViewPart {2526private readonly _viewDomNode: FastDomNode<HTMLElement>;27private _widgets: { [key: string]: Widget };2829public domNode: FastDomNode<HTMLElement>;30public overflowingContentWidgetsDomNode: FastDomNode<HTMLElement>;3132constructor(context: ViewContext, viewDomNode: FastDomNode<HTMLElement>) {33super(context);34this._viewDomNode = viewDomNode;35this._widgets = {};3637this.domNode = createFastDomNode(document.createElement('div'));38PartFingerprints.write(this.domNode, PartFingerprint.ContentWidgets);39this.domNode.setClassName('contentWidgets');40this.domNode.setPosition('absolute');41this.domNode.setTop(0);4243this.overflowingContentWidgetsDomNode = createFastDomNode(document.createElement('div'));44PartFingerprints.write(this.overflowingContentWidgetsDomNode, PartFingerprint.OverflowingContentWidgets);45this.overflowingContentWidgetsDomNode.setClassName('overflowingContentWidgets');46}4748public override dispose(): void {49super.dispose();50this._widgets = {};51}5253// --- begin event handlers5455public override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean {56const keys = Object.keys(this._widgets);57for (const widgetId of keys) {58this._widgets[widgetId].onConfigurationChanged(e);59}60return true;61}62public override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean {63// true for inline decorations that can end up relayouting text64return true;65}66public override onFlushed(e: viewEvents.ViewFlushedEvent): boolean {67return true;68}69public override onLineMappingChanged(e: viewEvents.ViewLineMappingChangedEvent): boolean {70this._updateAnchorsViewPositions();71return true;72}73public override onLinesChanged(e: viewEvents.ViewLinesChangedEvent): boolean {74this._updateAnchorsViewPositions();75return true;76}77public override onLinesDeleted(e: viewEvents.ViewLinesDeletedEvent): boolean {78this._updateAnchorsViewPositions();79return true;80}81public override onLinesInserted(e: viewEvents.ViewLinesInsertedEvent): boolean {82this._updateAnchorsViewPositions();83return true;84}85public override onScrollChanged(e: viewEvents.ViewScrollChangedEvent): boolean {86return true;87}88public override onZonesChanged(e: viewEvents.ViewZonesChangedEvent): boolean {89return true;90}9192// ---- end view event handlers9394private _updateAnchorsViewPositions(): void {95const keys = Object.keys(this._widgets);96for (const widgetId of keys) {97this._widgets[widgetId].updateAnchorViewPosition();98}99}100101public addWidget(_widget: IContentWidget): void {102const myWidget = new Widget(this._context, this._viewDomNode, _widget);103this._widgets[myWidget.id] = myWidget;104105if (myWidget.allowEditorOverflow) {106this.overflowingContentWidgetsDomNode.appendChild(myWidget.domNode);107} else {108this.domNode.appendChild(myWidget.domNode);109}110111this.setShouldRender();112}113114public setWidgetPosition(widget: IContentWidget, primaryAnchor: IPosition | null, secondaryAnchor: IPosition | null, preference: ContentWidgetPositionPreference[] | null, affinity: PositionAffinity | null): void {115const myWidget = this._widgets[widget.getId()];116myWidget.setPosition(primaryAnchor, secondaryAnchor, preference, affinity);117118if (!myWidget.useDisplayNone) {119this.setShouldRender();120}121}122123public removeWidget(widget: IContentWidget): void {124const widgetId = widget.getId();125if (this._widgets.hasOwnProperty(widgetId)) {126const myWidget = this._widgets[widgetId];127delete this._widgets[widgetId];128129const domNode = myWidget.domNode.domNode;130domNode.remove();131domNode.removeAttribute('monaco-visible-content-widget');132133this.setShouldRender();134}135}136137public shouldSuppressMouseDownOnWidget(widgetId: string): boolean {138if (this._widgets.hasOwnProperty(widgetId)) {139return this._widgets[widgetId].suppressMouseDown;140}141return false;142}143144public override onBeforeRender(viewportData: ViewportData): void {145const keys = Object.keys(this._widgets);146for (const widgetId of keys) {147this._widgets[widgetId].onBeforeRender(viewportData);148}149}150151public prepareRender(ctx: RenderingContext): void {152const keys = Object.keys(this._widgets);153for (const widgetId of keys) {154this._widgets[widgetId].prepareRender(ctx);155}156}157158public render(ctx: RestrictedRenderingContext): void {159const keys = Object.keys(this._widgets);160for (const widgetId of keys) {161this._widgets[widgetId].render(ctx);162}163}164}165166interface IBoxLayoutResult {167fitsAbove: boolean;168aboveTop: number;169170fitsBelow: boolean;171belowTop: number;172173left: number;174}175176interface IOffViewportRenderData {177kind: 'offViewport';178preserveFocus: boolean;179}180181interface IInViewportRenderData {182kind: 'inViewport';183coordinate: Coordinate;184position: ContentWidgetPositionPreference;185}186187type IRenderData = IInViewportRenderData | IOffViewportRenderData;188189class Widget {190private readonly _context: ViewContext;191private readonly _viewDomNode: FastDomNode<HTMLElement>;192private readonly _actual: IContentWidget;193194public readonly domNode: FastDomNode<HTMLElement>;195public readonly id: string;196public readonly allowEditorOverflow: boolean;197public readonly suppressMouseDown: boolean;198199private readonly _fixedOverflowWidgets: boolean;200private _contentWidth: number;201private _contentLeft: number;202203private _primaryAnchor: PositionPair = new PositionPair(null, null);204private _secondaryAnchor: PositionPair = new PositionPair(null, null);205private _affinity: PositionAffinity | null;206private _preference: ContentWidgetPositionPreference[] | null;207private _cachedDomNodeOffsetWidth: number;208private _cachedDomNodeOffsetHeight: number;209private _maxWidth: number;210private _isVisible: boolean;211212private _renderData: IRenderData | null;213public readonly useDisplayNone: boolean;214215constructor(context: ViewContext, viewDomNode: FastDomNode<HTMLElement>, actual: IContentWidget) {216this._context = context;217this._viewDomNode = viewDomNode;218this._actual = actual;219220const options = this._context.configuration.options;221const layoutInfo = options.get(EditorOption.layoutInfo);222const allowOverflow = options.get(EditorOption.allowOverflow);223224this.domNode = createFastDomNode(this._actual.getDomNode());225this.id = this._actual.getId();226this.allowEditorOverflow = (this._actual.allowEditorOverflow || false) && allowOverflow;227this.suppressMouseDown = this._actual.suppressMouseDown || false;228this.useDisplayNone = this._actual.useDisplayNone || false;229230this._fixedOverflowWidgets = options.get(EditorOption.fixedOverflowWidgets);231this._contentWidth = layoutInfo.contentWidth;232this._contentLeft = layoutInfo.contentLeft;233234this._affinity = null;235this._preference = [];236this._cachedDomNodeOffsetWidth = -1;237this._cachedDomNodeOffsetHeight = -1;238this._maxWidth = this._getMaxWidth();239this._isVisible = false;240this._renderData = null;241242this.domNode.setPosition((this._fixedOverflowWidgets && this.allowEditorOverflow) ? 'fixed' : 'absolute');243this.domNode.setDisplay('none');244this.domNode.setVisibility('hidden');245this.domNode.setAttribute('widgetId', this.id);246this.domNode.setMaxWidth(this._maxWidth);247}248249public onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): void {250const options = this._context.configuration.options;251if (e.hasChanged(EditorOption.layoutInfo)) {252const layoutInfo = options.get(EditorOption.layoutInfo);253this._contentLeft = layoutInfo.contentLeft;254this._contentWidth = layoutInfo.contentWidth;255this._maxWidth = this._getMaxWidth();256}257}258259public updateAnchorViewPosition(): void {260this._setPosition(this._affinity, this._primaryAnchor.modelPosition, this._secondaryAnchor.modelPosition);261}262263private _setPosition(affinity: PositionAffinity | null, primaryAnchor: IPosition | null, secondaryAnchor: IPosition | null): void {264this._affinity = affinity;265this._primaryAnchor = getValidPositionPair(primaryAnchor, this._context.viewModel, this._affinity);266this._secondaryAnchor = getValidPositionPair(secondaryAnchor, this._context.viewModel, this._affinity);267268function getValidPositionPair(position: IPosition | null, viewModel: IViewModel, affinity: PositionAffinity | null): PositionPair {269if (!position) {270return new PositionPair(null, null);271}272// Do not trust that widgets give a valid position273const validModelPosition = viewModel.model.validatePosition(position);274if (viewModel.coordinatesConverter.modelPositionIsVisible(validModelPosition)) {275const viewPosition = viewModel.coordinatesConverter.convertModelPositionToViewPosition(validModelPosition, affinity ?? undefined);276return new PositionPair(position, viewPosition);277}278return new PositionPair(position, null);279}280}281282private _getMaxWidth(): number {283const elDocument = this.domNode.domNode.ownerDocument;284const elWindow = elDocument.defaultView;285return (286this.allowEditorOverflow287? elWindow?.innerWidth || elDocument.documentElement.offsetWidth || elDocument.body.offsetWidth288: this._contentWidth289);290}291292public setPosition(primaryAnchor: IPosition | null, secondaryAnchor: IPosition | null, preference: ContentWidgetPositionPreference[] | null, affinity: PositionAffinity | null): void {293this._setPosition(affinity, primaryAnchor, secondaryAnchor);294this._preference = preference;295if (!this.useDisplayNone && this._primaryAnchor.viewPosition && this._preference && this._preference.length > 0) {296// this content widget would like to be visible if possible297// we change it from `display:none` to `display:block` even if it298// might be outside the viewport such that we can measure its size299// in `prepareRender`300this.domNode.setDisplay('block');301} else {302this.domNode.setDisplay('none');303}304this._cachedDomNodeOffsetWidth = -1;305this._cachedDomNodeOffsetHeight = -1;306}307308private _layoutBoxInViewport(anchor: AnchorCoordinate, width: number, height: number, ctx: RenderingContext): IBoxLayoutResult {309// Our visible box is split horizontally by the current line => 2 boxes310311// a) the box above the line312const aboveLineTop = anchor.top;313const heightAvailableAboveLine = aboveLineTop;314315// b) the box under the line316const underLineTop = anchor.top + anchor.height;317const heightAvailableUnderLine = ctx.viewportHeight - underLineTop;318319const aboveTop = aboveLineTop - height;320const fitsAbove = (heightAvailableAboveLine >= height);321const belowTop = underLineTop;322const fitsBelow = (heightAvailableUnderLine >= height);323324// And its left325let left = anchor.left;326if (left + width > ctx.scrollLeft + ctx.viewportWidth) {327left = ctx.scrollLeft + ctx.viewportWidth - width;328}329if (left < ctx.scrollLeft) {330left = ctx.scrollLeft;331}332333return { fitsAbove, aboveTop, fitsBelow, belowTop, left };334}335336private _layoutHorizontalSegmentInPage(windowSize: dom.Dimension, domNodePosition: dom.IDomNodePagePosition, left: number, width: number): [number, number] {337// Leave some clearance to the left/right338const LEFT_PADDING = 15;339const RIGHT_PADDING = 15;340341// Initially, the limits are defined as the dom node limits342const MIN_LIMIT = Math.max(LEFT_PADDING, domNodePosition.left - width);343const MAX_LIMIT = Math.min(domNodePosition.left + domNodePosition.width + width, windowSize.width - RIGHT_PADDING);344345const elDocument = this._viewDomNode.domNode.ownerDocument;346const elWindow = elDocument.defaultView;347let absoluteLeft = domNodePosition.left + left - (elWindow?.scrollX ?? 0);348349if (absoluteLeft + width > MAX_LIMIT) {350const delta = absoluteLeft - (MAX_LIMIT - width);351absoluteLeft -= delta;352left -= delta;353}354355if (absoluteLeft < MIN_LIMIT) {356const delta = absoluteLeft - MIN_LIMIT;357absoluteLeft -= delta;358left -= delta;359}360361return [left, absoluteLeft];362}363364private _layoutBoxInPage(anchor: AnchorCoordinate, width: number, height: number, ctx: RenderingContext): IBoxLayoutResult | null {365const aboveTop = anchor.top - height;366const belowTop = anchor.top + anchor.height;367368const domNodePosition = dom.getDomNodePagePosition(this._viewDomNode.domNode);369const elDocument = this._viewDomNode.domNode.ownerDocument;370const elWindow = elDocument.defaultView;371const absoluteAboveTop = domNodePosition.top + aboveTop - (elWindow?.scrollY ?? 0);372const absoluteBelowTop = domNodePosition.top + belowTop - (elWindow?.scrollY ?? 0);373374const windowSize = dom.getClientArea(elDocument.body);375const [left, absoluteAboveLeft] = this._layoutHorizontalSegmentInPage(windowSize, domNodePosition, anchor.left - ctx.scrollLeft + this._contentLeft, width);376377// Leave some clearance to the top/bottom378const TOP_PADDING = 22;379const BOTTOM_PADDING = 22;380381const fitsAbove = (absoluteAboveTop >= TOP_PADDING);382const fitsBelow = (absoluteBelowTop + height <= windowSize.height - BOTTOM_PADDING);383384if (this._fixedOverflowWidgets) {385return {386fitsAbove,387aboveTop: Math.max(absoluteAboveTop, TOP_PADDING),388fitsBelow,389belowTop: absoluteBelowTop,390left: absoluteAboveLeft391};392}393394return { fitsAbove, aboveTop, fitsBelow, belowTop, left };395}396397private _prepareRenderWidgetAtExactPositionOverflowing(topLeft: Coordinate): Coordinate {398return new Coordinate(topLeft.top, topLeft.left + this._contentLeft);399}400401/**402* Compute the coordinates above and below the primary and secondary anchors.403* The content widget *must* touch the primary anchor.404* The content widget should touch if possible the secondary anchor.405*/406private _getAnchorsCoordinates(ctx: RenderingContext): { primary: AnchorCoordinate | null; secondary: AnchorCoordinate | null } {407const primary = getCoordinates(this._primaryAnchor.viewPosition, this._affinity);408const secondaryViewPosition = (this._secondaryAnchor.viewPosition?.lineNumber === this._primaryAnchor.viewPosition?.lineNumber ? this._secondaryAnchor.viewPosition : null);409const secondary = getCoordinates(secondaryViewPosition, this._affinity);410return { primary, secondary };411412function getCoordinates(position: Position | null, affinity: PositionAffinity | null): AnchorCoordinate | null {413if (!position) {414return null;415}416417const horizontalPosition = ctx.visibleRangeForPosition(position);418if (!horizontalPosition) {419return null;420}421422// Left-align widgets that should appear :before content423const left = (position.column === 1 && affinity === PositionAffinity.LeftOfInjectedText ? 0 : horizontalPosition.left);424const top = ctx.getVerticalOffsetForLineNumber(position.lineNumber) - ctx.scrollTop;425const lineHeight = ctx.getLineHeightForLineNumber(position.lineNumber);426return new AnchorCoordinate(top, left, lineHeight);427}428}429430private _reduceAnchorCoordinates(primary: AnchorCoordinate, secondary: AnchorCoordinate | null, width: number): AnchorCoordinate {431if (!secondary) {432return primary;433}434435const fontInfo = this._context.configuration.options.get(EditorOption.fontInfo);436437let left = secondary.left;438if (left < primary.left) {439left = Math.max(left, primary.left - width + fontInfo.typicalFullwidthCharacterWidth);440} else {441left = Math.min(left, primary.left + width - fontInfo.typicalFullwidthCharacterWidth);442}443return new AnchorCoordinate(primary.top, left, primary.height);444}445446private _prepareRenderWidget(ctx: RenderingContext): IRenderData | null {447if (!this._preference || this._preference.length === 0) {448return null;449}450451const { primary, secondary } = this._getAnchorsCoordinates(ctx);452if (!primary) {453return {454kind: 'offViewport',455preserveFocus: this.domNode.domNode.contains(this.domNode.domNode.ownerDocument.activeElement)456};457// return null;458}459460if (this._cachedDomNodeOffsetWidth === -1 || this._cachedDomNodeOffsetHeight === -1) {461462let preferredDimensions: IDimension | null = null;463if (typeof this._actual.beforeRender === 'function') {464preferredDimensions = safeInvoke(this._actual.beforeRender, this._actual);465}466if (preferredDimensions) {467this._cachedDomNodeOffsetWidth = preferredDimensions.width;468this._cachedDomNodeOffsetHeight = preferredDimensions.height;469} else {470const domNode = this.domNode.domNode;471const clientRect = domNode.getBoundingClientRect();472this._cachedDomNodeOffsetWidth = Math.round(clientRect.width);473this._cachedDomNodeOffsetHeight = Math.round(clientRect.height);474}475}476477const anchor = this._reduceAnchorCoordinates(primary, secondary, this._cachedDomNodeOffsetWidth);478479let placement: IBoxLayoutResult | null;480if (this.allowEditorOverflow) {481placement = this._layoutBoxInPage(anchor, this._cachedDomNodeOffsetWidth, this._cachedDomNodeOffsetHeight, ctx);482} else {483placement = this._layoutBoxInViewport(anchor, this._cachedDomNodeOffsetWidth, this._cachedDomNodeOffsetHeight, ctx);484}485486// Do two passes, first for perfect fit, second picks first option487for (let pass = 1; pass <= 2; pass++) {488for (const pref of this._preference) {489// placement490if (pref === ContentWidgetPositionPreference.ABOVE) {491if (!placement) {492// Widget outside of viewport493return null;494}495if (pass === 2 || placement.fitsAbove) {496return {497kind: 'inViewport',498coordinate: new Coordinate(placement.aboveTop, placement.left),499position: ContentWidgetPositionPreference.ABOVE500};501}502} else if (pref === ContentWidgetPositionPreference.BELOW) {503if (!placement) {504// Widget outside of viewport505return null;506}507if (pass === 2 || placement.fitsBelow) {508return {509kind: 'inViewport',510coordinate: new Coordinate(placement.belowTop, placement.left),511position: ContentWidgetPositionPreference.BELOW512};513}514} else {515if (this.allowEditorOverflow) {516return {517kind: 'inViewport',518coordinate: this._prepareRenderWidgetAtExactPositionOverflowing(new Coordinate(anchor.top, anchor.left)),519position: ContentWidgetPositionPreference.EXACT520};521} else {522return {523kind: 'inViewport',524coordinate: new Coordinate(anchor.top, anchor.left),525position: ContentWidgetPositionPreference.EXACT526};527}528}529}530}531532return null;533}534535/**536* On this first pass, we ensure that the content widget (if it is in the viewport) has the max width set correctly.537*/538public onBeforeRender(viewportData: ViewportData): void {539if (!this._primaryAnchor.viewPosition || !this._preference) {540return;541}542543if (this._primaryAnchor.viewPosition.lineNumber < viewportData.startLineNumber || this._primaryAnchor.viewPosition.lineNumber > viewportData.endLineNumber) {544// Outside of viewport545return;546}547548this.domNode.setMaxWidth(this._maxWidth);549}550551public prepareRender(ctx: RenderingContext): void {552this._renderData = this._prepareRenderWidget(ctx);553}554555public render(ctx: RestrictedRenderingContext): void {556if (!this._renderData || this._renderData.kind === 'offViewport') {557// This widget should be invisible558if (this._isVisible) {559this.domNode.removeAttribute('monaco-visible-content-widget');560this._isVisible = false;561562if (this._renderData?.kind === 'offViewport' && this._renderData.preserveFocus) {563// widget wants to be shown, but it is outside of the viewport and it564// has focus which we need to preserve565this.domNode.setTop(-1000);566} else {567this.domNode.setVisibility('hidden');568}569}570571if (typeof this._actual.afterRender === 'function') {572safeInvoke(this._actual.afterRender, this._actual, null, null);573}574return;575}576577// This widget should be visible578if (this.allowEditorOverflow) {579this.domNode.setTop(this._renderData.coordinate.top);580this.domNode.setLeft(this._renderData.coordinate.left);581} else {582this.domNode.setTop(this._renderData.coordinate.top + ctx.scrollTop - ctx.bigNumbersDelta);583this.domNode.setLeft(this._renderData.coordinate.left);584}585586if (!this._isVisible) {587this.domNode.setVisibility('inherit');588this.domNode.setAttribute('monaco-visible-content-widget', 'true');589this._isVisible = true;590}591592if (typeof this._actual.afterRender === 'function') {593safeInvoke(this._actual.afterRender, this._actual, this._renderData.position, this._renderData.coordinate);594}595}596}597598class PositionPair {599constructor(600public readonly modelPosition: IPosition | null,601public readonly viewPosition: Position | null602) { }603}604605class Coordinate implements IContentWidgetRenderedCoordinate {606_coordinateBrand: void = undefined;607608constructor(609public readonly top: number,610public readonly left: number611) { }612}613614class AnchorCoordinate {615_anchorCoordinateBrand: void = undefined;616617constructor(618public readonly top: number,619public readonly left: number,620public readonly height: number621) { }622}623624// eslint-disable-next-line @typescript-eslint/no-explicit-any625function safeInvoke<T extends (...args: any[]) => any>(fn: T, thisArg: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T> | null {626try {627return fn.call(thisArg, ...args);628} catch {629// ignore630return null;631}632}633634635