Path: blob/main/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts
5332 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 = this._register(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 _renderedStickyLines: RenderedStickyLine[] = [];63private _lineNumbers: number[] = [];64private _lastLineRelativePosition: number = 0;65private _minContentWidthInPx: number = 0;66private _isOnGlyphMargin: boolean = false;67private _height: number = -1;6869public get height(): number { return this._height; }7071private readonly _onDidChangeStickyScrollHeight = this._register(new Emitter<{ height: number }>());72public readonly onDidChangeStickyScrollHeight = this._onDidChangeStickyScrollHeight.event;7374constructor(75editor: ICodeEditor76) {77super();7879this._editor = editor;80this._lineNumbersDomNode.className = 'sticky-widget-line-numbers';81this._lineNumbersDomNode.setAttribute('role', 'none');8283this._linesDomNode.className = 'sticky-widget-lines';84this._linesDomNode.setAttribute('role', 'list');8586this._linesDomNodeScrollable.className = 'sticky-widget-lines-scrollable';87this._linesDomNodeScrollable.appendChild(this._linesDomNode);8889this._rootDomNode.className = 'sticky-widget';90this._rootDomNode.classList.toggle('peek', editor instanceof EmbeddedCodeEditorWidget);91this._rootDomNode.appendChild(this._lineNumbersDomNode);92this._rootDomNode.appendChild(this._linesDomNodeScrollable);93this._setHeight(0);9495const updateScrollLeftPosition = () => {96this._linesDomNode.style.left = this._editor.getOption(EditorOption.stickyScroll).scrollWithEditor ? `-${this._editor.getScrollLeft()}px` : '0px';97};98this._register(this._editor.onDidChangeConfiguration((e) => {99if (e.hasChanged(EditorOption.stickyScroll)) {100updateScrollLeftPosition();101}102}));103this._register(this._editor.onDidScrollChange((e) => {104if (e.scrollLeftChanged) {105updateScrollLeftPosition();106}107if (e.scrollWidthChanged) {108this._updateWidgetWidth();109}110}));111this._register(this._editor.onDidChangeModel(() => {112updateScrollLeftPosition();113this._updateWidgetWidth();114}));115updateScrollLeftPosition();116117this._register(this._editor.onDidLayoutChange((e) => {118this._updateWidgetWidth();119}));120this._updateWidgetWidth();121}122123get lineNumbers(): number[] {124return this._lineNumbers;125}126127get lineNumberCount(): number {128return this._lineNumbers.length;129}130131getRenderedStickyLine(lineNumber: number): RenderedStickyLine | undefined {132return this._renderedStickyLines.find(stickyLine => stickyLine.lineNumber === lineNumber);133}134135getCurrentLines(): readonly number[] {136return this._lineNumbers;137}138139setState(state: StickyScrollWidgetState | undefined, foldingModel: FoldingModel | undefined, rebuildFromIndexCandidate?: number): void {140const currentStateAndPreviousStateUndefined = !this._state && !state;141const currentStateDefinedAndEqualsPreviousState = this._state && this._state.equals(state);142if (rebuildFromIndexCandidate === undefined && (currentStateAndPreviousStateUndefined || currentStateDefinedAndEqualsPreviousState)) {143return;144}145const data = this._findRenderingData(state);146const previousLineNumbers = this._lineNumbers;147this._lineNumbers = data.lineNumbers;148this._lastLineRelativePosition = data.lastLineRelativePosition;149const rebuildFromIndex = this._findIndexToRebuildFrom(previousLineNumbers, this._lineNumbers, rebuildFromIndexCandidate);150this._renderRootNode(this._lineNumbers, this._lastLineRelativePosition, foldingModel, rebuildFromIndex);151this._state = state;152}153154private _findRenderingData(state: StickyScrollWidgetState | undefined): { lineNumbers: number[]; lastLineRelativePosition: number } {155if (!state) {156return { lineNumbers: [], lastLineRelativePosition: 0 };157}158const candidateLineNumbers = [...state.startLineNumbers];159if (state.showEndForLine !== null) {160candidateLineNumbers[state.showEndForLine] = state.endLineNumbers[state.showEndForLine];161}162let totalHeight = 0;163for (let i = 0; i < candidateLineNumbers.length; i++) {164totalHeight += this._editor.getLineHeightForPosition(new Position(candidateLineNumbers[i], 1));165}166if (totalHeight === 0) {167return { lineNumbers: [], lastLineRelativePosition: 0 };168}169return { lineNumbers: candidateLineNumbers, lastLineRelativePosition: state.lastLineRelativePosition };170}171172private _findIndexToRebuildFrom(previousLineNumbers: number[], newLineNumbers: number[], rebuildFromIndexCandidate?: number): number {173if (newLineNumbers.length === 0) {174return 0;175}176if (rebuildFromIndexCandidate !== undefined) {177return rebuildFromIndexCandidate;178}179const validIndex = newLineNumbers.findIndex(startLineNumber => !previousLineNumbers.includes(startLineNumber));180return validIndex === -1 ? 0 : validIndex;181}182183private _updateWidgetWidth(): void {184const layoutInfo = this._editor.getLayoutInfo();185const lineNumbersWidth = layoutInfo.contentLeft;186this._lineNumbersDomNode.style.width = `${lineNumbersWidth}px`;187this._linesDomNodeScrollable.style.setProperty('--vscode-editorStickyScroll-scrollableWidth', `${this._editor.getScrollWidth() - layoutInfo.verticalScrollbarWidth}px`);188this._rootDomNode.style.width = `${layoutInfo.width - layoutInfo.verticalScrollbarWidth}px`;189}190191private _useFoldingOpacityTransition(requireTransitions: boolean) {192this._lineNumbersDomNode.style.setProperty('--vscode-editorStickyScroll-foldingOpacityTransition', `opacity ${requireTransitions ? 0.5 : 0}s`);193}194195private _setFoldingIconsVisibility(allVisible: boolean) {196for (const line of this._renderedStickyLines) {197const foldingIcon = line.foldingIcon;198if (!foldingIcon) {199continue;200}201foldingIcon.setVisible(allVisible ? true : foldingIcon.isCollapsed);202}203}204205private async _renderRootNode(lineNumbers: number[], lastLineRelativePosition: number, foldingModel: FoldingModel | undefined, rebuildFromIndex: number): Promise<void> {206const viewModel = this._editor._getViewModel();207if (!viewModel) {208this._clearWidget();209return;210}211if (lineNumbers.length === 0) {212this._clearWidget();213return;214}215const renderedStickyLines: RenderedStickyLine[] = [];216const lastLineNumber = lineNumbers[lineNumbers.length - 1];217let top: number = 0;218for (let i = 0; i < this._renderedStickyLines.length; i++) {219if (i < rebuildFromIndex) {220const renderedLine = this._renderedStickyLines[i];221renderedStickyLines.push(this._updatePosition(renderedLine, top, renderedLine.lineNumber === lastLineNumber));222top += renderedLine.height;223} else {224const renderedLine = this._renderedStickyLines[i];225renderedLine.lineNumberDomNode.remove();226renderedLine.lineDomNode.remove();227}228}229const layoutInfo = this._editor.getLayoutInfo();230for (let i = rebuildFromIndex; i < lineNumbers.length; i++) {231const stickyLine = this._renderChildNode(viewModel, i, lineNumbers[i], top, lastLineNumber === lineNumbers[i], foldingModel, layoutInfo);232top += stickyLine.height;233this._linesDomNode.appendChild(stickyLine.lineDomNode);234this._lineNumbersDomNode.appendChild(stickyLine.lineNumberDomNode);235renderedStickyLines.push(stickyLine);236}237if (foldingModel) {238this._setFoldingHoverListeners();239this._useFoldingOpacityTransition(!this._isOnGlyphMargin);240}241this._minContentWidthInPx = Math.max(...this._renderedStickyLines.map(l => l.scrollWidth)) + layoutInfo.verticalScrollbarWidth;242this._renderedStickyLines = renderedStickyLines;243this._setHeight(top + lastLineRelativePosition);244this._editor.layoutOverlayWidget(this);245}246247private _clearWidget(): void {248for (let i = 0; i < this._renderedStickyLines.length; i++) {249const stickyLine = this._renderedStickyLines[i];250stickyLine.lineNumberDomNode.remove();251stickyLine.lineDomNode.remove();252}253this._setHeight(0);254}255256private _setHeight(height: number): void {257if (this._height === height) {258return;259}260this._height = height;261262if (this._height === 0) {263this._rootDomNode.style.display = 'none';264} else {265this._rootDomNode.style.display = 'block';266this._lineNumbersDomNode.style.height = `${this._height}px`;267this._linesDomNodeScrollable.style.height = `${this._height}px`;268this._rootDomNode.style.height = `${this._height}px`;269}270271this._onDidChangeStickyScrollHeight.fire({ height: this._height });272}273274private _setFoldingHoverListeners(): void {275this._foldingIconStore.clear();276const showFoldingControls: 'mouseover' | 'always' | 'never' = this._editor.getOption(EditorOption.showFoldingControls);277if (showFoldingControls !== 'mouseover') {278return;279}280this._foldingIconStore.clear();281this._foldingIconStore.add(dom.addDisposableListener(this._lineNumbersDomNode, dom.EventType.MOUSE_ENTER, () => {282this._isOnGlyphMargin = true;283this._setFoldingIconsVisibility(true);284}));285this._foldingIconStore.add(dom.addDisposableListener(this._lineNumbersDomNode, dom.EventType.MOUSE_LEAVE, () => {286this._isOnGlyphMargin = false;287this._useFoldingOpacityTransition(true);288this._setFoldingIconsVisibility(false);289}));290}291292private _renderChildNode(viewModel: IViewModel, index: number, line: number, top: number, isLastLine: boolean, foldingModel: FoldingModel | undefined, layoutInfo: EditorLayoutInfo): RenderedStickyLine {293294const renderedLine = new RenderedStickyLine(295this._editor,296viewModel,297layoutInfo,298foldingModel,299this._isOnGlyphMargin,300index,301line302);303return this._updatePosition(renderedLine, top, isLastLine);304}305306private _updatePosition(stickyLine: RenderedStickyLine, top: number, isLastLine: boolean): RenderedStickyLine {307const lineHTMLNode = stickyLine.lineDomNode;308const lineNumberHTMLNode = stickyLine.lineNumberDomNode;309if (isLastLine) {310const zIndex = '0';311lineHTMLNode.style.zIndex = zIndex;312lineNumberHTMLNode.style.zIndex = zIndex;313const updatedTop = `${top + this._lastLineRelativePosition + (stickyLine.foldingIcon?.isCollapsed ? 1 : 0)}px`;314lineHTMLNode.style.top = updatedTop;315lineNumberHTMLNode.style.top = updatedTop;316} else {317const zIndex = '1';318lineHTMLNode.style.zIndex = zIndex;319lineNumberHTMLNode.style.zIndex = zIndex;320lineHTMLNode.style.top = `${top}px`;321lineNumberHTMLNode.style.top = `${top}px`;322}323return stickyLine;324}325326getId(): string {327return 'editor.contrib.stickyScrollWidget';328}329330getDomNode(): HTMLElement {331return this._rootDomNode;332}333334getPosition(): IOverlayWidgetPosition | null {335return {336preference: OverlayWidgetPositionPreference.TOP_CENTER,337stackOrdinal: 10,338};339}340341getMinContentWidthInPx(): number {342return this._minContentWidthInPx;343}344345focusLineWithIndex(index: number) {346if (0 <= index && index < this._renderedStickyLines.length) {347this._renderedStickyLines[index].lineDomNode.focus();348}349}350351/**352* Given a leaf dom node, tries to find the editor position.353*/354getEditorPositionFromNode(spanDomNode: HTMLElement | null): Position | null {355if (!spanDomNode || spanDomNode.children.length > 0) {356// This is not a leaf node357return null;358}359const renderedStickyLine = this._getRenderedStickyLineFromChildDomNode(spanDomNode);360if (!renderedStickyLine) {361return null;362}363const column = getColumnOfNodeOffset(renderedStickyLine.characterMapping, spanDomNode, 0);364return new Position(renderedStickyLine.lineNumber, column);365}366367getLineNumberFromChildDomNode(domNode: HTMLElement | null): number | null {368return this._getRenderedStickyLineFromChildDomNode(domNode)?.lineNumber ?? null;369}370371private _getRenderedStickyLineFromChildDomNode(domNode: HTMLElement | null): RenderedStickyLine | null {372const index = this.getLineIndexFromChildDomNode(domNode);373if (index === null || index < 0 || index >= this._renderedStickyLines.length) {374return null;375}376return this._renderedStickyLines[index];377}378379/**380* Given a child dom node, tries to find the line number attribute that was stored in the node.381* @returns the attribute value or null if none is found.382*/383getLineIndexFromChildDomNode(domNode: HTMLElement | null): number | null {384const lineIndex = this._getAttributeValue(domNode, STICKY_INDEX_ATTR);385return lineIndex ? parseInt(lineIndex, 10) : null;386}387388/**389* Given a child dom node, tries to find if it is (contained in) a sticky line.390* @returns a boolean.391*/392isInStickyLine(domNode: HTMLElement | null): boolean {393const isInLine = this._getAttributeValue(domNode, STICKY_IS_LINE_ATTR);394return isInLine !== undefined;395}396397/**398* Given a child dom node, tries to find if this dom node is (contained in) a sticky folding icon.399* @returns a boolean.400*/401isInFoldingIconDomNode(domNode: HTMLElement | null): boolean {402const isInFoldingIcon = this._getAttributeValue(domNode, STICKY_IS_FOLDING_ICON_ATTR);403return isInFoldingIcon !== undefined;404}405406/**407* Given the dom node, finds if it or its parent sequence contains the given attribute.408* @returns the attribute value or undefined.409*/410private _getAttributeValue(domNode: HTMLElement | null, attribute: string): string | undefined {411while (domNode && domNode !== this._rootDomNode) {412const line = domNode.getAttribute(attribute);413if (line !== null) {414return line;415}416domNode = domNode.parentElement;417}418return;419}420}421422class RenderedStickyLine {423424public readonly lineDomNode: HTMLElement;425public readonly lineNumberDomNode: HTMLElement;426427public readonly foldingIcon: StickyFoldingIcon | undefined;428public readonly characterMapping: CharacterMapping;429430public readonly scrollWidth: number;431public readonly height: number;432433constructor(434editor: ICodeEditor,435viewModel: IViewModel,436layoutInfo: EditorLayoutInfo,437foldingModel: FoldingModel | undefined,438isOnGlyphMargin: boolean,439public readonly index: number,440public readonly lineNumber: number,441) {442const viewLineNumber = viewModel.coordinatesConverter.convertModelPositionToViewPosition(new Position(lineNumber, 1)).lineNumber;443const lineRenderingData = viewModel.getViewLineRenderingData(viewLineNumber);444const lineNumberOption = editor.getOption(EditorOption.lineNumbers);445const verticalScrollbarSize = editor.getOption(EditorOption.scrollbar).verticalScrollbarSize;446447let actualInlineDecorations: LineDecoration[];448try {449actualInlineDecorations = LineDecoration.filter(lineRenderingData.inlineDecorations, viewLineNumber, lineRenderingData.minColumn, lineRenderingData.maxColumn);450} catch (err) {451actualInlineDecorations = [];452}453454const lineHeight = editor.getLineHeightForPosition(new Position(lineNumber, 1));455const textDirection = viewModel.getTextDirection(lineNumber);456const renderLineInput: RenderLineInput = new RenderLineInput(true, true, lineRenderingData.content,457lineRenderingData.continuesWithWrappedLine,458lineRenderingData.isBasicASCII, lineRenderingData.containsRTL, 0,459lineRenderingData.tokens, actualInlineDecorations,460lineRenderingData.tabSize, lineRenderingData.startVisibleColumn,4611, 1, 1, 500, 'none', true, true, null,462textDirection, verticalScrollbarSize463);464465const sb = new StringBuilder(2000);466const renderOutput = renderViewLine(renderLineInput, sb);467this.characterMapping = renderOutput.characterMapping;468469let newLine;470if (_ttPolicy) {471newLine = _ttPolicy.createHTML(sb.build());472} else {473newLine = sb.build();474}475476const lineHTMLNode = document.createElement('span');477lineHTMLNode.setAttribute(STICKY_INDEX_ATTR, String(index));478lineHTMLNode.setAttribute(STICKY_IS_LINE_ATTR, '');479lineHTMLNode.setAttribute('role', 'listitem');480lineHTMLNode.tabIndex = 0;481lineHTMLNode.className = 'sticky-line-content';482lineHTMLNode.classList.add(`stickyLine${lineNumber}`);483lineHTMLNode.style.lineHeight = `${lineHeight}px`;484lineHTMLNode.innerHTML = newLine as string;485486const lineNumberHTMLNode = document.createElement('span');487lineNumberHTMLNode.setAttribute(STICKY_INDEX_ATTR, String(index));488lineNumberHTMLNode.setAttribute(STICKY_IS_LINE_NUMBER_ATTR, '');489lineNumberHTMLNode.className = 'sticky-line-number';490lineNumberHTMLNode.style.lineHeight = `${lineHeight}px`;491const lineNumbersWidth = layoutInfo.contentLeft;492lineNumberHTMLNode.style.width = `${lineNumbersWidth}px`;493494const innerLineNumberHTML = document.createElement('span');495if (lineNumberOption.renderType === RenderLineNumbersType.On || lineNumberOption.renderType === RenderLineNumbersType.Interval && lineNumber % 10 === 0) {496innerLineNumberHTML.innerText = lineNumber.toString();497} else if (lineNumberOption.renderType === RenderLineNumbersType.Relative) {498innerLineNumberHTML.innerText = Math.abs(lineNumber - editor.getPosition()!.lineNumber).toString();499}500innerLineNumberHTML.className = 'sticky-line-number-inner';501innerLineNumberHTML.style.width = `${layoutInfo.lineNumbersWidth}px`;502innerLineNumberHTML.style.paddingLeft = `${layoutInfo.lineNumbersLeft}px`;503504lineNumberHTMLNode.appendChild(innerLineNumberHTML);505this.foldingIcon = this._renderFoldingIconForLine(editor, foldingModel, lineNumber, lineHeight, isOnGlyphMargin);506if (this.foldingIcon) {507lineNumberHTMLNode.appendChild(this.foldingIcon.domNode);508this.foldingIcon.domNode.style.left = `${layoutInfo.lineNumbersWidth + layoutInfo.lineNumbersLeft}px`;509this.foldingIcon.domNode.style.lineHeight = `${lineHeight}px`;510}511512editor.applyFontInfo(lineHTMLNode);513editor.applyFontInfo(lineNumberHTMLNode);514515lineNumberHTMLNode.style.lineHeight = `${lineHeight}px`;516lineHTMLNode.style.lineHeight = `${lineHeight}px`;517lineNumberHTMLNode.style.height = `${lineHeight}px`;518lineHTMLNode.style.height = `${lineHeight}px`;519520this.scrollWidth = lineHTMLNode.scrollWidth;521this.lineDomNode = lineHTMLNode;522this.lineNumberDomNode = lineNumberHTMLNode;523this.height = lineHeight;524}525526private _renderFoldingIconForLine(editor: ICodeEditor, foldingModel: FoldingModel | undefined, line: number, lineHeight: number, isOnGlyphMargin: boolean): StickyFoldingIcon | undefined {527const showFoldingControls: 'mouseover' | 'always' | 'never' = editor.getOption(EditorOption.showFoldingControls);528if (!foldingModel || showFoldingControls === 'never') {529return;530}531const foldingRegions = foldingModel.regions;532const indexOfFoldingRegion = foldingRegions.findRange(line);533const startLineNumber = foldingRegions.getStartLineNumber(indexOfFoldingRegion);534const isFoldingScope = line === startLineNumber;535if (!isFoldingScope) {536return;537}538const isCollapsed = foldingRegions.isCollapsed(indexOfFoldingRegion);539const foldingIcon = new StickyFoldingIcon(isCollapsed, startLineNumber, foldingRegions.getEndLineNumber(indexOfFoldingRegion), lineHeight);540foldingIcon.setVisible(isOnGlyphMargin ? true : (isCollapsed || showFoldingControls === 'always'));541foldingIcon.domNode.setAttribute(STICKY_IS_FOLDING_ICON_ATTR, '');542return foldingIcon;543}544}545546class StickyFoldingIcon {547548public domNode: HTMLElement;549550constructor(551public isCollapsed: boolean,552public foldingStartLine: number,553public foldingEndLine: number,554public dimension: number555) {556this.domNode = document.createElement('div');557this.domNode.style.width = `26px`;558this.domNode.style.height = `${dimension}px`;559this.domNode.style.lineHeight = `${dimension}px`;560this.domNode.className = ThemeIcon.asClassName(isCollapsed ? foldingCollapsedIcon : foldingExpandedIcon);561}562563public setVisible(visible: boolean) {564this.domNode.style.cursor = visible ? 'pointer' : 'default';565this.domNode.style.opacity = visible ? '1' : '0';566}567}568569570