diff --git a/src/vs/base/browser/ui/hover/hover.css b/src/vs/base/browser/ui/hover/hover.css index f3058d6c107..5f867a8913f 100644 --- a/src/vs/base/browser/ui/hover/hover.css +++ b/src/vs/base/browser/ui/hover/hover.css @@ -28,7 +28,6 @@ } .monaco-hover .markdown-hover > .hover-contents:not(.code-hover-contents) { - max-width: 500px; word-wrap: break-word; } diff --git a/src/vs/editor/contrib/hover/browser/resizableContentWidget.css b/src/vs/editor/contrib/hover/browser/resizableContentWidget.css new file mode 100644 index 00000000000..48840ea0931 --- /dev/null +++ b/src/vs/editor/contrib/hover/browser/resizableContentWidget.css @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-editor .resizable-widget { + z-index: 40; + display: flex; + flex-direction: column; +} diff --git a/src/vs/editor/contrib/hover/browser/resizableContentWidget.ts b/src/vs/editor/contrib/hover/browser/resizableContentWidget.ts new file mode 100644 index 00000000000..b87405f2854 --- /dev/null +++ b/src/vs/editor/contrib/hover/browser/resizableContentWidget.ts @@ -0,0 +1,407 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IResizeEvent, ResizableHTMLElement } from 'vs/base/browser/ui/resizable/resizable'; +import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; +import { ResourceMap } from 'vs/base/common/map'; +import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from 'vs/editor/browser/editorBrowser'; +import { IPosition } from 'vs/editor/common/core/position'; +import { PositionAffinity } from 'vs/editor/common/model'; +import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import * as dom from 'vs/base/browser/dom'; +import { clamp } from 'vs/base/common/numbers'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { Emitter, Event } from 'vs/base/common/event'; + +// TODO: need several types of constructors depending on what kind of resizable element we will be creating +export abstract class ResizableWidget implements IDisposable { + + readonly element: ResizableHTMLElement; + readonly _domNode: HTMLElement; + private readonly _disposables = new DisposableStore(); + private readonly _persistingMechanism: IPersistingMechanism; + private _renderingAbove: ContentWidgetPositionPreference = ContentWidgetPositionPreference.ABOVE; + + constructor( + // The ContentWidget is within a disposable and the disposable can be instatiated + private readonly _resizableContentWidget: ResizableContentWidget, + private readonly _editor: ICodeEditor, + private readonly _contents: HTMLElement, + private readonly _persistingOptions: IPersistingOptions, + ) { + this.element = new ResizableHTMLElement(); + this.element.domNode.classList.add('editor-widget', 'resizable-widget'); + + // TODO: setting the resizable widget to this instance like for the suggest widget + this._resizableContentWidget.resizableWidget = this; + this._domNode = dom.append(this.element.domNode, this._contents); + + if (this._persistingOptions instanceof SingleSizePersistingOptions) { + this._persistingMechanism = new SingleSizePersistingMechanism(this, this.element, this._editor, this._persistingOptions); + } else if (this._persistingOptions instanceof MultipleSizePersistingOptions) { + this._persistingMechanism = new MultipleSizePersistingMechanism(this, this.element, this._editor); + } else { + throw new Error('Please specify a valid persisting mechanism'); + } + } + + dispose(): void { + this._disposables.dispose(); + this.element.dispose(); + } + + resize(width: number, height: number): void { } + + hide(): void { + this.element.clearSashHoverState(); + } + + findMaximumRenderingHeight(): number { + return Infinity; + } + + findMaximumRenderingWidth(): number { + return Infinity; + } +} + +export abstract class ResizableContentWidget implements IContentWidget { + + // Previously was a static variable now it is a private one + private _ID: string = ''; + private _resizableWidget: ResizableWidget | null = null; + private _position: IPosition | null = null; + private _secondaryPosition: IPosition | null = null; + private _preference: ContentWidgetPositionPreference[] = []; + private _positionAffinity: PositionAffinity | undefined = undefined; + + constructor( + private readonly editor: ICodeEditor, + ) { } + + set resizableWidget(resizableWidget: ResizableWidget) { + this._resizableWidget = resizableWidget; + } + + // TODO: mus tbe implemented by the child class + abstract set ID(id: string); + + getId(): string { + return this._ID; + } + + getDomNode(): HTMLElement { + if (this._resizableWidget) { + return this._resizableWidget.element.domNode; + } else { + return new HTMLElement(); + } + } + + getPosition(): IContentWidgetPosition | null { + return { + position: this._position, + secondaryPosition: this._secondaryPosition, + preference: (this._preference), + positionAffinity: this._positionAffinity + }; + } + + set position(position: IPosition | null) { + this._position = position; + } + + set secondaryPosition(position: IPosition | null) { + this._secondaryPosition = position; + } + + set preference(preference: ContentWidgetPositionPreference[]) { + this._preference = preference; + } + + set positionAffinity(affinity: PositionAffinity | undefined) { + this._positionAffinity = affinity; + } +} + +interface IPersistingOptions { } + +export class SingleSizePersistingOptions implements IPersistingOptions { + constructor( + public readonly key: string, + public readonly defaultSize: dom.Dimension, + @IStorageService public readonly storageService: IStorageService + ) { } +} + +export class MultipleSizePersistingOptions implements IPersistingOptions { + constructor() { } +} + +interface IPersistingMechanism extends IDisposable { + findSize(): dom.Dimension | undefined; +} + +class SingleSizePersistingMechanism implements IPersistingMechanism { + + private readonly _persistedWidgetSize: PersistedWidgetSize | null = null; + private readonly _disposables = new DisposableStore(); + + constructor( + private readonly resizableWidget: ResizableWidget, + private readonly element: ResizableHTMLElement, + private readonly editor: ICodeEditor, + private readonly persistingOptions: SingleSizePersistingOptions + ) { + + this._persistedWidgetSize = new PersistedWidgetSize(this.persistingOptions.key, this.persistingOptions.storageService, this.editor); + + class ResizeState { + constructor( + readonly persistedSize: dom.Dimension | undefined, + readonly currentSize: dom.Dimension, + public persistHeight = false, + public persistWidth = false, + ) { } + } + + let state: ResizeState | undefined; + this._disposables.add(this.element.onDidWillResize(() => { + // TODO: add back, this._contentWidget.lockPreference(); + state = new ResizeState(this._persistedWidgetSize!.restore(), this.element.size); + })); + this._disposables.add(this.element.onDidResize(e => { + + this.resizableWidget.resize(e.dimension.width, e.dimension.height); + + if (state) { + state.persistHeight = state.persistHeight || !!e.north || !!e.south; + state.persistWidth = state.persistWidth || !!e.east || !!e.west; + } + + if (!e.done) { + return; + } + + // TODO: maybe need to make more generic, this is specific to the suggest widget + if (state) { + const fontInfo = this.editor.getOption(EditorOption.fontInfo); + const itemHeight = clamp(this.editor.getOption(EditorOption.suggestLineHeight) || fontInfo.lineHeight, 8, 1000); + const threshold = Math.round(itemHeight / 2); + let { width, height } = this.element.size; + if (!state.persistHeight || Math.abs(state.currentSize.height - height) <= threshold) { + height = state.persistedSize?.height ?? this.persistingOptions.defaultSize.height; + } + if (!state.persistWidth || Math.abs(state.currentSize.width - width) <= threshold) { + width = state.persistedSize?.width ?? this.persistingOptions.defaultSize.width; + } + this._persistedWidgetSize!.store(new dom.Dimension(width, height)); + } + + // TODO: add back, reset working state + // this._contentWidget.unlockPreference(); + state = undefined; + })); + } + + findSize(): dom.Dimension | undefined { + return undefined; + } + + dispose(): void { + this._disposables.dispose(); + } +} + +class MultipleSizePersistingMechanism implements IPersistingMechanism { + + private readonly _persistedWidgetSizes: ResourceMap> = new ResourceMap>(); + private readonly _disposables = new DisposableStore(); + private _tooltipPosition: IPosition | null = null; + + // TODO: not sure if I need the following + // private _initialHeight: number = 0; + // private _initialTop: number = 0; + + private _resizing: boolean = false; + private _size: dom.Dimension | undefined = undefined; + private _maxRenderingHeight: number = Infinity; + private _maxRenderingWidth: number = Infinity; + + private readonly _onDidResize = new Emitter(); + readonly onDidResize: Event = this._onDidResize.event; + // private _renderingAbove: ContentWidgetPositionPreference | undefined = undefined; + + constructor( + private readonly resizableWidget: ResizableWidget, + private readonly element: ResizableHTMLElement, + public readonly editor: ICodeEditor + ) { + + this.element.minSize = new dom.Dimension(10, 24); + this._disposables.add(this.editor.onDidChangeModelContent((e) => { + const uri = this.editor.getModel()?.uri; + if (!uri || !this._persistedWidgetSizes.has(uri)) { + return; + } + const persistedSizesForUri = this._persistedWidgetSizes.get(uri)!; + const updatedPersistedSizesForUri = new Map(); + for (const change of e.changes) { + const changeOffset = change.rangeOffset; + const rangeLength = change.rangeLength; + const endOffset = changeOffset + rangeLength; + const textLength = change.text.length; + for (const key of persistedSizesForUri.keys()) { + const parsedKey = JSON.parse(key); + const tokenOffset = parsedKey[0]; + const tokenLength = parsedKey[1]; + if (endOffset < tokenOffset) { + const oldSize = persistedSizesForUri.get(key)!; + const newKey: [number, number] = [tokenOffset - rangeLength + textLength, tokenLength]; + updatedPersistedSizesForUri.set(JSON.stringify(newKey), oldSize); + } else if (changeOffset >= tokenOffset + tokenLength) { + updatedPersistedSizesForUri.set(key, persistedSizesForUri.get(key)!); + } + } + } + this._persistedWidgetSizes.set(uri, updatedPersistedSizesForUri); + })); + this._disposables.add(this.element.onDidWillResize(() => { + this._resizing = true; + // this._initialHeight = this.element.domNode.clientHeight; + // this._initialTop = this.element.domNode.offsetTop; + })); + this._disposables.add(this.element.onDidResize(e => { + + let height = e.dimension.height; + let width = e.dimension.width; + const maxWidth = this.element.maxSize.width; + const maxHeight = this.element.maxSize.height; + + width = Math.min(maxWidth, width); + height = Math.min(maxHeight, height); + if (!this._maxRenderingHeight) { + return; + } + this._size = new dom.Dimension(width, height); + this.element.layout(height, width); + + // Update the top parameters only when we decided to render above + // TODO: presumably do not need to resize the element + // if (this._renderingAbove === ContentWidgetPositionPreference.ABOVE) { + // this.element.domNode.style.top = this._initialTop - (height - this._initialHeight) + 'px'; + // } + // const horizontalSashWidth = width - 2 * SASH_WIDTH + 2 * TOTAL_BORDER_WIDTH + 'px'; + // this.element.northSash.el.style.width = horizontalSashWidth; + // this.element.southSash.el.style.width = horizontalSashWidth; + // const verticalSashWidth = height - 2 * SASH_WIDTH + 2 * TOTAL_BORDER_WIDTH + 'px'; + // this.element.eastSash.el.style.height = verticalSashWidth; + // this.element.westSash.el.style.height = verticalSashWidth; + // this.element.eastSash.el.style.top = TOTAL_BORDER_WIDTH + 'px'; + + // Fire the current dimension + this._onDidResize.fire({ dimension: this._size, done: false }); + + this._maxRenderingWidth = this.resizableWidget.findMaximumRenderingWidth(); + this._maxRenderingHeight = this.resizableWidget.findMaximumRenderingHeight(); + // this._maxRenderingHeight = this.resizableWidget.findMaximumRenderingHeight(this._renderingAbove); + + if (!this._maxRenderingHeight || !this._maxRenderingWidth) { + return; + } + + this.element.maxSize = new dom.Dimension(this._maxRenderingWidth, this._maxRenderingHeight); + + // Persist the height only when the resizing has stopped + if (e.done) { + if (!this.editor.hasModel()) { + return; + } + const uri = this.editor.getModel().uri; + if (!uri || !this._tooltipPosition) { + return; + } + const persistedSize = new dom.Dimension(width, height); + const wordPosition = this.editor.getModel().getWordAtPosition(this._tooltipPosition); + if (!wordPosition) { + return; + } + const offset = this.editor.getModel().getOffsetAt({ lineNumber: this._tooltipPosition.lineNumber, column: wordPosition.startColumn }); + const length = wordPosition.word.length; + + // Suppose that the uri does not exist in the persisted widget hover sizes, then create a map + if (!this._persistedWidgetSizes.get(uri)) { + const persistedWidgetSizesForUri = new Map([]); + persistedWidgetSizesForUri.set(JSON.stringify([offset, length]), persistedSize); + this._persistedWidgetSizes.set(uri, persistedWidgetSizesForUri); + } else { + const persistedWidgetSizesForUri = this._persistedWidgetSizes.get(uri)!; + persistedWidgetSizesForUri.set(JSON.stringify([offset, length]), persistedSize); + } + this._resizing = false; + } + + // this.editor.layoutOverlayWidget(this); + // this.editor.render(); + })); + + } + + set tooltipPosition(position: IPosition) { + this._tooltipPosition = position; + } + + findSize(): dom.Dimension | undefined { + if (!this._tooltipPosition || !this.editor.hasModel()) { + return; + } + const wordPosition = this.editor.getModel().getWordAtPosition(this._tooltipPosition); + if (!wordPosition) { + return; + } + const offset = this.editor.getModel().getOffsetAt({ lineNumber: this._tooltipPosition.lineNumber, column: wordPosition.startColumn }); + const length = wordPosition.word.length; + const uri = this.editor.getModel().uri; + const persistedSizesForUri = this._persistedWidgetSizes.get(uri); + if (!persistedSizesForUri) { + return; + } + return persistedSizesForUri.get(JSON.stringify([offset, length])); + } + + dispose(): void { + this._disposables.dispose(); + } +} + +class PersistedWidgetSize { + + constructor( + private readonly _key: string, + private readonly _service: IStorageService, + editor: ICodeEditor + ) { } + + restore(): dom.Dimension | undefined { + const raw = this._service.get(this._key, StorageScope.PROFILE) ?? ''; + try { + const obj = JSON.parse(raw); + if (dom.Dimension.is(obj)) { + return dom.Dimension.lift(obj); + } + } catch { + // ignore + } + return undefined; + } + + store(size: dom.Dimension) { + this._service.store(this._key, JSON.stringify(size), StorageScope.PROFILE, StorageTarget.MACHINE); + } + + reset(): void { + this._service.remove(this._key, StorageScope.PROFILE); + } +}