Path: blob/main/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.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 { createTrustedTypesPolicy } from '../../../../base/browser/trustedTypes.js';7import { equals } from '../../../../base/common/arrays.js';8import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';9import { ThemeIcon } from '../../../../base/common/themables.js';10import './stickyScroll.css';11import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPositionPreference } from '../../../browser/editorBrowser.js';12import { getColumnOfNodeOffset } from '../../../browser/viewParts/viewLines/viewLine.js';13import { EmbeddedCodeEditorWidget } from '../../../browser/widget/codeEditor/embeddedCodeEditorWidget.js';14import { EditorLayoutInfo, EditorOption, RenderLineNumbersType } from '../../../common/config/editorOptions.js';15import { Position } from '../../../common/core/position.js';16import { StringBuilder } from '../../../common/core/stringBuilder.js';17import { LineDecoration } from '../../../common/viewLayout/lineDecorations.js';18import { CharacterMapping, RenderLineInput, renderViewLine } from '../../../common/viewLayout/viewLineRenderer.js';19import { foldingCollapsedIcon, foldingExpandedIcon } from '../../folding/browser/foldingDecorations.js';20import { FoldingModel } from '../../folding/browser/foldingModel.js';21import { Emitter } from '../../../../base/common/event.js';22import { IViewModel } from '../../../common/viewModel.js';2324export class StickyScrollWidgetState {25constructor(26readonly startLineNumbers: number[],27readonly endLineNumbers: number[],28readonly lastLineRelativePosition: number,29readonly showEndForLine: number | null = null30) { }3132equals(other: StickyScrollWidgetState | undefined): boolean {33return !!other34&& this.lastLineRelativePosition === other.lastLineRelativePosition35&& this.showEndForLine === other.showEndForLine36&& equals(this.startLineNumbers, other.startLineNumbers)37&& equals(this.endLineNumbers, other.endLineNumbers);38}3940static get Empty() {41return new StickyScrollWidgetState([], [], 0);42}43}4445const _ttPolicy = createTrustedTypesPolicy('stickyScrollViewLayer', { createHTML: value => value });46const STICKY_INDEX_ATTR = 'data-sticky-line-index';47const STICKY_IS_LINE_ATTR = 'data-sticky-is-line';48const STICKY_IS_LINE_NUMBER_ATTR = 'data-sticky-is-line-number';49const STICKY_IS_FOLDING_ICON_ATTR = 'data-sticky-is-folding-icon';5051export class StickyScrollWidget extends Disposable implements IOverlayWidget {5253private readonly _foldingIconStore = new DisposableStore();54private readonly _rootDomNode: HTMLElement = document.createElement('div');55private readonly _lineNumbersDomNode: HTMLElement = document.createElement('div');56private readonly _linesDomNodeScrollable: HTMLElement = document.createElement('div');57private readonly _linesDomNode: HTMLElement = document.createElement('div');5859private readonly _editor: ICodeEditor;6061private _state: StickyScrollWidgetState | undefined;62private _lineHeight: number;63private _renderedStickyLines: RenderedStickyLine[] = [];64private _lineNumbers: number[] = [];65private _lastLineRelativePosition: number = 0;66private _minContentWidthInPx: number = 0;67private _isOnGlyphMargin: boolean = false;68private _height: number = -1;6970public get height(): number { return this._height; }7172private readonly _onDidChangeStickyScrollHeight = this._register(new Emitter<{ height: number }>());73public readonly onDidChangeStickyScrollHeight = this._onDidChangeStickyScrollHeight.event;7475constructor(76editor: ICodeEditor77) {78super();7980this._editor = editor;81this._lineHeight = editor.getOption(EditorOption.lineHeight);82this._lineNumbersDomNode.className = 'sticky-widget-line-numbers';83this._lineNumbersDomNode.setAttribute('role', 'none');8485this._linesDomNode.className = 'sticky-widget-lines';86this._linesDomNode.setAttribute('role', 'list');8788this._linesDomNodeScrollable.className = 'sticky-widget-lines-scrollable';89this._linesDomNodeScrollable.appendChild(this._linesDomNode);9091this._rootDomNode.className = 'sticky-widget';92this._rootDomNode.classList.toggle('peek', editor instanceof EmbeddedCodeEditorWidget);93this._rootDomNode.appendChild(this._lineNumbersDomNode);94this._rootDomNode.appendChild(this._linesDomNodeScrollable);95this._setHeight(0);9697const updateScrollLeftPosition = () => {98this._linesDomNode.style.left = this._editor.getOption(EditorOption.stickyScroll).scrollWithEditor ? `-${this._editor.getScrollLeft()}px` : '0px';99};100this._register(this._editor.onDidChangeConfiguration((e) => {101if (e.hasChanged(EditorOption.stickyScroll)) {102updateScrollLeftPosition();103}104if (e.hasChanged(EditorOption.lineHeight)) {105this._lineHeight = this._editor.getOption(EditorOption.lineHeight);106}107}));108this._register(this._editor.onDidScrollChange((e) => {109if (e.scrollLeftChanged) {110updateScrollLeftPosition();111}112if (e.scrollWidthChanged) {113this._updateWidgetWidth();114}115}));116this._register(this._editor.onDidChangeModel(() => {117updateScrollLeftPosition();118this._updateWidgetWidth();119}));120this._register(this._foldingIconStore);121updateScrollLeftPosition();122123this._register(this._editor.onDidLayoutChange((e) => {124this._updateWidgetWidth();125}));126this._updateWidgetWidth();127}128129get lineNumbers(): number[] {130return this._lineNumbers;131}132133get lineNumberCount(): number {134return this._lineNumbers.length;135}136137getRenderedStickyLine(lineNumber: number): RenderedStickyLine | undefined {138return this._renderedStickyLines.find(stickyLine => stickyLine.lineNumber === lineNumber);139}140141getCurrentLines(): readonly number[] {142return this._lineNumbers;143}144145setState(state: StickyScrollWidgetState | undefined, foldingModel: FoldingModel | undefined, rebuildFromIndexCandidate?: number): void {146const currentStateAndPreviousStateUndefined = !this._state && !state;147const currentStateDefinedAndEqualsPreviousState = this._state && this._state.equals(state);148if (rebuildFromIndexCandidate === undefined && (currentStateAndPreviousStateUndefined || currentStateDefinedAndEqualsPreviousState)) {149return;150}151const data = this._findRenderingData(state);152const previousLineNumbers = this._lineNumbers;153this._lineNumbers = data.lineNumbers;154this._lastLineRelativePosition = data.lastLineRelativePosition;155const rebuildFromIndex = this._findIndexToRebuildFrom(previousLineNumbers, this._lineNumbers, rebuildFromIndexCandidate);156this._renderRootNode(this._lineNumbers, this._lastLineRelativePosition, foldingModel, rebuildFromIndex);157this._state = state;158}159160private _findRenderingData(state: StickyScrollWidgetState | undefined): { lineNumbers: number[]; lastLineRelativePosition: number } {161if (!state) {162return { lineNumbers: [], lastLineRelativePosition: 0 };163}164const candidateLineNumbers = [...state.startLineNumbers];165if (state.showEndForLine !== null) {166candidateLineNumbers[state.showEndForLine] = state.endLineNumbers[state.showEndForLine];167}168let totalHeight = 0;169for (let i = 0; i < candidateLineNumbers.length; i++) {170totalHeight += this._editor.getLineHeightForPosition(new Position(candidateLineNumbers[i], 1));171}172if (totalHeight === 0) {173return { lineNumbers: [], lastLineRelativePosition: 0 };174}175return { lineNumbers: candidateLineNumbers, lastLineRelativePosition: state.lastLineRelativePosition };176}177178private _findIndexToRebuildFrom(previousLineNumbers: number[], newLineNumbers: number[], rebuildFromIndexCandidate?: number): number {179if (newLineNumbers.length === 0) {180return 0;181}182if (rebuildFromIndexCandidate !== undefined) {183return rebuildFromIndexCandidate;184}185const validIndex = newLineNumbers.findIndex(startLineNumber => !previousLineNumbers.includes(startLineNumber));186return validIndex === -1 ? 0 : validIndex;187}188189private _updateWidgetWidth(): void {190const layoutInfo = this._editor.getLayoutInfo();191const lineNumbersWidth = layoutInfo.contentLeft;192this._lineNumbersDomNode.style.width = `${lineNumbersWidth}px`;193this._linesDomNodeScrollable.style.setProperty('--vscode-editorStickyScroll-scrollableWidth', `${this._editor.getScrollWidth() - layoutInfo.verticalScrollbarWidth}px`);194this._rootDomNode.style.width = `${layoutInfo.width - layoutInfo.verticalScrollbarWidth}px`;195}196197private _useFoldingOpacityTransition(requireTransitions: boolean) {198this._lineNumbersDomNode.style.setProperty('--vscode-editorStickyScroll-foldingOpacityTransition', `opacity ${requireTransitions ? 0.5 : 0}s`);199}200201private _setFoldingIconsVisibility(allVisible: boolean) {202for (const line of this._renderedStickyLines) {203const foldingIcon = line.foldingIcon;204if (!foldingIcon) {205continue;206}207foldingIcon.setVisible(allVisible ? true : foldingIcon.isCollapsed);208}209}210211private async _renderRootNode(lineNumbers: number[], lastLineRelativePosition: number, foldingModel: FoldingModel | undefined, rebuildFromIndex: number): Promise<void> {212const viewModel = this._editor._getViewModel();213if (!viewModel) {214this._clearWidget();215return;216}217if (lineNumbers.length === 0) {218this._clearWidget();219return;220}221const renderedStickyLines: RenderedStickyLine[] = [];222const lastLineNumber = lineNumbers[lineNumbers.length - 1];223let top: number = 0;224for (let i = 0; i < this._renderedStickyLines.length; i++) {225if (i < rebuildFromIndex) {226const renderedLine = this._renderedStickyLines[i];227renderedStickyLines.push(this._updatePosition(renderedLine, top, renderedLine.lineNumber === lastLineNumber));228top += renderedLine.height;229} else {230const renderedLine = this._renderedStickyLines[i];231renderedLine.lineNumberDomNode.remove();232renderedLine.lineDomNode.remove();233}234}235const layoutInfo = this._editor.getLayoutInfo();236for (let i = rebuildFromIndex; i < lineNumbers.length; i++) {237const stickyLine = this._renderChildNode(viewModel, i, lineNumbers[i], top, lastLineNumber === lineNumbers[i], foldingModel, layoutInfo);238top += stickyLine.height;239this._linesDomNode.appendChild(stickyLine.lineDomNode);240this._lineNumbersDomNode.appendChild(stickyLine.lineNumberDomNode);241renderedStickyLines.push(stickyLine);242}243if (foldingModel) {244this._setFoldingHoverListeners();245this._useFoldingOpacityTransition(!this._isOnGlyphMargin);246}247this._minContentWidthInPx = Math.max(...this._renderedStickyLines.map(l => l.scrollWidth)) + layoutInfo.verticalScrollbarWidth;248this._renderedStickyLines = renderedStickyLines;249this._setHeight(top + lastLineRelativePosition);250this._editor.layoutOverlayWidget(this);251}252253private _clearWidget(): void {254for (let i = 0; i < this._renderedStickyLines.length; i++) {255const stickyLine = this._renderedStickyLines[i];256stickyLine.lineNumberDomNode.remove();257stickyLine.lineDomNode.remove();258}259this._setHeight(0);260}261262private _setHeight(height: number): void {263if (this._height === height) {264return;265}266this._height = height;267268if (this._height === 0) {269this._rootDomNode.style.display = 'none';270} else {271this._rootDomNode.style.display = 'block';272this._lineNumbersDomNode.style.height = `${this._height}px`;273this._linesDomNodeScrollable.style.height = `${this._height}px`;274this._rootDomNode.style.height = `${this._height}px`;275}276277this._onDidChangeStickyScrollHeight.fire({ height: this._height });278}279280private _setFoldingHoverListeners(): void {281const showFoldingControls: 'mouseover' | 'always' | 'never' = this._editor.getOption(EditorOption.showFoldingControls);282if (showFoldingControls !== 'mouseover') {283return;284}285this._foldingIconStore.add(dom.addDisposableListener(this._lineNumbersDomNode, dom.EventType.MOUSE_ENTER, () => {286this._isOnGlyphMargin = true;287this._setFoldingIconsVisibility(true);288}));289this._foldingIconStore.add(dom.addDisposableListener(this._lineNumbersDomNode, dom.EventType.MOUSE_LEAVE, () => {290this._isOnGlyphMargin = false;291this._useFoldingOpacityTransition(true);292this._setFoldingIconsVisibility(false);293}));294}295296private _renderChildNode(viewModel: IViewModel, index: number, line: number, top: number, isLastLine: boolean, foldingModel: FoldingModel | undefined, layoutInfo: EditorLayoutInfo): RenderedStickyLine {297const viewLineNumber = viewModel.coordinatesConverter.convertModelPositionToViewPosition(new Position(line, 1)).lineNumber;298const lineRenderingData = viewModel.getViewLineRenderingData(viewLineNumber);299const lineNumberOption = this._editor.getOption(EditorOption.lineNumbers);300const verticalScrollbarSize = this._editor.getOption(EditorOption.scrollbar).verticalScrollbarSize;301302let actualInlineDecorations: LineDecoration[];303try {304actualInlineDecorations = LineDecoration.filter(lineRenderingData.inlineDecorations, viewLineNumber, lineRenderingData.minColumn, lineRenderingData.maxColumn);305} catch (err) {306actualInlineDecorations = [];307}308309const lineHeight = this._editor.getLineHeightForPosition(new Position(line, 1));310const textDirection = viewModel.getTextDirection(line);311const renderLineInput: RenderLineInput = new RenderLineInput(true, true, lineRenderingData.content,312lineRenderingData.continuesWithWrappedLine,313lineRenderingData.isBasicASCII, lineRenderingData.containsRTL, 0,314lineRenderingData.tokens, actualInlineDecorations,315lineRenderingData.tabSize, lineRenderingData.startVisibleColumn,3161, 1, 1, 500, 'none', true, true, null,317textDirection, verticalScrollbarSize318);319320const sb = new StringBuilder(2000);321const renderOutput = renderViewLine(renderLineInput, sb);322323let newLine;324if (_ttPolicy) {325newLine = _ttPolicy.createHTML(sb.build());326} else {327newLine = sb.build();328}329330const lineHTMLNode = document.createElement('span');331lineHTMLNode.setAttribute(STICKY_INDEX_ATTR, String(index));332lineHTMLNode.setAttribute(STICKY_IS_LINE_ATTR, '');333lineHTMLNode.setAttribute('role', 'listitem');334lineHTMLNode.tabIndex = 0;335lineHTMLNode.className = 'sticky-line-content';336lineHTMLNode.classList.add(`stickyLine${line}`);337lineHTMLNode.style.lineHeight = `${lineHeight}px`;338lineHTMLNode.innerHTML = newLine as string;339340const lineNumberHTMLNode = document.createElement('span');341lineNumberHTMLNode.setAttribute(STICKY_INDEX_ATTR, String(index));342lineNumberHTMLNode.setAttribute(STICKY_IS_LINE_NUMBER_ATTR, '');343lineNumberHTMLNode.className = 'sticky-line-number';344lineNumberHTMLNode.style.lineHeight = `${lineHeight}px`;345const lineNumbersWidth = layoutInfo.contentLeft;346lineNumberHTMLNode.style.width = `${lineNumbersWidth}px`;347348const innerLineNumberHTML = document.createElement('span');349if (lineNumberOption.renderType === RenderLineNumbersType.On || lineNumberOption.renderType === RenderLineNumbersType.Interval && line % 10 === 0) {350innerLineNumberHTML.innerText = line.toString();351} else if (lineNumberOption.renderType === RenderLineNumbersType.Relative) {352innerLineNumberHTML.innerText = Math.abs(line - this._editor.getPosition()!.lineNumber).toString();353}354innerLineNumberHTML.className = 'sticky-line-number-inner';355innerLineNumberHTML.style.width = `${layoutInfo.lineNumbersWidth}px`;356innerLineNumberHTML.style.paddingLeft = `${layoutInfo.lineNumbersLeft}px`;357358lineNumberHTMLNode.appendChild(innerLineNumberHTML);359const foldingIcon = this._renderFoldingIconForLine(foldingModel, line);360if (foldingIcon) {361lineNumberHTMLNode.appendChild(foldingIcon.domNode);362foldingIcon.domNode.style.left = `${layoutInfo.lineNumbersWidth + layoutInfo.lineNumbersLeft}px`;363foldingIcon.domNode.style.lineHeight = `${lineHeight}px`;364}365366this._editor.applyFontInfo(lineHTMLNode);367this._editor.applyFontInfo(lineNumberHTMLNode);368369lineNumberHTMLNode.style.lineHeight = `${lineHeight}px`;370lineHTMLNode.style.lineHeight = `${lineHeight}px`;371lineNumberHTMLNode.style.height = `${lineHeight}px`;372lineHTMLNode.style.height = `${lineHeight}px`;373374const renderedLine = new RenderedStickyLine(375index,376line,377lineHTMLNode,378lineNumberHTMLNode,379foldingIcon,380renderOutput.characterMapping,381lineHTMLNode.scrollWidth,382lineHeight383);384return this._updatePosition(renderedLine, top, isLastLine);385}386387private _updatePosition(stickyLine: RenderedStickyLine, top: number, isLastLine: boolean): RenderedStickyLine {388const lineHTMLNode = stickyLine.lineDomNode;389const lineNumberHTMLNode = stickyLine.lineNumberDomNode;390if (isLastLine) {391const zIndex = '0';392lineHTMLNode.style.zIndex = zIndex;393lineNumberHTMLNode.style.zIndex = zIndex;394const updatedTop = `${top + this._lastLineRelativePosition + (stickyLine.foldingIcon?.isCollapsed ? 1 : 0)}px`;395lineHTMLNode.style.top = updatedTop;396lineNumberHTMLNode.style.top = updatedTop;397} else {398const zIndex = '1';399lineHTMLNode.style.zIndex = zIndex;400lineNumberHTMLNode.style.zIndex = zIndex;401lineHTMLNode.style.top = `${top}px`;402lineNumberHTMLNode.style.top = `${top}px`;403}404return stickyLine;405}406407private _renderFoldingIconForLine(foldingModel: FoldingModel | undefined, line: number): StickyFoldingIcon | undefined {408const showFoldingControls: 'mouseover' | 'always' | 'never' = this._editor.getOption(EditorOption.showFoldingControls);409if (!foldingModel || showFoldingControls === 'never') {410return;411}412const foldingRegions = foldingModel.regions;413const indexOfFoldingRegion = foldingRegions.findRange(line);414const startLineNumber = foldingRegions.getStartLineNumber(indexOfFoldingRegion);415const isFoldingScope = line === startLineNumber;416if (!isFoldingScope) {417return;418}419const isCollapsed = foldingRegions.isCollapsed(indexOfFoldingRegion);420const foldingIcon = new StickyFoldingIcon(isCollapsed, startLineNumber, foldingRegions.getEndLineNumber(indexOfFoldingRegion), this._lineHeight);421foldingIcon.setVisible(this._isOnGlyphMargin ? true : (isCollapsed || showFoldingControls === 'always'));422foldingIcon.domNode.setAttribute(STICKY_IS_FOLDING_ICON_ATTR, '');423return foldingIcon;424}425426getId(): string {427return 'editor.contrib.stickyScrollWidget';428}429430getDomNode(): HTMLElement {431return this._rootDomNode;432}433434getPosition(): IOverlayWidgetPosition | null {435return {436preference: OverlayWidgetPositionPreference.TOP_CENTER,437stackOridinal: 10,438};439}440441getMinContentWidthInPx(): number {442return this._minContentWidthInPx;443}444445focusLineWithIndex(index: number) {446if (0 <= index && index < this._renderedStickyLines.length) {447this._renderedStickyLines[index].lineDomNode.focus();448}449}450451/**452* Given a leaf dom node, tries to find the editor position.453*/454getEditorPositionFromNode(spanDomNode: HTMLElement | null): Position | null {455if (!spanDomNode || spanDomNode.children.length > 0) {456// This is not a leaf node457return null;458}459const renderedStickyLine = this._getRenderedStickyLineFromChildDomNode(spanDomNode);460if (!renderedStickyLine) {461return null;462}463const column = getColumnOfNodeOffset(renderedStickyLine.characterMapping, spanDomNode, 0);464return new Position(renderedStickyLine.lineNumber, column);465}466467getLineNumberFromChildDomNode(domNode: HTMLElement | null): number | null {468return this._getRenderedStickyLineFromChildDomNode(domNode)?.lineNumber ?? null;469}470471private _getRenderedStickyLineFromChildDomNode(domNode: HTMLElement | null): RenderedStickyLine | null {472const index = this.getLineIndexFromChildDomNode(domNode);473if (index === null || index < 0 || index >= this._renderedStickyLines.length) {474return null;475}476return this._renderedStickyLines[index];477}478479/**480* Given a child dom node, tries to find the line number attribute that was stored in the node.481* @returns the attribute value or null if none is found.482*/483getLineIndexFromChildDomNode(domNode: HTMLElement | null): number | null {484const lineIndex = this._getAttributeValue(domNode, STICKY_INDEX_ATTR);485return lineIndex ? parseInt(lineIndex, 10) : null;486}487488/**489* Given a child dom node, tries to find if it is (contained in) a sticky line.490* @returns a boolean.491*/492isInStickyLine(domNode: HTMLElement | null): boolean {493const isInLine = this._getAttributeValue(domNode, STICKY_IS_LINE_ATTR);494return isInLine !== undefined;495}496497/**498* Given a child dom node, tries to find if this dom node is (contained in) a sticky folding icon.499* @returns a boolean.500*/501isInFoldingIconDomNode(domNode: HTMLElement | null): boolean {502const isInFoldingIcon = this._getAttributeValue(domNode, STICKY_IS_FOLDING_ICON_ATTR);503return isInFoldingIcon !== undefined;504}505506/**507* Given the dom node, finds if it or its parent sequence contains the given attribute.508* @returns the attribute value or undefined.509*/510private _getAttributeValue(domNode: HTMLElement | null, attribute: string): string | undefined {511while (domNode && domNode !== this._rootDomNode) {512const line = domNode.getAttribute(attribute);513if (line !== null) {514return line;515}516domNode = domNode.parentElement;517}518return;519}520}521522class RenderedStickyLine {523constructor(524public readonly index: number,525public readonly lineNumber: number,526public readonly lineDomNode: HTMLElement,527public readonly lineNumberDomNode: HTMLElement,528public readonly foldingIcon: StickyFoldingIcon | undefined,529public readonly characterMapping: CharacterMapping,530public readonly scrollWidth: number,531public readonly height: number532) { }533}534535class StickyFoldingIcon {536537public domNode: HTMLElement;538539constructor(540public isCollapsed: boolean,541public foldingStartLine: number,542public foldingEndLine: number,543public dimension: number544) {545this.domNode = document.createElement('div');546this.domNode.style.width = `26px`;547this.domNode.style.height = `${dimension}px`;548this.domNode.style.lineHeight = `${dimension}px`;549this.domNode.className = ThemeIcon.asClassName(isCollapsed ? foldingCollapsedIcon : foldingExpandedIcon);550}551552public setVisible(visible: boolean) {553this.domNode.style.cursor = visible ? 'pointer' : 'default';554this.domNode.style.opacity = visible ? '1' : '0';555}556}557558559