Path: blob/main/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.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 '../../dom.js';6import * as domStylesheetsJs from '../../domStylesheets.js';7import { IMouseEvent } from '../../mouseEvent.js';8import { DomScrollableElement } from '../scrollbar/scrollableElement.js';9import { commonPrefixLength } from '../../../common/arrays.js';10import { ThemeIcon } from '../../../common/themables.js';11import { Emitter, Event } from '../../../common/event.js';12import { DisposableStore, dispose, IDisposable } from '../../../common/lifecycle.js';13import { ScrollbarVisibility } from '../../../common/scrollable.js';14import './breadcrumbsWidget.css';1516export abstract class BreadcrumbsItem {17abstract dispose(): void;18abstract equals(other: BreadcrumbsItem): boolean;19abstract render(container: HTMLElement): void;20}2122export interface IBreadcrumbsWidgetStyles {23readonly breadcrumbsBackground: string | undefined;24readonly breadcrumbsForeground: string | undefined;25readonly breadcrumbsHoverForeground: string | undefined;26readonly breadcrumbsFocusForeground: string | undefined;27readonly breadcrumbsFocusAndSelectionForeground: string | undefined;28}2930export interface IBreadcrumbsItemEvent {31type: 'select' | 'focus';32item: BreadcrumbsItem;33node: HTMLElement;34payload: unknown;35}3637export class BreadcrumbsWidget {3839private readonly _disposables = new DisposableStore();40private readonly _domNode: HTMLDivElement;41private readonly _scrollable: DomScrollableElement;4243private readonly _onDidSelectItem = new Emitter<IBreadcrumbsItemEvent>();44private readonly _onDidFocusItem = new Emitter<IBreadcrumbsItemEvent>();45private readonly _onDidChangeFocus = new Emitter<boolean>();4647readonly onDidSelectItem: Event<IBreadcrumbsItemEvent> = this._onDidSelectItem.event;48readonly onDidFocusItem: Event<IBreadcrumbsItemEvent> = this._onDidFocusItem.event;49readonly onDidChangeFocus: Event<boolean> = this._onDidChangeFocus.event;5051private readonly _items = new Array<BreadcrumbsItem>();52private readonly _nodes = new Array<HTMLDivElement>();53private readonly _freeNodes = new Array<HTMLDivElement>();54private readonly _separatorIcon: ThemeIcon;5556private _enabled: boolean = true;57private _focusedItemIdx: number = -1;58private _selectedItemIdx: number = -1;5960private _pendingDimLayout: IDisposable | undefined;61private _pendingLayout: IDisposable | undefined;62private _dimension: dom.Dimension | undefined;6364constructor(65container: HTMLElement,66horizontalScrollbarSize: number,67horizontalScrollbarVisibility: ScrollbarVisibility = ScrollbarVisibility.Auto,68separatorIcon: ThemeIcon,69styles: IBreadcrumbsWidgetStyles70) {71this._domNode = document.createElement('div');72this._domNode.className = 'monaco-breadcrumbs';73this._domNode.tabIndex = 0;74this._domNode.setAttribute('role', 'list');75this._scrollable = new DomScrollableElement(this._domNode, {76vertical: ScrollbarVisibility.Hidden,77horizontal: horizontalScrollbarVisibility,78horizontalScrollbarSize,79useShadows: false,80scrollYToX: true81});82this._separatorIcon = separatorIcon;83this._disposables.add(this._scrollable);84this._disposables.add(dom.addStandardDisposableListener(this._domNode, 'click', e => this._onClick(e)));85container.appendChild(this._scrollable.getDomNode());8687const styleElement = domStylesheetsJs.createStyleSheet(this._domNode);88this._style(styleElement, styles);8990const focusTracker = dom.trackFocus(this._domNode);91this._disposables.add(focusTracker);92this._disposables.add(focusTracker.onDidBlur(_ => this._onDidChangeFocus.fire(false)));93this._disposables.add(focusTracker.onDidFocus(_ => this._onDidChangeFocus.fire(true)));94}9596setHorizontalScrollbarSize(size: number) {97this._scrollable.updateOptions({98horizontalScrollbarSize: size99});100}101102setHorizontalScrollbarVisibility(visibility: ScrollbarVisibility) {103this._scrollable.updateOptions({104horizontal: visibility105});106}107108dispose(): void {109this._disposables.dispose();110this._pendingLayout?.dispose();111this._pendingDimLayout?.dispose();112this._onDidSelectItem.dispose();113this._onDidFocusItem.dispose();114this._onDidChangeFocus.dispose();115this._domNode.remove();116this._nodes.length = 0;117this._freeNodes.length = 0;118}119120layout(dim: dom.Dimension | undefined): void {121if (dim && dom.Dimension.equals(dim, this._dimension)) {122return;123}124if (dim) {125// only measure126this._pendingDimLayout?.dispose();127this._pendingDimLayout = this._updateDimensions(dim);128} else {129this._pendingLayout?.dispose();130this._pendingLayout = this._updateScrollbar();131}132}133134private _updateDimensions(dim: dom.Dimension): IDisposable {135const disposables = new DisposableStore();136disposables.add(dom.modify(dom.getWindow(this._domNode), () => {137this._dimension = dim;138this._domNode.style.width = `${dim.width}px`;139this._domNode.style.height = `${dim.height}px`;140disposables.add(this._updateScrollbar());141}));142return disposables;143}144145private _updateScrollbar(): IDisposable {146return dom.measure(dom.getWindow(this._domNode), () => {147dom.measure(dom.getWindow(this._domNode), () => { // double RAF148this._scrollable.setRevealOnScroll(false);149this._scrollable.scanDomNode();150this._scrollable.setRevealOnScroll(true);151});152});153}154155private _style(styleElement: HTMLStyleElement, style: IBreadcrumbsWidgetStyles): void {156let content = '';157if (style.breadcrumbsBackground) {158content += `.monaco-breadcrumbs { background-color: ${style.breadcrumbsBackground}}`;159}160if (style.breadcrumbsForeground) {161content += `.monaco-breadcrumbs .monaco-breadcrumb-item { color: ${style.breadcrumbsForeground}}\n`;162}163if (style.breadcrumbsFocusForeground) {164content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused { color: ${style.breadcrumbsFocusForeground}}\n`;165}166if (style.breadcrumbsFocusAndSelectionForeground) {167content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused.selected { color: ${style.breadcrumbsFocusAndSelectionForeground}}\n`;168}169if (style.breadcrumbsHoverForeground) {170content += `.monaco-breadcrumbs:not(.disabled ) .monaco-breadcrumb-item:hover:not(.focused):not(.selected) { color: ${style.breadcrumbsHoverForeground}}\n`;171}172styleElement.textContent = content;173}174175setEnabled(value: boolean) {176this._enabled = value;177this._domNode.classList.toggle('disabled', !this._enabled);178}179180domFocus(): void {181const idx = this._focusedItemIdx >= 0 ? this._focusedItemIdx : this._items.length - 1;182if (idx >= 0 && idx < this._items.length) {183this._focus(idx, undefined);184} else {185this._domNode.focus();186}187}188189isDOMFocused(): boolean {190return dom.isAncestorOfActiveElement(this._domNode);191}192193getFocused(): BreadcrumbsItem {194return this._items[this._focusedItemIdx];195}196197setFocused(item: BreadcrumbsItem | undefined, payload?: any): void {198this._focus(this._items.indexOf(item!), payload);199}200201focusPrev(payload?: any): void {202if (this._focusedItemIdx > 0) {203this._focus(this._focusedItemIdx - 1, payload);204}205}206207focusNext(payload?: any): void {208if (this._focusedItemIdx + 1 < this._nodes.length) {209this._focus(this._focusedItemIdx + 1, payload);210}211}212213private _focus(nth: number, payload: any): void {214this._focusedItemIdx = -1;215for (let i = 0; i < this._nodes.length; i++) {216const node = this._nodes[i];217if (i !== nth) {218node.classList.remove('focused');219} else {220this._focusedItemIdx = i;221node.classList.add('focused');222node.focus();223}224}225this._reveal(this._focusedItemIdx, true);226this._onDidFocusItem.fire({ type: 'focus', item: this._items[this._focusedItemIdx], node: this._nodes[this._focusedItemIdx], payload });227}228229reveal(item: BreadcrumbsItem): void {230const idx = this._items.indexOf(item);231if (idx >= 0) {232this._reveal(idx, false);233}234}235236revealLast(): void {237this._reveal(this._items.length - 1, false);238}239240private _reveal(nth: number, minimal: boolean): void {241if (nth < 0 || nth >= this._nodes.length) {242return;243}244const node = this._nodes[nth];245if (!node) {246return;247}248const { width } = this._scrollable.getScrollDimensions();249const { scrollLeft } = this._scrollable.getScrollPosition();250if (!minimal || node.offsetLeft > scrollLeft + width || node.offsetLeft < scrollLeft) {251this._scrollable.setRevealOnScroll(false);252this._scrollable.setScrollPosition({ scrollLeft: node.offsetLeft });253this._scrollable.setRevealOnScroll(true);254}255}256257getSelection(): BreadcrumbsItem {258return this._items[this._selectedItemIdx];259}260261setSelection(item: BreadcrumbsItem | undefined, payload?: any): void {262this._select(this._items.indexOf(item!), payload);263}264265private _select(nth: number, payload: any): void {266this._selectedItemIdx = -1;267for (let i = 0; i < this._nodes.length; i++) {268const node = this._nodes[i];269if (i !== nth) {270node.classList.remove('selected');271} else {272this._selectedItemIdx = i;273node.classList.add('selected');274}275}276this._onDidSelectItem.fire({ type: 'select', item: this._items[this._selectedItemIdx], node: this._nodes[this._selectedItemIdx], payload });277}278279getItems(): readonly BreadcrumbsItem[] {280return this._items;281}282283setItems(items: BreadcrumbsItem[]): void {284let prefix: number | undefined;285let removed: BreadcrumbsItem[] = [];286try {287prefix = commonPrefixLength(this._items, items, (a, b) => a.equals(b));288removed = this._items.splice(prefix, this._items.length - prefix, ...items.slice(prefix));289this._render(prefix);290dispose(removed);291dispose(items.slice(0, prefix));292this._focus(-1, undefined);293} catch (e) {294const newError = new Error(`BreadcrumbsItem#setItems: newItems: ${items.length}, prefix: ${prefix}, removed: ${removed.length}`);295newError.name = e.name;296newError.stack = e.stack;297throw newError;298}299}300301private _render(start: number): void {302let didChange = false;303for (; start < this._items.length && start < this._nodes.length; start++) {304const item = this._items[start];305const node = this._nodes[start];306this._renderItem(item, node);307didChange = true;308}309// case a: more nodes -> remove them310while (start < this._nodes.length) {311const free = this._nodes.pop();312if (free) {313this._freeNodes.push(free);314free.remove();315didChange = true;316}317}318319// case b: more items -> render them320for (; start < this._items.length; start++) {321const item = this._items[start];322const node = this._freeNodes.length > 0 ? this._freeNodes.pop() : document.createElement('div');323if (node) {324this._renderItem(item, node);325this._domNode.appendChild(node);326this._nodes.push(node);327didChange = true;328}329}330if (didChange) {331this.layout(undefined);332}333}334335private _renderItem(item: BreadcrumbsItem, container: HTMLDivElement): void {336dom.clearNode(container);337container.className = '';338try {339item.render(container);340} catch (err) {341container.textContent = '<<RENDER ERROR>>';342console.error(err);343}344container.tabIndex = -1;345container.setAttribute('role', 'listitem');346container.classList.add('monaco-breadcrumb-item');347const iconContainer = dom.$(ThemeIcon.asCSSSelector(this._separatorIcon));348container.appendChild(iconContainer);349}350351private _onClick(event: IMouseEvent): void {352if (!this._enabled) {353return;354}355for (let el: HTMLElement | null = event.target; el; el = el.parentElement) {356const idx = this._nodes.indexOf(el as HTMLDivElement);357if (idx >= 0) {358this._focus(idx, event);359this._select(idx, event);360break;361}362}363}364}365366367