Path: blob/main/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts
3296 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);117118this.setShouldRender();119}120121public removeWidget(widget: IContentWidget): void {122const widgetId = widget.getId();123if (this._widgets.hasOwnProperty(widgetId)) {124const myWidget = this._widgets[widgetId];125delete this._widgets[widgetId];126127const domNode = myWidget.domNode.domNode;128domNode.remove();129domNode.removeAttribute('monaco-visible-content-widget');130131this.setShouldRender();132}133}134135public shouldSuppressMouseDownOnWidget(widgetId: string): boolean {136if (this._widgets.hasOwnProperty(widgetId)) {137return this._widgets[widgetId].suppressMouseDown;138}139return false;140}141142public onBeforeRender(viewportData: ViewportData): void {143const keys = Object.keys(this._widgets);144for (const widgetId of keys) {145this._widgets[widgetId].onBeforeRender(viewportData);146}147}148149public prepareRender(ctx: RenderingContext): void {150const keys = Object.keys(this._widgets);151for (const widgetId of keys) {152this._widgets[widgetId].prepareRender(ctx);153}154}155156public render(ctx: RestrictedRenderingContext): void {157const keys = Object.keys(this._widgets);158for (const widgetId of keys) {159this._widgets[widgetId].render(ctx);160}161}162}163164interface IBoxLayoutResult {165fitsAbove: boolean;166aboveTop: number;167168fitsBelow: boolean;169belowTop: number;170171left: number;172}173174interface IOffViewportRenderData {175kind: 'offViewport';176preserveFocus: boolean;177}178179interface IInViewportRenderData {180kind: 'inViewport';181coordinate: Coordinate;182position: ContentWidgetPositionPreference;183}184185type IRenderData = IInViewportRenderData | IOffViewportRenderData;186187class Widget {188private readonly _context: ViewContext;189private readonly _viewDomNode: FastDomNode<HTMLElement>;190private readonly _actual: IContentWidget;191192public readonly domNode: FastDomNode<HTMLElement>;193public readonly id: string;194public readonly allowEditorOverflow: boolean;195public readonly suppressMouseDown: boolean;196197private readonly _fixedOverflowWidgets: boolean;198private _contentWidth: number;199private _contentLeft: number;200201private _primaryAnchor: PositionPair = new PositionPair(null, null);202private _secondaryAnchor: PositionPair = new PositionPair(null, null);203private _affinity: PositionAffinity | null;204private _preference: ContentWidgetPositionPreference[] | null;205private _cachedDomNodeOffsetWidth: number;206private _cachedDomNodeOffsetHeight: number;207private _maxWidth: number;208private _isVisible: boolean;209210private _renderData: IRenderData | null;211212constructor(context: ViewContext, viewDomNode: FastDomNode<HTMLElement>, actual: IContentWidget) {213this._context = context;214this._viewDomNode = viewDomNode;215this._actual = actual;216217const options = this._context.configuration.options;218const layoutInfo = options.get(EditorOption.layoutInfo);219const allowOverflow = options.get(EditorOption.allowOverflow);220221this.domNode = createFastDomNode(this._actual.getDomNode());222this.id = this._actual.getId();223this.allowEditorOverflow = (this._actual.allowEditorOverflow || false) && allowOverflow;224this.suppressMouseDown = this._actual.suppressMouseDown || false;225226this._fixedOverflowWidgets = options.get(EditorOption.fixedOverflowWidgets);227this._contentWidth = layoutInfo.contentWidth;228this._contentLeft = layoutInfo.contentLeft;229230this._affinity = null;231this._preference = [];232this._cachedDomNodeOffsetWidth = -1;233this._cachedDomNodeOffsetHeight = -1;234this._maxWidth = this._getMaxWidth();235this._isVisible = false;236this._renderData = null;237238this.domNode.setPosition((this._fixedOverflowWidgets && this.allowEditorOverflow) ? 'fixed' : 'absolute');239this.domNode.setDisplay('none');240this.domNode.setVisibility('hidden');241this.domNode.setAttribute('widgetId', this.id);242this.domNode.setMaxWidth(this._maxWidth);243}244245public onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): void {246const options = this._context.configuration.options;247if (e.hasChanged(EditorOption.layoutInfo)) {248const layoutInfo = options.get(EditorOption.layoutInfo);249this._contentLeft = layoutInfo.contentLeft;250this._contentWidth = layoutInfo.contentWidth;251this._maxWidth = this._getMaxWidth();252}253}254255public updateAnchorViewPosition(): void {256this._setPosition(this._affinity, this._primaryAnchor.modelPosition, this._secondaryAnchor.modelPosition);257}258259private _setPosition(affinity: PositionAffinity | null, primaryAnchor: IPosition | null, secondaryAnchor: IPosition | null): void {260this._affinity = affinity;261this._primaryAnchor = getValidPositionPair(primaryAnchor, this._context.viewModel, this._affinity);262this._secondaryAnchor = getValidPositionPair(secondaryAnchor, this._context.viewModel, this._affinity);263264function getValidPositionPair(position: IPosition | null, viewModel: IViewModel, affinity: PositionAffinity | null): PositionPair {265if (!position) {266return new PositionPair(null, null);267}268// Do not trust that widgets give a valid position269const validModelPosition = viewModel.model.validatePosition(position);270if (viewModel.coordinatesConverter.modelPositionIsVisible(validModelPosition)) {271const viewPosition = viewModel.coordinatesConverter.convertModelPositionToViewPosition(validModelPosition, affinity ?? undefined);272return new PositionPair(position, viewPosition);273}274return new PositionPair(position, null);275}276}277278private _getMaxWidth(): number {279const elDocument = this.domNode.domNode.ownerDocument;280const elWindow = elDocument.defaultView;281return (282this.allowEditorOverflow283? elWindow?.innerWidth || elDocument.documentElement.offsetWidth || elDocument.body.offsetWidth284: this._contentWidth285);286}287288public setPosition(primaryAnchor: IPosition | null, secondaryAnchor: IPosition | null, preference: ContentWidgetPositionPreference[] | null, affinity: PositionAffinity | null): void {289this._setPosition(affinity, primaryAnchor, secondaryAnchor);290this._preference = preference;291if (this._primaryAnchor.viewPosition && this._preference && this._preference.length > 0) {292// this content widget would like to be visible if possible293// we change it from `display:none` to `display:block` even if it294// might be outside the viewport such that we can measure its size295// in `prepareRender`296this.domNode.setDisplay('block');297} else {298this.domNode.setDisplay('none');299}300this._cachedDomNodeOffsetWidth = -1;301this._cachedDomNodeOffsetHeight = -1;302}303304private _layoutBoxInViewport(anchor: AnchorCoordinate, width: number, height: number, ctx: RenderingContext): IBoxLayoutResult {305// Our visible box is split horizontally by the current line => 2 boxes306307// a) the box above the line308const aboveLineTop = anchor.top;309const heightAvailableAboveLine = aboveLineTop;310311// b) the box under the line312const underLineTop = anchor.top + anchor.height;313const heightAvailableUnderLine = ctx.viewportHeight - underLineTop;314315const aboveTop = aboveLineTop - height;316const fitsAbove = (heightAvailableAboveLine >= height);317const belowTop = underLineTop;318const fitsBelow = (heightAvailableUnderLine >= height);319320// And its left321let left = anchor.left;322if (left + width > ctx.scrollLeft + ctx.viewportWidth) {323left = ctx.scrollLeft + ctx.viewportWidth - width;324}325if (left < ctx.scrollLeft) {326left = ctx.scrollLeft;327}328329return { fitsAbove, aboveTop, fitsBelow, belowTop, left };330}331332private _layoutHorizontalSegmentInPage(windowSize: dom.Dimension, domNodePosition: dom.IDomNodePagePosition, left: number, width: number): [number, number] {333// Leave some clearance to the left/right334const LEFT_PADDING = 15;335const RIGHT_PADDING = 15;336337// Initially, the limits are defined as the dom node limits338const MIN_LIMIT = Math.max(LEFT_PADDING, domNodePosition.left - width);339const MAX_LIMIT = Math.min(domNodePosition.left + domNodePosition.width + width, windowSize.width - RIGHT_PADDING);340341const elDocument = this._viewDomNode.domNode.ownerDocument;342const elWindow = elDocument.defaultView;343let absoluteLeft = domNodePosition.left + left - (elWindow?.scrollX ?? 0);344345if (absoluteLeft + width > MAX_LIMIT) {346const delta = absoluteLeft - (MAX_LIMIT - width);347absoluteLeft -= delta;348left -= delta;349}350351if (absoluteLeft < MIN_LIMIT) {352const delta = absoluteLeft - MIN_LIMIT;353absoluteLeft -= delta;354left -= delta;355}356357return [left, absoluteLeft];358}359360private _layoutBoxInPage(anchor: AnchorCoordinate, width: number, height: number, ctx: RenderingContext): IBoxLayoutResult | null {361const aboveTop = anchor.top - height;362const belowTop = anchor.top + anchor.height;363364const domNodePosition = dom.getDomNodePagePosition(this._viewDomNode.domNode);365const elDocument = this._viewDomNode.domNode.ownerDocument;366const elWindow = elDocument.defaultView;367const absoluteAboveTop = domNodePosition.top + aboveTop - (elWindow?.scrollY ?? 0);368const absoluteBelowTop = domNodePosition.top + belowTop - (elWindow?.scrollY ?? 0);369370const windowSize = dom.getClientArea(elDocument.body);371const [left, absoluteAboveLeft] = this._layoutHorizontalSegmentInPage(windowSize, domNodePosition, anchor.left - ctx.scrollLeft + this._contentLeft, width);372373// Leave some clearance to the top/bottom374const TOP_PADDING = 22;375const BOTTOM_PADDING = 22;376377const fitsAbove = (absoluteAboveTop >= TOP_PADDING);378const fitsBelow = (absoluteBelowTop + height <= windowSize.height - BOTTOM_PADDING);379380if (this._fixedOverflowWidgets) {381return {382fitsAbove,383aboveTop: Math.max(absoluteAboveTop, TOP_PADDING),384fitsBelow,385belowTop: absoluteBelowTop,386left: absoluteAboveLeft387};388}389390return { fitsAbove, aboveTop, fitsBelow, belowTop, left };391}392393private _prepareRenderWidgetAtExactPositionOverflowing(topLeft: Coordinate): Coordinate {394return new Coordinate(topLeft.top, topLeft.left + this._contentLeft);395}396397/**398* Compute the coordinates above and below the primary and secondary anchors.399* The content widget *must* touch the primary anchor.400* The content widget should touch if possible the secondary anchor.401*/402private _getAnchorsCoordinates(ctx: RenderingContext): { primary: AnchorCoordinate | null; secondary: AnchorCoordinate | null } {403const primary = getCoordinates(this._primaryAnchor.viewPosition, this._affinity);404const secondaryViewPosition = (this._secondaryAnchor.viewPosition?.lineNumber === this._primaryAnchor.viewPosition?.lineNumber ? this._secondaryAnchor.viewPosition : null);405const secondary = getCoordinates(secondaryViewPosition, this._affinity);406return { primary, secondary };407408function getCoordinates(position: Position | null, affinity: PositionAffinity | null): AnchorCoordinate | null {409if (!position) {410return null;411}412413const horizontalPosition = ctx.visibleRangeForPosition(position);414if (!horizontalPosition) {415return null;416}417418// Left-align widgets that should appear :before content419const left = (position.column === 1 && affinity === PositionAffinity.LeftOfInjectedText ? 0 : horizontalPosition.left);420const top = ctx.getVerticalOffsetForLineNumber(position.lineNumber) - ctx.scrollTop;421const lineHeight = ctx.getLineHeightForLineNumber(position.lineNumber);422return new AnchorCoordinate(top, left, lineHeight);423}424}425426private _reduceAnchorCoordinates(primary: AnchorCoordinate, secondary: AnchorCoordinate | null, width: number): AnchorCoordinate {427if (!secondary) {428return primary;429}430431const fontInfo = this._context.configuration.options.get(EditorOption.fontInfo);432433let left = secondary.left;434if (left < primary.left) {435left = Math.max(left, primary.left - width + fontInfo.typicalFullwidthCharacterWidth);436} else {437left = Math.min(left, primary.left + width - fontInfo.typicalFullwidthCharacterWidth);438}439return new AnchorCoordinate(primary.top, left, primary.height);440}441442private _prepareRenderWidget(ctx: RenderingContext): IRenderData | null {443if (!this._preference || this._preference.length === 0) {444return null;445}446447const { primary, secondary } = this._getAnchorsCoordinates(ctx);448if (!primary) {449return {450kind: 'offViewport',451preserveFocus: this.domNode.domNode.contains(this.domNode.domNode.ownerDocument.activeElement)452};453// return null;454}455456if (this._cachedDomNodeOffsetWidth === -1 || this._cachedDomNodeOffsetHeight === -1) {457458let preferredDimensions: IDimension | null = null;459if (typeof this._actual.beforeRender === 'function') {460preferredDimensions = safeInvoke(this._actual.beforeRender, this._actual);461}462if (preferredDimensions) {463this._cachedDomNodeOffsetWidth = preferredDimensions.width;464this._cachedDomNodeOffsetHeight = preferredDimensions.height;465} else {466const domNode = this.domNode.domNode;467const clientRect = domNode.getBoundingClientRect();468this._cachedDomNodeOffsetWidth = Math.round(clientRect.width);469this._cachedDomNodeOffsetHeight = Math.round(clientRect.height);470}471}472473const anchor = this._reduceAnchorCoordinates(primary, secondary, this._cachedDomNodeOffsetWidth);474475let placement: IBoxLayoutResult | null;476if (this.allowEditorOverflow) {477placement = this._layoutBoxInPage(anchor, this._cachedDomNodeOffsetWidth, this._cachedDomNodeOffsetHeight, ctx);478} else {479placement = this._layoutBoxInViewport(anchor, this._cachedDomNodeOffsetWidth, this._cachedDomNodeOffsetHeight, ctx);480}481482// Do two passes, first for perfect fit, second picks first option483for (let pass = 1; pass <= 2; pass++) {484for (const pref of this._preference) {485// placement486if (pref === ContentWidgetPositionPreference.ABOVE) {487if (!placement) {488// Widget outside of viewport489return null;490}491if (pass === 2 || placement.fitsAbove) {492return {493kind: 'inViewport',494coordinate: new Coordinate(placement.aboveTop, placement.left),495position: ContentWidgetPositionPreference.ABOVE496};497}498} else if (pref === ContentWidgetPositionPreference.BELOW) {499if (!placement) {500// Widget outside of viewport501return null;502}503if (pass === 2 || placement.fitsBelow) {504return {505kind: 'inViewport',506coordinate: new Coordinate(placement.belowTop, placement.left),507position: ContentWidgetPositionPreference.BELOW508};509}510} else {511if (this.allowEditorOverflow) {512return {513kind: 'inViewport',514coordinate: this._prepareRenderWidgetAtExactPositionOverflowing(new Coordinate(anchor.top, anchor.left)),515position: ContentWidgetPositionPreference.EXACT516};517} else {518return {519kind: 'inViewport',520coordinate: new Coordinate(anchor.top, anchor.left),521position: ContentWidgetPositionPreference.EXACT522};523}524}525}526}527528return null;529}530531/**532* On this first pass, we ensure that the content widget (if it is in the viewport) has the max width set correctly.533*/534public onBeforeRender(viewportData: ViewportData): void {535if (!this._primaryAnchor.viewPosition || !this._preference) {536return;537}538539if (this._primaryAnchor.viewPosition.lineNumber < viewportData.startLineNumber || this._primaryAnchor.viewPosition.lineNumber > viewportData.endLineNumber) {540// Outside of viewport541return;542}543544this.domNode.setMaxWidth(this._maxWidth);545}546547public prepareRender(ctx: RenderingContext): void {548this._renderData = this._prepareRenderWidget(ctx);549}550551public render(ctx: RestrictedRenderingContext): void {552if (!this._renderData || this._renderData.kind === 'offViewport') {553// This widget should be invisible554if (this._isVisible) {555this.domNode.removeAttribute('monaco-visible-content-widget');556this._isVisible = false;557558if (this._renderData?.kind === 'offViewport' && this._renderData.preserveFocus) {559// widget wants to be shown, but it is outside of the viewport and it560// has focus which we need to preserve561this.domNode.setTop(-1000);562} else {563this.domNode.setVisibility('hidden');564}565}566567if (typeof this._actual.afterRender === 'function') {568safeInvoke(this._actual.afterRender, this._actual, null, null);569}570return;571}572573// This widget should be visible574if (this.allowEditorOverflow) {575this.domNode.setTop(this._renderData.coordinate.top);576this.domNode.setLeft(this._renderData.coordinate.left);577} else {578this.domNode.setTop(this._renderData.coordinate.top + ctx.scrollTop - ctx.bigNumbersDelta);579this.domNode.setLeft(this._renderData.coordinate.left);580}581582if (!this._isVisible) {583this.domNode.setVisibility('inherit');584this.domNode.setAttribute('monaco-visible-content-widget', 'true');585this._isVisible = true;586}587588if (typeof this._actual.afterRender === 'function') {589safeInvoke(this._actual.afterRender, this._actual, this._renderData.position, this._renderData.coordinate);590}591}592}593594class PositionPair {595constructor(596public readonly modelPosition: IPosition | null,597public readonly viewPosition: Position | null598) { }599}600601class Coordinate implements IContentWidgetRenderedCoordinate {602_coordinateBrand: void = undefined;603604constructor(605public readonly top: number,606public readonly left: number607) { }608}609610class AnchorCoordinate {611_anchorCoordinateBrand: void = undefined;612613constructor(614public readonly top: number,615public readonly left: number,616public readonly height: number617) { }618}619620function safeInvoke<T extends (...args: any[]) => any>(fn: T, thisArg: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T> | null {621try {622return fn.call(thisArg, ...args);623} catch {624// ignore625return null;626}627}628629630