chrome-devtools-frontend
Version:
Chrome DevTools UI
1,467 lines (1,288 loc) • 48.6 kB
text/typescript
/*
* Copyright (C) 2010 Google Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/* eslint-disable rulesdir/no-imperative-dom-api */
import './Toolbar.js';
import * as Common from '../../core/common/common.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import * as IconButton from '../components/icon_button/icon_button.js';
import * as ARIAUtils from './ARIAUtils.js';
import {ContextMenu} from './ContextMenu.js';
import {Constraints, Size} from './Geometry.js';
import tabbedPaneStyles from './tabbedPane.css.js';
import type {Toolbar} from './Toolbar.js';
import {Tooltip} from './Tooltip.js';
import {installDragHandle, invokeOnceAfterBatchUpdate} from './UIUtils.js';
import {VBox, type Widget} from './Widget.js';
import {Events as ZoomManagerEvents, ZoomManager} from './ZoomManager.js';
const UIStrings = {
/**
*@description The aria label for the button to open more tabs at the right tabbed pane in Elements tools
*/
moreTabs: 'More tabs',
/**
*@description Text in Tabbed Pane
*@example {tab} PH1
*/
closeS: 'Close {PH1}',
/**
*@description Text to close something
*/
close: 'Close',
/**
*@description Text on a menu option to close other drawers when right click on a drawer title
*/
closeOthers: 'Close others',
/**
*@description Text on a menu option to close the drawer to the right when right click on a drawer title
*/
closeTabsToTheRight: 'Close tabs to the right',
/**
*@description Text on a menu option to close all the drawers except Console when right click on a drawer title
*/
closeAll: 'Close all',
/**
*@description Indicates that a tab contains a preview feature (i.e., a beta / experimental feature).
*/
previewFeature: 'Preview feature',
/**
* @description Text to move a tab forwar.
*/
moveTabRight: 'Move right',
/**
* @description Text to move a tab backward.
*/
moveTabLeft: 'Move left',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/TabbedPane.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class TabbedPane extends Common.ObjectWrapper.eventMixin<EventTypes, typeof VBox>(VBox) {
private readonly headerElementInternal: HTMLElement;
private readonly headerContentsElement: HTMLElement;
tabSlider: HTMLDivElement;
readonly tabsElement: HTMLElement;
private readonly contentElementInternal: HTMLElement;
private tabs: TabbedPaneTab[];
private readonly tabsHistory: TabbedPaneTab[];
tabsById: Map<string, TabbedPaneTab>;
private currentTabLocked: boolean;
private autoSelectFirstItemOnShow: boolean;
private triggerDropDownTimeout: number|null;
private dropDownButton: HTMLDivElement;
private currentDevicePixelRatio: number;
private shrinkableTabs?: boolean;
private verticalTabLayout?: boolean;
private closeableTabs?: boolean;
private delegate?: TabbedPaneTabDelegate;
private currentTab?: TabbedPaneTab;
private sliderEnabled?: boolean;
private placeholderElement?: Element;
private focusedPlaceholderElement?: Element;
private placeholderContainerElement?: HTMLElement;
private lastSelectedOverflowTab?: TabbedPaneTab;
private overflowDisabled?: boolean;
private measuredDropDownButtonWidth?: number;
private leftToolbarInternal?: Toolbar;
private rightToolbarInternal?: Toolbar;
allowTabReorder?: boolean;
private automaticReorder?: boolean;
constructor(element?: HTMLElement) {
super(true, undefined, element);
this.registerRequiredCSS(tabbedPaneStyles);
this.element.classList.add('tabbed-pane');
this.contentElement.classList.add('tabbed-pane-shadow');
this.contentElement.tabIndex = -1;
this.setDefaultFocusedElement(this.contentElement);
this.headerElementInternal = this.contentElement.createChild('div', 'tabbed-pane-header');
this.headerContentsElement = this.headerElementInternal.createChild('div', 'tabbed-pane-header-contents');
this.tabSlider = document.createElement('div');
this.tabSlider.classList.add('tabbed-pane-tab-slider');
this.tabsElement = this.headerContentsElement.createChild('div', 'tabbed-pane-header-tabs');
this.tabsElement.setAttribute('role', 'tablist');
this.tabsElement.addEventListener('keydown', this.keyDown.bind(this), false);
this.contentElementInternal = this.contentElement.createChild('div', 'tabbed-pane-content');
this.contentElementInternal.createChild('slot');
this.tabs = [];
this.tabsHistory = [];
this.tabsById = new Map();
this.currentTabLocked = false;
this.autoSelectFirstItemOnShow = true;
this.triggerDropDownTimeout = null;
this.dropDownButton = this.createDropDownButton();
this.currentDevicePixelRatio = window.devicePixelRatio;
ZoomManager.instance().addEventListener(ZoomManagerEvents.ZOOM_CHANGED, this.zoomChanged, this);
this.makeTabSlider();
}
setAccessibleName(name: string): void {
ARIAUtils.setLabel(this.tabsElement, name);
}
setCurrentTabLocked(locked: boolean): void {
this.currentTabLocked = locked;
this.headerElementInternal.classList.toggle('locked', this.currentTabLocked);
}
setAutoSelectFirstItemOnShow(autoSelect: boolean): void {
this.autoSelectFirstItemOnShow = autoSelect;
}
get visibleView(): Widget|null {
return this.currentTab ? this.currentTab.view : null;
}
tabIds(): string[] {
return this.tabs.map(tab => tab.id);
}
tabIndex(tabId: string): number {
return this.tabs.findIndex(tab => tab.id === tabId);
}
tabViews(): Widget[] {
return this.tabs.map(tab => tab.view);
}
tabView(tabId: string): Widget|null {
const tab = this.tabsById.get(tabId);
return tab ? tab.view : null;
}
get selectedTabId(): string|null {
return this.currentTab ? this.currentTab.id : null;
}
setShrinkableTabs(shrinkableTabs: boolean): void {
this.shrinkableTabs = shrinkableTabs;
}
makeVerticalTabLayout(): void {
this.verticalTabLayout = true;
this.setTabSlider(false);
this.contentElement.classList.add('vertical-tab-layout');
this.invalidateConstraints();
}
setCloseableTabs(closeableTabs: boolean): void {
this.closeableTabs = closeableTabs;
}
override focus(): void {
if (this.visibleView) {
this.visibleView.focus();
} else {
this.contentElement.focus();
}
}
focusSelectedTabHeader(): void {
const selectedTab = this.currentTab;
if (selectedTab) {
selectedTab.tabElement.focus();
}
}
headerElement(): Element {
return this.headerElementInternal;
}
tabbedPaneContentElement(): Element {
return this.contentElementInternal;
}
setTabDelegate(delegate: TabbedPaneTabDelegate): void {
const tabs = this.tabs.slice();
for (let i = 0; i < tabs.length; ++i) {
tabs[i].setDelegate(delegate);
}
this.delegate = delegate;
}
appendTab(
id: string, tabTitle: string, view: Widget, tabTooltip?: string, userGesture?: boolean, isCloseable?: boolean,
isPreviewFeature?: boolean, index?: number, jslogContext?: string): void {
const closeable = typeof isCloseable === 'boolean' ? isCloseable : Boolean(this.closeableTabs);
const tab =
new TabbedPaneTab(this, id, tabTitle, closeable, Boolean(isPreviewFeature), view, tabTooltip, jslogContext);
tab.setDelegate((this.delegate as TabbedPaneTabDelegate));
console.assert(!this.tabsById.has(id), `Tabbed pane already contains a tab with id '${id}'`);
this.tabsById.set(id, tab);
tab.tabElement.tabIndex = -1;
tab.tabElement.setAttribute(
'jslog', `${VisualLogging.panelTabHeader().track({click: true, drag: true}).context(tab.jslogContext)}`);
if (index !== undefined) {
this.tabs.splice(index, 0, tab);
} else {
this.tabs.push(tab);
}
this.tabsHistory.push(tab);
if (this.tabsHistory[0] === tab && this.isShowing()) {
this.selectTab(tab.id, userGesture);
}
this.updateTabElements();
}
closeTab(id: string, userGesture?: boolean): void {
this.closeTabs([id], userGesture);
}
closeTabs(ids: string[], userGesture?: boolean): void {
if (ids.length === 0) {
return;
}
const focused = this.hasFocus();
for (let i = 0; i < ids.length; ++i) {
this.innerCloseTab(ids[i], userGesture);
}
this.updateTabElements();
if (this.tabsHistory.length) {
this.selectTab(this.tabsHistory[0].id, false);
}
if (focused) {
this.focus();
}
}
private innerCloseTab(id: string, userGesture?: boolean): true|undefined {
const tab = this.tabsById.get(id);
if (!tab) {
return;
}
if (userGesture && !tab.closeable) {
return;
}
if (this.currentTab && this.currentTab.id === id) {
this.hideCurrentTab();
}
this.tabsById.delete(id);
this.tabsHistory.splice(this.tabsHistory.indexOf(tab), 1);
this.tabs.splice(this.tabs.indexOf(tab), 1);
if (tab.shown) {
this.hideTabElement(tab);
}
const eventData: EventData = {prevTabId: undefined, tabId: id, view: tab.view, isUserGesture: userGesture};
this.dispatchEventToListeners(Events.TabClosed, eventData);
return true;
}
hasTab(tabId: string): boolean {
return this.tabsById.has(tabId);
}
otherTabs(id: string): string[] {
const result = [];
for (let i = 0; i < this.tabs.length; ++i) {
if (this.tabs[i].id !== id) {
result.push(this.tabs[i].id);
}
}
return result;
}
tabsToTheRight(id: string): string[] {
let index = -1;
for (let i = 0; i < this.tabs.length; ++i) {
if (this.tabs[i].id === id) {
index = i;
break;
}
}
if (index === -1) {
return [];
}
return this.tabs.slice(index + 1).map(function(tab) {
return tab.id;
});
}
private viewHasFocus(): boolean {
if (this.visibleView?.hasFocus()) {
return true;
}
const root = this.contentElement.getComponentRoot();
return root instanceof Document && this.contentElement === root.activeElement;
}
selectTab(id: string, userGesture?: boolean, forceFocus?: boolean): boolean {
if (this.currentTabLocked) {
return false;
}
const focused = this.viewHasFocus();
const tab = this.tabsById.get(id);
if (!tab) {
return false;
}
const eventData: EventData = {
prevTabId: this.currentTab ? this.currentTab.id : undefined,
tabId: id,
view: tab.view,
isUserGesture: userGesture,
};
this.dispatchEventToListeners(Events.TabInvoked, eventData);
if (this.currentTab && this.currentTab.id === id) {
return true;
}
this.suspendInvalidations();
this.hideCurrentTab();
this.showTab(tab);
this.resumeInvalidations();
this.currentTab = tab;
this.tabsHistory.splice(this.tabsHistory.indexOf(tab), 1);
this.tabsHistory.splice(0, 0, tab);
this.updateTabElements();
if (focused || forceFocus) {
this.focus();
}
this.dispatchEventToListeners(Events.TabSelected, eventData);
return true;
}
selectNextTab(): void {
const index = this.tabs.indexOf((this.currentTab as TabbedPaneTab));
const nextIndex = Platform.NumberUtilities.mod(index + 1, this.tabs.length);
this.selectTab(this.tabs[nextIndex].id, true);
}
selectPrevTab(): void {
const index = this.tabs.indexOf((this.currentTab as TabbedPaneTab));
const nextIndex = Platform.NumberUtilities.mod(index - 1, this.tabs.length);
this.selectTab(this.tabs[nextIndex].id, true);
}
getTabIndex(id: string): number {
const index = this.tabs.indexOf((this.tabsById.get(id) as TabbedPaneTab));
return index;
}
moveTabBackward(id: string, index: number): void {
this.insertBefore((this.tabsById.get(id) as TabbedPaneTab), index - 1);
this.updateTabSlider();
}
moveTabForward(id: string, index: number): void {
this.insertBefore((this.tabsById.get(id) as TabbedPaneTab), index + 2);
this.updateTabSlider();
}
lastOpenedTabIds(tabsCount: number): string[] {
function tabToTabId(tab: TabbedPaneTab): string {
return tab.id;
}
return this.tabsHistory.slice(0, tabsCount).map(tabToTabId);
}
setTabIcon(id: string, icon: IconButton.Icon.Icon|null): void {
const tab = this.tabsById.get(id);
if (!tab) {
return;
}
tab.setIcon(icon);
this.updateTabElements();
}
setTrailingTabIcon(id: string, icon: IconButton.Icon.Icon|null): void {
const tab = this.tabsById.get(id);
if (!tab) {
return;
}
tab.setSuffixElement(icon);
}
setSuffixElement(id: string, suffixElement: HTMLElement|null): void {
const tab = this.tabsById.get(id);
if (!tab) {
return;
}
tab.setSuffixElement(suffixElement);
this.updateTabElements();
}
setBadge(id: string, content: string|null): void {
const badge = document.createElement('span');
badge.textContent = content;
badge.classList.add('badge');
this.setSuffixElement(id, content ? badge : null);
}
setTabEnabled(id: string, enabled: boolean): void {
const tab = this.tabsById.get(id);
if (tab) {
tab.tabElement.classList.toggle('disabled', !enabled);
}
}
tabIsDisabled(id: string): boolean {
return !this.tabIsEnabled(id);
}
tabIsEnabled(id: string): boolean {
const tab = this.tabsById.get(id);
const disabled = tab?.tabElement.classList.contains('disabled') ?? false;
return !disabled;
}
private zoomChanged(): void {
this.clearMeasuredWidths();
if (this.isShowing()) {
this.updateTabElements();
}
}
private clearMeasuredWidths(): void {
for (let i = 0; i < this.tabs.length; ++i) {
delete this.tabs[i].measuredWidth;
}
}
changeTabTitle(id: string, tabTitle: string, tabTooltip?: string): void {
const tab = this.tabsById.get(id);
if (tab && tabTooltip !== undefined) {
tab.tooltip = tabTooltip;
}
if (tab && tab.title !== tabTitle) {
tab.title = tabTitle;
ARIAUtils.setLabel(tab.tabElement, tabTitle);
this.updateTabElements();
}
}
changeTabView(id: string, view: Widget): void {
const tab = this.tabsById.get(id);
if (!tab || tab.view === view) {
return;
}
this.suspendInvalidations();
const isSelected = this.currentTab && this.currentTab.id === id;
const shouldFocus = tab.view.hasFocus();
if (isSelected) {
this.hideTab(tab);
}
tab.view = view;
if (isSelected) {
this.showTab(tab);
}
if (shouldFocus) {
tab.view.focus();
}
this.resumeInvalidations();
}
override onResize(): void {
if (this.currentDevicePixelRatio !== window.devicePixelRatio) {
// Force recalculation of all tab widths on a DPI change
this.clearMeasuredWidths();
this.currentDevicePixelRatio = window.devicePixelRatio;
}
this.updateTabElements();
}
headerResized(): void {
this.updateTabElements();
}
override wasShown(): void {
const effectiveTab = this.currentTab || this.tabsHistory[0];
if (effectiveTab && this.autoSelectFirstItemOnShow) {
this.selectTab(effectiveTab.id);
}
this.updateTabElements();
}
makeTabSlider(): void {
if (this.verticalTabLayout) {
return;
}
this.setTabSlider(true);
}
private setTabSlider(enable: boolean): void {
this.sliderEnabled = enable;
this.tabSlider.classList.toggle('enabled', enable);
}
override calculateConstraints(): Constraints {
let constraints = super.calculateConstraints();
const minContentConstraints = new Constraints(new Size(0, 0), new Size(50, 50));
constraints = constraints.widthToMax(minContentConstraints).heightToMax(minContentConstraints);
if (this.verticalTabLayout) {
constraints = constraints.addWidth(new Constraints(new Size(120, 0)));
} else {
constraints = constraints.addHeight(new Constraints(new Size(0, 30)));
}
return constraints;
}
private updateTabElements(): void {
invokeOnceAfterBatchUpdate(this, this.innerUpdateTabElements);
}
setPlaceholderElement(element: Element, focusedElement?: Element): void {
this.placeholderElement = element;
if (focusedElement) {
this.focusedPlaceholderElement = focusedElement;
}
if (this.placeholderContainerElement) {
this.placeholderContainerElement.removeChildren();
this.placeholderContainerElement.appendChild(element);
}
}
async waitForTabElementUpdate(): Promise<void> {
this.innerUpdateTabElements();
}
private innerUpdateTabElements(): void {
if (!this.isShowing()) {
return;
}
if (!this.tabs.length) {
this.contentElementInternal.classList.add('has-no-tabs');
if (this.placeholderElement && !this.placeholderContainerElement) {
this.placeholderContainerElement =
this.contentElementInternal.createChild('div', 'tabbed-pane-placeholder fill');
this.placeholderContainerElement.appendChild(this.placeholderElement);
if (this.focusedPlaceholderElement) {
this.setDefaultFocusedElement(this.focusedPlaceholderElement);
}
}
} else {
this.contentElementInternal.classList.remove('has-no-tabs');
if (this.placeholderContainerElement) {
this.placeholderContainerElement.remove();
this.setDefaultFocusedElement(this.contentElement);
delete this.placeholderContainerElement;
}
}
this.measureDropDownButton();
this.adjustToolbarWidth();
this.updateWidths();
this.updateTabsDropDown();
this.updateTabSlider();
}
private adjustToolbarWidth(): void {
if (!this.rightToolbarInternal || !this.measuredDropDownButtonWidth) {
return;
}
const leftToolbarWidth = this.leftToolbarInternal?.getBoundingClientRect().width ?? 0;
const rightToolbarWidth = this.rightToolbarInternal.getBoundingClientRect().width;
const totalWidth = this.headerElementInternal.getBoundingClientRect().width;
if (!this.rightToolbarInternal.hasCompactLayout() &&
totalWidth - rightToolbarWidth - leftToolbarWidth < this.measuredDropDownButtonWidth + 10) {
this.rightToolbarInternal.setCompactLayout(true);
} else if (
this.rightToolbarInternal.hasCompactLayout() &&
// Estimate the right toolbar size in non-compact mode as 2 times its compact size.
totalWidth - 2 * rightToolbarWidth - leftToolbarWidth > this.measuredDropDownButtonWidth + 10) {
this.rightToolbarInternal.setCompactLayout(false);
}
}
private showTabElement(index: number, tab: TabbedPaneTab): void {
if (index >= this.tabsElement.children.length) {
this.tabsElement.appendChild(tab.tabElement);
} else {
this.tabsElement.insertBefore(tab.tabElement, this.tabsElement.children[index]);
}
tab.shown = true;
}
private hideTabElement(tab: TabbedPaneTab): void {
this.tabsElement.removeChild(tab.tabElement);
tab.shown = false;
}
private createDropDownButton(): HTMLDivElement {
const dropDownContainer = document.createElement('div');
dropDownContainer.classList.add('tabbed-pane-header-tabs-drop-down-container');
dropDownContainer.setAttribute('jslog', `${VisualLogging.dropDown('more-tabs').track({click: true})}`);
const chevronIcon = IconButton.Icon.create('chevron-double-right', 'chevron-icon');
const moreTabsString = i18nString(UIStrings.moreTabs);
dropDownContainer.title = moreTabsString;
ARIAUtils.markAsMenuButton(dropDownContainer);
ARIAUtils.setLabel(dropDownContainer, moreTabsString);
ARIAUtils.setExpanded(dropDownContainer, false);
dropDownContainer.tabIndex = 0;
dropDownContainer.appendChild(chevronIcon);
dropDownContainer.addEventListener('click', this.dropDownClicked.bind(this));
dropDownContainer.addEventListener('keydown', this.dropDownKeydown.bind(this));
dropDownContainer.addEventListener('mousedown', event => {
if (event.button !== 0 || this.triggerDropDownTimeout) {
return;
}
this.triggerDropDownTimeout = window.setTimeout(this.dropDownClicked.bind(this, event), 200);
});
return dropDownContainer;
}
private dropDownClicked(event: MouseEvent): void {
if (event.button !== 0) {
return;
}
if (this.triggerDropDownTimeout) {
clearTimeout(this.triggerDropDownTimeout);
this.triggerDropDownTimeout = null;
}
const rect = this.dropDownButton.getBoundingClientRect();
const menu = new ContextMenu(event, {
x: rect.left,
y: rect.bottom,
onSoftMenuClosed: () => {
ARIAUtils.setExpanded(this.dropDownButton, false);
},
});
for (const tab of this.tabs) {
if (tab.shown) {
continue;
}
if (this.numberOfTabsShown() === 0 && this.tabsHistory[0] === tab) {
menu.defaultSection().appendCheckboxItem(
tab.title, this.dropDownMenuItemSelected.bind(this, tab), {checked: true, jslogContext: tab.jslogContext});
} else {
menu.defaultSection().appendItem(
tab.title, this.dropDownMenuItemSelected.bind(this, tab), {jslogContext: tab.jslogContext});
}
}
void menu.show().then(() => ARIAUtils.setExpanded(this.dropDownButton, menu.isHostedMenuOpen()));
}
private dropDownKeydown(event: KeyboardEvent): void {
if (Platform.KeyboardUtilities.isEnterOrSpaceKey(event)) {
this.dropDownButton.click();
event.consume(true);
}
}
private dropDownMenuItemSelected(tab: TabbedPaneTab): void {
this.lastSelectedOverflowTab = tab;
this.selectTab(tab.id, true, true);
}
private totalWidth(): number {
return this.headerContentsElement.getBoundingClientRect().width;
}
private numberOfTabsShown(): number {
let numTabsShown = 0;
for (const tab of this.tabs) {
if (tab.shown) {
numTabsShown++;
}
}
return numTabsShown;
}
disableOverflowMenu(): void {
this.overflowDisabled = true;
}
private updateTabsDropDown(): void {
const tabsToShowIndexes =
this.tabsToShowIndexes(this.tabs, this.tabsHistory, this.totalWidth(), this.measuredDropDownButtonWidth || 0);
if (this.lastSelectedOverflowTab && this.numberOfTabsShown() !== tabsToShowIndexes.length) {
delete this.lastSelectedOverflowTab;
this.updateTabsDropDown();
return;
}
for (let i = 0; i < this.tabs.length; ++i) {
if (this.tabs[i].shown && tabsToShowIndexes.indexOf(i) === -1) {
this.hideTabElement(this.tabs[i]);
}
}
for (let i = 0; i < tabsToShowIndexes.length; ++i) {
const tab = this.tabs[tabsToShowIndexes[i]];
if (!tab.shown) {
this.showTabElement(i, tab);
}
}
if (!this.overflowDisabled) {
this.maybeShowDropDown(tabsToShowIndexes.length !== this.tabs.length);
}
}
private maybeShowDropDown(hasMoreTabs: boolean): void {
if (hasMoreTabs && !this.dropDownButton.parentElement) {
this.headerContentsElement.appendChild(this.dropDownButton);
} else if (!hasMoreTabs && this.dropDownButton.parentElement) {
this.headerContentsElement.removeChild(this.dropDownButton);
}
}
private measureDropDownButton(): void {
if (this.overflowDisabled || this.measuredDropDownButtonWidth) {
return;
}
this.dropDownButton.classList.add('measuring');
this.headerContentsElement.appendChild(this.dropDownButton);
this.measuredDropDownButtonWidth = this.dropDownButton.getBoundingClientRect().width;
this.headerContentsElement.removeChild(this.dropDownButton);
this.dropDownButton.classList.remove('measuring');
}
private updateWidths(): void {
const measuredWidths = this.measureWidths();
const maxWidth =
this.shrinkableTabs ? this.calculateMaxWidth(measuredWidths.slice(), this.totalWidth()) : Number.MAX_VALUE;
let i = 0;
for (const tab of this.tabs) {
tab.setWidth(this.verticalTabLayout ? -1 : Math.min(maxWidth, measuredWidths[i++]));
}
}
private measureWidths(): number[] {
// Add all elements to measure into this.tabsElement
this.tabsElement.style.setProperty('width', '2000px');
const measuringTabElements = new Map<HTMLElement, TabbedPaneTab>();
for (const tab of this.tabs) {
if (typeof tab.measuredWidth === 'number') {
continue;
}
const measuringTabElement = tab.createTabElement(/* measure */ true);
measuringTabElements.set(measuringTabElement, tab);
this.tabsElement.appendChild(measuringTabElement);
}
// Perform measurement
for (const [measuringTabElement, tab] of measuringTabElements) {
const width = measuringTabElement.getBoundingClientRect().width;
tab.measuredWidth = Math.ceil(width);
}
// Nuke elements from the UI
for (const measuringTabElement of measuringTabElements.keys()) {
measuringTabElement.remove();
}
// Combine the results.
const measuredWidths = [];
for (const tab of this.tabs) {
measuredWidths.push(tab.measuredWidth || 0);
}
this.tabsElement.style.removeProperty('width');
return measuredWidths;
}
private calculateMaxWidth(measuredWidths: number[], totalWidth: number): number {
if (!measuredWidths.length) {
return 0;
}
measuredWidths.sort(function(x, y) {
return x - y;
});
let totalMeasuredWidth = 0;
for (let i = 0; i < measuredWidths.length; ++i) {
totalMeasuredWidth += measuredWidths[i];
}
if (totalWidth >= totalMeasuredWidth) {
return measuredWidths[measuredWidths.length - 1];
}
let totalExtraWidth = 0;
for (let i = measuredWidths.length - 1; i > 0; --i) {
const extraWidth = measuredWidths[i] - measuredWidths[i - 1];
totalExtraWidth += (measuredWidths.length - i) * extraWidth;
if (totalWidth + totalExtraWidth >= totalMeasuredWidth) {
return measuredWidths[i - 1] +
(totalWidth + totalExtraWidth - totalMeasuredWidth) / (measuredWidths.length - i);
}
}
return totalWidth / measuredWidths.length;
}
private tabsToShowIndexes(
tabsOrdered: TabbedPaneTab[], tabsHistory: TabbedPaneTab[], totalWidth: number,
measuredDropDownButtonWidth: number): number[] {
const tabsToShowIndexes = [];
let totalTabsWidth = 0;
const tabCount = tabsOrdered.length;
const tabsToLookAt = tabsOrdered.slice(0);
if (this.currentTab !== undefined) {
tabsToLookAt.unshift(tabsToLookAt.splice(tabsToLookAt.indexOf(this.currentTab), 1)[0]);
}
if (this.lastSelectedOverflowTab !== undefined) {
tabsToLookAt.unshift(tabsToLookAt.splice(tabsToLookAt.indexOf(this.lastSelectedOverflowTab), 1)[0]);
}
for (let i = 0; i < tabCount; ++i) {
const tab = this.automaticReorder ? tabsHistory[i] : tabsToLookAt[i];
totalTabsWidth += tab.width();
let minimalRequiredWidth = totalTabsWidth;
if (i !== tabCount - 1) {
minimalRequiredWidth += measuredDropDownButtonWidth;
}
if (!this.verticalTabLayout && minimalRequiredWidth > totalWidth) {
break;
}
tabsToShowIndexes.push(tabsOrdered.indexOf(tab));
}
tabsToShowIndexes.sort(function(x, y) {
return x - y;
});
return tabsToShowIndexes;
}
private hideCurrentTab(): void {
if (!this.currentTab) {
return;
}
this.hideTab(this.currentTab);
delete this.currentTab;
}
private showTab(tab: TabbedPaneTab): void {
tab.tabElement.tabIndex = 0;
tab.tabElement.classList.add('selected');
ARIAUtils.setSelected(tab.tabElement, true);
tab.view.show(this.element);
this.updateTabSlider();
}
updateTabSlider(): void {
if (!this.sliderEnabled) {
return;
}
if (!this.currentTab) {
this.tabSlider.style.width = '0';
return;
}
let left = 0;
for (let i = 0; i < this.tabs.length && this.currentTab !== this.tabs[i]; i++) {
if (this.tabs[i].shown) {
left += this.tabs[i].measuredWidth || 0;
}
}
const sliderWidth = this.currentTab.shown ? this.currentTab.measuredWidth : this.dropDownButton.offsetWidth;
const scaleFactor = window.devicePixelRatio >= 1.5 ? ' scaleY(0.75)' : '';
this.tabSlider.style.transform = 'translateX(' + left + 'px)' + scaleFactor;
this.tabSlider.style.width = sliderWidth + 'px';
if (this.tabSlider.parentElement !== this.headerContentsElement) {
this.headerContentsElement.appendChild(this.tabSlider);
}
}
private hideTab(tab: TabbedPaneTab): void {
tab.tabElement.removeAttribute('tabIndex');
tab.tabElement.classList.remove('selected');
tab.tabElement.tabIndex = -1;
tab.tabElement.setAttribute('aria-selected', 'false');
tab.view.detach();
}
override elementsToRestoreScrollPositionsFor(): Element[] {
return [this.contentElementInternal];
}
insertBefore(tab: TabbedPaneTab, index: number): void {
this.tabsElement.insertBefore(tab.tabElement, this.tabsElement.childNodes[index]);
const oldIndex = this.tabs.indexOf(tab);
this.tabs.splice(oldIndex, 1);
if (oldIndex < index) {
--index;
}
this.tabs.splice(index, 0, tab);
const eventData: EventData = {prevTabId: undefined, tabId: tab.id, view: tab.view, isUserGesture: undefined};
this.dispatchEventToListeners(Events.TabOrderChanged, eventData);
}
leftToolbar(): Toolbar {
if (!this.leftToolbarInternal) {
this.leftToolbarInternal = document.createElement('devtools-toolbar');
this.leftToolbarInternal.classList.add('tabbed-pane-left-toolbar');
this.headerElementInternal.insertBefore(this.leftToolbarInternal, this.headerElementInternal.firstChild);
}
return this.leftToolbarInternal;
}
rightToolbar(): Toolbar {
if (!this.rightToolbarInternal) {
this.rightToolbarInternal = document.createElement('devtools-toolbar');
this.rightToolbarInternal.classList.add('tabbed-pane-right-toolbar');
this.headerElementInternal.appendChild(this.rightToolbarInternal);
}
return this.rightToolbarInternal;
}
setAllowTabReorder(allow: boolean, automatic?: boolean): void {
this.allowTabReorder = allow;
this.automaticReorder = automatic;
}
private keyDown(event: KeyboardEvent): void {
if (!this.currentTab) {
return;
}
let nextTabElement: (Element|null)|null = null;
switch (event.key) {
case 'ArrowUp':
case 'ArrowLeft':
nextTabElement = this.currentTab.tabElement.previousElementSibling;
if (!nextTabElement && !this.dropDownButton.parentElement) {
nextTabElement = this.currentTab.tabElement.parentElement ?
this.currentTab.tabElement.parentElement.lastElementChild :
null;
}
break;
case 'ArrowDown':
case 'ArrowRight':
nextTabElement = this.currentTab.tabElement.nextElementSibling;
if (!nextTabElement && !this.dropDownButton.parentElement) {
nextTabElement = this.currentTab.tabElement.parentElement ?
this.currentTab.tabElement.parentElement.firstElementChild :
null;
}
break;
case 'Enter':
case ' ':
this.currentTab.view.focus();
return;
default:
return;
}
if (!nextTabElement) {
this.dropDownButton.click();
return;
}
const tab = this.tabs.find(tab => tab.tabElement === nextTabElement);
if (tab) {
this.selectTab(tab.id, true);
}
(nextTabElement as HTMLElement).focus();
}
}
export interface EventData {
prevTabId?: string;
tabId: string;
view?: Widget;
isUserGesture?: boolean;
}
export enum Events {
/* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
TabInvoked = 'TabInvoked',
TabSelected = 'TabSelected',
TabClosed = 'TabClosed',
TabOrderChanged = 'TabOrderChanged',
/* eslint-enable @typescript-eslint/naming-convention */
}
export interface EventTypes {
[Events.TabInvoked]: EventData;
[Events.TabSelected]: EventData;
[Events.TabClosed]: EventData;
[Events.TabOrderChanged]: EventData;
}
export class TabbedPaneTab {
closeable: boolean;
previewFeature = false;
private readonly tabbedPane: TabbedPane;
idInternal: string;
private titleInternal: string;
private tooltipInternal: string|undefined;
private viewInternal: Widget;
shown: boolean;
measuredWidth!: number|undefined;
private tabElementInternal!: HTMLElement|undefined;
private icon: IconButton.Icon.Icon|null = null;
private suffixElement: HTMLElement|null = null;
private widthInternal?: number;
private delegate?: TabbedPaneTabDelegate;
private titleElement?: HTMLElement;
private dragStartX?: number;
private jslogContextInternal?: string;
constructor(
tabbedPane: TabbedPane, id: string, title: string, closeable: boolean, previewFeature: boolean, view: Widget,
tooltip?: string, jslogContext?: string) {
this.closeable = closeable;
this.previewFeature = previewFeature;
this.tabbedPane = tabbedPane;
this.idInternal = id;
this.titleInternal = title;
this.tooltipInternal = tooltip;
this.viewInternal = view;
this.shown = false;
this.jslogContextInternal = jslogContext;
}
get id(): string {
return this.idInternal;
}
get title(): string {
return this.titleInternal;
}
set title(title: string) {
if (title === this.titleInternal) {
return;
}
this.titleInternal = title;
if (this.titleElement) {
this.titleElement.textContent = title;
const closeIconContainer = this.tabElementInternal?.querySelector('.close-button');
closeIconContainer?.setAttribute('title', i18nString(UIStrings.closeS, {PH1: title}));
closeIconContainer?.setAttribute('aria-label', i18nString(UIStrings.closeS, {PH1: title}));
}
delete this.measuredWidth;
}
get jslogContext(): string {
return this.jslogContextInternal ?? (this.idInternal === 'console-view' ? 'console' : this.idInternal);
}
isCloseable(): boolean {
return this.closeable;
}
setIcon(icon: IconButton.Icon.Icon|null): void {
this.icon = icon;
if (this.tabElementInternal && this.titleElement) {
this.createIconElement(this.tabElementInternal, this.titleElement, false);
}
delete this.measuredWidth;
}
setSuffixElement(suffixElement: HTMLElement|null): void {
this.suffixElement = suffixElement;
if (this.tabElementInternal && this.titleElement) {
this.createSuffixElement(this.tabElementInternal, this.titleElement, false);
}
delete this.measuredWidth;
}
toggleClass(className: string, force?: boolean): boolean {
const element = this.tabElement;
const hasClass = element.classList.contains(className);
if (hasClass === force) {
return false;
}
element.classList.toggle(className, force);
delete this.measuredWidth;
return true;
}
get view(): Widget {
return this.viewInternal;
}
set view(view: Widget) {
this.viewInternal = view;
}
get tooltip(): string|undefined {
return this.tooltipInternal;
}
set tooltip(tooltip: string|undefined) {
this.tooltipInternal = tooltip;
if (this.titleElement) {
Tooltip.install(this.titleElement, tooltip || '');
}
}
get tabElement(): HTMLElement {
if (!this.tabElementInternal) {
this.tabElementInternal = this.createTabElement(false);
}
return this.tabElementInternal;
}
width(): number {
return this.widthInternal || 0;
}
setWidth(width: number): void {
this.tabElement.style.width = width === -1 ? '' : (width + 'px');
this.widthInternal = width;
}
setDelegate(delegate: TabbedPaneTabDelegate): void {
this.delegate = delegate;
}
private createIconElement(tabElement: Element, titleElement: Element, measuring: boolean): void {
const iconElement = tabIcons.get(tabElement);
if (iconElement) {
iconElement.remove();
tabIcons.delete(tabElement);
}
if (!this.icon) {
return;
}
const iconContainer = document.createElement('span');
iconContainer.classList.add('tabbed-pane-header-tab-icon');
const iconNode = measuring ? this.createMeasureClone(this.icon) : this.icon;
iconContainer.appendChild(iconNode);
titleElement.insertAdjacentElement('beforebegin', iconContainer);
tabIcons.set(tabElement, iconContainer);
}
private createSuffixElement(tabElement: Element, titleElement: Element, measuring: boolean): void {
const tabSuffixElement = tabSuffixElements.get(tabElement);
if (tabSuffixElement) {
tabSuffixElement.remove();
tabSuffixElements.delete(tabElement);
}
if (!this.suffixElement) {
return;
}
const suffixElementContainer = document.createElement('span');
suffixElementContainer.classList.add('tabbed-pane-header-tab-suffix-element');
const suffixElement = measuring ? this.suffixElement.cloneNode() : this.suffixElement;
suffixElementContainer.appendChild(suffixElement);
titleElement.insertAdjacentElement('afterend', suffixElementContainer);
tabSuffixElements.set(tabElement, suffixElementContainer);
}
private createMeasureClone(original: IconButton.Icon.Icon): Element {
// Cloning doesn't work for the icon component because the shadow
// root isn't copied, but it is sufficient to create a div styled
// to be the same size.
const fakeClone = document.createElement('div');
fakeClone.style.width = original.style.width;
fakeClone.style.height = original.style.height;
return fakeClone;
}
createTabElement(measuring: boolean): HTMLElement {
const tabElement = document.createElement('div');
tabElement.classList.add('tabbed-pane-header-tab');
tabElement.id = 'tab-' + this.idInternal;
ARIAUtils.markAsTab(tabElement);
ARIAUtils.setSelected(tabElement, false);
ARIAUtils.setLabel(tabElement, this.title);
const titleElement = tabElement.createChild('span', 'tabbed-pane-header-tab-title');
titleElement.textContent = this.title;
Tooltip.install(titleElement, this.tooltip || '');
this.createIconElement(tabElement, titleElement, measuring);
this.createSuffixElement(tabElement, titleElement, measuring);
if (!measuring) {
this.titleElement = titleElement;
}
if (this.previewFeature) {
const previewIcon = this.createPreviewIcon();
tabElement.appendChild(previewIcon);
tabElement.classList.add('preview');
}
if (this.closeable) {
const closeIcon = this.createCloseIconButton();
tabElement.appendChild(closeIcon);
tabElement.classList.add('closeable');
}
if (measuring) {
tabElement.classList.add('measuring');
} else {
tabElement.addEventListener('click', this.tabClicked.bind(this), false);
tabElement.addEventListener('keydown', this.tabKeyDown.bind(this), false);
tabElement.addEventListener('auxclick', this.tabClicked.bind(this), false);
tabElement.addEventListener('mousedown', this.tabMouseDown.bind(this), false);
tabElement.addEventListener('mouseup', this.tabMouseUp.bind(this), false);
tabElement.addEventListener('contextmenu', this.tabContextMenu.bind(this), false);
if (this.tabbedPane.allowTabReorder) {
installDragHandle(
tabElement, this.startTabDragging.bind(this), this.tabDragging.bind(this), this.endTabDragging.bind(this),
null, null, 200);
}
}
return tabElement as HTMLElement;
}
private createCloseIconButton(): Buttons.Button.Button {
const closeButton = new Buttons.Button.Button();
closeButton.data = {
variant: Buttons.Button.Variant.ICON,
size: Buttons.Button.Size.MICRO,
iconName: 'cross',
title: i18nString(UIStrings.closeS, {PH1: this.title}),
};
closeButton.classList.add('close-button', 'tabbed-pane-close-button');
closeButton.setAttribute('jslog', `${VisualLogging.close().track({click: true})}`);
closeButton.setAttribute('aria-label', i18nString(UIStrings.closeS, {PH1: this.title}));
return closeButton;
}
private createPreviewIcon(): HTMLDivElement {
const previewIcon = document.createElement('div');
previewIcon.classList.add('preview-icon');
const closeIcon = new IconButton.Icon.Icon();
closeIcon.data = {
iconName: 'experiment',
color: 'var(--override-tabbed-pane-preview-icon-color)',
height: '14px',
width: '14px',
};
previewIcon.appendChild(closeIcon);
previewIcon.setAttribute('title', i18nString(UIStrings.previewFeature));
previewIcon.setAttribute('aria-label', i18nString(UIStrings.previewFeature));
return previewIcon;
}
private isCloseIconClicked(element: HTMLElement): boolean {
return element?.classList.contains('tabbed-pane-close-button') ||
element?.parentElement?.classList.contains('tabbed-pane-close-button') || false;
}
private tabKeyDown(ev: Event): void {
const event = ev as KeyboardEvent;
switch (event.key) {
case 'Enter':
case ' ':
if (this.isCloseIconClicked(event.target as HTMLElement)) {
this.closeTabs([this.id]);
ev.consume(true);
return;
}
}
}
private tabClicked(event: MouseEvent): void {
const middleButton = event.button === 1;
const shouldClose = this.closeable && (middleButton || this.isCloseIconClicked(event.target as HTMLElement));
if (!shouldClose) {
this.tabbedPane.focus();
return;
}
this.closeTabs([this.id]);
event.consume(true);
}
private tabMouseDown(event: MouseEvent): void {
if (this.isCloseIconClicked(event.target as HTMLElement) || event.button !== 0) {
return;
}
this.tabbedPane.selectTab(this.id, true);
}
private tabMouseUp(event: MouseEvent): void {
// This is needed to prevent middle-click pasting on linux when tabs are clicked.
if (event.button === 1) {
event.consume(true);
}
}
private closeTabs(ids: string[]): void {
if (this.delegate) {
this.delegate.closeTabs(this.tabbedPane, ids);
return;
}
this.tabbedPane.closeTabs(ids, true);
}
private tabContextMenu(event: Event): void {
function close(this: TabbedPaneTab): void {
this.closeTabs([this.id]);
}
function closeOthers(this: TabbedPaneTab): void {
this.closeTabs(this.tabbedPane.otherTabs(this.id));
}
function closeAll(this: TabbedPaneTab): void {
this.closeTabs(this.tabbedPane.tabIds());
}
function closeToTheRight(this: TabbedPaneTab): void {
this.closeTabs(this.tabbedPane.tabsToTheRight(this.id));
}
function moveTabForward(this: TabbedPaneTab, tabIndex: number): void {
this.tabbedPane.moveTabForward(this.id, tabIndex);
}
function moveTabBackward(this: TabbedPaneTab, tabIndex: number): void {
this.tabbedPane.moveTabBackward(this.id, tabIndex);
}
const contextMenu = new ContextMenu(event);
if (this.closeable) {
contextMenu.defaultSection().appendItem(i18nString(UIStrings.close), close.bind(this), {jslogContext: 'close'});
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.closeOthers), closeOthers.bind(this), {jslogContext: 'close-others'});
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.closeTabsToTheRight), closeToTheRight.bind(this),
{jslogContext: 'close-tabs-to-the-right'});
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.closeAll), closeAll.bind(this), {jslogContext: 'close-all'});
}
if (this.delegate) {
this.delegate.onContextMenu(this.id, contextMenu);
}
const tabIndex = this.tabbedPane.getTabIndex(this.id);
if (tabIndex > 0) {
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.moveTabLeft), moveTabBackward.bind(this, tabIndex), {jslogContext: 'move-tab-backward'});
}
if (tabIndex < this.tabbedPane.tabsElement.childNodes.length - 1) {
contextMenu.defaultSection().appendItem(
i18nString(UIStrings.moveTabRight), moveTabForward.bind(this, tabIndex), {jslogContext: 'move-tab-forward'});
}
void contextMenu.show();
}
private startTabDragging(event: MouseEvent): boolean {
if (this.isCloseIconClicked(event.target as HTMLElement)) {
return false;
}
this.dragStartX = event.pageX;
if (this.tabElementInternal) {
this.tabElementInternal.classList.add('dragging');
}
this.tabbedPane.tabSlider.remove();
return true;
}
private tabDragging(event: MouseEvent): void {
const tabElements = this.tabbedPane.tabsElement.childNodes;
for (let i = 0; i < tabElements.length; ++i) {
let tabElement: HTMLElement = (tabElements[i] as HTMLElement);
if (!this.tabElementInternal || tabElement === this.tabElementInternal) {
continue;
}
const intersects = tabElement.offsetLeft + tabElement.clientWidth > this.tabElementInternal.offsetLeft &&
this.tabElementInternal.offsetLeft + this.tabElementInternal.clientWidth > tabElement.offsetLeft;
if (!intersects) {
continue;
}
const dragStartX = (this.dragStartX as number);
if (Math.abs(event.pageX - dragStartX) < tabElement.clientWidth / 2 + 5) {
break;
}
if (event.pageX - dragStartX > 0) {
tabElement = (tabElement.nextSibling as HTMLElement);
++i;
}
const oldOffsetLeft = this.tabElementInternal.offsetLeft;
this.tabbedPane.insertBefore(this, i);
this.dragStartX = dragStartX + this.tabElementInternal.offsetLeft - oldOffsetLeft;
break;
}
const dragStartX = (this.dragStartX as number);
const tabElement = (this.tabElementInternal as HTMLElement);
if (!tabElement.previousSibling && event.pageX - dragStartX < 0) {
tabElement.style.setProperty('left', '0px');
return;
}
if (!tabElement.nextSibling && event.pageX - dragStartX > 0) {
tabElement.style.setProperty('left', '0px');
return;
}
tabElement.style.setProperty('left', (event.pageX - dragStartX) + 'px');
}
private endTabDragging(_event: Event): void {
const tabElement = (this.tabElementInternal as HTMLElement);
tabElement.classList.remove('dragging');
tabElement.style.removeProperty('left');
delete this.dragStartX;
this.tabbedPane.updateTabSlider();
}
}
const tabIcons = new WeakMap<Element, Element>();
const tabSuffixElements = new WeakMap<Element, Element>();
export interface TabbedPaneTabDelegate {
closeTabs(tabbedPane: TabbedPane, ids: string[]): void;
onContextMenu(tabId: string, contextMenu: ContextMenu): void;
}