// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. // (Parts of the FreeTooltip code are copy-paste from Tooltip, ideally this would be PRed be merged) import { HoverBox } from '@jupyterlab/apputils'; import { CodeEditor } from '@jupyterlab/codeeditor'; import { IDocumentWidget } from '@jupyterlab/docregistry'; import { IRenderMime, MimeModel, IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { Tooltip } from '@jupyterlab/tooltip'; import { Widget } from '@lumino/widgets'; import * as lsProtocol from 'vscode-languageserver-protocol'; import { WidgetAdapter } from '../adapters/adapter'; import { PositionConverter } from '../converter'; import { IEditorPosition } from '../positioning'; const MIN_HEIGHT = 20; const MAX_HEIGHT = 250; const CLASS_NAME = 'lsp-tooltip'; interface IFreeTooltipOptions extends Tooltip.IOptions { /** * Position at which the tooltip should be placed, or null (default) to use the current cursor position. */ position: CodeEditor.IPosition | undefined; /** * HoverBox privilege. */ privilege?: 'above' | 'below' | 'forceAbove' | 'forceBelow'; /** * Alignment with respect to the current token. */ alignment?: 'start' | 'end' | undefined; /** * default: true; ESC will always hide */ hideOnKeyPress?: boolean; } /** * Tooltip which can be placed at any character, not only at the current position (derived from getCursorPosition) */ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore export class FreeTooltip extends Tooltip { constructor(protected options: IFreeTooltipOptions) { super(options); this._setGeometry(); // TODO: remove once https://github.com/jupyterlab/jupyterlab/pull/11010 is merged & released const model = new MimeModel({ data: options.bundle }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const content: IRenderMime.IRenderer = this._content; content .renderModel(model) .then(() => this._setGeometry()) .catch(console.warn); } handleEvent(event: Event): void { if (this.isHidden || this.isDisposed) { return; } const { node } = this; const target = event.target as HTMLElement; switch (event.type) { case 'keydown': { const keyCode = (event as KeyboardEvent).keyCode; // ESC or Backspace cancel anyways if ( node.contains(target) || (!this.options.hideOnKeyPress && keyCode != 27 && keyCode != 8) ) { return; } this.dispose(); break; } default: super.handleEvent(event); break; } } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore private _setGeometry(): void { // Find the start of the current token for hover box placement. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const editor = this._editor as CodeEditor.IEditor; const cursor: CodeEditor.IPosition = this.options.position == null ? editor.getCursorPosition() : this.options.position; const end = editor.getOffsetAt(cursor); const line = editor.getLine(cursor.line); if (!line) { return; } let position: CodeEditor.IPosition | undefined; switch (this.options.alignment) { case 'start': { const tokens = line.substring(0, end).split(/\W+/); const last = tokens[tokens.length - 1]; const start = last ? end - last.length : end; position = editor.getPositionAt(start); break; } case 'end': { const tokens = line.substring(0, end).split(/\W+/); const last = tokens[tokens.length - 1]; const start = last ? end - last.length : end; position = editor.getPositionAt(start); break; } default: { position = cursor; break; } } if (!position) { return; } const anchor = editor.getCoordinateForPosition(position) as ClientRect; const style = window.getComputedStyle(this.node); const paddingLeft = parseInt(style.paddingLeft!, 10) || 0; // Calculate the geometry of the tooltip. HoverBox.setGeometry({ anchor, host: editor.host, maxHeight: MAX_HEIGHT, minHeight: MIN_HEIGHT, node: this.node, offset: { horizontal: -1 * paddingLeft }, privilege: this.options.privilege || 'below', style: style }); } } export namespace EditorTooltip { export interface IOptions { id?: string; markup: lsProtocol.MarkupContent; ce_editor: CodeEditor.IEditor; position: IEditorPosition; adapter: WidgetAdapter; className?: string; tooltip?: Partial; } } export class EditorTooltipManager { private currentTooltip: FreeTooltip | null = null; private currentOptions: EditorTooltip.IOptions | null; constructor(private rendermime_registry: IRenderMimeRegistry) {} create(options: EditorTooltip.IOptions): FreeTooltip { this.remove(); this.currentOptions = options; let { markup, position, adapter } = options; let widget = adapter.widget; const bundle: { 'text/plain': string } | { 'text/markdown': string } = markup.kind === 'plaintext' ? { 'text/plain': markup.value } : { 'text/markdown': markup.value }; const tooltip = new FreeTooltip({ ...(options.tooltip || {}), anchor: widget.content, bundle: bundle, editor: options.ce_editor, rendermime: this.rendermime_registry, position: PositionConverter.cm_to_ce(position) }); tooltip.addClass(CLASS_NAME); if (options.className) { tooltip.addClass(options.className); } Widget.attach(tooltip, document.body); this.currentTooltip = tooltip; return tooltip; } get position(): IEditorPosition { return this.currentOptions!.position; } isShown(id?: string): boolean { if (id && this.currentOptions && this.currentOptions?.id !== id) { return false; } return ( this.currentTooltip !== null && !this.currentTooltip.isDisposed && this.currentTooltip.isVisible ); } remove() { if (this.currentTooltip !== null) { this.currentTooltip.dispose(); this.currentTooltip = null as any; } } }