[go: nahoru, domu]

blob: 9e8e5f06d76c15aa70c4117c59d0c7cbdabe1434 [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/*
* Copyright (C) 2006, 2007, 2008 Apple Inc. All rights reserved.
* Copyright (C) 2007 Matt Lilek (pewtermoose@gmail.com).
* Copyright (C) 2009 Joseph Pecoraro
* Copyright (C) 2011 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:
*
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. 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.
* 3. Neither the name of Apple Computer, Inc. ("Apple") 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 APPLE AND ITS 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 APPLE OR ITS 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 i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as ARIAUtils from './ARIAUtils.js';
import {HistoryInput} from './HistoryInput.js';
import {InspectorView} from './InspectorView.js';
import searchableViewStyles from './searchableView.css.legacy.js';
import {Toolbar, ToolbarButton, ToolbarToggle} from './Toolbar.js';
import {Tooltip} from './Tooltip.js';
import {createTextButton} from './UIUtils.js';
import {VBox} from './Widget.js';
const UIStrings = {
/**
*@description Text on a button to replace one instance with input text for the ctrl+F search bar
*/
replace: 'Replace',
/**
*@description Text to find an item
*/
findString: 'Find',
/**
*@description Text on a button to search previous instance for the ctrl+F search bar
*/
searchPrevious: 'Search previous',
/**
*@description Text on a button to search next instance for the ctrl+F search bar
*/
searchNext: 'Search next',
/**
*@description Text to search by matching case of the input
*/
matchCase: 'Match Case',
/**
*@description Text for searching with regular expressinn
*/
useRegularExpression: 'Use Regular Expression',
/**
*@description Text to cancel something
*/
cancel: 'Cancel',
/**
*@description Text on a button to replace all instances with input text for the ctrl+F search bar
*/
replaceAll: 'Replace all',
/**
*@description Text to indicate the current match index and the total number of matches for the ctrl+F search bar
*@example {2} PH1
*@example {3} PH2
*/
dOfD: '{PH1} of {PH2}',
/**
*@description Text to indicate search result for the ctrl+F search bar
*/
matchString: '1 match',
/**
*@description Text to indicate search result for the ctrl+F search bar
*@example {2} PH1
*/
dMatches: '{PH1} matches',
};
const str_ = i18n.i18n.registerUIStrings('ui/legacy/SearchableView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export class SearchableView extends VBox {
private searchProvider: Searchable;
private replaceProvider: Replaceable|null;
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private setting: Common.Settings.Setting<any>|null;
private replaceable: boolean;
private readonly footerElementContainer: HTMLElement;
private readonly footerElement: HTMLElement;
private replaceToggleButton: ToolbarToggle;
private searchInputElement: HistoryInput;
private matchesElement: HTMLElement;
private searchNavigationPrevElement: HTMLElement;
private searchNavigationNextElement: HTMLElement;
private readonly replaceInputElement: HTMLInputElement;
private readonly buttonsContainer: HTMLElement;
private caseSensitiveButton: ToolbarToggle|undefined;
private regexButton: ToolbarToggle|undefined;
private readonly secondRowButtons: HTMLElement;
private replaceButtonElement: HTMLButtonElement;
private replaceAllButtonElement: HTMLButtonElement;
private minimalSearchQuerySize: number;
private searchIsVisible?: boolean;
private currentQuery?: string;
private valueChangedTimeoutId?: number;
constructor(searchable: Searchable, replaceable: Replaceable|null, settingName?: string) {
super(true);
this.registerRequiredCSS(searchableViewStyles);
searchableViewsByElement.set(this.element, this);
this.searchProvider = searchable;
this.replaceProvider = replaceable;
this.setting = settingName ? Common.Settings.Settings.instance().createSetting(settingName, {}) : null;
this.replaceable = false;
this.contentElement.createChild('slot');
this.footerElementContainer = this.contentElement.createChild('div', 'search-bar hidden');
this.footerElementContainer.style.order = '100';
this.footerElement = this.footerElementContainer.createChild('div', 'toolbar-search');
const replaceToggleToolbar = new Toolbar('replace-toggle-toolbar', this.footerElement);
this.replaceToggleButton = new ToolbarToggle(i18nString(UIStrings.replace), 'mediumicon-replace');
this.replaceToggleButton.addEventListener(ToolbarButton.Events.Click, this.toggleReplace, this);
replaceToggleToolbar.appendToolbarItem(this.replaceToggleButton);
const searchInputElements = this.footerElement.createChild('div', 'toolbar-search-inputs');
const searchControlElement = searchInputElements.createChild('div', 'toolbar-search-control');
this.searchInputElement = HistoryInput.create();
this.searchInputElement.type = 'search';
this.searchInputElement.classList.add('search-replace', 'custom-search-input');
this.searchInputElement.id = 'search-input-field';
this.searchInputElement.placeholder = i18nString(UIStrings.findString);
searchControlElement.appendChild(this.searchInputElement);
this.matchesElement = searchControlElement.createChild('label', 'search-results-matches');
this.matchesElement.setAttribute('for', 'search-input-field');
const searchNavigationElement = searchControlElement.createChild('div', 'toolbar-search-navigation-controls');
this.searchNavigationPrevElement =
searchNavigationElement.createChild('div', 'toolbar-search-navigation toolbar-search-navigation-prev');
this.searchNavigationPrevElement.addEventListener('click', this.onPrevButtonSearch.bind(this), false);
Tooltip.install(this.searchNavigationPrevElement, i18nString(UIStrings.searchPrevious));
ARIAUtils.setAccessibleName(this.searchNavigationPrevElement, i18nString(UIStrings.searchPrevious));
this.searchNavigationNextElement =
searchNavigationElement.createChild('div', 'toolbar-search-navigation toolbar-search-navigation-next');
this.searchNavigationNextElement.addEventListener('click', this.onNextButtonSearch.bind(this), false);
Tooltip.install(this.searchNavigationNextElement, i18nString(UIStrings.searchNext));
ARIAUtils.setAccessibleName(this.searchNavigationNextElement, i18nString(UIStrings.searchNext));
this.searchInputElement.addEventListener('keydown', this.onSearchKeyDown.bind(this), true);
this.searchInputElement.addEventListener('input', this.onInput.bind(this), false);
this.replaceInputElement =
(searchInputElements.createChild('input', 'search-replace toolbar-replace-control hidden') as HTMLInputElement);
this.replaceInputElement.addEventListener('keydown', this.onReplaceKeyDown.bind(this), true);
this.replaceInputElement.placeholder = i18nString(UIStrings.replace);
this.buttonsContainer = this.footerElement.createChild('div', 'toolbar-search-buttons');
const firstRowButtons = this.buttonsContainer.createChild('div', 'first-row-buttons');
const toolbar = new Toolbar('toolbar-search-options', firstRowButtons);
if (this.searchProvider.supportsCaseSensitiveSearch()) {
this.caseSensitiveButton = new ToolbarToggle(i18nString(UIStrings.matchCase));
this.caseSensitiveButton.setText('Aa');
this.caseSensitiveButton.addEventListener(ToolbarButton.Events.Click, this.toggleCaseSensitiveSearch, this);
toolbar.appendToolbarItem(this.caseSensitiveButton);
}
if (this.searchProvider.supportsRegexSearch()) {
this.regexButton = new ToolbarToggle(i18nString(UIStrings.useRegularExpression));
this.regexButton.setText('.*');
this.regexButton.addEventListener(ToolbarButton.Events.Click, this.toggleRegexSearch, this);
toolbar.appendToolbarItem(this.regexButton);
}
const cancelButtonElement =
createTextButton(i18nString(UIStrings.cancel), this.closeSearch.bind(this), 'search-action-button');
firstRowButtons.appendChild(cancelButtonElement);
this.secondRowButtons = this.buttonsContainer.createChild('div', 'second-row-buttons hidden');
this.replaceButtonElement =
createTextButton(i18nString(UIStrings.replace), this.replace.bind(this), 'search-action-button');
this.replaceButtonElement.disabled = true;
this.secondRowButtons.appendChild(this.replaceButtonElement);
this.replaceAllButtonElement =
createTextButton(i18nString(UIStrings.replaceAll), this.replaceAll.bind(this), 'search-action-button');
this.secondRowButtons.appendChild(this.replaceAllButtonElement);
this.replaceAllButtonElement.disabled = true;
this.minimalSearchQuerySize = 3;
this.loadSetting();
}
static fromElement(element: Element|null): SearchableView|null {
let view: (SearchableView|null)|null = null;
while (element && !view) {
view = searchableViewsByElement.get(element) || null;
element = element.parentElementOrShadowHost();
}
return view;
}
private toggleCaseSensitiveSearch(): void {
if (this.caseSensitiveButton) {
this.caseSensitiveButton.setToggled(!this.caseSensitiveButton.toggled());
}
this.saveSetting();
this.performSearch(false, true);
}
private toggleRegexSearch(): void {
if (this.regexButton) {
this.regexButton.setToggled(!this.regexButton.toggled());
}
this.saveSetting();
this.performSearch(false, true);
}
private toggleReplace(): void {
this.replaceToggleButton.setToggled(!this.replaceToggleButton.toggled());
this.updateSecondRowVisibility();
}
private saveSetting(): void {
if (!this.setting) {
return;
}
const settingValue = this.setting.get() || {};
if (this.caseSensitiveButton) {
settingValue.caseSensitive = this.caseSensitiveButton.toggled();
}
if (this.regexButton) {
settingValue.isRegex = this.regexButton.toggled();
}
this.setting.set(settingValue);
}
private loadSetting(): void {
const settingValue = this.setting ? (this.setting.get() || {}) : {};
if (this.searchProvider.supportsCaseSensitiveSearch() && this.caseSensitiveButton) {
this.caseSensitiveButton.setToggled(Boolean(settingValue.caseSensitive));
}
if (this.searchProvider.supportsRegexSearch() && this.regexButton) {
this.regexButton.setToggled(Boolean(settingValue.isRegex));
}
}
setMinimalSearchQuerySize(minimalSearchQuerySize: number): void {
this.minimalSearchQuerySize = minimalSearchQuerySize;
}
setPlaceholder(placeholder: string, ariaLabel?: string): void {
this.searchInputElement.placeholder = placeholder;
if (ariaLabel) {
ARIAUtils.setAccessibleName(this.searchInputElement, ariaLabel);
}
}
setReplaceable(replaceable: boolean): void {
this.replaceable = replaceable;
}
updateSearchMatchesCount(matches: number): void {
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const untypedSearchProvider = (this.searchProvider as any);
if (untypedSearchProvider.currentSearchMatches === matches) {
return;
}
untypedSearchProvider.currentSearchMatches = matches;
this.updateSearchMatchesCountAndCurrentMatchIndex(untypedSearchProvider.currentQuery ? matches : 0, -1);
}
updateCurrentMatchIndex(currentMatchIndex: number): void {
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const untypedSearchProvider = (this.searchProvider as any);
this.updateSearchMatchesCountAndCurrentMatchIndex(untypedSearchProvider.currentSearchMatches, currentMatchIndex);
}
isSearchVisible(): boolean {
return Boolean(this.searchIsVisible);
}
closeSearch(): void {
this.cancelSearch();
if (this.footerElementContainer.hasFocus()) {
this.focus();
}
}
private toggleSearchBar(toggled: boolean): void {
this.footerElementContainer.classList.toggle('hidden', !toggled);
this.doResize();
}
cancelSearch(): void {
if (!this.searchIsVisible) {
return;
}
this.resetSearch();
delete this.searchIsVisible;
this.toggleSearchBar(false);
}
resetSearch(): void {
this.clearSearch();
this.updateReplaceVisibility();
this.matchesElement.textContent = '';
}
refreshSearch(): void {
if (!this.searchIsVisible) {
return;
}
this.resetSearch();
this.performSearch(false, false);
}
handleFindNextShortcut(): boolean {
if (!this.searchIsVisible) {
return false;
}
this.searchProvider.jumpToNextSearchResult();
return true;
}
handleFindPreviousShortcut(): boolean {
if (!this.searchIsVisible) {
return false;
}
this.searchProvider.jumpToPreviousSearchResult();
return true;
}
handleFindShortcut(): boolean {
this.showSearchField();
return true;
}
handleCancelSearchShortcut(): boolean {
if (!this.searchIsVisible) {
return false;
}
this.closeSearch();
return true;
}
private updateSearchNavigationButtonState(enabled: boolean): void {
this.replaceButtonElement.disabled = !enabled;
this.replaceAllButtonElement.disabled = !enabled;
this.searchNavigationPrevElement.classList.toggle('enabled', enabled);
this.searchNavigationNextElement.classList.toggle('enabled', enabled);
}
private updateSearchMatchesCountAndCurrentMatchIndex(matches: number, currentMatchIndex: number): void {
if (!this.currentQuery) {
this.matchesElement.textContent = '';
} else if (matches === 0 || currentMatchIndex >= 0) {
this.matchesElement.textContent = i18nString(UIStrings.dOfD, {PH1: currentMatchIndex + 1, PH2: matches});
} else if (matches === 1) {
this.matchesElement.textContent = i18nString(UIStrings.matchString);
} else {
this.matchesElement.textContent = i18nString(UIStrings.dMatches, {PH1: matches});
}
this.updateSearchNavigationButtonState(matches > 0);
}
showSearchField(): void {
if (this.searchIsVisible) {
this.cancelSearch();
}
let queryCandidate;
if (!this.searchInputElement.hasFocus()) {
const selection = InspectorView.instance().element.window().getSelection();
if (selection && selection.rangeCount) {
queryCandidate = selection.toString().replace(/\r?\n.*/, '');
}
}
this.toggleSearchBar(true);
this.updateReplaceVisibility();
if (queryCandidate) {
this.searchInputElement.value = queryCandidate;
}
this.performSearch(false, false);
this.searchInputElement.focus();
this.searchInputElement.select();
this.searchIsVisible = true;
}
private updateReplaceVisibility(): void {
this.replaceToggleButton.setVisible(this.replaceable);
if (!this.replaceable) {
this.replaceToggleButton.setToggled(false);
this.updateSecondRowVisibility();
}
}
private onSearchKeyDown(ev: Event): void {
const event = (ev as KeyboardEvent);
if (isEscKey(event)) {
this.closeSearch();
event.consume(true);
return;
}
if (!(event.key === 'Enter')) {
return;
}
if (!this.currentQuery) {
this.performSearch(true, true, event.shiftKey);
} else {
this.jumpToNextSearchResult(event.shiftKey);
}
}
private onReplaceKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
this.replace();
}
}
private jumpToNextSearchResult(isBackwardSearch?: boolean): void {
if (!this.currentQuery) {
return;
}
if (isBackwardSearch) {
this.searchProvider.jumpToPreviousSearchResult();
} else {
this.searchProvider.jumpToNextSearchResult();
}
}
private onNextButtonSearch(_event: Event): void {
if (!this.searchNavigationNextElement.classList.contains('enabled')) {
return;
}
this.jumpToNextSearchResult();
this.searchInputElement.focus();
}
private onPrevButtonSearch(_event: Event): void {
if (!this.searchNavigationPrevElement.classList.contains('enabled')) {
return;
}
this.jumpToNextSearchResult(true);
this.searchInputElement.focus();
}
private onFindClick(_event: Event): void {
if (!this.currentQuery) {
this.performSearch(true, true);
} else {
this.jumpToNextSearchResult();
}
this.searchInputElement.focus();
}
private onPreviousClick(_event: Event): void {
if (!this.currentQuery) {
this.performSearch(true, true, true);
} else {
this.jumpToNextSearchResult(true);
}
this.searchInputElement.focus();
}
private clearSearch(): void {
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const untypedSearchProvider = (this.searchProvider as any);
delete this.currentQuery;
if (Boolean(untypedSearchProvider.currentQuery)) {
delete untypedSearchProvider.currentQuery;
this.searchProvider.searchCanceled();
}
this.updateSearchMatchesCountAndCurrentMatchIndex(0, -1);
}
private performSearch(forceSearch: boolean, shouldJump: boolean, jumpBackwards?: boolean): void {
const query = this.searchInputElement.value;
if (!query || (!forceSearch && query.length < this.minimalSearchQuerySize && !this.currentQuery)) {
this.clearSearch();
return;
}
this.currentQuery = query;
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.searchProvider as any).currentQuery = query;
const searchConfig = this.currentSearchConfig();
this.searchProvider.performSearch(searchConfig, shouldJump, jumpBackwards);
}
private currentSearchConfig(): SearchConfig {
const query = this.searchInputElement.value;
const caseSensitive = this.caseSensitiveButton ? this.caseSensitiveButton.toggled() : false;
const isRegex = this.regexButton ? this.regexButton.toggled() : false;
return new SearchConfig(query, caseSensitive, isRegex);
}
private updateSecondRowVisibility(): void {
const secondRowVisible = this.replaceToggleButton.toggled();
this.footerElementContainer.classList.toggle('replaceable', secondRowVisible);
this.secondRowButtons.classList.toggle('hidden', !secondRowVisible);
this.replaceInputElement.classList.toggle('hidden', !secondRowVisible);
if (secondRowVisible) {
this.replaceInputElement.focus();
} else {
this.searchInputElement.focus();
}
this.doResize();
}
private replace(): void {
if (!this.replaceProvider) {
throw new Error('No \'replacable\' provided to SearchableView!');
}
const searchConfig = this.currentSearchConfig();
this.replaceProvider.replaceSelectionWith(searchConfig, this.replaceInputElement.value);
delete this.currentQuery;
this.performSearch(true, true);
}
private replaceAll(): void {
if (!this.replaceProvider) {
throw new Error('No \'replacable\' provided to SearchableView!');
}
const searchConfig = this.currentSearchConfig();
this.replaceProvider.replaceAllWith(searchConfig, this.replaceInputElement.value);
}
private onInput(_event: Event): void {
if (this.valueChangedTimeoutId) {
clearTimeout(this.valueChangedTimeoutId);
}
const timeout = this.searchInputElement.value.length < 3 ? 200 : 0;
this.valueChangedTimeoutId = window.setTimeout(this.onValueChanged.bind(this), timeout);
}
private onValueChanged(): void {
if (!this.searchIsVisible) {
return;
}
delete this.valueChangedTimeoutId;
this.performSearch(false, true);
}
}
// TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
// eslint-disable-next-line @typescript-eslint/naming-convention
export const _symbol = Symbol('searchableView');
const searchableViewsByElement = new WeakMap<Element, SearchableView>();
export interface Searchable {
searchCanceled(): void;
performSearch(searchConfig: SearchConfig, shouldJump: boolean, jumpBackwards?: boolean): void;
jumpToNextSearchResult(): void;
jumpToPreviousSearchResult(): void;
supportsCaseSensitiveSearch(): boolean;
supportsRegexSearch(): boolean;
}
export interface Replaceable {
replaceSelectionWith(searchConfig: SearchConfig, replacement: string): void;
replaceAllWith(searchConfig: SearchConfig, replacement: string): void;
}
export interface SearchRegexResult {
regex: RegExp;
fromQuery: boolean;
}
export class SearchConfig {
query: string;
caseSensitive: boolean;
isRegex: boolean;
constructor(query: string, caseSensitive: boolean, isRegex: boolean) {
this.query = query;
this.caseSensitive = caseSensitive;
this.isRegex = isRegex;
}
toSearchRegex(global?: boolean): SearchRegexResult {
let modifiers = this.caseSensitive ? '' : 'i';
if (global) {
modifiers += 'g';
}
const query = this.isRegex ? '/' + this.query + '/' : this.query;
let regex: RegExp|undefined;
let fromQuery = false;
// First try creating regex if user knows the / / hint.
try {
if (/^\/.+\/$/.test(query)) {
regex = new RegExp(query.substring(1, query.length - 1), modifiers);
fromQuery = true;
}
} catch (e) {
// Silent catch.
}
// Otherwise just do a plain text search.
if (!regex) {
regex = Platform.StringUtilities.createPlainTextSearchRegex(query, modifiers);
}
return {
regex,
fromQuery,
};
}
}