UNPKG

chrome-devtools-frontend

Version:
1,135 lines (974 loc) • 37 kB
/* * Copyright (C) 2009 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. */ import * as Common from '../../core/common/common.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Platform from '../../core/platform/platform.js'; import * as Root from '../../core/root/root.js'; import * as IconButton from '../components/icon_button/icon_button.js'; import * as Utils from './utils/utils.js'; import {Events as ActionEvents, type Action} from './ActionRegistration.js'; import {ActionRegistry} from './ActionRegistry.js'; import * as ARIAUtils from './ARIAUtils.js'; import {ContextMenu} from './ContextMenu.js'; import {GlassPane, PointerEventsBehavior} from './GlassPane.js'; import {Icon} from './Icon.js'; import {bindCheckbox} from './SettingsUI.js'; import {type Suggestion} from './SuggestBox.js'; import {Events as TextPromptEvents, TextPrompt} from './TextPrompt.js'; import toolbarStyles from './toolbar.css.legacy.js'; import {Tooltip} from './Tooltip.js'; import {CheckboxLabel, LongClickController} from './UIUtils.js'; const UIStrings = { /** *@description Announced screen reader message for ToolbarSettingToggle when the setting is toggled on. */ pressed: 'pressed', /** *@description Announced screen reader message for ToolbarSettingToggle when the setting is toggled off. */ notPressed: 'not pressed', /** *@description Tooltip shown when the user hovers over the clear icon to empty the text input. */ clearInput: 'Clear input', }; const str_ = i18n.i18n.registerUIStrings('ui/legacy/Toolbar.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export class Toolbar { private items: ToolbarItem[]; element: HTMLElement; enabled: boolean; private readonly shadowRoot: ShadowRoot; private contentElement: Element; private compactLayout = false; constructor(className: string, parentElement?: Element) { this.items = []; this.element = (parentElement ? parentElement.createChild('div') : document.createElement('div')) as HTMLElement; this.element.className = className; this.element.classList.add('toolbar'); this.enabled = true; this.shadowRoot = Utils.createShadowRootWithCoreStyles(this.element, {cssFile: toolbarStyles, delegatesFocus: undefined}); this.contentElement = this.shadowRoot.createChild('div', 'toolbar-shadow'); } hasCompactLayout(): boolean { return this.compactLayout; } registerCSSFiles(cssFiles: CSSStyleSheet[]): void { this.shadowRoot.adoptedStyleSheets = this.shadowRoot.adoptedStyleSheets.concat(cssFiles); } setCompactLayout(enable: boolean): void { if (this.compactLayout === enable) { return; } this.compactLayout = enable; for (const item of this.items) { item.setCompactLayout(enable); } } static createLongPressActionButton( action: Action, toggledOptions: ToolbarButton[], untoggledOptions: ToolbarButton[]): ToolbarButton { const button = Toolbar.createActionButton(action); const mainButtonClone = Toolbar.createActionButton(action); let longClickController: LongClickController|null = null; let longClickButtons: ToolbarButton[]|null = null; let longClickGlyph: Icon|null = null; action.addEventListener(ActionEvents.Toggled, updateOptions); updateOptions(); return button; function updateOptions(): void { const buttons = action.toggled() ? (toggledOptions || null) : (untoggledOptions || null); if (buttons && buttons.length) { if (!longClickController) { longClickController = new LongClickController(button.element, showOptions); longClickGlyph = Icon.create('triangle-bottom-right', 'long-click-glyph'); button.element.appendChild(longClickGlyph); longClickButtons = buttons; } } else { if (longClickController) { longClickController.dispose(); longClickController = null; if (longClickGlyph) { longClickGlyph.remove(); } longClickGlyph = null; longClickButtons = null; } } } function showOptions(): void { let buttons: ToolbarButton[] = longClickButtons ? longClickButtons.slice() : []; buttons.push(mainButtonClone); const document = button.element.ownerDocument; document.documentElement.addEventListener('mouseup', mouseUp, false); const optionsGlassPane = new GlassPane(); optionsGlassPane.setPointerEventsBehavior(PointerEventsBehavior.BlockedByGlassPane); optionsGlassPane.show(document); const optionsBar = new Toolbar('fill', optionsGlassPane.contentElement); optionsBar.contentElement.classList.add('floating'); const buttonHeight = 26; const hostButtonPosition = button.element.boxInWindow().relativeToElement(GlassPane.container(document)); const topNotBottom = hostButtonPosition.y + buttonHeight * buttons.length < document.documentElement.offsetHeight; if (topNotBottom) { buttons = buttons.reverse(); } optionsBar.element.style.height = (buttonHeight * buttons.length) + 'px'; if (topNotBottom) { optionsBar.element.style.top = (hostButtonPosition.y - 5) + 'px'; } else { optionsBar.element.style.top = (hostButtonPosition.y - (buttonHeight * (buttons.length - 1)) - 6) + 'px'; } optionsBar.element.style.left = (hostButtonPosition.x - 5) + 'px'; for (let i = 0; i < buttons.length; ++i) { buttons[i].element.addEventListener('mousemove', mouseOver, false); buttons[i].element.addEventListener('mouseout', mouseOut, false); optionsBar.appendToolbarItem(buttons[i]); } const hostButtonIndex = topNotBottom ? 0 : buttons.length - 1; buttons[hostButtonIndex].element.classList.add('emulate-active'); function mouseOver(e: Event): void { if ((e as MouseEvent).which !== 1) { return; } if (e.target instanceof HTMLElement) { const buttonElement = e.target.enclosingNodeOrSelfWithClass('toolbar-item'); buttonElement.classList.add('emulate-active'); } } function mouseOut(e: Event): void { if ((e as MouseEvent).which !== 1) { return; } if (e.target instanceof HTMLElement) { const buttonElement = e.target.enclosingNodeOrSelfWithClass('toolbar-item'); buttonElement.classList.remove('emulate-active'); } } function mouseUp(e: Event): void { if ((e as MouseEvent).which !== 1) { return; } optionsGlassPane.hide(); document.documentElement.removeEventListener('mouseup', mouseUp, false); for (let i = 0; i < buttons.length; ++i) { if (buttons[i].element.classList.contains('emulate-active')) { buttons[i].element.classList.remove('emulate-active'); buttons[i].clicked(e); break; } } } } } static createActionButton(action: Action, options: ToolbarButtonOptions|undefined = TOOLBAR_BUTTON_DEFAULT_OPTIONS): ToolbarButton { const button = action.toggleable() ? makeToggle() : makeButton(); if (options.showLabel) { button.setText(action.title()); } let handler = (_event: { // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any, }): void => { void action.execute(); }; if (options.userActionCode) { const actionCode = options.userActionCode; handler = (): void => { Host.userMetrics.actionTaken(actionCode); void action.execute(); }; } button.addEventListener(ToolbarButton.Events.Click, handler, action); action.addEventListener(ActionEvents.Enabled, enabledChanged); button.setEnabled(action.enabled()); return button; function makeButton(): ToolbarButton { const button = new ToolbarButton(action.title(), action.icon()); if (action.title()) { Tooltip.installWithActionBinding(button.element, action.title(), action.id()); } return button; } function makeToggle(): ToolbarToggle { const toggleButton = new ToolbarToggle(action.title(), action.icon(), action.toggledIcon()); toggleButton.setToggleWithRedColor(action.toggleWithRedColor()); action.addEventListener(ActionEvents.Toggled, toggled); toggled(); return toggleButton; function toggled(): void { toggleButton.setToggled(action.toggled()); if (action.title()) { toggleButton.setTitle(action.title()); Tooltip.installWithActionBinding(toggleButton.element, action.title(), action.id()); } } } function enabledChanged(event: Common.EventTarget.EventTargetEvent<boolean>): void { button.setEnabled(event.data); } } static createActionButtonForId( actionId: string, options: ToolbarButtonOptions|undefined = TOOLBAR_BUTTON_DEFAULT_OPTIONS): ToolbarButton { const action = ActionRegistry.instance().action(actionId); return Toolbar.createActionButton((action as Action), options); } gripElementForResize(): Element { return this.contentElement; } makeWrappable(growVertically?: boolean): void { this.contentElement.classList.add('wrappable'); if (growVertically) { this.contentElement.classList.add('toolbar-grow-vertical'); } } makeVertical(): void { this.contentElement.classList.add('vertical'); } makeBlueOnHover(): void { this.contentElement.classList.add('toolbar-blue-on-hover'); } makeToggledGray(): void { this.contentElement.classList.add('toolbar-toggled-gray'); } renderAsLinks(): void { this.contentElement.classList.add('toolbar-render-as-links'); } empty(): boolean { return !this.items.length; } setEnabled(enabled: boolean): void { this.enabled = enabled; for (const item of this.items) { item.applyEnabledState(this.enabled && item.enabled); } } appendToolbarItem(item: ToolbarItem): void { this.items.push(item); item.toolbar = this; item.setCompactLayout(this.hasCompactLayout()); if (!this.enabled) { item.applyEnabledState(false); } this.contentElement.appendChild(item.element); this.hideSeparatorDupes(); } appendSeparator(): void { this.appendToolbarItem(new ToolbarSeparator()); } appendSpacer(): void { this.appendToolbarItem(new ToolbarSeparator(true)); } appendText(text: string): void { this.appendToolbarItem(new ToolbarText(text)); } removeToolbarItem(itemToRemove: ToolbarItem): void { const updatedItems = []; for (const item of this.items) { if (item === itemToRemove) { item.element.remove(); } else { updatedItems.push(item); } } this.items = updatedItems; } removeToolbarItems(): void { for (const item of this.items) { item.toolbar = null; } this.items = []; this.contentElement.removeChildren(); } setColor(color: string): void { const style = document.createElement('style'); style.textContent = '.toolbar-glyph { background-color: ' + color + ' !important }'; this.shadowRoot.appendChild(style); } setToggledColor(color: string): void { const style = document.createElement('style'); style.textContent = '.toolbar-button.toolbar-state-on .toolbar-glyph { background-color: ' + color + ' !important }'; this.shadowRoot.appendChild(style); } hideSeparatorDupes(): void { if (!this.items.length) { return; } // Don't hide first and last separators if they were added explicitly. let previousIsSeparator = false; let lastSeparator; let nonSeparatorVisible = false; for (let i = 0; i < this.items.length; ++i) { if (this.items[i] instanceof ToolbarSeparator) { this.items[i].setVisible(!previousIsSeparator); previousIsSeparator = true; lastSeparator = this.items[i]; continue; } if (this.items[i].visible()) { previousIsSeparator = false; lastSeparator = null; nonSeparatorVisible = true; } } if (lastSeparator && lastSeparator !== this.items[this.items.length - 1]) { lastSeparator.setVisible(false); } this.element.classList.toggle( 'hidden', lastSeparator !== null && lastSeparator !== undefined && lastSeparator.visible() && !nonSeparatorVisible); } async appendItemsAtLocation(location: string): Promise<void> { const extensions: ToolbarItemRegistration[] = getRegisteredToolbarItems(); extensions.sort((extension1, extension2) => { const order1 = extension1.order || 0; const order2 = extension2.order || 0; return order1 - order2; }); const filtered = extensions.filter(e => e.location === location); const items = await Promise.all(filtered.map(extension => { const {separator, actionId, showLabel, loadItem} = extension; if (separator) { return new ToolbarSeparator(); } if (actionId) { return Toolbar.createActionButtonForId(actionId, {showLabel: Boolean(showLabel), userActionCode: undefined}); } // TODO(crbug.com/1134103) constratint the case checked with this if using TS type definitions once UI is TS-authored. if (!loadItem) { throw new Error('Could not load a toolbar item registration with no loadItem function'); } return loadItem().then(p => (p as Provider).item()); })); for (const item of items) { if (item) { this.appendToolbarItem(item); } } } } export interface ToolbarButtonOptions { showLabel: boolean; userActionCode?: Host.UserMetrics.Action; } const TOOLBAR_BUTTON_DEFAULT_OPTIONS: ToolbarButtonOptions = { showLabel: false, userActionCode: undefined, }; // We need any here because Common.ObjectWrapper.ObjectWrapper is invariant in T. // eslint-disable-next-line @typescript-eslint/no-explicit-any export class ToolbarItem<T = any> extends Common.ObjectWrapper.ObjectWrapper<T> { element: HTMLElement; private visibleInternal: boolean; enabled: boolean; toolbar: Toolbar|null; protected title?: string; constructor(element: Element) { super(); this.element = (element as HTMLElement); this.element.classList.add('toolbar-item'); this.visibleInternal = true; this.enabled = true; /** * Set by the parent toolbar during appending. */ this.toolbar = null; } setTitle(title: string, actionId: string|undefined = undefined): void { if (this.title === title) { return; } this.title = title; ARIAUtils.setAccessibleName(this.element, title); if (actionId === undefined) { Tooltip.install(this.element, title); } else { Tooltip.installWithActionBinding(this.element, title, actionId); } } setEnabled(value: boolean): void { if (this.enabled === value) { return; } this.enabled = value; this.applyEnabledState(this.enabled && (!this.toolbar || this.toolbar.enabled)); } applyEnabledState(enabled: boolean): void { // @ts-ignore: Ignoring in favor of an `instanceof` check for all the different // kind of HTMLElement classes that have a disabled attribute. this.element.disabled = !enabled; } visible(): boolean { return this.visibleInternal; } setVisible(x: boolean): void { if (this.visibleInternal === x) { return; } this.element.classList.toggle('hidden', !x); this.visibleInternal = x; if (this.toolbar && !(this instanceof ToolbarSeparator)) { this.toolbar.hideSeparatorDupes(); } } setRightAligned(alignRight: boolean): void { this.element.classList.toggle('toolbar-item-right-aligned', alignRight); } setCompactLayout(_enable: boolean): void { } } export const enum ToolbarItemWithCompactLayoutEvents { CompactLayoutUpdated = 'CompactLayoutUpdated', } type ToolbarItemWithCompactLayoutEventTypes = { [ToolbarItemWithCompactLayoutEvents.CompactLayoutUpdated]: boolean, }; export class ToolbarItemWithCompactLayout extends ToolbarItem<ToolbarItemWithCompactLayoutEventTypes> { constructor(element: Element) { super(element); } override setCompactLayout(enable: boolean): void { this.dispatchEventToListeners(ToolbarItemWithCompactLayoutEvents.CompactLayoutUpdated, enable); } } export class ToolbarText extends ToolbarItem<void> { constructor(text?: string) { const element = document.createElement('div'); element.classList.add('toolbar-text'); super(element); this.element.classList.add('toolbar-text'); this.setText(text || ''); } text(): string { return this.element.textContent || ''; } setText(text: string): void { this.element.textContent = text; } } export class ToolbarButton extends ToolbarItem<ToolbarButton.EventTypes> { private readonly glyphElement: Icon; private textElement: HTMLElement; private text?: string; private glyph?: string; private icon?: HTMLElement; /** * TODO(crbug.com/1126026): remove glyph parameter in favor of icon. */ constructor(title: string, glyphOrIcon?: string|HTMLElement, text?: string) { const element = document.createElement('button'); element.classList.add('toolbar-button'); super(element); this.element.addEventListener('click', this.clicked.bind(this), false); this.element.addEventListener('mousedown', this.mouseDown.bind(this), false); this.glyphElement = Icon.create('', 'toolbar-glyph hidden'); this.element.appendChild(this.glyphElement); this.textElement = this.element.createChild('div', 'toolbar-text hidden'); this.setTitle(title); if (glyphOrIcon) { this.setGlyphOrIcon(glyphOrIcon); } this.setText(text || ''); this.title = ''; } focus(): void { this.element.focus(); } setText(text: string): void { if (this.text === text) { return; } this.textElement.textContent = text; this.textElement.classList.toggle('hidden', !text); this.text = text; } setGlyphOrIcon(glyphOrIcon: string|HTMLElement): void { if (glyphOrIcon instanceof HTMLElement) { glyphOrIcon.classList.add('toolbar-icon'); if (this.icon) { this.icon.replaceWith(glyphOrIcon); } else { this.element.appendChild(glyphOrIcon); } this.icon = glyphOrIcon; } else if (glyphOrIcon) { this.setGlyph(glyphOrIcon); } } setGlyph(glyph: string): void { if (this.glyph === glyph) { return; } this.glyphElement.setIconType(glyph); this.glyphElement.classList.toggle('hidden', !glyph); this.element.classList.toggle('toolbar-has-glyph', Boolean(glyph)); this.glyph = glyph; } setBackgroundImage(iconURL: string): void { this.element.style.backgroundImage = 'url(' + iconURL + ')'; } setSecondary(): void { this.element.classList.add('toolbar-button-secondary'); } setDarkText(): void { this.element.classList.add('dark-text'); } turnIntoSelect(shrinkable: boolean|undefined = false): void { this.element.classList.add('toolbar-has-dropdown'); if (shrinkable) { this.element.classList.add('toolbar-has-dropdown-shrinkable'); } const dropdownArrowIcon = Icon.create('triangle-down', 'toolbar-dropdown-arrow'); this.element.appendChild(dropdownArrowIcon); } clicked(event: Event): void { if (!this.enabled) { return; } this.dispatchEventToListeners(ToolbarButton.Events.Click, event); event.consume(); } protected mouseDown(event: MouseEvent): void { if (!this.enabled) { return; } this.dispatchEventToListeners(ToolbarButton.Events.MouseDown, event); } } export namespace ToolbarButton { // TODO(crbug.com/1167717): Make this a const enum again // eslint-disable-next-line rulesdir/const_enum export enum Events { Click = 'Click', MouseDown = 'MouseDown', } export type EventTypes = { [Events.Click]: Event, [Events.MouseDown]: MouseEvent, }; } export class ToolbarInput extends ToolbarItem<ToolbarInput.EventTypes> { private prompt: TextPrompt; private readonly proxyElement: Element; constructor( placeholder: string, accessiblePlaceholder?: string, growFactor?: number, shrinkFactor?: number, tooltip?: string, completions?: ((arg0: string, arg1: string, arg2?: boolean|undefined) => Promise<Suggestion[]>), dynamicCompletions?: boolean) { const element = document.createElement('div'); element.classList.add('toolbar-input'); super(element); const internalPromptElement = this.element.createChild('div', 'toolbar-input-prompt'); ARIAUtils.setAccessibleName(internalPromptElement, placeholder); internalPromptElement.addEventListener('focus', () => this.element.classList.add('focused')); internalPromptElement.addEventListener('blur', () => this.element.classList.remove('focused')); this.prompt = new TextPrompt(); this.proxyElement = this.prompt.attach(internalPromptElement); this.proxyElement.classList.add('toolbar-prompt-proxy'); this.proxyElement.addEventListener('keydown', (event: Event) => this.onKeydownCallback(event as KeyboardEvent)); this.prompt.initialize(completions || ((): Promise<never[]> => Promise.resolve([])), ' ', dynamicCompletions); if (tooltip) { this.prompt.setTitle(tooltip); } this.prompt.setPlaceholder(placeholder, accessiblePlaceholder); this.prompt.addEventListener(TextPromptEvents.TextChanged, this.onChangeCallback.bind(this)); if (growFactor) { this.element.style.flexGrow = String(growFactor); } if (shrinkFactor) { this.element.style.flexShrink = String(shrinkFactor); } const clearButton = this.element.createChild('div', 'toolbar-input-clear-button'); clearButton.title = UIStrings.clearInput; const clearIcon = new IconButton.Icon.Icon(); clearIcon.data = {color: 'var(--icon-default)', width: '16px', height: '16px', iconName: 'cross-circle-filled'}; clearIcon.classList.add('search-cancel-button'); clearButton.appendChild(clearIcon); clearButton.addEventListener('click', () => { this.setValue('', true); this.prompt.focus(); }); this.updateEmptyStyles(); } override applyEnabledState(enabled: boolean): void { this.prompt.setEnabled(enabled); } setValue(value: string, notify?: boolean): void { this.prompt.setText(value); if (notify) { this.onChangeCallback(); } this.updateEmptyStyles(); } value(): string { return this.prompt.textWithCurrentSuggestion(); } valueWithoutSuggestion(): string { return this.prompt.text(); } private onKeydownCallback(event: KeyboardEvent): void { if (event.key === 'Enter' && this.prompt.text()) { this.dispatchEventToListeners(ToolbarInput.Event.EnterPressed, this.prompt.text()); } if (!Platform.KeyboardUtilities.isEscKey(event) || !this.prompt.text()) { return; } this.setValue('', true); event.consume(true); } private onChangeCallback(): void { this.updateEmptyStyles(); this.dispatchEventToListeners(ToolbarInput.Event.TextChanged, this.prompt.text()); } private updateEmptyStyles(): void { this.element.classList.toggle('toolbar-input-empty', !this.prompt.text()); } } export namespace ToolbarInput { // TODO(crbug.com/1167717): Make this a const enum again // eslint-disable-next-line rulesdir/const_enum export enum Event { TextChanged = 'TextChanged', EnterPressed = 'EnterPressed', } export interface EventTypes { [Event.TextChanged]: string; [Event.EnterPressed]: string; } } export class ToolbarToggle extends ToolbarButton { private toggledInternal: boolean; private readonly untoggledGlyphOrIcon: string|HTMLElement|undefined; private readonly toggledGlyphOrIcon: string|HTMLElement|undefined; constructor(title: string, glyphOrIcon?: string|HTMLElement, toggledGlyphOrIcon?: string|HTMLElement) { super(title, glyphOrIcon, ''); this.toggledInternal = false; this.untoggledGlyphOrIcon = glyphOrIcon; this.toggledGlyphOrIcon = toggledGlyphOrIcon; this.element.classList.add('toolbar-state-off'); ARIAUtils.setPressed(this.element, false); } toggled(): boolean { return this.toggledInternal; } setToggled(toggled: boolean): void { if (this.toggledInternal === toggled) { return; } this.toggledInternal = toggled; this.element.classList.toggle('toolbar-state-on', toggled); this.element.classList.toggle('toolbar-state-off', !toggled); ARIAUtils.setPressed(this.element, toggled); if (this.toggledGlyphOrIcon && this.untoggledGlyphOrIcon) { this.setGlyphOrIcon(toggled ? this.toggledGlyphOrIcon : this.untoggledGlyphOrIcon); } } setDefaultWithRedColor(withRedColor: boolean): void { this.element.classList.toggle('toolbar-default-with-red-color', withRedColor); } setToggleWithRedColor(toggleWithRedColor: boolean): void { this.element.classList.toggle('toolbar-toggle-with-red-color', toggleWithRedColor); } setToggleWithDot(toggleWithDot: boolean): void { this.element.classList.toggle('toolbar-toggle-with-dot', toggleWithDot); } } export class ToolbarMenuButton extends ToolbarButton { private readonly contextMenuHandler: (arg0: ContextMenu) => void; private readonly useSoftMenu: boolean; private triggerTimeout?: number; private lastTriggerTime?: number; constructor(contextMenuHandler: (arg0: ContextMenu) => void, useSoftMenu?: boolean) { super('', 'dots-vertical'); this.contextMenuHandler = contextMenuHandler; this.useSoftMenu = Boolean(useSoftMenu); ARIAUtils.markAsMenuButton(this.element); } override mouseDown(event: MouseEvent): void { if (event.buttons !== 1) { super.mouseDown(event); return; } if (!this.triggerTimeout) { this.triggerTimeout = window.setTimeout(this.trigger.bind(this, event), 200); } } private trigger(event: Event): void { delete this.triggerTimeout; // Throttling avoids entering a bad state on Macs when rapidly triggering context menus just // after the window gains focus. See crbug.com/655556 if (this.lastTriggerTime && Date.now() - this.lastTriggerTime < 300) { return; } const contextMenu = new ContextMenu(event, { useSoftMenu: this.useSoftMenu, x: this.element.getBoundingClientRect().left, y: this.element.getBoundingClientRect().top + this.element.offsetHeight, }); this.contextMenuHandler(contextMenu); void contextMenu.show(); this.lastTriggerTime = Date.now(); } override clicked(event: Event): void { if (this.triggerTimeout) { clearTimeout(this.triggerTimeout); } this.trigger(event); } } export class ToolbarSettingToggle extends ToolbarToggle { private readonly defaultTitle: string; private readonly setting: Common.Settings.Setting<boolean>; private willAnnounceState: boolean; constructor(setting: Common.Settings.Setting<boolean>, glyph: string, title: string, toggledGlyph?: string) { super(title, glyph, toggledGlyph); this.defaultTitle = title; this.setting = setting; this.settingChanged(); this.setting.addChangeListener(this.settingChanged, this); // Determines whether the toggle state will be announced to a screen reader this.willAnnounceState = false; } private settingChanged(): void { const toggled = this.setting.get(); this.setToggled(toggled); const toggleAnnouncement = toggled ? i18nString(UIStrings.pressed) : i18nString(UIStrings.notPressed); if (this.willAnnounceState) { ARIAUtils.alert(toggleAnnouncement); } this.willAnnounceState = false; this.setTitle(this.defaultTitle); } override clicked(event: Event): void { this.willAnnounceState = true; this.setting.set(!this.toggled()); super.clicked(event); } } export class ToolbarSeparator extends ToolbarItem<void> { constructor(spacer?: boolean) { const element = document.createElement('div'); element.classList.add(spacer ? 'toolbar-spacer' : 'toolbar-divider'); super(element); } } export interface Provider { item(): ToolbarItem|null; } export interface ItemsProvider { toolbarItems(): ToolbarItem[]; } export class ToolbarComboBox extends ToolbarItem<void> { protected selectElementInternal: HTMLSelectElement; constructor(changeHandler: ((arg0: Event) => void)|null, title: string, className?: string) { const element = document.createElement('span'); element.classList.add('toolbar-select-container'); super(element); this.selectElementInternal = (this.element.createChild('select', 'toolbar-item') as HTMLSelectElement); const dropdownArrowIcon = Icon.create('triangle-down', 'toolbar-dropdown-arrow'); this.element.appendChild(dropdownArrowIcon); if (changeHandler) { this.selectElementInternal.addEventListener('change', changeHandler, false); } ARIAUtils.setAccessibleName(this.selectElementInternal, title); super.setTitle(title); if (className) { this.selectElementInternal.classList.add(className); } } selectElement(): HTMLSelectElement { return this.selectElementInternal; } size(): number { return this.selectElementInternal.childElementCount; } options(): HTMLOptionElement[] { return Array.prototype.slice.call(this.selectElementInternal.children, 0); } addOption(option: Element): void { this.selectElementInternal.appendChild(option); } createOption(label: string, value?: string): Element { const option = (this.selectElementInternal.createChild('option') as HTMLOptionElement); option.text = label; if (typeof value !== 'undefined') { option.value = value; } return option; } override applyEnabledState(enabled: boolean): void { super.applyEnabledState(enabled); this.selectElementInternal.disabled = !enabled; } removeOption(option: Element): void { this.selectElementInternal.removeChild(option); } removeOptions(): void { this.selectElementInternal.removeChildren(); } selectedOption(): HTMLOptionElement|null { if (this.selectElementInternal.selectedIndex >= 0) { return this.selectElementInternal[this.selectElementInternal.selectedIndex] as HTMLOptionElement; } return null; } select(option: Element): void { this.selectElementInternal.selectedIndex = // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // eslint-disable-next-line @typescript-eslint/no-explicit-any Array.prototype.indexOf.call((this.selectElementInternal as any), option); } setSelectedIndex(index: number): void { this.selectElementInternal.selectedIndex = index; } selectedIndex(): number { return this.selectElementInternal.selectedIndex; } setMaxWidth(width: number): void { this.selectElementInternal.style.maxWidth = width + 'px'; } setMinWidth(width: number): void { this.selectElementInternal.style.minWidth = width + 'px'; } } export interface Option { value: string; label: string; } export class ToolbarSettingComboBox extends ToolbarComboBox { private optionsInternal: Option[]; private readonly setting: Common.Settings.Setting<string>; private muteSettingListener?: boolean; constructor(options: Option[], setting: Common.Settings.Setting<string>, accessibleName: string) { super(null, accessibleName); this.optionsInternal = options; this.setting = setting; this.selectElementInternal.addEventListener('change', this.valueChanged.bind(this), false); this.setOptions(options); setting.addChangeListener(this.settingChanged, this); } setOptions(options: Option[]): void { this.optionsInternal = options; this.selectElementInternal.removeChildren(); for (let i = 0; i < options.length; ++i) { const dataOption = options[i]; const option = this.createOption(dataOption.label, dataOption.value); this.selectElementInternal.appendChild(option); if (this.setting.get() === dataOption.value) { this.setSelectedIndex(i); } } } value(): string { return this.optionsInternal[this.selectedIndex()].value; } private settingChanged(): void { if (this.muteSettingListener) { return; } const value = this.setting.get(); for (let i = 0; i < this.optionsInternal.length; ++i) { if (value === this.optionsInternal[i].value) { this.setSelectedIndex(i); break; } } } private valueChanged(_event: Event): void { const option = this.optionsInternal[this.selectedIndex()]; this.muteSettingListener = true; this.setting.set(option.value); this.muteSettingListener = false; } } export class ToolbarCheckbox extends ToolbarItem<void> { inputElement: HTMLInputElement; constructor(text: string, tooltip?: string, listener?: ((arg0: MouseEvent) => void)) { super(CheckboxLabel.create(text)); this.element.classList.add('checkbox'); this.inputElement = (this.element as CheckboxLabel).checkboxElement; if (tooltip) { // install on the checkbox Tooltip.install(this.inputElement, tooltip); Tooltip.install((this.element as CheckboxLabel).textElement, tooltip); } if (listener) { this.inputElement.addEventListener('click', listener, false); } } checked(): boolean { return this.inputElement.checked; } setChecked(value: boolean): void { this.inputElement.checked = value; } override applyEnabledState(enabled: boolean): void { super.applyEnabledState(enabled); this.inputElement.disabled = !enabled; } setIndeterminate(indeterminate: boolean): void { this.inputElement.indeterminate = indeterminate; } } export class ToolbarSettingCheckbox extends ToolbarCheckbox { constructor(setting: Common.Settings.Setting<boolean>, tooltip?: string, alternateTitle?: string) { super(alternateTitle || setting.title() || '', tooltip); bindCheckbox(this.inputElement, setting); } } const registeredToolbarItems: ToolbarItemRegistration[] = []; export function registerToolbarItem(registration: ToolbarItemRegistration): void { registeredToolbarItems.push(registration); } function getRegisteredToolbarItems(): ToolbarItemRegistration[] { return registeredToolbarItems.filter( item => Root.Runtime.Runtime.isDescriptorEnabled({experiment: item.experiment, condition: item.condition})); } export interface ToolbarItemRegistration { order?: number; location: ToolbarItemLocation; separator?: boolean; showLabel?: boolean; actionId?: string; condition?: string; loadItem?: (() => Promise<Provider>); experiment?: string; } // TODO(crbug.com/1167717): Make this a const enum again // eslint-disable-next-line rulesdir/const_enum export enum ToolbarItemLocation { FILES_NAVIGATION_TOOLBAR = 'files-navigator-toolbar', MAIN_TOOLBAR_RIGHT = 'main-toolbar-right', MAIN_TOOLBAR_LEFT = 'main-toolbar-left', STYLES_SIDEBARPANE_TOOLBAR = 'styles-sidebarpane-toolbar', }